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>
138 lines
4.5 KiB
Python
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()
|