Bei einer Fremdwaehrungs-Zahlung nennt die Bank oft ihren echten Kurs und den Betrag in der Ausgangswaehrung. Certain warf das weg und naeherte mit dem EZB-Kurs. Das faellt genau dort auf, wo es teuer ist: Die spaeter gelieferte Gegenseite eines Uebertrags laesst sich mit einem geschaetzten Betrag nicht als dieselbe Bewegung wiedererkennen. Der Uebertrag waehlt jetzt in drei Stufen: der von der Bank genannte Gegenbetrag (exakt), sonst ihr Kurs, sonst die EZB. Nur die erste Stufe gilt als bekannt – daran haengt, ob bei einem angebundenen Zielkonto nachgefragt wird. Der Parser klopft mehrere Formen ab, weil die Feldnamen je Anbindung verschieden sind, und setzt still nichts, wenn keine passt. Keine dieser Formen ist gegen echte Daten geprueft; ein falscher Kurs saehe aus wie ein richtiger, deshalb wird lieber gar keiner gesetzt. Ausserdem ein Knopf auf der Kontenseite: den Saldo der Bank als richtig uebernehmen. Die Differenz wandert in den ANFANGSBESTAND – sie steht fuer Bewegungen, die Certain nie gesehen hat, und genau dafuer ist er da. Eine Korrekturbuchung stuende dagegen fuer immer in der Liste und liefe durch jede Auswertung. Die Buchungen bleiben unangetastet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
283 lines
10 KiB
Python
283 lines
10 KiB
Python
"""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,
|
||
IdentifierKind,
|
||
Transaction,
|
||
TxnType,
|
||
User,
|
||
)
|
||
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOrder, AccountOut, AccountUpdate
|
||
from ..services import balances, 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,
|
||
is_archived=acc.is_archived,
|
||
position=acc.position,
|
||
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,
|
||
)
|
||
|
||
|
||
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]
|
||
|
||
|
||
@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)
|
||
):
|
||
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,
|
||
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)
|
||
for key, value in data.items():
|
||
if value is not None or key in ("opening_date",):
|
||
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.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()
|