Files
certain/backend/app/routers/subscriptions.py
T
ScarriffleandClaude Opus 5 f99be47587 feat: Abos & Vertraege – was regelmaessig abgeht, an einer Stelle
Netflix, Handyvertrag, Krankenkasse, Halbtax. Bisher sah man sie nur als
einzelne Buchungen im Auszug; die Frage "welche Abos habe ich eigentlich, und
was kosten die zusammen im Monat" liess sich nicht beantworten. Genau die
steht jetzt oben auf der Seite.

Der Bereich heisst "Abos & Vertraege", nicht "Abos": Ein Handyvertrag ist kein
Abo und die Versicherung erst recht nicht – niemand soll ueberlegen muessen,
wo sie hingehoert.

Der Monatswert wird gerechnet, nicht gespeichert (Betrag durch Intervall).
Gespeichert veraltete er beim naechsten Preisaufschlag stillschweigend.

Bei laengerem Intervall klappt "Ich lege monatlich zurueck auf" auf, mit dem
gerechneten Monatsbetrag daneben. Bei monatlicher Abrechnung gibt es nichts
anzusparen, also steht es da auch nicht.

DAZU: Waehrungen sind offen.

Die Rechen-Maschinerie konnte immer schon beliebige Waehrungen – sie rechnet
ueber den Euro als Drehpunkt. Beschraenkt haben es zwei Konstanten: eine
Erlaubnisliste mit EUR und CHF, und ein Filter, der aus dem EZB-Feed alles
ausser CHF wegwarf, obwohl er dreissig Waehrungen liefert.

Massgeblich ist jetzt die Liste der EZB. Frei erfundene Waehrungen bleiben
draussen, und das ist kein Geiz: Ohne Kurs liesse sich der Betrag nie
umrechnen – er stuende in keiner Summe und in keinem Budget, und das faellt
erst spaet auf. Ein Vertrag in einer Waehrung ohne Kurs wird deshalb genannt,
statt die Summe stillschweigend zu klein anzuzeigen.

Erkannt und gebucht wird noch nichts; der Erkennungstext wird nur erfasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:45:59 +02:00

163 lines
6.2 KiB
Python
Raw 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.
"""Abos und Verträge: wiederkehrende Zahlungen mit bekanntem Betrag.
Netflix, Handyvertrag, Krankenkasse, Halbtax – dieselbe Form, deshalb
derselbe Bereich. Hier wird nur verzeichnet und gerechnet; erkannt und
gebucht wird (noch) nichts.
Der Monatswert entsteht in ``Subscription.monatlich_cents``: Betrag geteilt
durch Intervall. Für die Summe am Kopf wird er zusätzlich in die Hauptwährung
umgerechnet – ein Vertrag in Euro und einer in Franken lassen sich sonst nicht
addieren.
"""
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Account, Category, CategoryKind, Subscription, User
from ..schemas import (
SubscriptionIn,
SubscriptionListOut,
SubscriptionOut,
SubscriptionSummary,
SubscriptionUpdate,
)
from ..services import currency
from .settings import BASE_CURRENCY_KEY, get_user_setting
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
def _base(db: Session, user: User) -> str:
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
def _pruefe_verweise(db: Session, user: User, daten: dict) -> None:
"""Konten und Kategorie müssen dem Nutzer gehören.
Die Kategorie muss ausserdem eine AUSGABE sein: Ein Vertrag kostet Geld,
und gegen eine Einnahme-Kategorie liesse er sich später nicht budgetieren.
"""
for feld in ("account_id", "savings_account_id"):
if daten.get(feld) is not None:
get_owned(db, Account, daten[feld], user)
if daten.get("category_id") is not None:
kategorie = get_owned(db, Category, daten["category_id"], user)
if kategorie.type != CategoryKind.expense:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(f"'{kategorie.name}' ist keine Ausgabe-Kategorie. Ein "
f"Vertrag kostet Geld."),
)
def to_out(db: Session, s: Subscription, base: str, heute: date) -> SubscriptionOut:
monatlich = s.monatlich_cents
umgerechnet = (
monatlich if s.currency == base
else currency.convert(db, monatlich, s.currency, base, heute)
)
return SubscriptionOut(
**{k: getattr(s, k) for k in (
"id", "name", "icon", "color", "amount_cents", "currency",
"interval_months", "account_id", "category_id", "next_due",
"payee_pattern", "savings_account_id", "is_active", "cancelled_at",
"notes", "position")},
monatlich_cents=monatlich,
monatlich_base_cents=umgerechnet,
tage_bis_faellig=(s.next_due - heute).days if s.next_due else None,
)
@router.get("", response_model=SubscriptionListOut)
def list_subscriptions(db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
base = _base(db, user)
heute = date.today()
zeilen = [
to_out(db, s, base, heute)
for s in db.query(Subscription)
.filter(Subscription.owner_id == user.id)
.order_by(Subscription.position, Subscription.name)
]
# Gekündigtes zählt nicht mit – es steht nur noch für den Rückblick da.
aktiv = [z for z in zeilen if z.is_active]
summe = sum(z.monatlich_base_cents or 0 for z in aktiv)
return SubscriptionListOut(
subscriptions=zeilen,
summary=SubscriptionSummary(
currency=base,
monatlich_cents=summe,
# Nicht monatlich × 12: Bei einem Jahresvertrag hat das Runden des
# Monatswerts sonst bis zu elf Rappen Fehler.
jaehrlich_cents=summe * 12,
anzahl_aktiv=len(aktiv),
ohne_kurs=sorted({
z.currency for z in aktiv if z.monatlich_base_cents is None
}),
),
)
@router.post("", response_model=SubscriptionOut, status_code=status.HTTP_201_CREATED)
def create_subscription(payload: SubscriptionIn, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
daten = payload.model_dump()
_pruefe_verweise(db, user, daten)
max_pos = (
db.query(Subscription.position)
.filter(Subscription.owner_id == user.id)
.order_by(Subscription.position.desc())
.first()
)
s = Subscription(owner_id=user.id, position=(max_pos[0] + 1) if max_pos else 0,
**daten)
s.name = s.name.strip()
# Ohne eigenes Muster der Name: Er ist der beste erste Versuch, und wer es
# besser weiss, traegt es ein.
if not s.payee_pattern:
s.payee_pattern = s.name
db.add(s)
db.commit()
db.refresh(s)
return to_out(db, s, _base(db, user), date.today())
@router.get("/{sub_id}", response_model=SubscriptionOut)
def get_subscription(sub_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
return to_out(db, get_owned(db, Subscription, sub_id, user),
_base(db, user), date.today())
@router.patch("/{sub_id}", response_model=SubscriptionOut)
def update_subscription(sub_id: int, payload: SubscriptionUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
s = get_owned(db, Subscription, sub_id, user)
daten = payload.model_dump(exclude_unset=True)
_pruefe_verweise(db, user, daten)
for key, value in daten.items():
# Diese Felder muessen sich LEEREN lassen – sonst wird man ein einmal
# gesetztes Sparkonto oder Faelligkeitsdatum nie wieder los.
leerbar = key in ("account_id", "category_id", "next_due",
"savings_account_id", "cancelled_at", "notes",
"payee_pattern")
if value is not None or leerbar:
setattr(s, key, value)
if s.name:
s.name = s.name.strip()
db.commit()
db.refresh(s)
return to_out(db, s, _base(db, user), date.today())
@router.delete("/{sub_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_subscription(sub_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
db.delete(get_owned(db, Subscription, sub_id, user))
db.commit()