diff --git a/backend/app/scripts/seed_demo.py b/backend/app/scripts/seed_demo.py new file mode 100644 index 0000000..3d7aa40 --- /dev/null +++ b/backend/app/scripts/seed_demo.py @@ -0,0 +1,57 @@ +"""CLI: Demodaten anlegen – `python -m app.scripts.seed_demo [--reset]`. + +Legt einen vollständigen deutschen Beispielhaushalt an: Konten, Firmen samt +Logos, wiederkehrende Posten, bestätigte Historie, Buchungen, Budgets und +Sparziele. Alle Termine liegen relativ zum heutigen Monat. +""" + +import argparse +import asyncio +import sys + +from app.db.session import SessionLocal, engine +from app.services.demo import has_data, seed_demo + + +async def main(reset: bool, resolve_logos: bool) -> int: + async with SessionLocal() as session: + if not reset and await has_data(session): + print( + "Es sind bereits Daten vorhanden. Mit --reset werden Konten, Firmen, " + "Posten, Buchungen, Budgets und Sparziele zuvor gelöscht.\n" + "Kategorien und Benutzer bleiben in jedem Fall erhalten.", + file=sys.stderr, + ) + await engine.dispose() + return 1 + + ergebnis = await seed_demo(session, reset=reset, resolve_logos=resolve_logos) + + await engine.dispose() + print( + "Demodaten angelegt:\n" + f" {ergebnis.accounts} Konten\n" + f" {ergebnis.merchants} Firmen ({ergebnis.logos} mit Logo)\n" + f" {ergebnis.recurrences} wiederkehrende Posten\n" + f" {ergebnis.occurrences} bestätigte Fälligkeiten\n" + f" {ergebnis.transactions} Buchungen\n" + f" {ergebnis.budgets} Budgets und Vorlagen\n" + f" {ergebnis.goals} Sparziele" + ) + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--reset", + action="store_true", + help="Vorhandene Konten, Firmen, Posten, Buchungen, Budgets und Sparziele löschen.", + ) + parser.add_argument( + "--no-logos", + action="store_true", + help="Logosuche überspringen – schneller und ohne jeden Netzzugriff.", + ) + args = parser.parse_args() + raise SystemExit(asyncio.run(main(args.reset, not args.no_logos))) diff --git a/backend/app/services/demo.py b/backend/app/services/demo.py new file mode 100644 index 0000000..691033f --- /dev/null +++ b/backend/app/services/demo.py @@ -0,0 +1,545 @@ +"""Demodaten: ein realistischer deutscher Haushalt. + +Alle Termine liegen relativ zum heutigen Monat, damit Dashboard, Kalender und +Auswertungen unmittelbar etwas zeigen – unabhängig davon, wann der Seed läuft. +""" + +import logging +from dataclasses import dataclass +from datetime import date, timedelta +from decimal import Decimal + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.clock import add_months, month_start, today +from app.models import ( + Account, + AmountVersion, + Budget, + BudgetTemplate, + Category, + Merchant, + Occurrence, + Recurrence, + ReserveLedger, + SavingsGoal, + Transaction, +) +from app.models.enums import ( + AccountType, + BusinessDayShift, + EntryKind, + LogoStatus, +) +from app.services.logos import resolve_merchant_logo +from app.services.merchants import normalize_name +from app.services.occurrences import confirm +from app.services.recurrence import expand +from app.services.seed import seed_all + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class DemoResult: + """Was der Seed angelegt hat.""" + + accounts: int = 0 + merchants: int = 0 + recurrences: int = 0 + transactions: int = 0 + occurrences: int = 0 + budgets: int = 0 + goals: int = 0 + logos: int = 0 + + +# Firmen mit ihrer Domain – die Domain verbessert die Logosuche. +DEMO_MERCHANTS: tuple[tuple[str, str | None], ...] = ( + ("Netflix", "netflix.com"), + ("Spotify", "spotify.com"), + ("Vodafone", "vodafone.de"), + ("Deutsche Telekom", "telekom.de"), + ("REWE", "rewe.de"), + ("dm", "dm.de"), + ("Deutsche Bahn", "bahn.de"), + ("Sparkasse", "sparkasse.de"), + # Bewusst ohne Eintrag im Markenkatalog: zeigt den erzeugten Avatar. + ("Stadtwerke Musterstadt", None), + ("HUK-COBURG", "huk.de"), +) + + +async def _category_map(session: AsyncSession) -> dict[str, int]: + """Kategoriename -> ID. Bei doppelten Namen gewinnt die Unterkategorie.""" + rows = (await session.execute(select(Category))).scalars().all() + namen: dict[str, int] = {} + for kategorie in rows: + if kategorie.name not in namen or kategorie.parent_id is not None: + namen[kategorie.name] = kategorie.id + return namen + + +async def has_data(session: AsyncSession) -> bool: + """True, wenn schon Bewegungsdaten vorhanden sind.""" + for modell in (Recurrence, Transaction, Account): + anzahl = (await session.execute(select(func.count()).select_from(modell))).scalar_one() + if anzahl: + return True + return False + + +async def reset_data(session: AsyncSession) -> None: + """Entfernt alle Bewegungs- und Stammdaten außer Kategorien und Benutzern. + + Die Reihenfolge folgt den Fremdschlüsseln. + """ + for modell in ( + Occurrence, + AmountVersion, + ReserveLedger, + Recurrence, + Transaction, + Budget, + BudgetTemplate, + SavingsGoal, + Merchant, + Account, + ): + await session.execute(delete(modell)) + await session.flush() + + +async def seed_demo( + session: AsyncSession, *, reset: bool = False, resolve_logos: bool = True +) -> DemoResult: + """Legt einen vollständigen Beispielhaushalt an.""" + await seed_all(session) + + if reset: + await reset_data(session) + + ergebnis = DemoResult() + heute = today() + monat = month_start(heute) + + # --- Konten --------------------------------------------------------------- + konten = { + "giro": Account( + name="Girokonto", + type=AccountType.CHECKING, + iban_last4="4711", + opening_balance=Decimal("2450.00"), + # Sechs Monate Historie, damit Vergleiche etwas zeigen. + opening_balance_date=add_months(monat, -6), + color="#3b82f6", + icon="wallet", + sort_order=0, + ), + "karte": Account( + name="Kreditkarte", + type=AccountType.CREDIT_CARD, + iban_last4="8820", + opening_balance=Decimal("0.00"), + opening_balance_date=add_months(monat, -6), + color="#a855f7", + icon="credit-card", + sort_order=1, + ), + "tagesgeld": Account( + name="Tagesgeld", + type=AccountType.SAVINGS, + opening_balance=Decimal("8200.00"), + opening_balance_date=add_months(monat, -6), + color="#10b981", + icon="piggy-bank", + sort_order=2, + ), + } + session.add_all(konten.values()) + await session.flush() + ergebnis.accounts = len(konten) + + # --- Firmen --------------------------------------------------------------- + firmen: dict[str, Merchant] = {} + for name, domain in DEMO_MERCHANTS: + firma = Merchant( + name=name, + normalized_name=normalize_name(name), + domain=domain, + logo_status=LogoStatus.PENDING, + ) + session.add(firma) + firmen[name] = firma + await session.flush() + ergebnis.merchants = len(firmen) + + if resolve_logos: + for firma in firmen.values(): + try: + if await resolve_merchant_logo(session, firma) is not None: + ergebnis.logos += 1 + except Exception: # eine fehlende Grafik darf den Seed nicht stoppen + logger.exception("Logo für %s konnte nicht ermittelt werden.", firma.name) + + # --- Wiederkehrende Posten ------------------------------------------------ + kategorie = await _category_map(session) + giro = konten["giro"].id + karte = konten["karte"].id + + posten: list[Recurrence] = [] + + def anlegen(**felder) -> Recurrence: + eintrag = Recurrence(**felder) + session.add(eintrag) + posten.append(eintrag) + return eintrag + + anlegen( + kind=EntryKind.INCOME, + title="Gehalt", + category_id=kategorie["Gehalt"], + account_id=giro, + amount=Decimal("3450.00"), + rrule="FREQ=MONTHLY;BYMONTHDAY=28", + dtstart=add_months(monat, -6).replace(day=28), + # Das Gehalt kommt vor dem Wochenende, nicht danach. + business_day_shift=BusinessDayShift.PREVIOUS, + notes="Monatliches Nettogehalt.", + ) + + anlegen( + kind=EntryKind.EXPENSE, + title="Miete", + category_id=kategorie["Miete"], + account_id=giro, + amount=Decimal("1180.00"), + rrule="FREQ=MONTHLY;BYMONTHDAY=1", + dtstart=add_months(monat, -6), + merchant_id=None, + notes="Kaltmiete zuzüglich Nebenkosten.", + ) + + anlegen( + kind=EntryKind.EXPENSE, + title="Strom", + category_id=kategorie["Strom"], + account_id=giro, + merchant_id=firmen["Stadtwerke Musterstadt"].id, + amount=Decimal("89.00"), + rrule="FREQ=MONTHLY;BYMONTHDAY=15", + dtstart=add_months(monat, -6).replace(day=15), + # Abschlag; die Jahresabrechnung weicht regelmäßig ab. + is_variable=True, + ) + + anlegen( + kind=EntryKind.EXPENSE, + title="Internet & Telefon", + category_id=kategorie["Internet"], + account_id=giro, + merchant_id=firmen["Vodafone"].id, + amount=Decimal("39.99"), + rrule="FREQ=MONTHLY;BYMONTHDAY=5", + dtstart=add_months(monat, -6).replace(day=5), + ) + + netflix = anlegen( + kind=EntryKind.EXPENSE, + title="Netflix Standard", + category_id=kategorie["Streaming"], + account_id=karte, + merchant_id=firmen["Netflix"].id, + amount=Decimal("13.99"), + rrule="FREQ=MONTHLY;BYMONTHDAY=17", + dtstart=add_months(monat, -6).replace(day=17), + ) + + anlegen( + kind=EntryKind.EXPENSE, + title="Spotify Premium", + category_id=kategorie["Streaming"], + account_id=karte, + merchant_id=firmen["Spotify"].id, + amount=Decimal("11.99"), + rrule="FREQ=MONTHLY;BYMONTHDAY=3", + dtstart=add_months(monat, -6).replace(day=3), + ) + + anlegen( + kind=EntryKind.EXPENSE, + title="Rundfunkbeitrag", + category_id=kategorie["Rundfunkbeitrag"], + account_id=giro, + amount=Decimal("55.08"), + # Vierteljährlich, jeweils Mitte des Quartalsmonats. + rrule="FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15", + dtstart=add_months(monat, -6).replace(day=15), + ) + + anlegen( + kind=EntryKind.EXPENSE, + title="Kfz-Versicherung", + category_id=kategorie["Kfz"], + account_id=giro, + merchant_id=firmen["HUK-COBURG"].id, + amount=Decimal("612.00"), + rrule="FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15", + # Vorjahr, damit eine Zahlung in der Historie liegt und die nächste im Januar. + dtstart=date(heute.year - 1, 1, 15), + # Jahresbeitrag auf zwölf Monate verteilt zurücklegen. + reserve_enabled=True, + notes="Jahresbeitrag, Zahlung im Januar.", + ) + + handyvertrag_start = add_months(monat, -8) + anlegen( + kind=EntryKind.EXPENSE, + title="Handyvertrag", + category_id=kategorie["Internet"], + account_id=giro, + merchant_id=firmen["Deutsche Telekom"].id, + amount=Decimal("29.99"), + rrule="FREQ=MONTHLY;BYMONTHDAY=8", + dtstart=handyvertrag_start.replace(day=8), + contract_start=handyvertrag_start, + contract_min_term_months=24, + contract_notice_period_days=90, + contract_auto_renew_months=12, + notes="Mindestlaufzeit 24 Monate, danach jährliche Verlängerung.", + ) + + kredit_start = add_months(monat, -9) + anlegen( + kind=EntryKind.EXPENSE, + title="Autokredit", + category_id=kategorie["Kredite"], + account_id=giro, + merchant_id=firmen["Sparkasse"].id, + amount=Decimal("289.00"), + rrule="FREQ=MONTHLY;BYMONTHDAY=1", + dtstart=kredit_start, + installments_total=36, + principal_amount=Decimal("10404.00"), + notes="36 Raten, Restschuld siehe Detailansicht.", + ) + + anlegen( + kind=EntryKind.EXPENSE, + title="Sparplan Tagesgeld", + category_id=kategorie["Sparen"], + account_id=giro, + amount=Decimal("250.00"), + rrule="FREQ=MONTHLY;BYMONTHDAY=28", + dtstart=add_months(monat, -6).replace(day=28), + ) + + anlegen( + kind=EntryKind.INCOME, + title="Nebeneinkünfte", + category_id=kategorie["Nebeneinkünfte"], + account_id=giro, + amount=Decimal("320.00"), + # Alle zwei Monate. + rrule="FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=20", + dtstart=add_months(monat, -6).replace(day=20), + is_variable=True, + ) + + await session.flush() + ergebnis.recurrences = len(posten) + + # Preishistorie: für jeden Posten der Anfangsbetrag ab Serienstart. + for eintrag in posten: + session.add( + AmountVersion( + recurrence_id=eintrag.id, + amount=eintrag.amount, + valid_from=eintrag.dtstart, + note="Anfangsbetrag", + ) + ) + + # Die Versionen müssen in der Datenbank stehen, bevor sie abgefragt werden. + await session.flush() + + # Netflix ist vor drei Monaten teurer geworden – vergangene Monate bleiben + # dadurch betragstreu. + preiswechsel = add_months(monat, -3) + session.add( + AmountVersion( + recurrence_id=netflix.id, + amount=Decimal("13.99"), + valid_from=preiswechsel, + note="Preiserhöhung laut Schreiben", + ) + ) + # Der Anfangsbetrag lag darunter. + anfang = next( + version + for version in await _versions_of(session, netflix.id) + if version.valid_from == netflix.dtstart + ) + anfang.amount = Decimal("11.99") + await session.flush() + + # --- Vergangene Fälligkeiten bestätigen ----------------------------------- + # Erst dadurch zeigen die Berichte einen Plan-Ist-Vergleich. + ergebnis.occurrences = await _confirm_past(session, posten, heute) + + # --- Einmalige Buchungen -------------------------------------------------- + ergebnis.transactions = await _seed_transactions(session, kategorie, konten, firmen, monat) + + # --- Budgets -------------------------------------------------------------- + budgets = [ + Budget( + category_id=kategorie["Lebensmittel"], + period_month=monat, + limit_amount=Decimal("450.00"), + rollover=True, + ), + Budget( + category_id=kategorie["Restaurant"], + period_month=monat, + limit_amount=Decimal("120.00"), + ), + Budget( + category_id=kategorie["Drogerie"], + period_month=monat, + limit_amount=Decimal("60.00"), + ), + ] + session.add_all(budgets) + session.add( + BudgetTemplate( + category_id=kategorie["Sprit"], + valid_from=add_months(monat, -6), + limit_amount=Decimal("180.00"), + ) + ) + ergebnis.budgets = len(budgets) + 1 + + # --- Sparziele ------------------------------------------------------------ + ziele = [ + SavingsGoal( + name="Neues Fahrrad", + target_amount=Decimal("1800.00"), + current_amount=Decimal("620.00"), + target_date=add_months(monat, 10), + monthly_contribution=Decimal("100.00"), + account_id=konten["tagesgeld"].id, + color="#22c55e", + icon="bike", + ), + SavingsGoal( + name="Notgroschen", + target_amount=Decimal("6000.00"), + current_amount=Decimal("4200.00"), + monthly_contribution=Decimal("250.00"), + account_id=konten["tagesgeld"].id, + color="#3b82f6", + icon="shield", + ), + ] + session.add_all(ziele) + ergebnis.goals = len(ziele) + + await session.commit() + logger.info("Demodaten angelegt: %s", ergebnis) + return ergebnis + + +async def _versions_of(session: AsyncSession, recurrence_id: int) -> list[AmountVersion]: + stmt = select(AmountVersion).where(AmountVersion.recurrence_id == recurrence_id) + return list((await session.execute(stmt)).scalars().all()) + + +async def _confirm_past(session: AsyncSession, posten: list[Recurrence], heute: date) -> int: + """Bestätigt alle Fälligkeiten, die vor heute lagen. + + Beim Strom weicht der Ist-Betrag ab – so wie eine echte Abrechnung. + """ + anzahl = 0 + for eintrag in posten: + versionen = await _versions_of(session, eintrag.id) + faellig = expand( + eintrag, + eintrag.dtstart, + heute - timedelta(days=1), + amount_versions=versionen, + ) + + for index, geplant in enumerate(faellig): + ist = geplant.amount + if eintrag.is_variable: + # Abwechselnd etwas darüber und darunter. + abweichung = Decimal("7.40") if index % 2 == 0 else Decimal("-4.20") + ist = max(geplant.amount + abweichung, Decimal("0.01")) + + await confirm( + session, + eintrag.id, + geplant.nominal_date, + actual_amount=ist, + ) + anzahl += 1 + + await session.flush() + return anzahl + + +async def _seed_transactions( + session: AsyncSession, + kategorie: dict[str, int], + konten: dict[str, Account], + firmen: dict[str, Merchant], + monat: date, +) -> int: + """Einmalige Buchungen der letzten drei Monate.""" + giro = konten["giro"].id + karte = konten["karte"].id + + muster: tuple[tuple[int, int, str, str, str, str | None, int], ...] = ( + # (Monatsversatz, Tag, Titel, Betrag, Kategorie, Firma, Konto) + (0, 3, "Wocheneinkauf", "84.30", "Lebensmittel", "REWE", giro), + (0, 7, "Drogerie", "23.40", "Drogerie", "dm", giro), + (0, 9, "Tanken", "72.10", "Sprit", None, karte), + (0, 11, "Wocheneinkauf", "96.85", "Lebensmittel", "REWE", giro), + (0, 12, "Abendessen", "48.60", "Restaurant", None, karte), + (0, 18, "Wocheneinkauf", "78.20", "Lebensmittel", "REWE", giro), + (0, 21, "Bahnticket", "49.00", "ÖPNV", "Deutsche Bahn", giro), + (-1, 4, "Wocheneinkauf", "91.15", "Lebensmittel", "REWE", giro), + (-1, 8, "Werkstatt Inspektion", "312.00", "Werkstatt", None, giro), + (-1, 14, "Drogerie", "31.75", "Drogerie", "dm", giro), + (-1, 16, "Tanken", "68.40", "Sprit", None, karte), + (-1, 19, "Wocheneinkauf", "88.90", "Lebensmittel", "REWE", giro), + (-1, 24, "Restaurant", "62.30", "Restaurant", None, karte), + (-2, 6, "Wocheneinkauf", "79.55", "Lebensmittel", "REWE", giro), + (-2, 13, "Tanken", "70.20", "Sprit", None, karte), + (-2, 22, "Steuererstattung", "418.00", "Erstattungen", None, giro), + ) + + anzahl = 0 + for versatz, tag, titel, betrag, kategoriename, firmenname, konto in muster: + buchungsmonat = add_months(monat, versatz) + ist_einkunft = kategoriename == "Erstattungen" + + session.add( + Transaction( + kind=EntryKind.INCOME if ist_einkunft else EntryKind.EXPENSE, + title=titel, + category_id=kategorie[kategoriename], + account_id=konto, + merchant_id=firmen[firmenname].id if firmenname else None, + amount=Decimal(betrag), + booking_date=buchungsmonat.replace(day=tag), + ) + ) + anzahl += 1 + + await session.flush() + return anzahl + + +__all__ = ["DemoResult", "has_data", "reset_data", "seed_demo"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f21c073..4830761 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -13,6 +13,10 @@ os.environ.setdefault( ) os.environ.setdefault("SECRET_KEY", "test-secret-key-mindestens-32-zeichen-lang") os.environ.setdefault("ENVIRONMENT", "test") +# Der Testclient spricht über http; `Secure`-Cookies würde httpx verwerfen und +# jede geschützte Route liefe in einen 401. Ohne diese Zeile hinge die Suite an +# einer lokalen .env – die CI hat genau das aufgedeckt. +os.environ.setdefault("COOKIE_SECURE", "false") # Die Hintergrundsuche liefe außerhalb der Testtransaktion – sie wird gezielt aufgerufen. os.environ.setdefault("LOGO_AUTO_RESOLVE", "false") os.environ.setdefault("LOGO_STORAGE_DIR", tempfile.mkdtemp(prefix="moneyfy-logos-"))