"""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"]