Files
Jonas MenzelandClaude Opus 5 bdf4097bd7
Images bauen / build (backend) (push) Failing after 29s
Images bauen / build (frontend) (push) Failing after 30s
CI / backend (push) Successful in 2m6s
CI / frontend (push) Successful in 6m5s
fix(tests): Testsuite unabhängig von einer lokalen .env machen
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
2026-09-09 17:25:34 +02:00

546 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]