diff --git a/backend/app/main.py b/backend/app/main.py index 723c060..b1deab3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -43,6 +43,7 @@ from .routers import ( uebertraege, rates, settings as settings_router, + subscriptions, transactions, users, ) @@ -223,6 +224,7 @@ api.include_router(inbox.router) api.include_router(mail.router) api.include_router(bank.router) api.include_router(uebertraege.router) +api.include_router(subscriptions.router) # Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern. # Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler diff --git a/backend/app/models.py b/backend/app/models.py index 5ff37d4..23f58cd 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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. diff --git a/backend/app/routers/subscriptions.py b/backend/app/routers/subscriptions.py new file mode 100644 index 0000000..6a9cf38 --- /dev/null +++ b/backend/app/routers/subscriptions.py @@ -0,0 +1,162 @@ +"""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() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a5a409c..1378ca0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 ------------------------------------------------------------ diff --git a/backend/app/services/backup_data.py b/backend/app/services/backup_data.py index 9a6db19..df5f745 100644 --- a/backend/app/services/backup_data.py +++ b/backend/app/services/backup_data.py @@ -48,6 +48,7 @@ from ..models import ( ImportBatch, ImportTemplate, InboxItem, + Subscription, LinkIgnored, PlannedIncomeLine, RecoveryCode, @@ -130,6 +131,13 @@ USER_TABLES: list[TableSpec] = [ "category_id": "categories", "transaction_id": "transactions", }, label="Eingang"), + # Abos & Vertraege: reine Stammdaten, aber muehsam nachzutragen – und sie + # verweisen auf Konten und Kategorien, stehen deshalb nach beiden. + TableSpec("subscriptions", Subscription, "owner", { + "account_id": "accounts", + "savings_account_id": "accounts", + "category_id": "categories", + }, label="Abos & Vertraege"), TableSpec("transfer_links", TransferLink, "owner", {"from_txn_id": "transactions", "to_txn_id": "transactions"}, label="Übertrags-Verknüpfungen"), @@ -370,6 +378,7 @@ _MERGE_KEYS: dict[str, Callable[[dict], tuple | None]] = { # zwei Zeilen dieselbe Zahlung sind – und ohne sie liefe „Ergaenzen" gegen # die Eindeutigkeit von (owner_id, source_uid), statt die Zeile zu # erkennen. Erst danach der Aehnlichkeits-Schluessel. + "subscriptions": lambda r: ("abo", (r.get("name") or "").strip().lower()), "transactions": lambda r: ( ("uid", r["source_uid"]) if r.get("source_uid") else ("hash", r["import_hash"]) if r.get("import_hash") diff --git a/backend/app/services/ecb_rates.py b/backend/app/services/ecb_rates.py index 40f8ffc..9bb35a9 100644 --- a/backend/app/services/ecb_rates.py +++ b/backend/app/services/ecb_rates.py @@ -12,13 +12,17 @@ from decimal import Decimal import httpx from sqlalchemy.orm import Session +from .. import waehrungen from ..models import ExchangeRate FEED_90D = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml" FEED_FULL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml" -# Welche Währungen wir speichern (Hauptwährungen des Nutzers: EUR ist Pivot). -CURRENCIES = {"CHF"} +# Welche Währungen wir speichern. Alle, die der Feed hergibt: Sie werden ohnehin +# schon heruntergeladen und eingelesen: sie wegzuwerfen sparte nichts und hiesse +# nur, dass ein Abo in Dollar später nicht anlegbar wäre. EUR ist der Pivot und +# hat keine eigene Zeile. +CURRENCIES = waehrungen.EZB_WAEHRUNGEN def _parse(xml_bytes: bytes) -> list[tuple[date, str, str]]: diff --git a/backend/app/waehrungen.py b/backend/app/waehrungen.py new file mode 100644 index 0000000..5066a1b --- /dev/null +++ b/backend/app/waehrungen.py @@ -0,0 +1,33 @@ +"""Welche Waehrungen Certain kennt. + +Eine Waehrung ist nur dann brauchbar, wenn es Kurse zu ihr gibt: Ohne Kurs +liesse sich ein Betrag zwar hinschreiben, aber nie in die Hauptwaehrung +umrechnen – er stuende in keiner Summe, in keinem Budget, in keiner +Auswertung. Frei erfundene Waehrungen anzubieten waere deshalb eine Falle. + +Massgeblich ist darum die Liste der EZB: genau die Waehrungen, fuer die sie +werktaeglich einen Referenzkurs veroeffentlicht. EUR ist der Drehpunkt und +braucht keinen eigenen Kurs (implizit 1.0). +""" + +# Der Drehpunkt. Steht bewusst getrennt: Fuer ihn gibt es keine Kurszeile. +PIVOT = "EUR" + +# Die Waehrungen des taeglichen EZB-Referenzkurs-Feeds. +EZB_WAEHRUNGEN = { + "USD", "JPY", "BGN", "CZK", "DKK", "GBP", "HUF", "PLN", "RON", "SEK", + "CHF", "ISK", "NOK", "TRY", "AUD", "BRL", "CAD", "CNY", "HKD", "IDR", + "ILS", "INR", "KRW", "MXN", "MYR", "NZD", "PHP", "SGD", "THB", "ZAR", +} + +# Alles, was irgendwo als Waehrung eingetragen werden darf. +ERLAUBT = EZB_WAEHRUNGEN | {PIVOT} + +# Reihenfolge fuer Auswahlfelder: das Naheliegende zuerst, der Rest alphabetisch. +VORNE = ["EUR", "CHF", "USD", "GBP"] + + +def auswahl() -> list[str]: + """Alle erlaubten Waehrungen, brauchbar sortiert.""" + rest = sorted(ERLAUBT - set(VORNE)) + return VORNE + rest diff --git a/backend/migrations/versions/0030_abos_und_vertraege.py b/backend/migrations/versions/0030_abos_und_vertraege.py new file mode 100644 index 0000000..a0bff1d --- /dev/null +++ b/backend/migrations/versions/0030_abos_und_vertraege.py @@ -0,0 +1,52 @@ +"""Abos und Vertraege: wiederkehrende Zahlungen mit bekanntem Betrag. + +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. + +Revision ID: 0030 +Revises: 0029 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0030" +down_revision = "0029" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "subscriptions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("icon", sa.String(length=40), nullable=False, server_default="refresh-ccw"), + sa.Column("color", sa.String(length=9), nullable=False, server_default="#6b7480"), + sa.Column("amount_cents", sa.Integer(), nullable=False, server_default="0"), + sa.Column("currency", sa.String(length=3), nullable=False, server_default="EUR"), + sa.Column("interval_months", sa.Integer(), nullable=False, server_default="1"), + sa.Column("account_id", sa.Integer(), + sa.ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True), + sa.Column("category_id", sa.Integer(), + sa.ForeignKey("categories.id", ondelete="SET NULL"), nullable=True), + sa.Column("next_due", sa.Date(), nullable=True), + sa.Column("payee_pattern", sa.String(length=255), nullable=True), + sa.Column("savings_account_id", sa.Integer(), + sa.ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("cancelled_at", sa.Date(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_subscriptions_owner_id", "subscriptions", ["owner_id"]) + + +def downgrade() -> None: + op.drop_index("ix_subscriptions_owner_id", table_name="subscriptions") + op.drop_table("subscriptions") diff --git a/backend/tests/test_abos.py b/backend/tests/test_abos.py new file mode 100644 index 0000000..c1a4214 --- /dev/null +++ b/backend/tests/test_abos.py @@ -0,0 +1,163 @@ +"""Abos & Vertraege: verzeichnen und zusammenrechnen. + +Der Nutzen dieser Stufe ist eine einzige Zahl – was die Vertraege zusammen im +Monat kosten. Alles andere haengt daran, dass sie stimmt: auch ueber +Waehrungen und ueber verschiedene Intervalle hinweg. +""" + +import secrets +from datetime import date, timedelta + +from conftest import login_ready, new_user + + +def _abo(admin, **kw): + body = {"name": "Netflix " + secrets.token_hex(3), "amount_cents": 1790, + "currency": "EUR", "interval_months": 1} + body.update(kw) + r = admin.post("/api/subscriptions", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def _liste(admin): + r = admin.get("/api/subscriptions") + assert r.status_code == 200, r.text + return r.json() + + +def _kategorie(admin, name): + for g in admin.get("/api/categories").json(): + for c in g["categories"]: + if c["name"] == name: + return c["id"] + raise AssertionError(f"Kategorie '{name}' fehlt") + + +def test_monatswert_wird_aus_dem_intervall_gerechnet(admin): + """Nicht gespeichert, sondern gerechnet – sonst veraltet er beim naechsten + Preisaufschlag stillschweigend.""" + jahr = _abo(admin, amount_cents=40000, interval_months=12) + assert jahr["monatlich_cents"] == 3333 + + quartal = _abo(admin, amount_cents=3000, interval_months=3) + assert quartal["monatlich_cents"] == 1000 + + monat = _abo(admin, amount_cents=1790, interval_months=1) + assert monat["monatlich_cents"] == 1790 + + +def test_preisaenderung_zieht_den_monatswert_mit(admin): + abo = _abo(admin, amount_cents=1790, interval_months=1) + r = admin.patch(f"/api/subscriptions/{abo['id']}", json={"amount_cents": 1990}) + assert r.status_code == 200, r.text + assert r.json()["monatlich_cents"] == 1990 + + +def test_summe_zaehlt_gekuendigte_nicht_mit(admin): + vorher = _liste(admin)["summary"]["monatlich_cents"] + abo = _abo(admin, amount_cents=2500, interval_months=1, currency="EUR") + assert _liste(admin)["summary"]["monatlich_cents"] == vorher + 2500 + + admin.patch(f"/api/subscriptions/{abo['id']}", json={"is_active": False}) + assert _liste(admin)["summary"]["monatlich_cents"] == vorher + + +def test_fremde_waehrung_wird_umgerechnet(admin): + """Ein deutscher Handyvertrag laeuft in Euro, auch wenn das Konto Franken + fuehrt. Ohne Umrechnung liessen sich die Zeilen nicht addieren.""" + from app.database import SessionLocal + from app.models import ExchangeRate + + with SessionLocal() as s: + if s.get(ExchangeRate, (date.today(), "CHF")) is None: + s.add(ExchangeRate(date=date.today(), currency="CHF", + rate_per_eur=0.95, source="test")) + s.commit() + + abo = _abo(admin, amount_cents=1000, interval_months=1, currency="CHF") + # Hauptwaehrung ist EUR: 10.00 CHF bei 0.95 CHF je EUR sind 10.53 EUR. + assert abo["monatlich_base_cents"] == 1053 + assert abo["monatlich_cents"] == 1000, "die eigene Waehrung bleibt unangetastet" + + +def test_waehrung_ohne_kurs_wird_gemeldet_statt_verschluckt(admin): + """Lieber sagen, dass eine Zeile fehlt, als die Summe zu klein anzuzeigen.""" + abo = _abo(admin, amount_cents=999, interval_months=1, currency="ZAR") + summary = _liste(admin)["summary"] + assert "ZAR" in summary["ohne_kurs"], summary + treffer = next(z for z in _liste(admin)["subscriptions"] if z["id"] == abo["id"]) + assert treffer["monatlich_base_cents"] is None + + +def test_dollar_ist_jetzt_erlaubt(admin): + """Manche Abos werden in Dollar abgerechnet. Frueher lehnte das Formular + alles ausser EUR und CHF ab.""" + abo = _abo(admin, currency="USD", amount_cents=1200) + assert abo["currency"] == "USD" + + +def test_erfundene_waehrung_wird_abgelehnt(admin): + """Ohne Kurs liesse sich der Betrag nie umrechnen – er stuende in keiner + Summe. Das faellt spaeter auf und ist dann schwer zu erklaeren.""" + r = admin.post("/api/subscriptions", json={ + "name": "Fantasie", "amount_cents": 100, "currency": "XYZ", + "interval_months": 1}) + assert r.status_code == 422, r.text + + +def test_krummes_intervall_wird_abgelehnt(admin): + r = admin.post("/api/subscriptions", json={ + "name": "Krumm", "amount_cents": 100, "currency": "EUR", + "interval_months": 37}) + assert r.status_code == 422, r.text + + +def test_kategorie_muss_eine_ausgabe_sein(admin): + gehalt = _kategorie(admin, "Gehalt") + r = admin.post("/api/subscriptions", json={ + "name": "Falsch", "amount_cents": 100, "currency": "EUR", + "interval_months": 1, "category_id": gehalt}) + assert r.status_code == 400, r.text + assert "Ausgabe" in r.json()["detail"] + + +def test_erkennungstext_wird_aus_dem_namen_vorbelegt(admin): + abo = _abo(admin, name="Spotify Familie") + assert abo["payee_pattern"] == "Spotify Familie" + + +def test_faelligkeit_zaehlt_die_tage(admin): + abo = _abo(admin, next_due=str(date.today() + timedelta(days=9))) + assert abo["tage_bis_faellig"] == 9 + + ueberfaellig = _abo(admin, next_due=str(date.today() - timedelta(days=2))) + assert ueberfaellig["tage_bis_faellig"] == -2 + + +def test_sparkonto_laesst_sich_wieder_loesen(admin): + """Sonst wird man ein einmal gesetztes Pocket nie wieder los.""" + konto = admin.post("/api/accounts", json={ + "name": "Pocket " + secrets.token_hex(3), "type": "sparkonto", + "currency": "EUR", "opening_balance_cents": 0}).json() + abo = _abo(admin, interval_months=12, amount_cents=40000, + savings_account_id=konto["id"]) + assert abo["savings_account_id"] == konto["id"] + + r = admin.patch(f"/api/subscriptions/{abo['id']}", json={"savings_account_id": None}) + assert r.status_code == 200, r.text + assert r.json()["savings_account_id"] is None + + +def test_fremdes_konto_wird_nicht_angenommen(admin): + """Sonst schriebe ein Vertrag seine Zahlungen spaeter in ein Konto, das + einem anderen gehoert.""" + fremd = admin.post("/api/accounts", json={ + "name": "Fremd " + secrets.token_hex(3), "type": "girokonto", + "currency": "EUR", "opening_balance_cents": 0}).json() + username, password = new_user(admin) + anderer, _ = login_ready(username, password) + r = anderer.post("/api/subscriptions", json={ + "name": "Fremdzugriff", "amount_cents": 100, "currency": "EUR", + "interval_months": 1, "account_id": fremd["id"]}) + assert r.status_code == 404, r.text diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py index 9298ba9..7af78d3 100644 --- a/backend/tests/test_accounts.py +++ b/backend/tests/test_accounts.py @@ -34,8 +34,17 @@ def test_starter_categories_present(admin): def test_currency_validation(admin): - r = admin.post("/api/accounts", json={"name": "X", "currency": "USD"}) - assert r.status_code == 422 + """Waehrungen mit EZB-Referenzkurs sind erlaubt, erfundene nicht. + + Frueher standen hier nur EUR und CHF. Ohne Kurs laesst sich ein Betrag + aber nie umrechnen – er stuende in keiner Summe und in keinem Budget, und + genau daran haengt die Grenze, nicht an einer Vorliebe. + """ + r = admin.post("/api/accounts", json={"name": "Dollar", "currency": "USD"}) + assert r.status_code == 201, r.text + + r = admin.post("/api/accounts", json={"name": "Fantasie", "currency": "XYZ"}) + assert r.status_code == 422, r.text def test_identifiers_stored_and_deduped(admin): diff --git a/web/src/App.jsx b/web/src/App.jsx index b37b605..a85c71c 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -12,6 +12,7 @@ import Accounts from "./pages/Accounts"; import Categories from "./pages/Categories"; import Transactions from "./pages/Transactions"; import Budget from "./pages/Budget"; +import Abos from "./pages/Abos"; import Import from "./pages/Import"; import BankRueckkehr from "./pages/BankRueckkehr"; import Eingang from "./pages/Eingang"; @@ -53,6 +54,7 @@ function Sidebar() { +
{t("nav.system")}
@@ -147,6 +149,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index 9eab73b..b5be798 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -267,6 +267,12 @@ export const api = { request(`/bank/links/${linkId}/account`, { method: "DELETE" }), // Kategorien (Gruppen + Kategorien) + // Abos & Verträge + listSubscriptions: () => request("/subscriptions"), + createSubscription: (body) => request("/subscriptions", { method: "POST", body }), + updateSubscription: (id, body) => request(`/subscriptions/${id}`, { method: "PATCH", body }), + deleteSubscription: (id) => request(`/subscriptions/${id}`, { method: "DELETE" }), + listCategories: () => request("/categories"), createGroup: (body) => request("/categories/groups", { method: "POST", body }), updateGroup: (id, body) => request(`/categories/groups/${id}`, { method: "PATCH", body }), diff --git a/web/src/finance.css b/web/src/finance.css index 88c6d14..a7342d3 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1625,3 +1625,66 @@ tr.row-dup { background: var(--surface-2); font-size: 0.95em; } + +/* --- Abos & Verträge ----------------------------------------------------- */ + +/* Die Summe ganz oben: Sie ist die Antwort auf die Frage, wegen der man die + Seite überhaupt öffnet. */ +.abo-summe { + display: flex; + flex-wrap: wrap; + gap: var(--sp-5); +} +.abo-summe-zahl { + font-size: 1.4rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + margin-top: 2px; +} + +.abo-liste { + display: flex; + flex-direction: column; +} +.abo-zeile { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) 0; + border-bottom: 1px solid var(--border); +} +.abo-zeile:last-child { border-bottom: none; } +/* Gekündigtes bleibt lesbar, tritt aber zurück – es zählt nicht mehr mit. */ +.abo-beendet { opacity: 0.55; } + +.abo-mitte { flex: 1; min-width: 0; } +.abo-kopf { + display: flex; + align-items: baseline; + gap: var(--sp-2); + min-width: 0; +} +.abo-unten { + display: flex; + gap: 5px; + min-width: 0; + margin-top: 1px; +} +.abo-faellig { color: var(--muted); white-space: nowrap; } +.abo-faellig.warn { color: var(--warn); } + +.abo-betrag { + display: flex; + flex-direction: column; + align-items: flex-end; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.abo-knoepfe { display: flex; gap: 2px; } + +/* Das Ansparen gehört sichtbar zum Intervall darüber, nicht daneben. */ +.abo-ansparen { + border-left: 2px solid var(--border-strong); + padding-left: var(--sp-3); + margin-bottom: var(--sp-3); +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index da8c9c8..0549adc 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -23,7 +23,8 @@ "system": "System", "users": "Benutzer", "settings": "Einstellungen", - "todo": "Zu erledigen" + "todo": "Zu erledigen", + "abos": "Abos" }, "login": { "subtitle": "Bitte anmelden", @@ -320,6 +321,51 @@ "incomeCategory": "Kategorie", "targetAccount": "Zielkonto" }, + "abos": { + "title": "Abos & Verträge", + "new": "Neuer Vertrag", + "edit": "Vertrag bearbeiten", + "delete": "Löschen", + "deleteConfirm": "„{{name}}“ wirklich löschen? Der Vertrag verschwindet samt seiner Angaben. Wenn er nur ausgelaufen ist, setz ihn lieber auf gekündigt – dann bleibt er zum Nachsehen stehen.", + "monthlyTotal": "Im Monat", + "yearlyTotal": "Im Jahr", + "activeCount": "Laufende Verträge", + "dueSoon": "Demnächst fällig", + "all": "Alle Verträge", + "cancelled": "Gekündigt", + "cancelledHint": "Zählen nicht mehr in der Summe. Sie bleiben stehen, damit du später nachsehen kannst, was du hattest.", + "empty": "Noch keine Verträge. Trag ein, was regelmässig abgeht – Streaming, Handy, Versicherung, Halbtax.", + "noRate": "Für {{waehrungen}} liegt kein Kurs vor. Diese Verträge fehlen in der Summe.", + "name": "Name", + "amount": "Betrag je Abrechnung", + "currency": "Währung", + "interval": "Abgerechnet", + "every": { + "1": "monatlich", + "2": "alle 2 Monate", + "3": "vierteljährlich", + "6": "halbjährlich", + "12": "jährlich" + }, + "perMonth": "{{betrag}} im Monat", + "paidFrom": "Bezahlt über", + "accountNone": "Kein Konto gewählt", + "category": "Kategorie", + "categoryNone": "Keine Kategorie", + "nextDue": "Nächste Fälligkeit", + "dueIn": "in {{tage}} Tagen", + "dueToday": "heute fällig", + "overdue": "seit {{tage}} Tagen überfällig", + "savingsAccount": "Ich lege monatlich zurück auf", + "savingsNone": "Nicht ansparen", + "savingsHint": "Bei diesem Intervall wären das {{betrag}} im Monat. Wähl das Konto, auf das du zurücklegst – dann sprengt die Rechnung nicht einen einzelnen Monat.", + "savingInto": "spart auf {{name}}", + "pattern": "Erkennungstext", + "patternHint": "Woran die Abbuchung im Auszug zu erkennen ist. Auf dem Auszug steht selten der Name, unter dem du den Vertrag kennst. Wird noch nicht ausgewertet – kommt mit der automatischen Zuordnung.", + "cancelledFlag": "Gekündigt", + "cancelledFlagHint": "Zählt dann nicht mehr in der Monatssumme, bleibt aber in der Liste stehen.", + "notes": "Notiz" + }, "import": { "title": "Import", "modeCsv": "CSV", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 296eafd..4200ce1 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -23,7 +23,8 @@ "system": "System", "users": "Users", "settings": "Settings", - "todo": "To do" + "todo": "To do", + "abos": "Subscriptions" }, "login": { "subtitle": "Please sign in", @@ -320,6 +321,51 @@ "incomeCategory": "Category", "targetAccount": "Target account" }, + "abos": { + "title": "Subscriptions & contracts", + "new": "New contract", + "edit": "Edit contract", + "delete": "Delete", + "deleteConfirm": "Really delete “{{name}}”? The contract and its details are gone. If it merely ended, mark it cancelled instead — then it stays for reference.", + "monthlyTotal": "Per month", + "yearlyTotal": "Per year", + "activeCount": "Active contracts", + "dueSoon": "Due soon", + "all": "All contracts", + "cancelled": "Cancelled", + "cancelledHint": "No longer counted in the total. They stay so you can look back at what you had.", + "empty": "No contracts yet. Add what goes out regularly — streaming, phone, insurance, travel pass.", + "noRate": "No exchange rate for {{waehrungen}}. Those contracts are missing from the total.", + "name": "Name", + "amount": "Amount per billing", + "currency": "Currency", + "interval": "Billed", + "every": { + "1": "monthly", + "2": "every 2 months", + "3": "quarterly", + "6": "twice a year", + "12": "yearly" + }, + "perMonth": "{{betrag}} per month", + "paidFrom": "Paid from", + "accountNone": "No account chosen", + "category": "Category", + "categoryNone": "No category", + "nextDue": "Next due", + "dueIn": "in {{tage}} days", + "dueToday": "due today", + "overdue": "{{tage}} days overdue", + "savingsAccount": "I set aside monthly into", + "savingsNone": "Do not set aside", + "savingsHint": "At this interval that would be {{betrag}} per month. Pick the account you set aside into — then the bill does not blow up a single month.", + "savingInto": "saving into {{name}}", + "pattern": "Match text", + "patternHint": "How the charge appears on the statement — rarely the name you know it by. Not used yet; it arrives with automatic matching.", + "cancelledFlag": "Cancelled", + "cancelledFlagHint": "Stops counting towards the monthly total but stays in the list.", + "notes": "Note" + }, "import": { "title": "Import", "modeCsv": "CSV", diff --git a/web/src/pages/Abos.jsx b/web/src/pages/Abos.jsx new file mode 100644 index 0000000..2483778 --- /dev/null +++ b/web/src/pages/Abos.jsx @@ -0,0 +1,481 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api } from "../api"; +import { useSettings } from "../settings"; +import { useConfirm } from "../confirm"; +import { useToast } from "../toast"; +import Icon from "../components/Icon"; +import IconChip from "../components/IconChip"; +import Modal from "../components/Modal"; +import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker"; +import CategorySelect from "../components/CategorySelect"; +import AccountSelect from "../components/AccountSelect"; +import { gruppenFuer } from "../kategorien"; +import { WAEHRUNGEN } from "../waehrungen"; +import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format"; + +// Muss zu INTERVALL_MONATE im Server passen. +const INTERVALLE = [1, 2, 3, 6, 12]; + +// Ab wann eine Fälligkeit als „demnächst" gilt. +const BALD_TAGE = 30; + +function leeresFormular() { + return { + name: "", + icon: "refresh-ccw", + color: "#6b7480", + amount: "", + currency: "EUR", + interval_months: "1", + account_id: "", + category_id: "", + next_due: "", + payee_pattern: "", + savings_account_id: "", + notes: "", + is_active: true, + }; +} + +export default function Abos() { + const { t } = useTranslation(); + const { locale } = useSettings(); + const confirm = useConfirm(); + const toast = useToast(); + + const [daten, setDaten] = useState(null); // { subscriptions, summary } + const [konten, setKonten] = useState([]); + const [groups, setGroups] = useState([]); + const [form, setForm] = useState(null); // Formularzustand oder null + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + + async function laden() { + try { + setDaten(await api.listSubscriptions()); + } catch (e) { + toast(e.message, "error"); + } + } + useEffect(() => { + laden(); + api.listAccounts().then(setKonten).catch(() => setKonten([])); + api.listCategories().then(setGroups).catch(() => setGroups([])); + }, []); + + const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); + const kontoById = useMemo( + () => Object.fromEntries(konten.map((a) => [a.id, a])), + [konten], + ); + const katById = useMemo(() => { + const m = {}; + for (const g of groups) for (const c of g.categories) m[c.id] = c; + return m; + }, [groups]); + + // Ansparen ergibt nur Sinn, wenn nicht ohnehin jeden Monat abgebucht wird. + const spartAn = form && Number(form.interval_months) > 1; + + function neu() { + setEditing(null); + setForm(leeresFormular()); + } + + function bearbeiten(s) { + setEditing(s); + setForm({ + name: s.name, + icon: s.icon, + color: s.color, + amount: centsToInput(s.amount_cents), + currency: s.currency, + interval_months: String(s.interval_months), + account_id: s.account_id ? String(s.account_id) : "", + category_id: s.category_id ? String(s.category_id) : "", + next_due: s.next_due || "", + payee_pattern: s.payee_pattern || "", + savings_account_id: s.savings_account_id ? String(s.savings_account_id) : "", + notes: s.notes || "", + is_active: s.is_active, + }); + } + + async function speichern(e) { + e.preventDefault(); + setBusy(true); + const body = { + name: form.name.trim(), + icon: form.icon, + color: form.color, + amount_cents: parseAmountToCents(form.amount) ?? 0, + currency: form.currency, + interval_months: Number(form.interval_months), + account_id: form.account_id ? Number(form.account_id) : null, + category_id: form.category_id ? Number(form.category_id) : null, + next_due: form.next_due || null, + payee_pattern: form.payee_pattern.trim() || null, + // Bei monatlicher Abrechnung gibt es nichts anzusparen – ein aus einer + // früheren Eingabe stehengebliebenes Konto wäre sonst still wirksam. + savings_account_id: + spartAn && form.savings_account_id ? Number(form.savings_account_id) : null, + notes: form.notes.trim() || null, + is_active: form.is_active, + }; + try { + if (editing) await api.updateSubscription(editing.id, body); + else await api.createSubscription(body); + setForm(null); + toast(t("settings.saved")); + laden(); + } catch (err) { + toast(err.message, "error"); + } finally { + setBusy(false); + } + } + + async function loeschen(s) { + const ok = await confirm({ + title: t("abos.delete"), + message: t("abos.deleteConfirm", { name: s.name }), + confirmLabel: t("abos.delete"), + danger: true, + }); + if (!ok) return; + try { + await api.deleteSubscription(s.id); + laden(); + } catch (e) { + toast(e.message, "error"); + } + } + + if (!daten) return null; + + const { subscriptions: liste, summary } = daten; + const aktiv = liste.filter((s) => s.is_active); + const gekuendigt = liste.filter((s) => !s.is_active); + const bald = aktiv + .filter((s) => s.tage_bis_faellig != null && s.tage_bis_faellig <= BALD_TAGE) + .sort((a, b) => a.tage_bis_faellig - b.tage_bis_faellig); + + function faelligText(s) { + if (s.tage_bis_faellig == null) return null; + if (s.tage_bis_faellig < 0) return t("abos.overdue", { tage: -s.tage_bis_faellig }); + if (s.tage_bis_faellig === 0) return t("abos.dueToday"); + return t("abos.dueIn", { tage: s.tage_bis_faellig }); + } + + function Zeile({ s }) { + const konto = s.account_id ? kontoById[s.account_id] : null; + const kat = s.category_id ? katById[s.category_id] : null; + const pocket = s.savings_account_id ? kontoById[s.savings_account_id] : null; + const beendet = s.is_active ? "" : " abo-beendet"; + const ueberfaellig = s.tage_bis_faellig < 0 ? " warn" : ""; + return ( +
+ +
+
+ {s.name} + {s.tage_bis_faellig != null && s.is_active && ( + {faelligText(s)} + )} +
+
+ {kat && {kat.name}} + {konto && · {konto.name}} + {pocket && ( + · {t("abos.savingInto", { name: pocket.name })} + )} + {s.next_due && · {formatDate(s.next_due, locale)}} +
+
+
+ {formatMoney(s.amount_cents, s.currency)} + {t("abos.every." + s.interval_months)} + {s.interval_months > 1 && ( + + {t("abos.perMonth", { betrag: formatMoney(s.monatlich_cents, s.currency) })} + + )} +
+
+ + +
+
+ ); + } + + return ( + <> +
+

{t("abos.title")}

+ +
+ + {/* Die Zahl, wegen der man die Seite öffnet – ganz oben, nicht unten. */} +
+
+ {t("abos.monthlyTotal")} +
+ {formatMoney(summary.monatlich_cents, summary.currency)} +
+
+
+ {t("abos.yearlyTotal")} +
+ {formatMoney(summary.jaehrlich_cents, summary.currency)} +
+
+
+ {t("abos.activeCount")} +
{summary.anzahl_aktiv}
+
+
+ + {/* Eine zu kleine Summe wäre schlimmer als gar keine. */} + {summary.ohne_kurs.length > 0 && ( +

+ {t("abos.noRate", { waehrungen: summary.ohne_kurs.join(", ") })} +

+ )} + + {bald.length > 0 && ( +
+
+ +

{t("abos.dueSoon")}

+
+
+ {bald.map((s) => ( + + ))} +
+
+ )} + +
+
+ +

{t("abos.all")}

+
+ {aktiv.length === 0 ? ( +

{t("abos.empty")}

+ ) : ( +
+ {aktiv.map((s) => ( + + ))} +
+ )} +
+ + {gekuendigt.length > 0 && ( +
+
+ +

{t("abos.cancelled")}

+
+

{t("abos.cancelledHint")}

+
+ {gekuendigt.map((s) => ( + + ))} +
+
+ )} + + {form && ( + setForm(null)} + footer={ + <> + + + + } + > +
+ + +
+ + +
+ + + + {/* Nur bei längerem Intervall: Monatlich abgebucht gibt es nichts + anzusparen. */} + {spartAn && ( +
+ +

+ {t("abos.savingsHint", { + betrag: formatMoney( + Math.round( + (parseAmountToCents(form.amount) ?? 0) / Number(form.interval_months), + ), + form.currency, + ), + })} +

+
+ )} + + + + + + + + +

{t("abos.patternHint")}

+ + + + + +

{t("abos.cancelledFlagHint")}

+ +