Die Liste stand in der Anlegereihenfolge, die selten die gewuenschte ist.
Neuer Endpunkt PUT /accounts/order nimmt die IDs in der gewuenschten Folge
und schreibt die Position; er muss vor /{account_id} stehen, sonst faengt
die ID-Route den Pfad ab. Fremde IDs werden abgewiesen.
In der Kontenliste zwei Pfeile je Zeile, optimistisch sortiert, damit die
Liste beim Klicken nicht springt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
7.9 KiB
Python
218 lines
7.9 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, 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 to_out(db: Session, acc: Account, base: str, on: date) -> AccountOut:
|
||
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),
|
||
)
|
||
|
||
|
||
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()
|
||
)
|
||
return [to_out(db, a, base, today) 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())
|
||
|
||
|
||
@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())
|
||
|
||
|
||
@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())
|
||
|
||
|
||
@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.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()
|