Files
finance/backend/app/budgeting/router.py
T

341 lines
11 KiB
Python

import uuid
from datetime import date
from typing import Annotated, Literal
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from app.budgeting import service
from app.categorization import service as categorization
from app.dependencies import Context, Csrf, Db
router = APIRouter(prefix="/budget", tags=["budget"])
class CategoryBody(BaseModel):
name: str = Field(min_length=1, max_length=200)
kind: Literal["monthly", "savings_envelope"] = "monthly"
class AssignmentItem(BaseModel):
category_id: uuid.UUID
planned_kopecks: int = Field(ge=0)
class AssignmentsBody(BaseModel):
items: list[AssignmentItem]
class FundingBody(BaseModel):
category_id: uuid.UUID
amount_kopecks: int = Field(gt=0)
kind: Literal["fund", "unfund"] = "fund"
note: str | None = Field(default=None, max_length=500)
class ExpectedIncomeBody(BaseModel):
expected_on: date
amount_kopecks: int = Field(gt=0)
source_name: str | None = Field(default=None, max_length=200)
note: str | None = Field(default=None, max_length=500)
def period_for(db: Db, context: Context, year: int, month: int):
return service.get_or_create_period(db, space_id=context.space.id, year=year, month=month)
class MonthBody(BaseModel):
month: str
class PlanBody(MonthBody):
plannedKopecks: int = Field(ge=0)
class CategoryFundingBody(MonthBody):
amountKopecks: int = Field(gt=0)
def parse_month(value: str) -> tuple[int, int]:
try:
year_text, month_text = value.split("-", 1)
year, month = int(year_text), int(month_text)
except (ValueError, AttributeError) as exc:
from app.errors import bad_request
raise bad_request("BUDGET_PERIOD_INVALID", "Month must use YYYY-MM") from exc
service.validate_year_month(year, month)
return year, month
def frontend_summary(state: dict) -> dict:
monthly = state["monthly_categories"]
envelopes = state["envelopes"]
funded = sum(row["funded"] for row in monthly) + sum(row["funded_total"] for row in envelopes)
spent = sum(row["spent"] for row in monthly)
available = sum(row["available"] for row in monthly + envelopes)
return {
"month": f"{state['period']['year']:04d}-{state['period']['month']:02d}",
"status": state["period"]["status"],
"projectedPoolKopecks": state["carry_pool"]
+ state["expected_income_total"]
- state["planned_total"],
"actualPoolKopecks": state["pool"],
"plannedTotalKopecks": state["planned_total"],
"fundedTotalKopecks": funded,
"spentTotalKopecks": spent,
"availableKopecks": available,
"warnings": [{"code": code} for code in state["warnings"]],
"blockers": state["close_blockers"],
"expectedIncomes": [
{
"id": item["id"],
"expectedOn": item["expected_on"],
"amountKopecks": item["amount_kopecks"],
"sourceName": item["source_name"],
"matchedKopecks": item["matched_kopecks"],
"status": "matched"
if item["matched_kopecks"] >= item["amount_kopecks"]
else "partially_matched"
if item["matched_kopecks"]
else "expected",
}
for item in state["expected_incomes"]
],
}
@router.get("/summary")
def summary(month: str, db: Db, context: Context) -> dict:
year, month_number = parse_month(month)
period = period_for(db, context, year, month_number)
state = service.compute_period_state(db, space_id=context.space.id, period=period)
return frontend_summary(state)
@router.get("/categories")
def categories(month: str, db: Db, context: Context) -> list[dict]:
year, month_number = parse_month(month)
period = period_for(db, context, year, month_number)
state = service.compute_period_state(db, space_id=context.space.id, period=period)
rows = state["monthly_categories"] + state["envelopes"]
category_by_id = {
str(item.id): item
for item in service.list_categories(db, space_id=context.space.id, include_archived=True)
}
result = []
for row in rows:
category = category_by_id[row["category_id"]]
result.append(
{
"id": row["category_id"],
"name": row["name"],
"nameKey": row["name_key"],
"kind": category.kind,
"system": row["system"],
"archived": category.archived,
"creditAccountId": category.linked_account_id,
"plannedKopecks": row["planned"],
"fundedKopecks": row["funded"],
"spentKopecks": row["spent"],
"availableKopecks": row["available"],
}
)
return result
def category_definition_json(category) -> dict:
return {
"id": category.id,
"name": categorization.resolve_category_name(category),
"nameKey": categorization.resolve_category_name_key(category),
"kind": category.kind,
"systemKey": category.system_key,
"linkedAccountId": category.linked_account_id,
"archived": category.archived,
}
@router.get("/category-definitions")
def category_definitions(
db: Db,
context: Context,
include_archived: Annotated[bool, Query(alias="includeArchived")] = False,
) -> list[dict]:
items = service.list_categories(
db, space_id=context.space.id, include_archived=include_archived
)
return [category_definition_json(item) for item in items]
class CategoryDefinitionPatchBody(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
archived: bool | None = None
@router.patch("/category-definitions/{category_id}")
def patch_category_definition(
category_id: uuid.UUID,
body: CategoryDefinitionPatchBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
category = service.update_category_definition(
db,
space_id=context.space.id,
user=context.user,
category_id=category_id,
name=body.name,
archived=body.archived,
)
return category_definition_json(category)
@router.post("/categories", status_code=201)
def create_category(body: CategoryBody, db: Db, context: Context, _csrf: Csrf) -> dict:
category = service.create_category(
db,
space_id=context.space.id,
user=context.user,
kind=body.kind,
name=body.name,
)
return category_definition_json(category)
@router.patch("/categories/{category_id}")
def update_plan(
category_id: uuid.UUID, body: PlanBody, db: Db, context: Context, _csrf: Csrf
) -> dict:
year, month_number = parse_month(body.month)
period = period_for(db, context, year, month_number)
service.set_assignments(
db,
space_id=context.space.id,
user=context.user,
period=period,
items=[{"category_id": category_id, "planned_kopecks": body.plannedKopecks}],
)
return next(item for item in categories(body.month, db, context) if item["id"] == category_id)
@router.post("/categories/{category_id}/fund")
def fund_category(
category_id: uuid.UUID,
body: CategoryFundingBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
year, month_number = parse_month(body.month)
period = period_for(db, context, year, month_number)
service.fund(
db,
space_id=context.space.id,
user=context.user,
period=period,
category_id=category_id,
amount_kopecks=body.amountKopecks,
kind="fund",
)
return next(item for item in categories(body.month, db, context) if item["id"] == category_id)
@router.put("/{year}/{month}/assignments")
def assignments(
year: int, month: int, body: AssignmentsBody, db: Db, context: Context, _csrf: Csrf
) -> dict:
period = period_for(db, context, year, month)
rows = service.set_assignments(
db,
space_id=context.space.id,
user=context.user,
period=period,
items=[item.model_dump() for item in body.items],
)
return {"count": len(rows)}
@router.post("/{year}/{month}/funding", status_code=201)
def fund(year: int, month: int, body: FundingBody, db: Db, context: Context, _csrf: Csrf) -> dict:
period = period_for(db, context, year, month)
movement = service.fund(
db,
space_id=context.space.id,
user=context.user,
period=period,
**body.model_dump(),
)
return {"id": movement.id}
@router.post("/{year}/{month}/expected-incomes", status_code=201)
def expected_income(
year: int,
month: int,
body: ExpectedIncomeBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
period = period_for(db, context, year, month)
income = service.add_expected_income(
db,
space_id=context.space.id,
user=context.user,
period=period,
**body.model_dump(),
)
return {"id": income.id}
@router.post("/{year}/{month}/close")
def close(year: int, month: int, db: Db, context: Context, _csrf: Csrf) -> dict:
period = period_for(db, context, year, month)
return service.close_period(db, space_id=context.space.id, user=context.user, period=period)
@router.post("/{year}/{month}/reopen")
def reopen(year: int, month: int, db: Db, context: Context, _csrf: Csrf) -> dict:
period = period_for(db, context, year, month)
affected = service.reopen_period(
db, space_id=context.space.id, user=context.user, period=period
)
return {"affected_period_ids": [item.id for item in affected]}
@router.post("/{year}/{month}/copy-previous")
def copy_previous(year: int, month: int, db: Db, context: Context, _csrf: Csrf) -> dict:
period = period_for(db, context, year, month)
return service.copy_previous_month(
db, space_id=context.space.id, user=context.user, period=period
)
@router.post("/close")
def close_frontend(body: MonthBody, db: Db, context: Context, _csrf: Csrf) -> dict:
year, month_number = parse_month(body.month)
period = period_for(db, context, year, month_number)
service.close_period(db, space_id=context.space.id, user=context.user, period=period)
state = service.compute_period_state(db, space_id=context.space.id, period=period)
return frontend_summary(state)
@router.post("/reopen")
def reopen_frontend(body: MonthBody, db: Db, context: Context, _csrf: Csrf) -> dict:
year, month_number = parse_month(body.month)
period = period_for(db, context, year, month_number)
service.reopen_period(db, space_id=context.space.id, user=context.user, period=period)
state = service.compute_period_state(db, space_id=context.space.id, period=period)
return frontend_summary(state)
@router.post("/copy-previous")
def copy_frontend(body: MonthBody, db: Db, context: Context, _csrf: Csrf) -> dict:
year, month_number = parse_month(body.month)
period = period_for(db, context, year, month_number)
service.copy_previous_month(db, space_id=context.space.id, user=context.user, period=period)
state = service.compute_period_state(db, space_id=context.space.id, period=period)
return frontend_summary(state)