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
+77
View File
@@ -913,6 +913,83 @@ class LinkIgnored(Base):
)
class Subscription(Base):
"""Ein Abo oder Vertrag: wiederkehrende Zahlung mit bekanntem Betrag.
Netflix, Handyvertrag, Krankenkasse, Halbtax – dieselbe Form. Deshalb
heisst der Bereich "Abos & Vertraege": Ein Handyvertrag ist kein Abo, und
niemand soll ueberlegen muessen, wo die Versicherung hingehoert.
Der Monatswert wird NICHT gespeichert, sondern gerechnet
(``amount_cents / interval_months``). Gespeichert veraltete er beim
naechsten Preisaufschlag stillschweigend.
"""
__tablename__ = "subscriptions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
# Wie bei Konten und Kategorien: Am Symbol findet man die Zeile schneller
# wieder als am Namen, wenn dreissig davon untereinander stehen.
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="refresh-ccw")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Eigene Waehrung, nicht die des Kontos: Ein deutscher Handyvertrag laeuft
# in Euro, auch wenn er von einem Franken-Konto bezahlt wird.
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
# 1 = monatlich, 3 = vierteljaehrlich, 6 = halbjaehrlich, 12 = jaehrlich.
# Als Zahl und nicht als Aufzaehlung: Der Monatswert ist dann eine Division
# statt einer Tabelle, und ein zweimonatlicher Vertrag braucht keine
# Migration.
interval_months: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# Womit bezahlt wird. Manche Abos laufen ueber die Kreditkarte, andere per
# eBill direkt vom Konto – ohne diese Angabe findet man den Abbucher nicht.
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
# Naechste Verlaengerung/Abbuchung.
next_due: Mapped[date_t | None] = mapped_column(Date, nullable=True)
# Woran die Abbuchung im Kontoauszug zu erkennen ist. Aus dem Namen
# vorbelegt, aenderbar – auf dem Auszug steht selten "Netflix".
payee_pattern: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Nur bei laengerem Intervall: das Konto, auf das monatlich zurueckgelegt
# wird. Die Jahresrechnung wird dann daraus bezahlt, statt einen Monat zu
# sprengen.
savings_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Gekuendigtes wird nicht geloescht: Der Verlauf ist die Antwort auf
# "was habe ich dafuer insgesamt bezahlt".
cancelled_at: Mapped[date_t | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
@property
def monatlich_cents(self) -> int:
"""Was der Vertrag im Monat kostet – in SEINER Waehrung.
Kaufmaennisch gerundet. Die Summe ueber viele Vertraege weicht dadurch
um ein paar Rappen von der exakten ab; das ist der Preis dafuer, dass
jede einzelne Zeile eine Zahl zeigt, die man nachrechnen kann.
"""
n = self.interval_months or 1
return round(self.amount_cents / n)
class BankApp(Base):
"""Die bei Enable Banking registrierte Anwendung dieser Installation.