diff --git a/cateory-tree-sketch.json b/cateory-tree-sketch.json new file mode 100644 index 0000000..24c7f3a --- /dev/null +++ b/cateory-tree-sketch.json @@ -0,0 +1,27 @@ +{ + "month": "2026-10", + "categories": [ + { + "category": "super-category-1", + "total_amount": 100, + "total_budget": 120, + "sub_categories": [ + { + "category": "category-1", + "total_amount": 60, + "total_budget": 50, + "sub_categories": [], + "transactions": [] + }, + { + "category": "category-2", + "total_amount": 40, + "total_budget": 60, + "sub_categories": [], + "transactions": [] + } + ], + "transactions": [] + } + ] +} diff --git a/src/models/category.py b/src/models/category.py index 4c615a5..f11b709 100644 --- a/src/models/category.py +++ b/src/models/category.py @@ -18,7 +18,8 @@ from sqlalchemy import ( from sqlalchemy.orm import Mapped, Session, mapped_column, relationship from src.db_utils import upsert -from src.models.common import BaseModelDb, ExportBaseModel, hashify +from src.models.common import Amount, AmountPos, BaseModelDb, ExportBaseModel, hashify +from src.models.transactions import Transaction class UTCDateTime(TypeDecorator[datetime]): @@ -75,6 +76,9 @@ class Category(ExportBaseModel): def __repr__(self): return f'Category.from_str("{self.__str__()}")' + def get_budget(self, date_valid: date | None) -> float: + ... + class CategoryDb(BaseModelDb): __tablename__ = "category" @@ -187,7 +191,7 @@ def upsert_category( class CategoryBudget(BaseModel): category: Category - amount_eur: Decimal = Field(gt=0, decimal_places=2) + amount_eur: AmountPos valid_from: date | None = Field( default=None, description="Start of validity. None means unbounded" ) @@ -228,3 +232,31 @@ class CategoryBudgetDb(BaseModelDb): # relationship category = relationship("CategoryDb", back_populates="budgets") + + +class CategoryTree(BaseModel): + category: Category + total_amount: float + total_budget: float + sub_categories: list[ CategoryTree ] | None + transactions: list[Transaction] | None + + def get_total_amount(self) -> float: + result = 0.0 + if self.transactions: + sum(transaction.amount for transaction in self.transactions ) + if self.sub_categories: + sum(sub_category.get_total_amount() for sub_category in self.sub_categories) + return result + + def get_total_budget(self, date_valid: date) -> float: + result = 0.0 + result += self.category.get_budget(date_valid) + if self.sub_categories: + result += sum(category.category.get_budget(date_valid) for category in self.sub_categories) + return result + +def build_category_tree(transactions: list[Transaction]) -> list[CategoryTree]: + result = [] + for transaction in transactions: + pass diff --git a/src/models/common.py b/src/models/common.py index 3edd0c7..094ad5f 100644 --- a/src/models/common.py +++ b/src/models/common.py @@ -2,8 +2,9 @@ import hashlib from datetime import date from decimal import Decimal from enum import Enum +from typing import Annotated -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import Date, Numeric, String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -39,12 +40,15 @@ def date2str(value: date | None, default="--", date_format="%Y-%m-%d") -> str: return value.strftime(date_format) return default +type AmountPos = Annotated[Decimal, Field(decimal_places=2, gt=0)] +type Amount = Annotated[Decimal, Field(decimal_places=2)] -def _parse_decimal_german(decimal_str: str) -> Decimal: + +def _parse_decimal_german(decimal_str: str) -> Amount: return Decimal(decimal_str.replace(".", "").replace(",", ".")) -def decimal2str(value: Decimal) -> str: +def decimal2str(value: Amount) -> str: value = Decimal(f"{value:.2f}") int_part, _, frac = str(value).partition(".") groups: list[str] = [] @@ -60,6 +64,7 @@ type Csv2Dict = list[Csv2DictRow] type CsV2DictMulti = dict[str, Csv2Dict] + class ExportBaseModel(BaseModel): @classmethod diff --git a/src/models/transactions.py b/src/models/transactions.py index 23fe48b..8ed9268 100644 --- a/src/models/transactions.py +++ b/src/models/transactions.py @@ -5,7 +5,7 @@ from pydantic import Field, computed_field from sqlalchemy import Date, ForeignKey, Index, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship -from src.models.common import ExportBaseModel, IngestdbBase, date2str, hashify +from src.models.common import Amount, ExportBaseModel, IngestdbBase, date2str, hashify class Transaction(ExportBaseModel): @@ -22,12 +22,13 @@ class Transaction(ExportBaseModel): account_iban: str account_currency: str date_valid: date - amount: Decimal = Field(description="Signed amount; negative = money out") + amount: Amount amount_currency: str date_book: date | None = None text: str | None = None other_name: str | None = None other_iban: str | None = None + # TODO: The Pydantic model should hold a category object, NOT the category id which is owned by the DB category_id: int | None = None @computed_field # type: ignore[prop-decorator]