import uuid from datetime import date from typing import Annotated, Any, Literal from fastapi import APIRouter, Header, Query from pydantic import BaseModel, ConfigDict, Field from app.categorization import service as categorization from app.categorization.models import IncomeSource, Payee from app.dependencies import Context, Csrf, Db from app.ledger import service from app.ledger.models import Category router = APIRouter(tags=["ledger"]) class AccountBody(BaseModel): model_config = ConfigDict(populate_by_name=True) name: str = Field(min_length=1, max_length=200) kind: Literal["cash", "debit", "credit"] = Field(alias="type") opened_on: date = Field(alias="openedOn") opening_amount_kopecks: int = Field(default=0, alias="openingBalanceKopecks") bank_code: str | None = Field(default=None, alias="bankCode", max_length=32) masked_identifier: str | None = Field(default=None, alias="maskedNumber", max_length=32) class OperationBody(BaseModel): model_config = ConfigDict(populate_by_name=True) operation_type: Literal["expense", "income", "transfer", "refund"] = Field(alias="kind") amount_kopecks: int = Field(alias="amountKopecks", gt=0) account_id: uuid.UUID | None = Field(default=None, alias="accountId") source_account_id: uuid.UUID | None = Field(default=None, alias="fromAccountId") counter_account_id: uuid.UUID | None = Field(default=None, alias="toAccountId") parent_operation_id: uuid.UUID | None = Field(default=None, alias="parentOperationId") category_id: uuid.UUID | None = Field(default=None, alias="categoryId") income_source_name: str | None = Field(default=None, alias="incomeSourceName") payee_name: str | None = Field(default=None, alias="payeeName") occurred_on: date = Field(alias="occurredOn") posted_on: date | None = Field(default=None, alias="postedOn") description: str | None = Field(default=None, max_length=500) class VoidBody(BaseModel): reason: str = Field(min_length=1, max_length=500) class OperationPatchBody(BaseModel): model_config = ConfigDict(populate_by_name=True) category_id: uuid.UUID | None = Field(default=None, alias="categoryId") class AdjustBalanceBody(BaseModel): model_config = ConfigDict(populate_by_name=True) target_balance_kopecks: int = Field(ge=0, alias="targetBalanceKopecks") occurred_on: date = Field(alias="occurredOn") reason: str = Field(min_length=1, max_length=500) def account_json(db: Db, account: Any) -> dict: return { "id": account.id, "name": account.name, "type": account.kind, "currency": account.currency, "openingBalanceKopecks": account.opening_amount_kopecks, "openedOn": account.opened_on, "bankCode": account.bank_code, "maskedNumber": account.masked_identifier, "systemKey": account.system_key, "nameKey": f"account.{account.system_key}" if account.system_key else None, "status": account.status, "balanceKopecks": service.account_balance(db, account), "version": account.version, } def operation_json(db: Db, operation: Any) -> dict: category = db.get(Category, operation.category_id) if operation.category_id else None payee = db.get(Payee, operation.payee_id) if operation.payee_id else None income_source = ( db.get(IncomeSource, operation.income_source_id) if operation.income_source_id else None ) return { "id": operation.id, "kind": operation.type, "status": operation.status, "amountKopecks": operation.amount_kopecks, "accountId": operation.account_id, "counterpartyAccountId": operation.counter_account_id, "categoryId": operation.category_id, "categoryName": categorization.resolve_category_name(category) if category else None, "categoryKey": category.system_key if category else None, "payeeName": payee.name if payee else None, "incomeSourceName": income_source.name if income_source else None, "parentOperationId": operation.parent_operation_id, "occurredOn": operation.occurred_on, "postedOn": operation.posted_on, "description": operation.description, "voidReason": operation.void_reason, "convertedFromExpense": operation.converted_from_expense, "version": operation.version, "createdAt": operation.created_at, } @router.get("/accounts") def accounts(db: Db, context: Context) -> list[dict]: return [account_json(db, item) for item in service.list_accounts(db, space_id=context.space.id)] @router.post("/accounts", status_code=201) def create_account(body: AccountBody, db: Db, context: Context, _csrf: Csrf) -> dict: account = service.create_account( db, space_id=context.space.id, user=context.user, **body.model_dump(by_alias=False), ) return account_json(db, account) @router.post("/accounts/{account_id}/archive") def archive_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict: account = service.archive_account( db, space_id=context.space.id, user=context.user, account_id=account_id ) return account_json(db, account) @router.patch("/accounts/{account_id}") def patch_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict: account = service.archive_account( db, space_id=context.space.id, user=context.user, account_id=account_id ) return account_json(db, account) @router.delete("/accounts/{account_id}", status_code=204) def delete_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> None: service.delete_account(db, space_id=context.space.id, user=context.user, account_id=account_id) @router.get("/operations") def operations( db: Db, context: Context, account_id: Annotated[uuid.UUID | None, Query(alias="accountId")] = None, operation_type: Annotated[str | None, Query(alias="kind")] = None, category_id: Annotated[uuid.UUID | None, Query(alias="categoryId")] = None, status: str | None = None, date_from: Annotated[date | None, Query(alias="occurredFrom")] = None, date_to: Annotated[date | None, Query(alias="occurredTo")] = None, search: str | None = None, uncategorized: bool = False, limit: Annotated[int, Query(ge=1, le=200)] = 50, cursor: str | None = None, ) -> dict: offset = int(cursor) if cursor and cursor.isdigit() else 0 items, total = service.list_operations( db, space_id=context.space.id, account_id=account_id, operation_type=operation_type, category_id=category_id, status=status, date_from=date_from, date_to=date_to, search=search, uncategorized=uncategorized, limit=limit, offset=offset, ) next_offset = offset + len(items) return { "items": [operation_json(db, item) for item in items], "nextCursor": str(next_offset) if next_offset < total else None, } @router.post("/operations", status_code=201) def create_operation( body: OperationBody, db: Db, context: Context, _csrf: Csrf, idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, ) -> dict: if body.operation_type == "expense" and body.account_id: payee_id = None if body.payee_name: payee_id = categorization.get_or_create_payee( db, space_id=context.space.id, name=body.payee_name, alias=body.payee_name ).id operation = service.create_expense( db, space_id=context.space.id, user=context.user, account_id=body.account_id, amount_kopecks=body.amount_kopecks, occurred_on=body.occurred_on, category_id=body.category_id, payee_id=payee_id, description=body.description, posted_on=body.posted_on, request_key=idempotency_key, ) elif body.operation_type == "income" and body.account_id: income_source_id = None if body.income_source_name: income_source_id = categorization.ensure_income_source( db, space_id=context.space.id, name=body.income_source_name ).id operation = service.create_income( db, space_id=context.space.id, user=context.user, account_id=body.account_id, amount_kopecks=body.amount_kopecks, occurred_on=body.occurred_on, income_source_id=income_source_id, description=body.description, posted_on=body.posted_on, request_key=idempotency_key, ) elif body.operation_type == "transfer" and body.source_account_id and body.counter_account_id: operation = service.create_transfer( db, space_id=context.space.id, user=context.user, source_account_id=body.source_account_id, target_account_id=body.counter_account_id, amount_kopecks=body.amount_kopecks, occurred_on=body.occurred_on, description=body.description, posted_on=body.posted_on, request_key=idempotency_key, ) elif body.operation_type == "refund" and body.parent_operation_id: operation = service.create_refund( db, space_id=context.space.id, user=context.user, parent_operation_id=body.parent_operation_id, amount_kopecks=body.amount_kopecks, occurred_on=body.occurred_on, description=body.description, request_key=idempotency_key, ) else: from app.errors import bad_request raise bad_request("LEDGER_OPERATION_FIELDS_INVALID", "Required account fields are missing") return operation_json(db, operation) @router.post("/operations/{operation_id}/void") def void_operation( operation_id: uuid.UUID, body: VoidBody, db: Db, context: Context, _csrf: Csrf ) -> dict: operation = service.void_operation( db, space_id=context.space.id, user=context.user, operation_id=operation_id, reason=body.reason, ) return operation_json(db, operation) @router.patch("/operations/{operation_id}") def patch_operation( operation_id: uuid.UUID, body: OperationPatchBody, db: Db, context: Context, _csrf: Csrf, ) -> dict: operation = service.update_operation( db, space_id=context.space.id, user=context.user, operation_id=operation_id, category_id=body.category_id, ) return operation_json(db, operation) @router.post("/accounts/{account_id}/adjust-balance") def adjust_balance( account_id: uuid.UUID, body: AdjustBalanceBody, db: Db, context: Context, _csrf: Csrf ) -> dict: from app.ledger import savings operation, old_balance, new_balance = savings.adjust_balance( db, space_id=context.space.id, user=context.user, account_id=account_id, target_balance_kopecks=body.target_balance_kopecks, occurred_on=body.occurred_on, reason=body.reason, ) payload = operation_json(db, operation) payload["oldBalanceKopecks"] = old_balance payload["newBalanceKopecks"] = new_balance return payload @router.post("/operations/{operation_id}/savings-transfer") def savings_transfer(operation_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict: from app.ledger import savings operation = savings.convert_expense_to_savings( db, space_id=context.space.id, user=context.user, operation_id=operation_id ) return operation_json(db, operation) @router.post("/operations/{operation_id}/savings-transfer/revert") def savings_transfer_revert( operation_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf ) -> dict: from app.ledger import savings operation = savings.revert_savings_conversion( db, space_id=context.space.id, user=context.user, operation_id=operation_id ) return operation_json(db, operation)