Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eccf81a5ed | ||
|
|
73298b3e49 | ||
|
|
e0c32319e8 | ||
|
|
8a0c98e9bb | ||
|
|
5810f16901 | ||
|
|
6b2821ba96 | ||
|
|
6babc560c7 |
@@ -23,20 +23,29 @@ alembic upgrade head
|
||||
|
||||
```python
|
||||
from src.ingest.b4w import ingest_b4w
|
||||
from src.db import get_session
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
```python
|
||||
from src.ingest.comdirect import ingest_comdirect
|
||||
from src.db import get_session
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
@@ -45,6 +54,7 @@ ingest_comdirect(csv_path)
|
||||
from src.models.category import Category, CategoryBudget
|
||||
from datetime import date
|
||||
from src.domain_utils import set_category_budget
|
||||
from src.db import get_session
|
||||
|
||||
# Create category hierarchy
|
||||
cat = Category.from_str("Lebenshaltung:Essen:Supermarkt")
|
||||
@@ -56,14 +66,17 @@ budget = CategoryBudget(
|
||||
valid_from=date(2026, 10, 1),
|
||||
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)
|
||||
budget = CategoryBudget(
|
||||
# Budgets can also be unbounded (no dates specified)
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=200.00, # Monthly average
|
||||
)
|
||||
set_category_budget(db_session, budget)
|
||||
)
|
||||
set_category_budget(session, budget)
|
||||
session.commit()
|
||||
```
|
||||
|
||||
## Testing
|
||||
@@ -141,7 +154,7 @@ alembic upgrade head
|
||||
pytest
|
||||
|
||||
# Run with coverage report
|
||||
pytest --cov=src --cov-report=html -c .coverage htmlcov
|
||||
pytest --cov=src --cov-report=html:htmlcov
|
||||
```
|
||||
|
||||
### 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"]
|
||||
)
|
||||
@@ -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
@@ -33,11 +33,7 @@ build-backend = "uv_build"
|
||||
[tool.mypy]
|
||||
explicit_package_bases = true
|
||||
mypy_path = "."
|
||||
# Ignore test file type errors (inline class definitions, float literals)
|
||||
# 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
|
||||
exclude = ["^tests/"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@ def find_category_db(session: Session, category: Category | None) -> CategoryDb
|
||||
return (
|
||||
session.query(CategoryDb)
|
||||
.filter_by(name=node.name, parent_id=parent_id)
|
||||
.first()
|
||||
.filter(CategoryDb.valid_to.is_(None))
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
return find_existing(category)
|
||||
|
||||
+89
-1
@@ -1,3 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
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.transactions import consolidate_transactions, map_b4w_to_transaction
|
||||
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]:
|
||||
@@ -14,6 +15,93 @@ def reader_b4w(path: Path) -> list[B4wExport]:
|
||||
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(
|
||||
session: Session, data: B4wExport, do_update=False, do_commit=True
|
||||
) -> B4wDb | None:
|
||||
|
||||
@@ -5,11 +5,21 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.b4w import B4wDb
|
||||
from src.models.category import Category, CategoryDb
|
||||
from src.models.comdirect import ComdirectGiroDb
|
||||
from src.models.common import BaseModelDb
|
||||
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:
|
||||
"""Project one staging row of ``import_b4w`` onto a canonical Transaction."""
|
||||
return Transaction(
|
||||
@@ -24,7 +34,7 @@ def map_b4w_to_transaction(row: B4wDb) -> Transaction:
|
||||
text=row.text,
|
||||
other_name=row.other_name,
|
||||
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(
|
||||
session: Session, transaction: Transaction, do_commit: bool = True
|
||||
session: Session,
|
||||
transaction: Transaction,
|
||||
*,
|
||||
category_db: CategoryDb | None = None,
|
||||
do_commit: bool = True,
|
||||
) -> None:
|
||||
"""Insert one canonical transaction, ignoring duplicates on ``hash_id``.
|
||||
|
||||
@@ -65,7 +79,7 @@ def upsert_transaction(
|
||||
"text": transaction.text,
|
||||
"other_name": transaction.other_name,
|
||||
"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)
|
||||
_ = session.execute(stmt.prefix_with("OR IGNORE"))
|
||||
@@ -92,6 +106,11 @@ def consolidate_transactions[StagingDbT: BaseModelDb](
|
||||
transaction = mapper(staging_row)
|
||||
if transaction.hash_id in existing_hashes:
|
||||
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:
|
||||
session.commit()
|
||||
|
||||
+9
-2
@@ -2,11 +2,16 @@ from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
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 src.constants import IBAN_PATTERN, Currency
|
||||
from src.models.category import Category
|
||||
from src.models.category import Category, CategoryDb
|
||||
from src.models.common import (
|
||||
ExportBaseModel,
|
||||
IngestdbBase,
|
||||
@@ -15,6 +20,8 @@ from src.models.common import (
|
||||
hashify,
|
||||
)
|
||||
|
||||
__all__ = ["B4wDb", "B4wExport", "Category", "CategoryDb"]
|
||||
|
||||
|
||||
class B4wExport(ExportBaseModel):
|
||||
id: int = Field(alias="Id1", description="internal B4w ID")
|
||||
|
||||
+122
-10
@@ -1,13 +1,48 @@
|
||||
from datetime import date
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Self
|
||||
|
||||
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 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):
|
||||
@@ -34,17 +69,35 @@ class Category(ExportBaseModel):
|
||||
else:
|
||||
return f"{self.parent.to_str()}:{self.name}"
|
||||
|
||||
def __str__(self):
|
||||
return self.to_str()
|
||||
def __str__(self) -> str:
|
||||
return self.to_str() or ""
|
||||
|
||||
def __repr__(self):
|
||||
return f'Category.from_str("{self.__str__()}")'
|
||||
|
||||
def get_budget(self, date_valid: date | None) -> float:
|
||||
# TODO
|
||||
return 0.0
|
||||
|
||||
|
||||
class CategoryDb(BaseModelDb):
|
||||
__tablename__ = "category"
|
||||
__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)
|
||||
@@ -52,6 +105,29 @@ class CategoryDb(BaseModelDb):
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
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
|
||||
parent = relationship(
|
||||
@@ -69,11 +145,45 @@ def upsert_category(
|
||||
|
||||
parent = upsert_category(session, category.parent, do_commit=False)
|
||||
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(
|
||||
session,
|
||||
CategoryDb,
|
||||
parameters,
|
||||
parameters,
|
||||
{**parameters, "valid_to": None},
|
||||
parameters_new,
|
||||
do_update=False,
|
||||
do_commit=do_commit,
|
||||
)
|
||||
@@ -81,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"
|
||||
)
|
||||
@@ -113,7 +223,9 @@ class CategoryBudgetDb(BaseModelDb):
|
||||
__tablename__ = "category_budget"
|
||||
|
||||
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)
|
||||
valid_from: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
|
||||
valid_to: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -40,11 +41,15 @@ def date2str(value: date | None, default="--", date_format="%Y-%m-%d") -> str:
|
||||
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(",", "."))
|
||||
|
||||
|
||||
def decimal2str(value: Decimal) -> str:
|
||||
def decimal2str(value: Amount) -> str:
|
||||
value = Decimal(f"{value:.2f}")
|
||||
int_part, _, frac = str(value).partition(".")
|
||||
groups: list[str] = []
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
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.category import Category
|
||||
from src.models.common import Amount, ExportBaseModel, IngestdbBase, date2str, hashify
|
||||
|
||||
|
||||
class Transaction(ExportBaseModel):
|
||||
@@ -22,13 +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
|
||||
category_id: int | None = None
|
||||
category: Category | None = None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
@@ -55,7 +55,7 @@ class Transaction(ExportBaseModel):
|
||||
"text": f"{self.text or ''}",
|
||||
"other_name": f"{self.other_name or ''}",
|
||||
"other_iban": f"{self.other_iban or ''}",
|
||||
"category_id": f"{self.category_id}",
|
||||
"category": (self.category.to_str() if self.category else "") or "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -12,24 +13,30 @@ class TestCategoryComputedFields:
|
||||
def test_hash_id_computed_field_simple(self):
|
||||
"""Test hash_id for single-level category."""
|
||||
c = Category.from_str("single")
|
||||
assert c
|
||||
assert hasattr(c, "hash_id")
|
||||
assert isinstance(c.hash_id, str)
|
||||
# Hash should be deterministic for same inputs
|
||||
c2 = Category.from_str("single")
|
||||
assert c2
|
||||
assert c.hash_id == c2.hash_id
|
||||
|
||||
def test_hash_id_computed_field_nested(self):
|
||||
"""Test hash_id for multi-level category hierarchy."""
|
||||
c1 = Category.from_str("level1:level2:level3")
|
||||
assert c1
|
||||
assert hasattr(c1, "hash_id")
|
||||
|
||||
c2 = Category.from_str("level1:level2:level3")
|
||||
assert c2
|
||||
assert c1.hash_id == c2.hash_id
|
||||
|
||||
def test_hash_id_different_hierarchy(self):
|
||||
"""Test that different hierarchies have different hash_ids."""
|
||||
c1 = Category.from_str("A:B")
|
||||
c2 = Category.from_str("A:C")
|
||||
assert c1
|
||||
assert c2
|
||||
# Different last level -> different hash_id
|
||||
assert c1.hash_id != c2.hash_id
|
||||
|
||||
@@ -40,22 +47,26 @@ class TestCategoryComputedFields:
|
||||
|
||||
# Create another with same name but no parent
|
||||
orphan = Category.from_str("Budget")
|
||||
assert orphan
|
||||
|
||||
assert cat1.hash_id != orphan.hash_id
|
||||
|
||||
def test_to_str_single_level(self):
|
||||
"""Test to_str() for single-level category."""
|
||||
c = Category.from_str("single")
|
||||
assert c
|
||||
assert c.to_str() == "single"
|
||||
|
||||
def test_to_str_nested(self):
|
||||
"""Test to_str() for nested category."""
|
||||
c = Category.from_str("parent:child:grandchild")
|
||||
assert c
|
||||
assert c.to_str() == "parent:child:grandchild"
|
||||
|
||||
def test_str_uses_to_str(self):
|
||||
"""Test that __str__ delegates to to_str()."""
|
||||
c = Category.from_str("A:B:C")
|
||||
assert c
|
||||
assert str(c) == c.to_str() == "A:B:C"
|
||||
|
||||
def test_repr_simple(self):
|
||||
@@ -79,7 +90,7 @@ class TestCategoryBudgetValidation:
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
amount_eur=Decimal("100.00"),
|
||||
)
|
||||
assert budget.valid_from is None
|
||||
assert budget.valid_to is None
|
||||
@@ -90,7 +101,7 @@ class TestCategoryBudgetValidation:
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
amount_eur=Decimal("100.00"),
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 31),
|
||||
)
|
||||
@@ -105,7 +116,7 @@ class TestCategoryBudgetValidation:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
amount_eur=Decimal("100.00"),
|
||||
valid_from=date(2026, 10, 31),
|
||||
valid_to=date(2026, 10, 1),
|
||||
)
|
||||
@@ -120,7 +131,7 @@ class TestCategoryBudgetValidation:
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
amount_eur=Decimal("100.00"),
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 1),
|
||||
)
|
||||
@@ -133,7 +144,7 @@ class TestCategoryBudgetValidation:
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=150.75,
|
||||
amount_eur=Decimal("150.75"),
|
||||
valid_from=date(2026, 9, 1),
|
||||
valid_to=date(2026, 9, 30),
|
||||
)
|
||||
@@ -142,7 +153,7 @@ class TestCategoryBudgetValidation:
|
||||
try:
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=200.00,
|
||||
amount_eur=Decimal("200.00"),
|
||||
valid_from=date(2026, 12, 31),
|
||||
valid_to=date(2026, 12, 30),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Comprehensive tests for set_category_budget overlap handling."""
|
||||
|
||||
from datetime import date
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.domain_utils import find_category_db, set_category_budget
|
||||
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
|
||||
|
||||
# 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"))
|
||||
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:
|
||||
"""Test error handling in set_category_budget."""
|
||||
|
||||
@@ -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()
|
||||
@@ -101,7 +101,7 @@ def test_map_comdirect_giro_to_transaction():
|
||||
assert transaction.amount == Decimal("32.43")
|
||||
assert transaction.other_name 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):
|
||||
@@ -125,6 +125,9 @@ def test_consolidate_b4w_signed_amount_and_category(db_session: Session):
|
||||
assert transaction.amount == Decimal("-50.00")
|
||||
assert transaction.category_id == staging.category_id
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user