from datetime import date from decimal import Decimal from pathlib import Path from sqlalchemy import func, select from sqlalchemy.orm import Session from src.ingest.b4w import ingest_b4w, upsert_b4w from src.ingest.comdirect import ingest_comdirect from src.ingest.transactions import ( consolidate_transactions, map_b4w_to_transaction, map_comdirect_giro_to_transaction, ) from src.models.b4w import B4wDb from src.models.comdirect import ComdirectGiroDb from src.models.transactions import Transaction, TransactionDb from tests.factories import B4wExportFactory B4W_PATH = Path("tests/test_data/b4w_export_dummy_01.csv") COMDIRECT_PATH = Path("tests/test_data/comdirect_export_dummy_01.txt") def test_transaction_hash_is_business_key(): first = Transaction( source="b4w", source_row_id=1, account_iban="DE89370400440532013000", account_currency="EUR", date_valid=date(2026, 9, 7), amount=Decimal("-50.00"), amount_currency="EUR", date_book=date(2026, 9, 8), text="Supermarket", other_name="Aldi", ) second = Transaction( source="b4w", source_row_id=999, account_iban="DE89370400440532013000", account_currency="EUR", date_valid=date(2026, 9, 7), amount=Decimal("-50.00"), amount_currency="EUR", date_book=date(2026, 9, 8), text="Supermarket", other_name="Aldi", ) # Stable across source-row ids (re-imports) ... assert second.hash_id == first.hash_id # ... but source is part of the key, so no cross-source collision. third = second.model_copy(update={"source": "comdirect_giro"}) assert third.hash_id != first.hash_id def test_map_b4w_to_transaction(): row = B4wDb( id=7, hash_id="unused", id_b4w=1, account_iban="DE89370400440532013000", account_currency="EUR", date_valid=date(2026, 9, 7), date_book=date(2026, 9, 8), amount=Decimal("-50.00"), amount_currency="EUR", text="Supermarket", other_name="Aldi", other_iban="DE02120300000000202051", category_id=None, notes="", ) transaction = map_b4w_to_transaction(row) assert isinstance(transaction, Transaction) assert transaction.source == "b4w" assert transaction.source_row_id == 7 assert transaction.amount == Decimal("-50.00") assert transaction.date_book == date(2026, 9, 8) assert transaction.text == "Supermarket" assert transaction.other_name == "Aldi" assert transaction.other_iban == "DE02120300000000202051" def test_map_comdirect_giro_to_transaction(): row = ComdirectGiroDb( id=3, hash_id="unused", account_iban="DE89370400440532013000", account_currency="EUR", amount_currency="EUR", date_valid=date(2026, 9, 7), amount=Decimal("32.43"), date_book=date(2026, 9, 8), type="Kartenumsatz", text="Supermarkt", ) transaction = map_comdirect_giro_to_transaction(row) assert isinstance(transaction, Transaction) assert transaction.source == "comdirect_giro" assert transaction.source_row_id == 3 assert transaction.amount == Decimal("32.43") assert transaction.other_name is None assert transaction.other_iban is None assert transaction.category_id is None def test_consolidate_b4w_signed_amount_and_category(db_session: Session): export = B4wExportFactory.build( Amt="50.00", RmtInf="Supermarket", Category="Lebenshaltung:Lebensmittel", CdtDbtInd="DBIT", ) staging = upsert_b4w(db_session, export, do_commit=False) assert staging is not None consolidate_transactions( db_session, "b4w", B4wDb, map_b4w_to_transaction, do_commit=False ) db_session.commit() transaction = db_session.execute(select(TransactionDb)).scalars().one() assert transaction.source == "b4w" assert transaction.source_row_id == staging.id # b4w amount_signed: DBIT -> money out assert transaction.amount == Decimal("-50.00") assert transaction.category_id == staging.category_id assert transaction.text == "Supermarket" def test_ingest_b4w_e2e_idempotent(db_session: Session): for _ in range(2): ingest_b4w(B4W_PATH, db_session) staging_count = db_session.execute( select(func.count()).select_from(B4wDb) ).scalar_one() transaction_count = db_session.execute( select(func.count()).select_from(TransactionDb) ).scalar_one() assert staging_count > 0 assert transaction_count == staging_count transactions = db_session.execute(select(TransactionDb)).scalars().all() assert all(t.source == "b4w" for t in transactions) assert all(t.source_row_id is not None for t in transactions) def test_ingest_comdirect_e2e_idempotent(db_session: Session): for _ in range(2): ingest_comdirect( session=db_session, path=COMDIRECT_PATH, account_iban="DE89370400440532013000", account_currency="EUR", ) staging_count = db_session.execute( select(func.count()).select_from(ComdirectGiroDb) ).scalar_one() transaction_count = db_session.execute( select(func.count()).select_from(TransactionDb) ).scalar_one() assert staging_count > 0 assert transaction_count == staging_count transactions = db_session.execute(select(TransactionDb)).scalars().all() assert all(t.source == "comdirect_giro" for t in transactions) assert all(t.account_iban == "DE89370400440532013000" for t in transactions) def test_ingest_mixed_sources_into_one_table(db_session: Session): ingest_b4w(B4W_PATH, db_session) ingest_comdirect( session=db_session, path=COMDIRECT_PATH, account_iban="DE89370400440532013000", account_currency="EUR", ) b4w_count = db_session.execute(select(func.count()).select_from(B4wDb)).scalar_one() giro_count = db_session.execute( select(func.count()).select_from(ComdirectGiroDb) ).scalar_one() transaction_count = db_session.execute( select(func.count()).select_from(TransactionDb) ).scalar_one() assert b4w_count > 0 assert giro_count > 0 assert transaction_count == b4w_count + giro_count sources = set(db_session.execute(select(TransactionDb.source).distinct()).scalars()) assert sources == {"b4w", "comdirect_giro"}