Files
certain/backend/app/routers/accounts.py
T
GuidoandClaude Opus 4.8 1b7107b9bd feat: Phase 1 – Konten, Kategorien, Transaktionen, EZB-Währungsumrechnung
Backend:
- Modelle + Migration 0002: accounts, account_identifiers, category_groups,
  categories, transactions, exchange_rates (alle owner-gescoped).
- EZB-Kursdienst (90-Tage/Historie) + Backfill-Skript; Umrechnung EUR<->CHF zum
  Buchungsdatum mit lokalem Cache und manueller Übersteuerung; EUR-Pivot
  (amount_eur_cents) eingefroren.
- Kontostände + Netto-Vermögen (Umrechnung in Hauptwährung).
- Router: accounts (inkl. IBAN-Identifikatoren), categories (Gruppen+Kategorien),
  transactions (Ausgabe/Einnahme/Transfer, cross-currency), rates.
- Gruppierte Standard-Kategorien je Nutzer beim Anlegen.
- pytest-Suite: Währung, Salden, Cross-Currency-Transfer, Nutzer-Isolation.

Frontend:
- Seiten Konten, Kategorien, Buchungen; Icon-Chip, Geldbetrag mit Umrechnung,
  Icon-/Farbwähler, Modal; erweitertes SVG-Icon-Set (Konto-/Kategorie-Icons).
- Währungs-/Datumsformatierung (EUR de-DE, CHF de-CH), i18n DE/EN erweitert.
- Live-Umrechnungs-Vorschau im Transfer-Formular.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 18:59:24 +02:00

138 lines
4.5 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, User
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate
from ..services import balances
from .settings import BASE_CURRENCY_KEY, get_user_setting
router = APIRouter(prefix="/accounts", tags=["accounts"])
def normalize_identifier(value: str) -> str:
"""Vereinheitlicht IBAN/Nummer für den Abgleich: Großbuchstaben, nur alphanumerisch."""
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:
"""Ersetzt die Identifikator-Liste (dedupliziert nach Art + Normalform)."""
acc.identifiers.clear()
seen: set[tuple[str, str]] = set()
for it in items:
norm = normalize_identifier(it.value)
key = (it.kind.value, norm)
if not norm or key in seen:
continue
seen.add(key)
acc.identifiers.append(
AccountIdentifier(kind=it.kind, value=it.value.strip(), 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.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)
db.delete(acc)
db.commit()