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("abos.noRate", { waehrungen: summary.ohne_kurs.join(", ") })} +
+ )} + + {bald.length > 0 && ( +{t("abos.empty")}
+ ) : ( +{t("abos.cancelledHint")}
+