Compare commits

..
Author SHA1 Message Date
Mathias Schneider eccf81a5ed more tests and bugfixes
Tests / test (push) Successful in 10s
Tests / lint (push) Successful in 11s
2026-10-29 14:43:18 +01:00
mathias2784 73298b3e49 finalized helper functions for category trees; started writing tests
Tests / test (push) Successful in 11s
Tests / lint (push) Successful in 10s
2026-10-28 21:17:54 +01:00
mathias2784 e0c32319e8 draft: category tree building
Tests / test (push) Failing after 12s
Tests / lint (push) Failing after 11s
2026-10-28 07:12:31 +01:00
mathias2784 8a0c98e9bb todo comment (some more)
Tests / test (push) Successful in 11s
Tests / lint (push) Failing after 7s
2026-10-27 20:48:56 +01:00
mathias2784 5810f16901 todo comment
Tests / test (push) Successful in 11s
Tests / lint (push) Failing after 7s
2026-10-27 20:38:28 +01:00
mathias2784 6b2821ba96 first sketch; incomplete; WiP
Tests / test (push) Successful in 11s
Tests / lint (push) Failing after 8s
2026-10-27 20:35:49 +01:00
mathias2784 6babc560c7 Category overhaul (#20)
Tests / test (push) Successful in 16s
Tests / lint (push) Successful in 16s
Reviewed-on: #20
Co-authored-by: Mathias <mathias2784@web.de>
2026-10-27 18:08:10 +00:00
18 changed files with 817 additions and 50 deletions
+18 -5
View File
@@ -23,20 +23,29 @@ alembic upgrade head
```python ```python
from src.ingest.b4w import ingest_b4w from src.ingest.b4w import ingest_b4w
from src.db import get_session
from pathlib import Path from pathlib import Path
csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/b4w_export_dummy_01.csv") csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/b4w_export_dummy_01.csv")
ingest_b4w(csv_path) with get_session() as session:
ingest_b4w(csv_path, session)
``` ```
### Import comdirect Export ### Import comdirect Export
```python ```python
from src.ingest.comdirect import ingest_comdirect from src.ingest.comdirect import ingest_comdirect
from src.db import get_session
from pathlib import Path from pathlib import Path
csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/comdirect_giro_01.csv") csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/comdirect_giro_01.csv")
ingest_comdirect(csv_path) with get_session() as session:
ingest_comdirect(
session,
csv_path,
account_iban="DE89370400440532013000",
account_currency="EUR",
)
``` ```
### Set Category Budgets ### Set Category Budgets
@@ -45,6 +54,7 @@ ingest_comdirect(csv_path)
from src.models.category import Category, CategoryBudget from src.models.category import Category, CategoryBudget
from datetime import date from datetime import date
from src.domain_utils import set_category_budget from src.domain_utils import set_category_budget
from src.db import get_session
# Create category hierarchy # Create category hierarchy
cat = Category.from_str("Lebenshaltung:Essen:Supermarkt") cat = Category.from_str("Lebenshaltung:Essen:Supermarkt")
@@ -56,14 +66,17 @@ budget = CategoryBudget(
valid_from=date(2026, 10, 1), valid_from=date(2026, 10, 1),
valid_to=date(2026, 11, 30), valid_to=date(2026, 11, 30),
) )
set_category_budget(db_session, budget) with get_session() as session:
set_category_budget(session, budget)
session.commit()
# Budgets can also be unbounded (no dates specified) # Budgets can also be unbounded (no dates specified)
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=200.00, # Monthly average amount_eur=200.00, # Monthly average
) )
set_category_budget(db_session, budget) set_category_budget(session, budget)
session.commit()
``` ```
## Testing ## Testing
@@ -141,7 +154,7 @@ alembic upgrade head
pytest pytest
# Run with coverage report # Run with coverage report
pytest --cov=src --cov-report=html -c .coverage htmlcov pytest --cov=src --cov-report=html:htmlcov
``` ```
### Common Patterns ### Common Patterns
@@ -0,0 +1,23 @@
"""merge category validity and budget migration branches
Revision ID: c18ae67f4b2a
Revises: d9c6533f8a1b, fe23064de1c5
Create Date: 2026-10-27
"""
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "c18ae67f4b2a"
down_revision: str | Sequence[str] | None = ("d9c6533f8a1b", "fe23064de1c5")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,102 @@
"""add temporal validity to categories
Revision ID: d9c6533f8a1b
Revises: 78ec55b9e1d1
Create Date: 2026-10-25 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d9c6533f8a1b"
down_revision: str | Sequence[str] | None = "78ec55b9e1d1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("category") as batch_op:
batch_op.drop_constraint("uq_category_name_parent", type_="unique")
batch_op.add_column(
sa.Column(
"valid_from",
sa.DateTime(timezone=True),
nullable=True,
info={
"description": "UTC start of validity; NULL means valid since the indefinite past."
},
)
)
batch_op.add_column(
sa.Column(
"valid_to",
sa.DateTime(timezone=True),
nullable=True,
info={
"description": "UTC end of validity; NULL means the version is currently valid."
},
)
)
batch_op.add_column(
sa.Column(
"last_action",
sa.String(),
nullable=False,
server_default="created",
info={
"description": "Most recent lifecycle action recorded for this category version."
},
)
)
# Existing rows predate temporal tracking, so their start is unbounded.
op.execute("UPDATE category SET last_action = 'migrated'")
# Separate root and non-root indexes so NULL parent IDs are unique too.
op.create_index(
"uq_category_active_name_parent",
"category",
["name", "parent_id"],
unique=True,
sqlite_where=sa.text("valid_to IS NULL AND parent_id IS NOT NULL"),
postgresql_where=sa.text("valid_to IS NULL AND parent_id IS NOT NULL"),
)
op.create_index(
"uq_category_active_root_name",
"category",
["name"],
unique=True,
sqlite_where=sa.text("valid_to IS NULL AND parent_id IS NULL"),
postgresql_where=sa.text("valid_to IS NULL AND parent_id IS NULL"),
)
def downgrade() -> None:
bind = op.get_bind()
has_temporal_history = bind.execute(
sa.text(
"SELECT 1 FROM category "
"WHERE valid_from IS NOT NULL OR valid_to IS NOT NULL LIMIT 1"
)
).first()
if has_temporal_history:
raise RuntimeError(
"Cannot downgrade category validity while temporal history exists; "
"the downgrade would discard it."
)
op.drop_index("uq_category_active_root_name", table_name="category")
op.drop_index("uq_category_active_name_parent", table_name="category")
with op.batch_alter_table("category") as batch_op:
batch_op.drop_column("last_action")
batch_op.drop_column("valid_to")
batch_op.drop_column("valid_from")
batch_op.create_unique_constraint(
"uq_category_name_parent", ["name", "parent_id"]
)
+27
View File
@@ -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": []
}
]
}
+1 -5
View File
@@ -33,11 +33,7 @@ build-backend = "uv_build"
[tool.mypy] [tool.mypy]
explicit_package_bases = true explicit_package_bases = true
mypy_path = "." mypy_path = "."
# Ignore test file type errors (inline class definitions, float literals) exclude = ["^tests/"]
# See AGENTS.md for type checking notes
[[tool.mypy.overrides]]
module = ["tests.test_db_utils_update_path", "tests.test_domain_utils_budget_overlap"]
ignore_errors = true
[tool.ruff] [tool.ruff]
target-version = "py314" target-version = "py314"
+2 -1
View File
@@ -29,7 +29,8 @@ def find_category_db(session: Session, category: Category | None) -> CategoryDb
return ( return (
session.query(CategoryDb) session.query(CategoryDb)
.filter_by(name=node.name, parent_id=parent_id) .filter_by(name=node.name, parent_id=parent_id)
.first() .filter(CategoryDb.valid_to.is_(None))
.one_or_none()
) )
return find_existing(category) return find_existing(category)
+89 -1
View File
@@ -1,3 +1,4 @@
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -6,7 +7,7 @@ from src.db_utils import upsert
from src.ingest.common import _reader_raw from src.ingest.common import _reader_raw
from src.ingest.transactions import consolidate_transactions, map_b4w_to_transaction from src.ingest.transactions import consolidate_transactions, map_b4w_to_transaction
from src.models.b4w import B4wDb, B4wExport from src.models.b4w import B4wDb, B4wExport
from src.models.category import upsert_category from src.models.category import Category, CategoryDb, upsert_category
def reader_b4w(path: Path) -> list[B4wExport]: def reader_b4w(path: Path) -> list[B4wExport]:
@@ -14,6 +15,93 @@ def reader_b4w(path: Path) -> list[B4wExport]:
return list(map(B4wExport.model_validate, raw)) return list(map(B4wExport.model_validate, raw))
def _utc_datetime(value: datetime | None) -> datetime:
if value is None:
return datetime.now(UTC)
if value.tzinfo is None or value.utcoffset() is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _ensure_after_valid_from(category: CategoryDb, effective_at: datetime) -> None:
if category.valid_from is None:
return
valid_from = _utc_datetime(category.valid_from)
if effective_at <= valid_from:
raise ValueError("The action time must be later than valid_from")
def change_category(
session: Session,
category_id: int,
replacement: Category,
effective_at: datetime | None = None,
do_commit: bool = True,
) -> CategoryDb:
"""Close an active category version and start its replacement version."""
current = (
session.query(CategoryDb).filter_by(id=category_id, valid_to=None).one_or_none()
)
if current is None:
raise ValueError(f"No active category found with id {category_id}")
action_time = _utc_datetime(effective_at)
_ensure_after_valid_from(current, action_time)
parent = upsert_category(session, replacement.parent, do_commit=False)
parent_id = parent.id if parent else None
if current.name == replacement.name and current.parent_id == parent_id:
return current
replacement_active = (
session.query(CategoryDb)
.filter_by(name=replacement.name, parent_id=parent_id, valid_to=None)
.one_or_none()
)
current.valid_to = action_time
current.last_action = "changed"
session.flush()
if replacement_active is None:
replacement_active = CategoryDb(
name=replacement.name,
parent=parent,
valid_from=action_time,
valid_to=None,
last_action="changed",
)
session.add(replacement_active)
session.flush()
if do_commit:
session.commit()
return replacement_active
def retire_category(
session: Session,
category_id: int,
effective_at: datetime | None = None,
do_commit: bool = True,
) -> CategoryDb:
"""Close an active category version without creating a replacement."""
category = (
session.query(CategoryDb).filter_by(id=category_id, valid_to=None).one_or_none()
)
if category is None:
raise ValueError(f"No active category found with id {category_id}")
action_time = _utc_datetime(effective_at)
_ensure_after_valid_from(category, action_time)
category.valid_to = action_time
category.last_action = "retired"
session.flush()
if do_commit:
session.commit()
return category
def upsert_b4w( def upsert_b4w(
session: Session, data: B4wExport, do_update=False, do_commit=True session: Session, data: B4wExport, do_update=False, do_commit=True
) -> B4wDb | None: ) -> B4wDb | None:
+23 -4
View File
@@ -5,11 +5,21 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.models.b4w import B4wDb from src.models.b4w import B4wDb
from src.models.category import Category, CategoryDb
from src.models.comdirect import ComdirectGiroDb from src.models.comdirect import ComdirectGiroDb
from src.models.common import BaseModelDb from src.models.common import BaseModelDb
from src.models.transactions import Transaction, TransactionDb from src.models.transactions import Transaction, TransactionDb
def _category_from_db(category_db: CategoryDb | None) -> Category | None:
if category_db is None:
return None
return Category(
name=category_db.name,
parent=_category_from_db(category_db.parent),
)
def map_b4w_to_transaction(row: B4wDb) -> Transaction: def map_b4w_to_transaction(row: B4wDb) -> Transaction:
"""Project one staging row of ``import_b4w`` onto a canonical Transaction.""" """Project one staging row of ``import_b4w`` onto a canonical Transaction."""
return Transaction( return Transaction(
@@ -24,7 +34,7 @@ def map_b4w_to_transaction(row: B4wDb) -> Transaction:
text=row.text, text=row.text,
other_name=row.other_name, other_name=row.other_name,
other_iban=row.other_iban, other_iban=row.other_iban,
category_id=row.category_id, category=_category_from_db(row.category),
) )
@@ -44,7 +54,11 @@ def map_comdirect_giro_to_transaction(row: ComdirectGiroDb) -> Transaction:
def upsert_transaction( def upsert_transaction(
session: Session, transaction: Transaction, do_commit: bool = True session: Session,
transaction: Transaction,
*,
category_db: CategoryDb | None = None,
do_commit: bool = True,
) -> None: ) -> None:
"""Insert one canonical transaction, ignoring duplicates on ``hash_id``. """Insert one canonical transaction, ignoring duplicates on ``hash_id``.
@@ -65,7 +79,7 @@ def upsert_transaction(
"text": transaction.text, "text": transaction.text,
"other_name": transaction.other_name, "other_name": transaction.other_name,
"other_iban": transaction.other_iban, "other_iban": transaction.other_iban,
"category_id": transaction.category_id, "category_id": category_db.id if category_db is not None else None,
} }
stmt = sqlite_insert(TransactionDb).values(parameters) stmt = sqlite_insert(TransactionDb).values(parameters)
_ = session.execute(stmt.prefix_with("OR IGNORE")) _ = session.execute(stmt.prefix_with("OR IGNORE"))
@@ -92,6 +106,11 @@ def consolidate_transactions[StagingDbT: BaseModelDb](
transaction = mapper(staging_row) transaction = mapper(staging_row)
if transaction.hash_id in existing_hashes: if transaction.hash_id in existing_hashes:
continue continue
upsert_transaction(session, transaction, do_commit=False) upsert_transaction(
session,
transaction,
category_db=getattr(staging_row, "category", None),
do_commit=False,
)
if do_commit: if do_commit:
session.commit() session.commit()
+9 -2
View File
@@ -2,11 +2,16 @@ from datetime import date
from decimal import Decimal from decimal import Decimal
from pydantic import Field, computed_field, field_validator from pydantic import Field, computed_field, field_validator
from sqlalchemy import Date, ForeignKey, Integer, String from sqlalchemy import (
Date,
ForeignKey,
Integer,
String,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.constants import IBAN_PATTERN, Currency from src.constants import IBAN_PATTERN, Currency
from src.models.category import Category from src.models.category import Category, CategoryDb
from src.models.common import ( from src.models.common import (
ExportBaseModel, ExportBaseModel,
IngestdbBase, IngestdbBase,
@@ -15,6 +20,8 @@ from src.models.common import (
hashify, hashify,
) )
__all__ = ["B4wDb", "B4wExport", "Category", "CategoryDb"]
class B4wExport(ExportBaseModel): class B4wExport(ExportBaseModel):
id: int = Field(alias="Id1", description="internal B4w ID") id: int = Field(alias="Id1", description="internal B4w ID")
+122 -10
View File
@@ -1,13 +1,48 @@
from datetime import date from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Self from typing import Self
from pydantic import BaseModel, Field, computed_field, model_validator from pydantic import BaseModel, Field, computed_field, model_validator
from sqlalchemy import Date, ForeignKey, Numeric, String, UniqueConstraint from sqlalchemy import (
Date,
DateTime,
ForeignKey,
Index,
Numeric,
String,
TypeDecorator,
text,
)
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
from src.db_utils import upsert from src.db_utils import upsert
from src.models.common import BaseModelDb, ExportBaseModel, hashify from src.models.common import AmountPos, BaseModelDb, ExportBaseModel, hashify
class UTCDateTime(TypeDecorator[datetime]):
"""Store UTC timestamps and return timezone-aware UTC values on every dialect."""
impl = DateTime
cache_ok = True
def load_dialect_impl(self, dialect):
return dialect.type_descriptor(DateTime(timezone=True))
def process_bind_param(self, value: datetime | None, dialect):
if value is None:
return None
if value.tzinfo is None or value.utcoffset() is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def process_result_value(self, value: datetime | None, dialect):
if value is None:
return None
if value.tzinfo is None or value.utcoffset() is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
class Category(ExportBaseModel): class Category(ExportBaseModel):
@@ -34,17 +69,35 @@ class Category(ExportBaseModel):
else: else:
return f"{self.parent.to_str()}:{self.name}" return f"{self.parent.to_str()}:{self.name}"
def __str__(self): def __str__(self) -> str:
return self.to_str() return self.to_str() or ""
def __repr__(self): def __repr__(self):
return f'Category.from_str("{self.__str__()}")' return f'Category.from_str("{self.__str__()}")'
def get_budget(self, date_valid: date | None) -> float:
# TODO
return 0.0
class CategoryDb(BaseModelDb): class CategoryDb(BaseModelDb):
__tablename__ = "category" __tablename__ = "category"
__table_args__ = ( __table_args__ = (
UniqueConstraint("name", "parent_id", name="uq_category_name_parent"), Index(
"uq_category_active_name_parent",
"name",
"parent_id",
unique=True,
sqlite_where=text("valid_to IS NULL AND parent_id IS NOT NULL"),
postgresql_where=text("valid_to IS NULL AND parent_id IS NOT NULL"),
),
Index(
"uq_category_active_root_name",
"name",
unique=True,
sqlite_where=text("valid_to IS NULL AND parent_id IS NULL"),
postgresql_where=text("valid_to IS NULL AND parent_id IS NULL"),
),
) )
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
@@ -52,6 +105,29 @@ class CategoryDb(BaseModelDb):
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(
ForeignKey("category.id"), nullable=True ForeignKey("category.id"), nullable=True
) )
valid_from: Mapped[datetime | None] = mapped_column(
UTCDateTime(),
nullable=True,
info={
"description": "UTC start of validity; NULL means valid since the indefinite past."
},
)
valid_to: Mapped[datetime | None] = mapped_column(
UTCDateTime(),
nullable=True,
info={
"description": "UTC end of validity; NULL means the version is currently valid."
},
)
last_action: Mapped[str] = mapped_column(
String,
nullable=False,
default="created",
server_default="created",
info={
"description": "Most recent lifecycle action recorded for this category version."
},
)
# Relationships # Relationships
parent = relationship( parent = relationship(
@@ -69,11 +145,45 @@ def upsert_category(
parent = upsert_category(session, category.parent, do_commit=False) parent = upsert_category(session, category.parent, do_commit=False)
parameters = {"name": category.name, "parent": parent} parameters = {"name": category.name, "parent": parent}
matching_rows = session.query(CategoryDb).filter_by(
name=category.name, parent_id=parent.id if parent else None
)
active = matching_rows.filter(CategoryDb.valid_to.is_(None)).one_or_none()
if active is not None:
return active
historical = (
matching_rows.filter(CategoryDb.valid_to.is_not(None))
.order_by(CategoryDb.valid_to.desc())
.first()
)
valid_from = None
last_action = "created"
if historical is not None:
valid_from = datetime.now(UTC)
if historical.valid_to is not None:
previous_valid_to = historical.valid_to
if (
previous_valid_to.tzinfo is None
or previous_valid_to.utcoffset() is None
):
previous_valid_to = previous_valid_to.replace(tzinfo=UTC)
else:
previous_valid_to = previous_valid_to.astimezone(UTC)
valid_from = max(valid_from, previous_valid_to + timedelta(microseconds=1))
last_action = "changed"
parameters_new = {
**parameters,
"valid_from": valid_from,
"valid_to": None,
"last_action": last_action,
}
return upsert( return upsert(
session, session,
CategoryDb, CategoryDb,
parameters, {**parameters, "valid_to": None},
parameters, parameters_new,
do_update=False, do_update=False,
do_commit=do_commit, do_commit=do_commit,
) )
@@ -81,7 +191,7 @@ def upsert_category(
class CategoryBudget(BaseModel): class CategoryBudget(BaseModel):
category: Category category: Category
amount_eur: Decimal = Field(gt=0, decimal_places=2) amount_eur: AmountPos
valid_from: date | None = Field( valid_from: date | None = Field(
default=None, description="Start of validity. None means unbounded" default=None, description="Start of validity. None means unbounded"
) )
@@ -113,7 +223,9 @@ class CategoryBudgetDb(BaseModelDb):
__tablename__ = "category_budget" __tablename__ = "category_budget"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
category_id: Mapped[int] = mapped_column(ForeignKey("category.id"), nullable=False) category_id: Mapped[int] = mapped_column(
ForeignKey("category.id"), nullable=False, index=True
)
amount_eur: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) amount_eur: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
valid_from: Mapped[date | None] = mapped_column(Date, nullable=True, default=None) valid_from: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
valid_to: Mapped[date | None] = mapped_column(Date, nullable=True, default=None) valid_to: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
+85
View File
@@ -0,0 +1,85 @@
from datetime import date
from pydantic import BaseModel, computed_field
from src.models.category import Category
from src.models.transactions import Transaction
class CategoryTree(BaseModel):
category: Category | None
sub_categories: list[CategoryTree] | None = None
transactions: list[Transaction] | None = None
@computed_field # type: ignore[prop-decorator]
@property
def total_amount(self) -> float:
result = 0.0
if self.transactions:
result += sum(
float(transaction.amount) for transaction in self.transactions
)
if self.sub_categories:
result += sum(
float(sub_category.total_amount) for sub_category in self.sub_categories
)
return result
def total_budget(self, date_valid: date) -> float:
result = 0.0
if self.category is None:
return result
result += self.category.get_budget(date_valid)
if self.sub_categories:
result += sum(
sub_category.total_budget(date_valid)
for sub_category in self.sub_categories
)
return result
def __add__(self, other: CategoryTree):
if self.category != other.category:
return
sub_trees = []
seen = []
for sub_tree_1 in self.sub_categories or []:
new = sub_tree_1
for sub_tree_2 in other.sub_categories or []:
if sub_tree_1.category == sub_tree_2.category:
new += sub_tree_2
seen.append(sub_tree_2)
break
sub_trees.append(new)
sub_trees += [
sub_tree
for sub_tree in (other.sub_categories or [])
if sub_tree not in seen
]
transactions = ((self.transactions or []) + (other.transactions or [])) or None
return CategoryTree(
category=self.category,
sub_categories=list(sub_trees),
transactions=transactions,
)
def build_category_tree(transactions: list[Transaction]) -> list[CategoryTree]:
results: dict[str, CategoryTree] = {}
for branch in map(_transaction_to_category_branch, transactions):
key = str(branch.category) if branch.category else ""
results[key] = (results[key] + branch) if key in results else branch
return list(results.values())
def _transaction_to_category_branch(transaction: Transaction) -> CategoryTree:
category = transaction.category
result = CategoryTree(category=category, transactions=[transaction])
if not category:
return result
while category.parent is not None:
category = category.parent
result = CategoryTree(category=category, sub_categories=[result])
return result
+8 -3
View File
@@ -2,8 +2,9 @@ import hashlib
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from enum import Enum 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 import Date, Numeric, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -40,11 +41,15 @@ def date2str(value: date | None, default="--", date_format="%Y-%m-%d") -> str:
return default return default
def _parse_decimal_german(decimal_str: str) -> Decimal: 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) -> Amount:
return Decimal(decimal_str.replace(".", "").replace(",", ".")) return Decimal(decimal_str.replace(".", "").replace(",", "."))
def decimal2str(value: Decimal) -> str: def decimal2str(value: Amount) -> str:
value = Decimal(f"{value:.2f}") value = Decimal(f"{value:.2f}")
int_part, _, frac = str(value).partition(".") int_part, _, frac = str(value).partition(".")
groups: list[str] = [] groups: list[str] = []
+5 -5
View File
@@ -1,11 +1,11 @@
from datetime import date from datetime import date
from decimal import Decimal
from pydantic import Field, computed_field from pydantic import Field, computed_field
from sqlalchemy import Date, ForeignKey, Index, Integer, String, UniqueConstraint from sqlalchemy import Date, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.common import ExportBaseModel, IngestdbBase, date2str, hashify from src.models.category import Category
from src.models.common import Amount, ExportBaseModel, IngestdbBase, date2str, hashify
class Transaction(ExportBaseModel): class Transaction(ExportBaseModel):
@@ -22,13 +22,13 @@ class Transaction(ExportBaseModel):
account_iban: str account_iban: str
account_currency: str account_currency: str
date_valid: date date_valid: date
amount: Decimal = Field(description="Signed amount; negative = money out") amount: Amount
amount_currency: str amount_currency: str
date_book: date | None = None date_book: date | None = None
text: str | None = None text: str | None = None
other_name: str | None = None other_name: str | None = None
other_iban: str | None = None other_iban: str | None = None
category_id: int | None = None category: Category | None = None
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
@@ -55,7 +55,7 @@ class Transaction(ExportBaseModel):
"text": f"{self.text or ''}", "text": f"{self.text or ''}",
"other_name": f"{self.other_name or ''}", "other_name": f"{self.other_name or ''}",
"other_iban": f"{self.other_iban or ''}", "other_iban": f"{self.other_iban or ''}",
"category_id": f"{self.category_id}", "category": (self.category.to_str() if self.category else "") or "",
} }
+17 -6
View File
@@ -1,4 +1,5 @@
from datetime import date from datetime import date
from decimal import Decimal
import pytest import pytest
@@ -12,24 +13,30 @@ class TestCategoryComputedFields:
def test_hash_id_computed_field_simple(self): def test_hash_id_computed_field_simple(self):
"""Test hash_id for single-level category.""" """Test hash_id for single-level category."""
c = Category.from_str("single") c = Category.from_str("single")
assert c
assert hasattr(c, "hash_id") assert hasattr(c, "hash_id")
assert isinstance(c.hash_id, str) assert isinstance(c.hash_id, str)
# Hash should be deterministic for same inputs # Hash should be deterministic for same inputs
c2 = Category.from_str("single") c2 = Category.from_str("single")
assert c2
assert c.hash_id == c2.hash_id assert c.hash_id == c2.hash_id
def test_hash_id_computed_field_nested(self): def test_hash_id_computed_field_nested(self):
"""Test hash_id for multi-level category hierarchy.""" """Test hash_id for multi-level category hierarchy."""
c1 = Category.from_str("level1:level2:level3") c1 = Category.from_str("level1:level2:level3")
assert c1
assert hasattr(c1, "hash_id") assert hasattr(c1, "hash_id")
c2 = Category.from_str("level1:level2:level3") c2 = Category.from_str("level1:level2:level3")
assert c2
assert c1.hash_id == c2.hash_id assert c1.hash_id == c2.hash_id
def test_hash_id_different_hierarchy(self): def test_hash_id_different_hierarchy(self):
"""Test that different hierarchies have different hash_ids.""" """Test that different hierarchies have different hash_ids."""
c1 = Category.from_str("A:B") c1 = Category.from_str("A:B")
c2 = Category.from_str("A:C") c2 = Category.from_str("A:C")
assert c1
assert c2
# Different last level -> different hash_id # Different last level -> different hash_id
assert c1.hash_id != c2.hash_id assert c1.hash_id != c2.hash_id
@@ -40,22 +47,26 @@ class TestCategoryComputedFields:
# Create another with same name but no parent # Create another with same name but no parent
orphan = Category.from_str("Budget") orphan = Category.from_str("Budget")
assert orphan
assert cat1.hash_id != orphan.hash_id assert cat1.hash_id != orphan.hash_id
def test_to_str_single_level(self): def test_to_str_single_level(self):
"""Test to_str() for single-level category.""" """Test to_str() for single-level category."""
c = Category.from_str("single") c = Category.from_str("single")
assert c
assert c.to_str() == "single" assert c.to_str() == "single"
def test_to_str_nested(self): def test_to_str_nested(self):
"""Test to_str() for nested category.""" """Test to_str() for nested category."""
c = Category.from_str("parent:child:grandchild") c = Category.from_str("parent:child:grandchild")
assert c
assert c.to_str() == "parent:child:grandchild" assert c.to_str() == "parent:child:grandchild"
def test_str_uses_to_str(self): def test_str_uses_to_str(self):
"""Test that __str__ delegates to to_str().""" """Test that __str__ delegates to to_str()."""
c = Category.from_str("A:B:C") c = Category.from_str("A:B:C")
assert c
assert str(c) == c.to_str() == "A:B:C" assert str(c) == c.to_str() == "A:B:C"
def test_repr_simple(self): def test_repr_simple(self):
@@ -79,7 +90,7 @@ class TestCategoryBudgetValidation:
cat = CategoryFactory.build() cat = CategoryFactory.build()
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=100.00, amount_eur=Decimal("100.00"),
) )
assert budget.valid_from is None assert budget.valid_from is None
assert budget.valid_to is None assert budget.valid_to is None
@@ -90,7 +101,7 @@ class TestCategoryBudgetValidation:
cat = CategoryFactory.build() cat = CategoryFactory.build()
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=100.00, amount_eur=Decimal("100.00"),
valid_from=date(2026, 10, 1), valid_from=date(2026, 10, 1),
valid_to=date(2026, 10, 31), valid_to=date(2026, 10, 31),
) )
@@ -105,7 +116,7 @@ class TestCategoryBudgetValidation:
with pytest.raises(ValueError) as exc_info: with pytest.raises(ValueError) as exc_info:
CategoryBudget( CategoryBudget(
category=cat, category=cat,
amount_eur=100.00, amount_eur=Decimal("100.00"),
valid_from=date(2026, 10, 31), valid_from=date(2026, 10, 31),
valid_to=date(2026, 10, 1), valid_to=date(2026, 10, 1),
) )
@@ -120,7 +131,7 @@ class TestCategoryBudgetValidation:
cat = CategoryFactory.build() cat = CategoryFactory.build()
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=100.00, amount_eur=Decimal("100.00"),
valid_from=date(2026, 10, 1), valid_from=date(2026, 10, 1),
valid_to=date(2026, 10, 1), valid_to=date(2026, 10, 1),
) )
@@ -133,7 +144,7 @@ class TestCategoryBudgetValidation:
cat = CategoryFactory.build() cat = CategoryFactory.build()
budget = CategoryBudget( budget = CategoryBudget(
category=cat, category=cat,
amount_eur=150.75, amount_eur=Decimal("150.75"),
valid_from=date(2026, 9, 1), valid_from=date(2026, 9, 1),
valid_to=date(2026, 9, 30), valid_to=date(2026, 9, 30),
) )
@@ -142,7 +153,7 @@ class TestCategoryBudgetValidation:
try: try:
CategoryBudget( CategoryBudget(
category=cat, category=cat,
amount_eur=200.00, amount_eur=Decimal("200.00"),
valid_from=date(2026, 12, 31), valid_from=date(2026, 12, 31),
valid_to=date(2026, 12, 30), valid_to=date(2026, 12, 30),
) )
+116
View File
@@ -0,0 +1,116 @@
from src.models.category import Category
from src.models.category_tree import (
CategoryTree,
_transaction_to_category_branch,
build_category_tree,
)
from src.models.transactions import Transaction
from tests.factories import TransactionFactory
def test_transaction_to_category_branch_simple():
transaction = TransactionFactory.build(category=Category.from_str("dummy category"))
result = _transaction_to_category_branch(transaction)
assert isinstance(result, CategoryTree)
assert result.category
assert result.category.name == "dummy category"
assert result.transactions
assert isinstance(result.transactions, list)
assert len(result.transactions) == 1
assert isinstance(result.transactions[0], Transaction)
def test_transaction_to_category_branch_two_category_levels():
transaction = TransactionFactory.build(category=Category.from_str("father:son"))
result = _transaction_to_category_branch(transaction)
assert isinstance(result, CategoryTree)
assert result.category
assert result.category.name == "father"
assert result.transactions is None
assert result.sub_categories
assert isinstance(result.sub_categories, list)
assert len(result.sub_categories) == 1
assert isinstance(result.sub_categories[0], CategoryTree)
assert isinstance(result.sub_categories[0].category, Category)
assert result.sub_categories[0].category.name == "son"
assert result.sub_categories[0].transactions
assert isinstance(result.sub_categories[0].transactions, list)
assert len(result.sub_categories[0].transactions) == 1
assert isinstance(result.sub_categories[0].transactions[0], Transaction)
assert result.sub_categories[0].transactions[0] == transaction
def test_transaction_to_category_branch_no_category():
transaction = TransactionFactory.build(category=None)
result = _transaction_to_category_branch(transaction)
assert result.category is None
assert result.transactions
assert isinstance(result.transactions, list)
assert len(result.transactions) == 1
assert result.transactions[0] == transaction
def test_category_tree_add():
category_tree_1 = CategoryTree(
category=Category.from_str("super"),
sub_categories=[
CategoryTree(
category=Category.from_str("super:sub1"),
transactions=[TransactionFactory.build(amount=10)],
),
CategoryTree(
category=Category.from_str("super:sub2"),
transactions=[TransactionFactory.build(amount=30)],
),
],
)
category_tree_2 = CategoryTree(
category=Category.from_str("super"),
sub_categories=[
CategoryTree(
category=Category.from_str("super:sub2"),
transactions=[TransactionFactory.build(amount=20)],
),
CategoryTree(
category=Category.from_str("super:sub3"),
transactions=[TransactionFactory.build(amount=100)],
),
],
)
result = category_tree_1 + category_tree_2
assert result
assert result.sub_categories
assert isinstance(result.sub_categories, list)
assert len(result.sub_categories) == 3
assert all(s.category for s in result.sub_categories)
assert sorted([s.category.name for s in result.sub_categories]) == [
"sub1",
"sub2",
"sub3",
]
assert result.total_amount == 160.0
def test_build_category_tree():
transactions = [
TransactionFactory.build(category=Category.from_str("super1:sub1"), amount=10),
TransactionFactory.build(category=Category.from_str("super1:sub2"), amount=13),
TransactionFactory.build(
category=Category.from_str("super1:sub2:sub2_sub1"), amount=104
),
TransactionFactory.build(
category=Category.from_str("super1:sub2:sub2_sub1"), amount=109
),
TransactionFactory.build(category=Category.from_str("super2"), amount=5000),
]
result = build_category_tree(transactions)
assert result
assert isinstance(result, list)
assert len(result) == 2
assert isinstance(result[0], CategoryTree)
assert result[0].category
assert result[0].category.name == "super1"
assert result[0].transactions is None
assert result[0].sub_categories
assert isinstance(result[0].sub_categories, list)
assert len(result[0].sub_categories) == 2
+29 -2
View File
@@ -1,13 +1,13 @@
"""Comprehensive tests for set_category_budget overlap handling.""" """Comprehensive tests for set_category_budget overlap handling."""
from datetime import date from datetime import UTC, date, datetime
import pytest import pytest
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.domain_utils import find_category_db, set_category_budget from src.domain_utils import find_category_db, set_category_budget
from src.models import Category, CategoryBudget, CategoryBudgetDb from src.models import Category, CategoryBudget, CategoryBudgetDb
from src.models.category import upsert_category from src.models.category import CategoryDb, upsert_category
from tests.factories import CategoryFactory from tests.factories import CategoryFactory
# NOTE: MYPY errors suppressed for test fixtures - see AGENTS.md for type checking notes. # NOTE: MYPY errors suppressed for test fixtures - see AGENTS.md for type checking notes.
@@ -615,6 +615,33 @@ class TestFindCategoryDb:
result = find_category_db(db_session, Category(name="NonExistent")) result = find_category_db(db_session, Category(name="NonExistent"))
assert result is None assert result is None
def test_find_reintroduced_category_returns_active_version(
self, db_session: Session
):
category = Category(name="Reintroduced")
historical = CategoryDb(
name=category.name,
valid_to=datetime(2026, 1, 1, tzinfo=UTC),
last_action="retired",
)
db_session.add(historical)
db_session.commit()
active = upsert_category(db_session, category, do_commit=False)
assert active is not None
db_session.commit()
result = find_category_db(db_session, category)
assert result is not None
assert result.id == active.id
set_category_budget(
db_session,
CategoryBudget(category=category, amount_eur=100.00),
)
budget = db_session.query(CategoryBudgetDb).one()
assert budget.category_id == active.id
class TestSetCategoryBudgetErrorHandling: class TestSetCategoryBudgetErrorHandling:
"""Test error handling in set_category_budget.""" """Test error handling in set_category_budget."""
+132
View File
@@ -0,0 +1,132 @@
from datetime import UTC, datetime
import pytest
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.ingest.b4w import change_category, retire_category, upsert_category
from src.models import Category
from src.models.b4w import CategoryDb
def test_upsert_category_returns_orm_object(db_session: Session):
result = upsert_category(
db_session,
Category.from_str("dummy_parent_1:dummy_parent_2:dummy"),
do_commit=False,
)
assert result
assert isinstance(result, CategoryDb)
assert str(result.name) == "dummy"
assert str(result.parent.name) == "dummy_parent_2"
assert str(result.parent.parent.name) == "dummy_parent_1"
assert result.valid_from is None
assert result.valid_to is None
assert result.last_action == "created"
assert all(
CategoryDb.__table__.c[name].info.get("description")
for name in ("valid_from", "valid_to", "last_action")
)
def test_upsert_category_writes_new_to_db(db_session: Session):
upsert_category(
db_session,
Category.from_str("grand_father:father:new_category"),
do_commit=True,
)
result = db_session.execute(
select(CategoryDb).filter_by(name="new_category")
).scalar_one()
assert result
assert str(result.name) == "new_category"
assert str(result.parent.name) == "father"
assert str(result.parent.parent.name) == "grand_father"
def test_upsert_category_skips_existing(db_session: Session):
for _ in range(3):
upsert_category(
db_session,
Category.from_str("grand_father:father:try_again_and_again"),
do_commit=True,
)
result = db_session.execute(
select(CategoryDb).filter_by(name="try_again_and_again")
).all()
assert len(result) == 1
def test_change_category_closes_old_version_and_starts_new_one(
db_session: Session,
):
original = upsert_category(
db_session, Category.from_str("old_category"), do_commit=False
)
assert original is not None
effective_at = datetime(2026, 10, 16, 12, tzinfo=UTC)
replacement = change_category(
db_session,
original.id,
Category.from_str("new_category"),
effective_at=effective_at,
do_commit=False,
)
assert original.valid_from is None
assert original.valid_to == effective_at
assert original.last_action == "changed"
assert replacement.name == "new_category"
assert replacement.valid_from == effective_at
assert replacement.valid_to is None
assert replacement.last_action == "changed"
db_session.expire(original)
db_session.expire(replacement)
assert original.valid_to == effective_at
assert replacement.valid_from == effective_at
# A historical name can be introduced again as a new active version.
reintroduced = upsert_category(
db_session, Category.from_str("old_category"), do_commit=False
)
assert reintroduced is not None
assert reintroduced.id != original.id
assert reintroduced.valid_from is not None
assert reintroduced.valid_from > original.valid_to
assert reintroduced.last_action == "changed"
def test_retire_category_closes_active_version(db_session: Session):
category = upsert_category(
db_session, Category.from_str("category_to_retire"), do_commit=False
)
assert category is not None
effective_at = datetime(2026, 10, 16, 12, tzinfo=UTC)
retired = retire_category(
db_session, category.id, effective_at=effective_at, do_commit=False
)
assert retired.valid_to == effective_at
assert retired.last_action == "retired"
assert (
db_session.query(CategoryDb)
.filter_by(name="category_to_retire", valid_to=None)
.one_or_none()
is None
)
def test_active_root_categories_are_unique(db_session: Session):
db_session.add_all(
[
CategoryDb(name="duplicate_root"),
CategoryDb(name="duplicate_root"),
]
)
with pytest.raises(IntegrityError):
db_session.flush()
+4 -1
View File
@@ -101,7 +101,7 @@ def test_map_comdirect_giro_to_transaction():
assert transaction.amount == Decimal("32.43") assert transaction.amount == Decimal("32.43")
assert transaction.other_name is None assert transaction.other_name is None
assert transaction.other_iban is None assert transaction.other_iban is None
assert transaction.category_id is None assert transaction.category is None
def test_consolidate_b4w_signed_amount_and_category(db_session: Session): def test_consolidate_b4w_signed_amount_and_category(db_session: Session):
@@ -125,6 +125,9 @@ def test_consolidate_b4w_signed_amount_and_category(db_session: Session):
assert transaction.amount == Decimal("-50.00") assert transaction.amount == Decimal("-50.00")
assert transaction.category_id == staging.category_id assert transaction.category_id == staging.category_id
assert transaction.text == "Supermarket" assert transaction.text == "Supermarket"
mapped = map_b4w_to_transaction(staging)
assert mapped.category is not None
assert mapped.category.to_str() == export.category.to_str()
def test_ingest_b4w_e2e_idempotent(db_session: Session): def test_ingest_b4w_e2e_idempotent(db_session: Session):