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>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user