"""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()