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>
This commit is contained in:
Scarriffle
2026-09-07 15:45:59 +02:00
co-authored by Claude Opus 5
parent ff621984dc
commit f99be47587
18 changed files with 1297 additions and 10 deletions
+116 -2
View File
@@ -6,6 +6,7 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from . import waehrungen
from .models import (
AccountType,
AmountMode,
@@ -17,13 +18,16 @@ from .models import (
TxnType,
)
ALLOWED_CURRENCIES = {"EUR", "CHF"}
ALLOWED_CURRENCIES = waehrungen.ERLAUBT
def _check_currency(v: str) -> str:
v = v.upper()
if v not in ALLOWED_CURRENCIES:
raise ValueError("Währung muss EUR oder CHF sein")
raise ValueError(
f"'{v}' ist keine bekannte Währung. Möglich sind die Währungen mit "
f"EZB-Referenzkurs – ohne Kurs liesse sich der Betrag nie umrechnen."
)
return v
@@ -213,6 +217,116 @@ class AccountOut(BaseModel):
bank_balance_vergleichbar: bool = False
# --- Abos & Verträge --------------------------------------------------------
# Was als Intervall erlaubt ist. Offen genug für einen zweimonatlichen Vertrag,
# eng genug, dass niemand "0" oder "37" einträgt.
INTERVALL_MONATE = (1, 2, 3, 6, 12)
class SubscriptionIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
icon: str = "refresh-ccw"
color: str = "#6b7480"
amount_cents: int = Field(ge=0)
currency: str = "EUR"
interval_months: int = 1
account_id: int | None = None
category_id: int | None = None
next_due: date_t | None = None
payee_pattern: str | None = Field(default=None, max_length=255)
savings_account_id: int | None = None
is_active: bool = True
cancelled_at: date_t | None = None
notes: str | None = None
@field_validator("currency")
@classmethod
def _cur(cls, v: str) -> str:
return _check_currency(v)
@field_validator("interval_months")
@classmethod
def _iv(cls, v: int) -> int:
if v not in INTERVALL_MONATE:
raise ValueError(f"Intervall muss eines von {INTERVALL_MONATE} Monaten sein")
return v
class SubscriptionUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
icon: str | None = None
color: str | None = None
amount_cents: int | None = Field(default=None, ge=0)
currency: str | None = None
interval_months: int | None = None
account_id: int | None = None
category_id: int | None = None
next_due: date_t | None = None
payee_pattern: str | None = Field(default=None, max_length=255)
savings_account_id: int | None = None
is_active: bool | None = None
cancelled_at: date_t | None = None
notes: str | None = None
@field_validator("currency")
@classmethod
def _cur(cls, v: str | None) -> str | None:
return _check_currency(v) if v is not None else None
@field_validator("interval_months")
@classmethod
def _iv(cls, v: int | None) -> int | None:
if v is not None and v not in INTERVALL_MONATE:
raise ValueError(f"Intervall muss eines von {INTERVALL_MONATE} Monaten sein")
return v
class SubscriptionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
icon: str
color: str
amount_cents: int
currency: str
interval_months: int
account_id: int | None
category_id: int | None
next_due: date_t | None
payee_pattern: str | None
savings_account_id: int | None
is_active: bool
cancelled_at: date_t | None
notes: str | None
position: int
# Gerechnet, nicht gespeichert – in der Währung des Vertrags.
monatlich_cents: int
# Und derselbe Wert in der Hauptwährung, damit sich Zeilen vergleichen und
# summieren lassen. None, wenn für die Währung kein Kurs vorliegt.
monatlich_base_cents: int | None = None
# Wie viele Tage bis zur nächsten Abbuchung (negativ = überfällig).
tage_bis_faellig: int | None = None
class SubscriptionSummary(BaseModel):
"""Was am Kopf der Seite steht – die Zahl, wegen der man sie öffnet."""
currency: str
monatlich_cents: int
jaehrlich_cents: int
anzahl_aktiv: int
# Verträge, deren Währung sich mangels Kurs nicht umrechnen liess. Lieber
# nennen als stillschweigend zu klein summieren.
ohne_kurs: list[str] = Field(default_factory=list)
class SubscriptionListOut(BaseModel):
subscriptions: list[SubscriptionOut]
summary: SubscriptionSummary
# --- Kategorien ------------------------------------------------------------