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
+2
View File
@@ -43,6 +43,7 @@ from .routers import (
uebertraege, uebertraege,
rates, rates,
settings as settings_router, settings as settings_router,
subscriptions,
transactions, transactions,
users, users,
) )
@@ -223,6 +224,7 @@ api.include_router(inbox.router)
api.include_router(mail.router) api.include_router(mail.router)
api.include_router(bank.router) api.include_router(bank.router)
api.include_router(uebertraege.router) api.include_router(uebertraege.router)
api.include_router(subscriptions.router)
# Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern. # Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern.
# Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler # Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler
+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): class BankApp(Base):
"""Die bei Enable Banking registrierte Anwendung dieser Installation. """Die bei Enable Banking registrierte Anwendung dieser Installation.
+162
View File
@@ -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()
+116 -2
View File
@@ -6,6 +6,7 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from . import waehrungen
from .models import ( from .models import (
AccountType, AccountType,
AmountMode, AmountMode,
@@ -17,13 +18,16 @@ from .models import (
TxnType, TxnType,
) )
ALLOWED_CURRENCIES = {"EUR", "CHF"} ALLOWED_CURRENCIES = waehrungen.ERLAUBT
def _check_currency(v: str) -> str: def _check_currency(v: str) -> str:
v = v.upper() v = v.upper()
if v not in ALLOWED_CURRENCIES: 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 return v
@@ -213,6 +217,116 @@ class AccountOut(BaseModel):
bank_balance_vergleichbar: bool = False 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 ------------------------------------------------------------ # --- Kategorien ------------------------------------------------------------
+9
View File
@@ -48,6 +48,7 @@ from ..models import (
ImportBatch, ImportBatch,
ImportTemplate, ImportTemplate,
InboxItem, InboxItem,
Subscription,
LinkIgnored, LinkIgnored,
PlannedIncomeLine, PlannedIncomeLine,
RecoveryCode, RecoveryCode,
@@ -130,6 +131,13 @@ USER_TABLES: list[TableSpec] = [
"category_id": "categories", "category_id": "categories",
"transaction_id": "transactions", "transaction_id": "transactions",
}, label="Eingang"), }, 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", TableSpec("transfer_links", TransferLink, "owner",
{"from_txn_id": "transactions", "to_txn_id": "transactions"}, {"from_txn_id": "transactions", "to_txn_id": "transactions"},
label="Übertrags-Verknüpfungen"), 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 # zwei Zeilen dieselbe Zahlung sind – und ohne sie liefe „Ergaenzen" gegen
# die Eindeutigkeit von (owner_id, source_uid), statt die Zeile zu # die Eindeutigkeit von (owner_id, source_uid), statt die Zeile zu
# erkennen. Erst danach der Aehnlichkeits-Schluessel. # erkennen. Erst danach der Aehnlichkeits-Schluessel.
"subscriptions": lambda r: ("abo", (r.get("name") or "").strip().lower()),
"transactions": lambda r: ( "transactions": lambda r: (
("uid", r["source_uid"]) if r.get("source_uid") ("uid", r["source_uid"]) if r.get("source_uid")
else ("hash", r["import_hash"]) if r.get("import_hash") else ("hash", r["import_hash"]) if r.get("import_hash")
+6 -2
View File
@@ -12,13 +12,17 @@ from decimal import Decimal
import httpx import httpx
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import waehrungen
from ..models import ExchangeRate from ..models import ExchangeRate
FEED_90D = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml" 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" FEED_FULL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml"
# Welche Währungen wir speichern (Hauptwährungen des Nutzers: EUR ist Pivot). # Welche Währungen wir speichern. Alle, die der Feed hergibt: Sie werden ohnehin
CURRENCIES = {"CHF"} # 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]]: def _parse(xml_bytes: bytes) -> list[tuple[date, str, str]]:
+33
View File
@@ -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
@@ -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")
+163
View File
@@ -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
+11 -2
View File
@@ -34,8 +34,17 @@ def test_starter_categories_present(admin):
def test_currency_validation(admin): def test_currency_validation(admin):
r = admin.post("/api/accounts", json={"name": "X", "currency": "USD"}) """Waehrungen mit EZB-Referenzkurs sind erlaubt, erfundene nicht.
assert r.status_code == 422
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): def test_identifiers_stored_and_deduped(admin):
+3
View File
@@ -12,6 +12,7 @@ import Accounts from "./pages/Accounts";
import Categories from "./pages/Categories"; import Categories from "./pages/Categories";
import Transactions from "./pages/Transactions"; import Transactions from "./pages/Transactions";
import Budget from "./pages/Budget"; import Budget from "./pages/Budget";
import Abos from "./pages/Abos";
import Import from "./pages/Import"; import Import from "./pages/Import";
import BankRueckkehr from "./pages/BankRueckkehr"; import BankRueckkehr from "./pages/BankRueckkehr";
import Eingang from "./pages/Eingang"; import Eingang from "./pages/Eingang";
@@ -53,6 +54,7 @@ function Sidebar() {
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} /> <NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
<NavItem to="/eingang" icon="inbox" label={t("nav.todo")} /> <NavItem to="/eingang" icon="inbox" label={t("nav.todo")} />
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} /> <NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
<NavItem to="/abos" icon="refresh-ccw" label={t("nav.abos")} />
<NavItem to="/import" icon="download" label={t("nav.import")} /> <NavItem to="/import" icon="download" label={t("nav.import")} />
<div className="nav-section">{t("nav.system")}</div> <div className="nav-section">{t("nav.system")}</div>
<NavItem to="/settings" icon="settings" label={t("nav.settings")} /> <NavItem to="/settings" icon="settings" label={t("nav.settings")} />
@@ -147,6 +149,7 @@ export default function App() {
<Route path="/eingang" element={<Protected><Eingang /></Protected>} /> <Route path="/eingang" element={<Protected><Eingang /></Protected>} />
<Route path="/bank/rueckkehr" element={<Protected><BankRueckkehr /></Protected>} /> <Route path="/bank/rueckkehr" element={<Protected><BankRueckkehr /></Protected>} />
<Route path="/budget" element={<Protected wide><Budget /></Protected>} /> <Route path="/budget" element={<Protected wide><Budget /></Protected>} />
<Route path="/abos" element={<Protected><Abos /></Protected>} />
<Route path="/import" element={<Protected wide><Import /></Protected>} /> <Route path="/import" element={<Protected wide><Import /></Protected>} />
<Route path="/settings" element={<Protected><Settings /></Protected>} /> <Route path="/settings" element={<Protected><Settings /></Protected>} />
<Route path="/konto" element={<Protected><Account /></Protected>} /> <Route path="/konto" element={<Protected><Account /></Protected>} />
+6
View File
@@ -267,6 +267,12 @@ export const api = {
request(`/bank/links/${linkId}/account`, { method: "DELETE" }), request(`/bank/links/${linkId}/account`, { method: "DELETE" }),
// Kategorien (Gruppen + Kategorien) // 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"), listCategories: () => request("/categories"),
createGroup: (body) => request("/categories/groups", { method: "POST", body }), createGroup: (body) => request("/categories/groups", { method: "POST", body }),
updateGroup: (id, body) => request(`/categories/groups/${id}`, { method: "PATCH", body }), updateGroup: (id, body) => request(`/categories/groups/${id}`, { method: "PATCH", body }),
+63
View File
@@ -1625,3 +1625,66 @@ tr.row-dup {
background: var(--surface-2); background: var(--surface-2);
font-size: 0.95em; 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);
}
+47 -1
View File
@@ -23,7 +23,8 @@
"system": "System", "system": "System",
"users": "Benutzer", "users": "Benutzer",
"settings": "Einstellungen", "settings": "Einstellungen",
"todo": "Zu erledigen" "todo": "Zu erledigen",
"abos": "Abos"
}, },
"login": { "login": {
"subtitle": "Bitte anmelden", "subtitle": "Bitte anmelden",
@@ -320,6 +321,51 @@
"incomeCategory": "Kategorie", "incomeCategory": "Kategorie",
"targetAccount": "Zielkonto" "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": { "import": {
"title": "Import", "title": "Import",
"modeCsv": "CSV", "modeCsv": "CSV",
+47 -1
View File
@@ -23,7 +23,8 @@
"system": "System", "system": "System",
"users": "Users", "users": "Users",
"settings": "Settings", "settings": "Settings",
"todo": "To do" "todo": "To do",
"abos": "Subscriptions"
}, },
"login": { "login": {
"subtitle": "Please sign in", "subtitle": "Please sign in",
@@ -320,6 +321,51 @@
"incomeCategory": "Category", "incomeCategory": "Category",
"targetAccount": "Target account" "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": { "import": {
"title": "Import", "title": "Import",
"modeCsv": "CSV", "modeCsv": "CSV",
+481
View File
@@ -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 (
<div className={"abo-zeile" + beendet}>
<IconChip name={s.icon} color={s.color} size={30} />
<div className="abo-mitte">
<div className="abo-kopf">
<strong className="ellipsis">{s.name}</strong>
{s.tage_bis_faellig != null && s.is_active && (
<span className={"abo-faellig small" + ueberfaellig}>{faelligText(s)}</span>
)}
</div>
<div className="abo-unten muted small">
{kat && <span className="ellipsis">{kat.name}</span>}
{konto && <span className="ellipsis">· {konto.name}</span>}
{pocket && (
<span className="ellipsis">· {t("abos.savingInto", { name: pocket.name })}</span>
)}
{s.next_due && <span>· {formatDate(s.next_due, locale)}</span>}
</div>
</div>
<div className="abo-betrag">
<strong>{formatMoney(s.amount_cents, s.currency)}</strong>
<span className="muted small">{t("abos.every." + s.interval_months)}</span>
{s.interval_months > 1 && (
<span className="muted small">
{t("abos.perMonth", { betrag: formatMoney(s.monatlich_cents, s.currency) })}
</span>
)}
</div>
<div className="abo-knoepfe">
<button className="btn-icon" title={t("common.edit")} onClick={() => bearbeiten(s)}>
<Icon name="edit" size={16} />
</button>
<button className="btn-icon" title={t("abos.delete")} onClick={() => loeschen(s)}>
<Icon name="trash" size={16} className="icon-danger" />
</button>
</div>
</div>
);
}
return (
<>
<div className="page-head">
<h1>{t("abos.title")}</h1>
<button className="btn primary" onClick={neu}>
<Icon name="plus" size={15} /> {t("abos.new")}
</button>
</div>
{/* Die Zahl, wegen der man die Seite öffnet – ganz oben, nicht unten. */}
<div className="card abo-summe">
<div>
<span className="muted small">{t("abos.monthlyTotal")}</span>
<div className="abo-summe-zahl">
{formatMoney(summary.monatlich_cents, summary.currency)}
</div>
</div>
<div>
<span className="muted small">{t("abos.yearlyTotal")}</span>
<div className="abo-summe-zahl">
{formatMoney(summary.jaehrlich_cents, summary.currency)}
</div>
</div>
<div>
<span className="muted small">{t("abos.activeCount")}</span>
<div className="abo-summe-zahl">{summary.anzahl_aktiv}</div>
</div>
</div>
{/* Eine zu kleine Summe wäre schlimmer als gar keine. */}
{summary.ohne_kurs.length > 0 && (
<p className="muted warn small">
{t("abos.noRate", { waehrungen: summary.ohne_kurs.join(", ") })}
</p>
)}
{bald.length > 0 && (
<div className="card">
<div className="card-head">
<Icon name="calendar" size={18} />
<h2>{t("abos.dueSoon")}</h2>
</div>
<div className="abo-liste">
{bald.map((s) => (
<Zeile key={s.id} s={s} />
))}
</div>
</div>
)}
<div className="card">
<div className="card-head">
<Icon name="refresh-ccw" size={18} />
<h2>{t("abos.all")}</h2>
</div>
{aktiv.length === 0 ? (
<p className="muted">{t("abos.empty")}</p>
) : (
<div className="abo-liste">
{aktiv.map((s) => (
<Zeile key={s.id} s={s} />
))}
</div>
)}
</div>
{gekuendigt.length > 0 && (
<div className="card">
<div className="card-head">
<Icon name="close" size={18} />
<h2>{t("abos.cancelled")}</h2>
</div>
<p className="muted small">{t("abos.cancelledHint")}</p>
<div className="abo-liste">
{gekuendigt.map((s) => (
<Zeile key={s.id} s={s} />
))}
</div>
</div>
)}
{form && (
<Modal
title={editing ? t("abos.edit") : t("abos.new")}
onClose={() => setForm(null)}
footer={
<>
<button type="button" className="btn" onClick={() => setForm(null)}>
{t("common.cancel")}
</button>
<button type="submit" form="abo-form" className="btn primary" disabled={busy}>
{t("common.save")}
</button>
</>
}
>
<form id="abo-form" onSubmit={speichern}>
<label>
<span>{t("abos.name")}</span>
<input
autoFocus
value={form.name}
required
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</label>
<div className="row">
<label className="grow">
<span>{t("abos.amount")}</span>
<input
inputMode="decimal"
value={form.amount}
placeholder="0,00"
required
onChange={(e) => setForm({ ...form, amount: e.target.value })}
/>
</label>
<label style={{ minWidth: 110 }}>
<span>{t("abos.currency")}</span>
<select
value={form.currency}
onChange={(e) => setForm({ ...form, currency: e.target.value })}
>
{WAEHRUNGEN.map((w) => (
<option key={w} value={w}>
{w}
</option>
))}
</select>
</label>
</div>
<label>
<span>{t("abos.interval")}</span>
<select
value={form.interval_months}
onChange={(e) => setForm({ ...form, interval_months: e.target.value })}
>
{INTERVALLE.map((n) => (
<option key={n} value={String(n)}>
{t("abos.every." + n)}
</option>
))}
</select>
</label>
{/* Nur bei längerem Intervall: Monatlich abgebucht gibt es nichts
anzusparen. */}
{spartAn && (
<div className="abo-ansparen">
<label>
<span>{t("abos.savingsAccount")}</span>
<AccountSelect
accounts={konten}
value={form.savings_account_id}
onChange={(id) =>
setForm({ ...form, savings_account_id: id != null ? String(id) : "" })
}
allowNone
noneLabel={t("abos.savingsNone")}
placeholder={t("abos.savingsNone")}
/>
</label>
<p className="muted small">
{t("abos.savingsHint", {
betrag: formatMoney(
Math.round(
(parseAmountToCents(form.amount) ?? 0) / Number(form.interval_months),
),
form.currency,
),
})}
</p>
</div>
)}
<label>
<span>{t("abos.paidFrom")}</span>
<AccountSelect
accounts={konten}
value={form.account_id}
onChange={(id) => setForm({ ...form, account_id: id != null ? String(id) : "" })}
allowNone
noneLabel={t("abos.accountNone")}
placeholder={t("abos.accountNone")}
/>
</label>
<label>
<span>{t("abos.category")}</span>
<CategorySelect
groups={expenseGroups}
value={form.category_id}
onChange={(id) => setForm({ ...form, category_id: id != null ? String(id) : "" })}
allowNone
noneLabel={t("abos.categoryNone")}
placeholder={t("abos.categoryNone")}
/>
</label>
<label>
<span>{t("abos.nextDue")}</span>
<input
type="date"
value={form.next_due}
onChange={(e) => setForm({ ...form, next_due: e.target.value })}
/>
</label>
<label>
<span>{t("abos.pattern")}</span>
<input
value={form.payee_pattern}
placeholder={form.name}
onChange={(e) => setForm({ ...form, payee_pattern: e.target.value })}
/>
</label>
<p className="muted small">{t("abos.patternHint")}</p>
<label>
<span>{t("accounts.icon")}</span>
<IconPicker
icons={CATEGORY_ICONS}
value={form.icon}
color={form.color}
onChange={(name) => setForm({ ...form, icon: name })}
/>
</label>
<label>
<span>{t("accounts.color")}</span>
<div className="color-row">
<input
type="color"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
<IconChip name={form.icon} color={form.color} />
</div>
</label>
<label className="checkline">
<input
type="checkbox"
checked={!form.is_active}
onChange={(e) => setForm({ ...form, is_active: !e.target.checked })}
/>
<span>{t("abos.cancelledFlag")}</span>
</label>
<p className="muted small">{t("abos.cancelledFlagHint")}</p>
<label>
<span>{t("abos.notes")}</span>
<textarea
rows={2}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</label>
</form>
</Modal>
)}
</>
);
}
+2 -2
View File
@@ -11,6 +11,7 @@ import Modal from "../components/Modal";
import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker"; import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker";
import CategorySelect from "../components/CategorySelect"; import CategorySelect from "../components/CategorySelect";
import { gruppenFuer } from "../kategorien"; import { gruppenFuer } from "../kategorien";
import { WAEHRUNGEN } from "../waehrungen";
import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format"; import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format";
const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"]; const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"];
@@ -470,8 +471,7 @@ export default function Accounts() {
<label style={{ minWidth: 120 }}> <label style={{ minWidth: 120 }}>
<span>{t("accounts.currency")}</span> <span>{t("accounts.currency")}</span>
<select value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })}> <select value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })}>
<option value="EUR">EUR</option> {WAEHRUNGEN.map((w) => <option key={w} value={w}>{w}</option>)}
<option value="CHF">CHF</option>
</select> </select>
</label> </label>
</div> </div>
+17
View File
@@ -0,0 +1,17 @@
// Welche Währungen die Oberfläche anbietet.
//
// Muss zur Erlaubnisliste des Servers passen (backend/app/waehrungen.py):
// genau die Währungen, für die es einen EZB-Referenzkurs gibt. Ohne Kurs
// liesse sich ein Betrag nie umrechnen – er stünde in keiner Summe.
//
// Reihenfolge: das Naheliegende zuerst, der Rest alphabetisch. Wer CHF sucht,
// soll nicht an zwanzig Währungen vorbeiscrollen, die er nie braucht.
const VORNE = ["EUR", "CHF", "USD", "GBP"];
const REST = [
"AUD", "BGN", "BRL", "CAD", "CNY", "CZK", "DKK", "HKD", "HUF", "IDR",
"ILS", "INR", "ISK", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PHP",
"PLN", "RON", "SEK", "SGD", "THB", "TRY", "ZAR",
];
export const WAEHRUNGEN = [...VORNE, ...REST];