Files
certain/backend/app/routers/accounts.py
T
ScarriffleandClaude Opus 5 76dbd3b481 feat: Geld, an das man nicht herankommt, zaehlt nicht mehr als verfuegbar
Eine Saeule 3a ist gesperrt – bis zur Rente, zur Selbstaendigkeit oder
zur Auswanderung. Bisher zaehlte ihr Stand in dieselbe Summe wie das
Girokonto, und die Antwort auf "wie viel habe ich" war um genau diesen
Betrag zu hoch. Das Geld steht da und ist doch nicht da.

Konten lassen sich jetzt als gebunden kennzeichnen. "Gesamt" zeigt dann
nur noch, was wirklich ausgegeben werden kann; darunter steht
"zusaetzlich CHF 2'004.61 gebunden" – aufklappbar, mit jedem Konto
einzeln. Eine Summe, die man nicht nachrechnen kann, muss man glauben.

Verschwiegen wird nichts: Der Saldo bleibt am Konto stehen, und
Vermoegensverlauf wie Nettovermoegen rechnen weiter damit. Es nicht
ausgeben zu koennen heisst nicht, es nicht zu besitzen – das ist der
ganze Unterschied, und ein Test haelt ihn fest.

Ein eigenes Kennzeichen, keine Ableitung aus dem Kontotyp: Ein frei
verkaeufliches Depot und eine gesperrte Saeule 3a kommen beide als
"depot" herein. Am Typ laesst sich das nicht ablesen – am
ausdruecklichen Eintrag schon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 20:29:34 +02:00

368 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Konten inkl. Erkennungs-Identifikatoren. Alles owner-gescoped."""
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,
AccountIdentifier,
BankAccountLink,
Category,
CategoryKind,
IdentifierKind,
Transaction,
TxnType,
User,
)
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOrder, AccountOut, AccountUpdate
from ..services import balances, bank_sync, currency
from ..services.csv_import import normalize_text
from .settings import BASE_CURRENCY_KEY, get_user_setting
router = APIRouter(prefix="/accounts", tags=["accounts"])
def normalize_identifier(value: str, kind: IdentifierKind) -> str:
"""Vereinheitlicht einen Identifikator für den Abgleich. IBAN/Nummer:
Großbuchstaben, nur alphanumerisch. Freier Text: klein, getrimmt (für die
Zielkonto-Erkennung im Buchungstext)."""
if kind == IdentifierKind.text:
return normalize_text(value)
return "".join(ch for ch in value.upper() if ch.isalnum())
def base_currency(db: Session, user: User) -> str:
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
def _bank_saldi(db: Session, user: User) -> dict:
"""Bank-Saldo je Konto, einmal nachgeschlagen.
Als Sammelabfrage und nicht je Konto: Die Kontenliste wird auf jeder Seite
geladen, eine Abfrage pro Zeile summierte sich schnell.
"""
return {
v.account_id: v
for v in db.query(BankAccountLink).filter(
BankAccountLink.owner_id == user.id,
BankAccountLink.account_id.isnot(None),
BankAccountLink.balance_cents.isnot(None),
)
}
def to_out(db: Session, acc: Account, base: str, on: date,
bank_saldi: dict | None = None) -> AccountOut:
bank = (bank_saldi or {}).get(acc.id)
return AccountOut(
id=acc.id,
name=acc.name,
type=acc.type,
currency=acc.currency,
icon=acc.icon,
color=acc.color,
opening_balance_cents=acc.opening_balance_cents,
opening_date=acc.opening_date,
credit_limit_cents=acc.credit_limit_cents,
is_locked=acc.is_locked,
is_archived=acc.is_archived,
position=acc.position,
savings_category_id=acc.savings_category_id,
identifiers=[
AccountIdentifierOut(id=i.id, kind=i.kind, value=i.value) for i in acc.identifiers
],
balance_cents=balances.account_balance_cents(db, acc),
balance_base_cents=balances.account_balance_base_cents(db, acc, base, on),
bank_balance_cents=bank.balance_cents if bank else None,
bank_balance_at=bank.balance_at if bank else None,
bank_balance_vergleichbar=(
bank_sync.saldo_vergleichbar(bank.balance_type) if bank else False),
)
def _set_identifiers(acc: Account, items) -> None:
"""Gleicht die Identifikator-Liste auf den Ziel-Zustand ab (dedupliziert nach
Art + Normalform). Bewusst als Diff (unveränderte behalten, nur fehlende
hinzufügen / überzählige entfernen): ein einfaches clear()+neu-Anlegen würde
beim Speichern zuerst einfügen und dann löschen und so die UNIQUE-Bedingung
(account_id, kind, value_norm) verletzen (Fehler beim Umbenennen)."""
desired: dict[tuple[str, str], tuple] = {}
for it in items:
norm = normalize_identifier(it.value, it.kind)
if not norm:
continue
key = (it.kind.value, norm)
desired.setdefault(key, (it.kind, it.value.strip(), norm))
keep: set[tuple[str, str]] = set()
for existing in list(acc.identifiers):
key = (existing.kind.value, existing.value_norm)
if key in desired:
keep.add(key)
existing.value = desired[key][1] # Original-Schreibweise ggf. angleichen
else:
acc.identifiers.remove(existing)
for key, (kind, value, norm) in desired.items():
if key not in keep:
acc.identifiers.append(AccountIdentifier(kind=kind, value=value, value_norm=norm))
@router.get("", response_model=list[AccountOut])
def list_accounts(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
base = base_currency(db, user)
today = date.today()
accounts = (
db.query(Account)
.filter(Account.owner_id == user.id)
.order_by(Account.position, Account.name)
.all()
)
saldi = _bank_saldi(db, user)
return [to_out(db, a, base, today, saldi) for a in accounts]
def _pruefe_sparziel(db: Session, user: User, category_id: int | None) -> None:
"""Die Spar-Kategorie muss dem Nutzer gehoeren und eine AUSGABE sein.
Sparen zaehlt im Budget wie eine Ausgabe – gegen eine Einnahme-Kategorie
liesse es sich nicht verplanen, und die Zahl landete in einer Spalte, in
der sie niemand sucht.
"""
if category_id is None:
return
kategorie = get_owned(db, Category, 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. Sparen "
f"zaehlt im Budget wie eine Ausgabe."),
)
@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED)
def create_account(
payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
_pruefe_sparziel(db, user, payload.savings_category_id)
max_pos = (
db.query(Account.position)
.filter(Account.owner_id == user.id)
.order_by(Account.position.desc())
.first()
)
acc = Account(
owner_id=user.id,
name=payload.name.strip(),
type=payload.type,
currency=payload.currency,
icon=payload.icon,
color=payload.color,
opening_balance_cents=payload.opening_balance_cents,
opening_date=payload.opening_date,
credit_limit_cents=payload.credit_limit_cents,
is_locked=payload.is_locked,
savings_category_id=payload.savings_category_id,
position=(max_pos[0] + 1) if max_pos else 0,
)
_set_identifiers(acc, payload.identifiers)
db.add(acc)
db.commit()
db.refresh(acc)
return to_out(db, acc, base_currency(db, user), date.today(),
_bank_saldi(db, user))
@router.get("/{account_id}", response_model=AccountOut)
def get_account(
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
acc = get_owned(db, Account, account_id, user)
return to_out(db, acc, base_currency(db, user), date.today(),
_bank_saldi(db, user))
@router.patch("/{account_id}", response_model=AccountOut)
def update_account(
account_id: int,
payload: AccountUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
acc = get_owned(db, Account, account_id, user)
data = payload.model_dump(exclude_unset=True)
identifiers = data.pop("identifiers", None)
if "savings_category_id" in data:
_pruefe_sparziel(db, user, data["savings_category_id"])
for key, value in data.items():
# "savings_category_id" gehoert in die Ausnahme: Ohne sie liesse sich
# ein einmal gesetztes Sparziel nie wieder loesen. Fuer den
# Kreditrahmen gilt dasselbe – wer ein Konto vom Typ Kreditkarte auf
# Girokonto umstellt, muss den Rahmen wieder loswerden koennen.
if value is not None or key in (
"opening_date", "savings_category_id", "credit_limit_cents"
):
setattr(acc, key, value)
if identifiers is not None:
_set_identifiers(acc, payload.identifiers)
db.commit()
db.refresh(acc)
return to_out(db, acc, base_currency(db, user), date.today(),
_bank_saldi(db, user))
@router.put("/order", status_code=status.HTTP_204_NO_CONTENT)
def set_account_order(
payload: AccountOrder, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
"""Reihenfolge der Konten setzen (Liste der IDs in der gewünschten Folge).
Muss vor ``/{account_id}`` stehen, sonst fängt die ID-Route den Pfad ab.
"""
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
fremd = [i for i in payload.ids if i not in konten]
if fremd:
raise HTTPException(status_code=404, detail=f"Unbekanntes Konto: {fremd[0]}")
for platz, konto_id in enumerate(payload.ids):
konten[konto_id].position = platz
db.commit()
def _entkoppele_uebertraege(db: Session, acc: Account) -> None:
"""Überträge lösen, bevor das Konto verschwindet.
Beide Konto-Spalten einer Buchung hängen an ON DELETE CASCADE. Beim Löschen
eines Kontos verschwand deshalb auch die Gegenseite eines Übertrags – und
damit Geld, das ein ANDERES Konto verlassen hat. Dessen Saldo stieg
stillschweigend. Solche Überträge werden hier zu gewöhnlichen Ausgaben bzw.
Einnahmen auf dem verbleibenden Konto; nur was allein am gelöschten Konto
hängt, geht mit ihm.
"""
# Gelöschtes Konto ist das Ziel: Der Abgang bleibt Ausgabe beim Quellkonto.
for t in (
db.query(Transaction)
.filter(Transaction.to_account_id == acc.id, Transaction.account_id != acc.id)
.all()
):
t.type = TxnType.expense
t.to_account_id = None
t.to_amount_cents = None
# Gelöschtes Konto ist die Quelle: Der Zugang bleibt Einnahme beim Zielkonto.
for t in (
db.query(Transaction)
.filter(
Transaction.account_id == acc.id,
Transaction.to_account_id.isnot(None),
Transaction.to_account_id != acc.id,
)
.all()
):
ziel = db.get(Account, t.to_account_id)
betrag = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
t.type = TxnType.income
t.account_id = t.to_account_id
t.amount_cents = betrag
t.to_account_id = None
t.to_amount_cents = None
if ziel is not None:
t.amount_eur_cents = currency.compute_amount_eur_cents(
db, betrag, ziel.currency, t.date
)
db.flush()
@router.get("/{account_id}/aufschluesselung")
def balance_breakdown(account_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Woraus sich der Saldo zusammensetzt – samt aller Übertrags-Beine.
Stimmt ein Kontostand nicht, liegt es fast immer an einer dieser Zahlen
oder an einem Übertrag, der zu viel oder zu wenig verschiebt. Ohne diese
Ansicht sucht man in einer langen Buchungsliste; mit ihr sieht man sofort,
welcher Teil nicht passt.
"""
konto = get_owned(db, Account, account_id, user)
teile = balances.aufschluesselung(db, konto)
namen = {a.id: a.name for a in db.query(Account).filter(Account.owner_id == user.id)}
# Die Übertrags-Beine einzeln: Sie sind der Teil, den man in der Liste am
# schwersten überblickt, und die haeufigste Ursache.
beine = [
{"id": t.id, "datum": t.date.isoformat(),
"von": namen.get(t.account_id, "?"), "nach": namen.get(t.to_account_id, "?"),
"raus": t.account_id == konto.id,
"cents": (t.amount_cents if t.account_id == konto.id
else (t.to_amount_cents or t.amount_cents)),
"text": t.payee or t.description}
for t in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.transfer,
(Transaction.account_id == konto.id) | (Transaction.to_account_id == konto.id),
).order_by(Transaction.date.desc(), Transaction.id.desc()).limit(50)
]
verknuepfung = (
db.query(BankAccountLink)
.filter(BankAccountLink.owner_id == user.id,
BankAccountLink.account_id == konto.id)
.first()
)
return {
"waehrung": konto.currency,
"teile": teile,
"uebertraege": beine,
"bank": {
"cents": verknuepfung.balance_cents if verknuepfung else None,
"art": verknuepfung.balance_type if verknuepfung else None,
"vergleichbar": (bank_sync.saldo_vergleichbar(verknuepfung.balance_type)
if verknuepfung else False),
},
}
@router.post("/{account_id}/saldo-uebernehmen", response_model=AccountOut)
def adopt_bank_balance(account_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Den von der Bank gemeldeten Saldo als richtig übernehmen.
Die Differenz wandert in den ANFANGSBESTAND, nicht in eine Korrektur-
buchung. Sie steht für Bewegungen, die Certain nie gesehen hat – und
genau dafür ist der Anfangsbestand da. Eine erfundene Buchung stünde
dagegen für immer in der Liste und liefe durch jede Auswertung.
Die Buchungen selbst bleiben unangetastet.
"""
konto = get_owned(db, Account, account_id, user)
verknuepfung = (
db.query(BankAccountLink)
.filter(BankAccountLink.owner_id == user.id,
BankAccountLink.account_id == konto.id,
BankAccountLink.balance_cents.isnot(None))
.first()
)
if verknuepfung is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=("Für dieses Konto liegt kein Saldo der Bank vor. Erst "
"abrufen, dann übernehmen."),
)
unterschied = verknuepfung.balance_cents - balances.account_balance_cents(db, konto)
konto.opening_balance_cents += unterschied
db.commit()
db.refresh(konto)
return to_out(db, konto, base_currency(db, user), date.today(), _bank_saldi(db, user))
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account(
account_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
acc = get_owned(db, Account, account_id, user)
_entkoppele_uebertraege(db, acc)
db.delete(acc)
db.commit()