Beide Konto-Spalten einer Buchung hingen an ON DELETE CASCADE. Wurde ein Zielkonto geloescht, verschwand die ganze Buchung – auch die Abgangsseite, die einem anderen Konto gehoert. Dessen Saldo stieg dadurch stillschweigend um den Uebertrag; beim Loeschen von 'Revolut investments' waren es 1'020.99. Vor dem Loeschen werden solche Uebertraege jetzt zu gewoehnlicher Ausgabe bzw. Einnahme auf dem verbleibenden Konto. Nur was allein am geloeschten Konto haengt, geht mit ihm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
7.2 KiB
Python
201 lines
7.2 KiB
Python
"""Konten inkl. Erkennungs-Identifikatoren. Alles owner-gescoped."""
|
||
|
||
from datetime import date
|
||
|
||
from fastapi import APIRouter, Depends, 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, 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())
|
||
|
||
|
||
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()
|