"""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()