Ohne backend/.env galt COOKIE_SECURE=true. Der Testclient spricht über http, httpx verwarf die Secure-Cookies, und 106 Tests scheiterten mit 401. Lokal fiel das nicht auf, weil dort eine nicht eingecheckte .env den Wert überschrieb – die CI hat es aufgedeckt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
546 lines
17 KiB
Python
546 lines
17 KiB
Python
"""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"]
|