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:
co-authored by
Claude Opus 5
parent
ff621984dc
commit
f99be47587
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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 ------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user