"""Pydantic v2 schemas of the finance module (datamodel-finance.md ยง9). Amounts travel as JSON numbers with 2 decimals (`Money`), dates as `YYYY-MM-DD`, months as `YYYY-MM`. """ import uuid from datetime import date from decimal import Decimal from typing import Annotated, Any, Literal from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, field_validator from app.core.timeutils import StoredUtcDatetime from app.modules.finance.enums import ( AccountKind, CategoryKind, CategorySource, ImportStatus, SourceKind, ) Money = Annotated[ Decimal, PlainSerializer( lambda v: float(round(Decimal(v), 2)), return_type=float, when_used="json" ), ] MAX_BULK_IDS = 500 class ORMModel(BaseModel): model_config = ConfigDict(from_attributes=True) # --------------------------------------------------------------------------- # Accounts # --------------------------------------------------------------------------- class AccountCreate(BaseModel): name: str = Field(min_length=1, max_length=100) kind: AccountKind = AccountKind.CHECKING currency: str = Field(default="EUR", min_length=3, max_length=3) institution: str | None = Field(default=None, max_length=100) iban_masked: str | None = Field(default=None, max_length=34) initial_balance: Money = Decimal("0.00") class AccountUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=100) kind: AccountKind | None = None currency: str | None = Field(default=None, min_length=3, max_length=3) institution: str | None = Field(default=None, max_length=100) iban_masked: str | None = Field(default=None, max_length=34) initial_balance: Money | None = None is_archived: bool | None = None class AccountRead(ORMModel): id: uuid.UUID name: str kind: AccountKind currency: str institution: str | None iban_masked: str | None initial_balance: Money is_archived: bool balance: Money transaction_count: int last_transaction_date: date | None = None # --------------------------------------------------------------------------- # Categories # --------------------------------------------------------------------------- class CategoryCreate(BaseModel): name: str = Field(min_length=1, max_length=80) parent_id: uuid.UUID | None = None icon: str | None = Field(default=None, max_length=50) color: str | None = Field(default=None, max_length=7) kind: CategoryKind = CategoryKind.EXPENSE sort_order: int = 0 class CategoryUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=80) parent_id: uuid.UUID | None = None icon: str | None = Field(default=None, max_length=50) color: str | None = Field(default=None, max_length=7) sort_order: int | None = None class CategoryRead(ORMModel): id: uuid.UUID parent_id: uuid.UUID | None name: str icon: str | None color: str | None kind: CategoryKind is_system: bool sort_order: int transaction_count: int = 0 children: list["CategoryRead"] = Field(default_factory=list) # --------------------------------------------------------------------------- # Source profiles # --------------------------------------------------------------------------- class SourceProfileCreate(BaseModel): name: str = Field(min_length=1, max_length=100) kind: SourceKind config: dict[str, Any] = Field(default_factory=dict) class SourceProfileUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=100) config: dict[str, Any] | None = None class SourceProfileRead(ORMModel): id: uuid.UUID name: str kind: SourceKind config: dict[str, Any] is_builtin: bool # --------------------------------------------------------------------------- # Transactions # --------------------------------------------------------------------------- class TransactionRead(BaseModel): id: uuid.UUID account_id: uuid.UUID account_name: str | None = None booked_date: date value_date: date | None = None amount: Money currency: str label_raw: str label_clean: str counterparty: str | None = None category_id: uuid.UUID | None = None category_name: str | None = None category_color: str | None = None category_source: CategorySource | None = None notes: str | None = None transfer_group_id: uuid.UUID | None = None external_id: str | None = None import_run_id: int | None = None class TransactionCreate(BaseModel): account_id: uuid.UUID booked_date: date amount: Money label_clean: str = Field(min_length=1) value_date: date | None = None currency: str | None = Field(default=None, min_length=3, max_length=3) category_id: uuid.UUID | None = None counterparty: str | None = Field(default=None, max_length=150) notes: str | None = None class TransactionUpdate(BaseModel): model_config = ConfigDict(extra="forbid") label_clean: str | None = Field(default=None, min_length=1) counterparty: str | None = Field(default=None, max_length=150) category_id: uuid.UUID | None = None notes: str | None = None booked_date: date | None = None amount: Money | None = None class BulkCategorizeRequest(BaseModel): transaction_ids: list[uuid.UUID] = Field(min_length=1, max_length=MAX_BULK_IDS) category_id: uuid.UUID | None = None class BulkCategorizeResponse(BaseModel): updated: int # --------------------------------------------------------------------------- # Rules # --------------------------------------------------------------------------- class RuleCreate(BaseModel): name: str = Field(min_length=1, max_length=100) priority: int = 100 enabled: bool = True stop: bool = True matchers: dict[str, Any] actions: dict[str, Any] class RuleUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=100) priority: int | None = None enabled: bool | None = None stop: bool | None = None matchers: dict[str, Any] | None = None actions: dict[str, Any] | None = None class RuleRead(ORMModel): id: uuid.UUID name: str priority: int enabled: bool stop: bool matchers: dict[str, Any] actions: dict[str, Any] hit_count: int # Stored instants: aware on PostgreSQL, naive on SQLite -> always UTC "Z". last_applied_at: StoredUtcDatetime | None class RuleReorderRequest(BaseModel): ordered_ids: list[uuid.UUID] = Field(min_length=1) class RuleApplyRequest(BaseModel): scope: Literal["uncategorized", "all_non_manual", "all"] = "uncategorized" date_from: date | None = None date_to: date | None = None account_id: uuid.UUID | None = None rule_id: uuid.UUID | None = None dry_run: bool = False force: bool = False class RuleApplyByRule(BaseModel): rule_id: uuid.UUID name: str matched: int class RuleApplyResponse(BaseModel): scanned: int matched: int updated: int dry_run: bool by_rule: list[RuleApplyByRule] class RulePreviewRequest(BaseModel): matchers: dict[str, Any] class RulePreviewResponse(BaseModel): items: list[TransactionRead] total_matched: int # --------------------------------------------------------------------------- # Budgets # --------------------------------------------------------------------------- def _first_of_month(value: date | None) -> date | None: if value is None: return None return value.replace(day=1) class BudgetCreate(BaseModel): category_id: uuid.UUID monthly_amount: Money = Field(gt=0) start_month: date end_month: date | None = None @field_validator("start_month", "end_month") @classmethod def normalize_month(cls, value: date | None) -> date | None: return _first_of_month(value) class BudgetUpdate(BaseModel): monthly_amount: Money | None = Field(default=None, gt=0) end_month: date | None = None effective_from: date | None = None @field_validator("end_month", "effective_from") @classmethod def normalize_month(cls, value: date | None) -> date | None: return _first_of_month(value) class BudgetRead(ORMModel): id: uuid.UUID category_id: uuid.UUID category_name: str | None = None category_color: str | None = None monthly_amount: Money start_month: date end_month: date | None actual: Money = Decimal("0.00") remaining: Money = Decimal("0.00") progress_pct: float = 0.0 projected_eom: Money | None = None status: str = "ok" # --------------------------------------------------------------------------- # Imports # --------------------------------------------------------------------------- class NormalizedRowRead(ORMModel): booked_date: date value_date: date | None amount: Money currency: str label_raw: str counterparty: str | None external_id: str | None source_row_index: int class ImportPreviewResponse(BaseModel): rows_preview: list[NormalizedRowRead] rows_total: int rows_error: int rows_skipped_filtered: int would_skip_duplicates: int date_min: date | None date_max: date | None errors: list[dict[str, Any]] duplicate_file_of: int | None = None class ImportRunRead(ORMModel): id: uuid.UUID import_run_id: int account_id: uuid.UUID source_profile_id: uuid.UUID | None filename: str file_sha256: str status: ImportStatus # Stored instants: aware on PostgreSQL, naive on SQLite -> always UTC "Z". started_at: StoredUtcDatetime finished_at: StoredUtcDatetime | None stats: dict[str, Any] error_message: str | None # --------------------------------------------------------------------------- # Transfers # --------------------------------------------------------------------------- class TransferDetectRequest(BaseModel): date_from: date | None = None date_to: date | None = None class TransferDetectResponse(BaseModel): pairs_created: int class TransferLinkRequest(BaseModel): transaction_id_a: uuid.UUID transaction_id_b: uuid.UUID class TransferLinkResponse(BaseModel): transfer_group_id: uuid.UUID # --------------------------------------------------------------------------- # Stats (ECharts-ready) # --------------------------------------------------------------------------- class MonthlySeries(BaseModel): category_id: uuid.UUID | None name: str color: str data: list[Money] class MonthlyByCategoryResponse(BaseModel): months: list[str] series: list[MonthlySeries] totals: list[Money] class CashflowResponse(BaseModel): months: list[str] income: list[Money] expenses: list[Money] net: list[Money] cumulative_net: list[Money] class PeriodRead(BaseModel): from_: date = Field(alias="from") to: date model_config = ConfigDict(populate_by_name=True) class MerchantRead(BaseModel): merchant: str total: Money count: int average: Money category_name: str | None = None category_color: str | None = None class TopMerchantsResponse(BaseModel): period: PeriodRead items: list[MerchantRead] class RecurringItem(BaseModel): model_config = ConfigDict(from_attributes=True) merchant_key: str label_display: str category_id: uuid.UUID | None category_name: str | None periodicity: str occurrences: int average_amount: Money expected_amount: Money last_date: date next_date_predicted: date is_active: bool class RecurringResponse(BaseModel): items: list[RecurringItem] monthly_total_estimate: Money class BudgetProgressItem(BaseModel): budget_id: uuid.UUID category_id: uuid.UUID category_name: str category_color: str budget: Money actual: Money remaining: Money progress_pct: float projected_eom: Money | None status: str class BudgetProgressTotals(BaseModel): budget: Money actual: Money progress_pct: float class BudgetProgressResponse(BaseModel): month: str items: list[BudgetProgressItem] totals: BudgetProgressTotals class SankeyNode(BaseModel): name: str color: str class SankeyLink(BaseModel): source: str target: str value: Money class SankeyResponse(BaseModel): period: PeriodRead nodes: list[SankeyNode] links: list[SankeyLink] class DashboardResponse(BaseModel): month: str total_balance: Money accounts_count: int month_expenses: Money month_income: Money month_net: Money average_expenses_6m: Money budget_total: Money budget_actual: Money budget_progress_pct: float uncategorized_count: int