341 lines
11 KiB
Python
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)
|