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:
@@ -34,3 +34,14 @@ def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
|||||||
detail="Nur Administratoren dürfen diese Aktion ausführen",
|
detail="Nur Administratoren dürfen diese Aktion ausführen",
|
||||||
)
|
)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def get_owned(db: Session, model, obj_id: int, user: User):
|
||||||
|
"""Holt eine Zeile und stellt sicher, dass sie dem Nutzer gehört – sonst 404.
|
||||||
|
|
||||||
|
Bewusst 404 (nicht 403): fremde IDs sollen nicht einmal als existent gelten.
|
||||||
|
Zentrale Stelle für die Mandanten-Isolation."""
|
||||||
|
obj = db.get(model, obj_id)
|
||||||
|
if obj is None or getattr(obj, "owner_id", None) != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nicht gefunden")
|
||||||
|
return obj
|
||||||
|
|||||||
+16
-1
@@ -17,8 +17,17 @@ from .branding import APP_NAME, VERSION, git_sha
|
|||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .migrate import upgrade_to_head
|
from .migrate import upgrade_to_head
|
||||||
from .routers import auth, settings as settings_router, users
|
from .routers import (
|
||||||
|
accounts,
|
||||||
|
auth,
|
||||||
|
categories,
|
||||||
|
rates,
|
||||||
|
settings as settings_router,
|
||||||
|
transactions,
|
||||||
|
users,
|
||||||
|
)
|
||||||
from .seed import ensure_first_admin
|
from .seed import ensure_first_admin
|
||||||
|
from .services import ecb_rates
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -34,6 +43,8 @@ async def lifespan(app: FastAPI):
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ensure_first_admin(db)
|
ensure_first_admin(db)
|
||||||
|
# Best-effort: aktuelle EZB-Kurse nachladen (ohne Internet läuft es weiter).
|
||||||
|
ecb_rates.ensure_rates_on_startup(db, settings.ecb_fetch_on_start)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
yield
|
yield
|
||||||
@@ -73,6 +84,10 @@ def version() -> dict:
|
|||||||
api.include_router(auth.router)
|
api.include_router(auth.router)
|
||||||
api.include_router(users.router)
|
api.include_router(users.router)
|
||||||
api.include_router(settings_router.router)
|
api.include_router(settings_router.router)
|
||||||
|
api.include_router(accounts.router)
|
||||||
|
api.include_router(categories.router)
|
||||||
|
api.include_router(transactions.router)
|
||||||
|
api.include_router(rates.router)
|
||||||
|
|
||||||
app.include_router(api)
|
app.include_router(api)
|
||||||
|
|
||||||
|
|||||||
+194
-1
@@ -12,15 +12,22 @@ hinzu und werden dann alle über ``owner_id`` je Benutzer getrennt.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
from datetime import datetime, timezone
|
# date/time unter Alias, weil es Spalten namens "date"/"time" gibt, die die
|
||||||
|
# gleichnamigen Typen sonst in den (späten) Annotationen beschatten.
|
||||||
|
from datetime import date as date_t, datetime, time as time_t, timezone
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Boolean,
|
||||||
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum as SAEnum,
|
Enum as SAEnum,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
|
Numeric,
|
||||||
String,
|
String,
|
||||||
|
Text,
|
||||||
|
Time,
|
||||||
|
UniqueConstraint,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -41,6 +48,42 @@ class Role(str, enum.Enum):
|
|||||||
user = "user"
|
user = "user"
|
||||||
|
|
||||||
|
|
||||||
|
class AccountType(str, enum.Enum):
|
||||||
|
girokonto = "girokonto"
|
||||||
|
sparkonto = "sparkonto"
|
||||||
|
bargeld = "bargeld"
|
||||||
|
kreditkarte = "kreditkarte"
|
||||||
|
depot = "depot"
|
||||||
|
sonstiges = "sonstiges"
|
||||||
|
|
||||||
|
|
||||||
|
# Kontotypen, deren Ziel-Transfer standardmäßig als budgetierte Ausgabe zählt
|
||||||
|
# (Sparen/Investment). Andere Transfers sind budgetneutral.
|
||||||
|
SAVINGS_ACCOUNT_TYPES = {AccountType.sparkonto, AccountType.depot}
|
||||||
|
|
||||||
|
|
||||||
|
class IdentifierKind(str, enum.Enum):
|
||||||
|
iban = "iban"
|
||||||
|
account_no = "account_no"
|
||||||
|
card_no = "card_no"
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryKind(str, enum.Enum):
|
||||||
|
income = "income"
|
||||||
|
expense = "expense"
|
||||||
|
|
||||||
|
|
||||||
|
class TxnType(str, enum.Enum):
|
||||||
|
expense = "expense"
|
||||||
|
income = "income"
|
||||||
|
transfer = "transfer"
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetTreatment(str, enum.Enum):
|
||||||
|
budgeted = "budgeted"
|
||||||
|
neutral = "neutral"
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -96,3 +139,153 @@ class UserSetting(Base):
|
|||||||
value: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
value: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
user: Mapped[User] = relationship(back_populates="settings")
|
user: Mapped[User] = relationship(back_populates="settings")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1: Konten, Kategorien, Transaktionen, Wechselkurse
|
||||||
|
# Alle fachlichen Tabellen tragen owner_id und werden nur owner-gescoped gelesen.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Account(Base):
|
||||||
|
__tablename__ = "accounts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
type: Mapped[AccountType] = mapped_column(_enum(AccountType), nullable=False, default=AccountType.girokonto)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||||
|
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="wallet")
|
||||||
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f51b5")
|
||||||
|
opening_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
opening_date: Mapped[date_t | None] = mapped_column(Date, nullable=True)
|
||||||
|
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
identifiers: Mapped[list[AccountIdentifier]] = relationship(
|
||||||
|
back_populates="account", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountIdentifier(Base):
|
||||||
|
"""Erkennungsmerkmal eines Kontos (IBAN/Kontonummer/Kartennummer) für die
|
||||||
|
Transfer-Erkennung beim Import. ``value_norm`` ist die normalisierte Form."""
|
||||||
|
|
||||||
|
__tablename__ = "account_identifiers"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
account_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
kind: Mapped[IdentifierKind] = mapped_column(_enum(IdentifierKind), nullable=False, default=IdentifierKind.iban)
|
||||||
|
value: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
value_norm: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
|
||||||
|
account: Mapped[Account] = relationship(back_populates="identifiers")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("account_id", "kind", "value_norm", name="uq_ident_account_kind_value"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryGroup(Base):
|
||||||
|
__tablename__ = "category_groups"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
kind: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||||
|
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="list")
|
||||||
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
categories: Mapped[list[Category]] = relationship(
|
||||||
|
back_populates="group", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
__tablename__ = "categories"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
group_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("category_groups.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
type: Mapped[CategoryKind] = mapped_column(_enum(CategoryKind), nullable=False, default=CategoryKind.expense)
|
||||||
|
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="more-horizontal")
|
||||||
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
group: Mapped[CategoryGroup] = relationship(back_populates="categories")
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(Base):
|
||||||
|
__tablename__ = "transactions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
type: Mapped[TxnType] = mapped_column(_enum(TxnType), nullable=False)
|
||||||
|
date: Mapped[date_t] = mapped_column(Date, nullable=False, index=True)
|
||||||
|
time: Mapped[time_t | None] = mapped_column(Time, nullable=True)
|
||||||
|
|
||||||
|
account_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
to_account_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Betrag in Kontowährung (bei Transfers: abgebuchter Betrag vom Quellkonto).
|
||||||
|
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
# Nur bei währungsübergreifenden Transfers: gutgeschriebener Betrag am Zielkonto.
|
||||||
|
to_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
# Manuell gesetzter Kurs (Kontowährung je 1 EUR); überschreibt den EZB-Kurs.
|
||||||
|
fx_rate: Mapped[float | None] = mapped_column(Numeric(18, 8), nullable=True)
|
||||||
|
# Eingefrorener EUR-Wert von amount_cents zum Buchungsdatum (Pivot für Budget/Dashboard).
|
||||||
|
amount_eur_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
category_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
# Übersteuert die Standard-Regel für Sparen/Depot-Transfers (budgeted|neutral).
|
||||||
|
budget_treatment: Mapped[BudgetTreatment | None] = mapped_column(_enum(BudgetTreatment), nullable=True)
|
||||||
|
# Kategorie, gegen die ein budgetierter Transfer zählt (Sparen/Investment).
|
||||||
|
budget_category_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeRate(Base):
|
||||||
|
"""EZB-Referenzkurs: wie viele Einheiten der Währung ein Euro wert war.
|
||||||
|
|
||||||
|
EUR ist implizit 1.0 (keine Zeile). In der Praxis nur CHF. Nicht owner-gescoped
|
||||||
|
(öffentliche Referenzdaten)."""
|
||||||
|
|
||||||
|
__tablename__ = "exchange_rates"
|
||||||
|
|
||||||
|
date: Mapped[date_t] = mapped_column(Date, primary_key=True)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), primary_key=True)
|
||||||
|
rate_per_eur: Mapped[float] = mapped_column(Numeric(18, 8), nullable=False)
|
||||||
|
source: Mapped[str] = mapped_column(String(16), nullable=False, default="ecb")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Kategorie-Gruppen und Kategorien (zweistufig). Alles owner-gescoped.
|
||||||
|
|
||||||
|
Eine Kategorie erbt ihre Art (Einnahme/Ausgabe) von ihrer Gruppe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 Category, CategoryGroup, User
|
||||||
|
from ..schemas import (
|
||||||
|
CategoryCreate,
|
||||||
|
CategoryGroupCreate,
|
||||||
|
CategoryGroupOut,
|
||||||
|
CategoryGroupUpdate,
|
||||||
|
CategoryOut,
|
||||||
|
CategoryUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||||
|
|
||||||
|
|
||||||
|
def group_out(g: CategoryGroup) -> CategoryGroupOut:
|
||||||
|
cats = sorted(g.categories, key=lambda c: (c.position, c.name.lower()))
|
||||||
|
return CategoryGroupOut(
|
||||||
|
id=g.id,
|
||||||
|
name=g.name,
|
||||||
|
kind=g.kind,
|
||||||
|
icon=g.icon,
|
||||||
|
color=g.color,
|
||||||
|
position=g.position,
|
||||||
|
is_builtin=g.is_builtin,
|
||||||
|
categories=[CategoryOut.model_validate(c) for c in cats],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_position(db: Session, model, **filters) -> int:
|
||||||
|
q = db.query(model.position)
|
||||||
|
for k, v in filters.items():
|
||||||
|
q = q.filter(getattr(model, k) == v)
|
||||||
|
row = q.order_by(model.position.desc()).first()
|
||||||
|
return (row[0] + 1) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[CategoryGroupOut])
|
||||||
|
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
groups = (
|
||||||
|
db.query(CategoryGroup)
|
||||||
|
.filter(CategoryGroup.owner_id == user.id)
|
||||||
|
.order_by(CategoryGroup.position, CategoryGroup.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [group_out(g) for g in groups]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Gruppen ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/groups", response_model=CategoryGroupOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_group(
|
||||||
|
payload: CategoryGroupCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
g = CategoryGroup(
|
||||||
|
owner_id=user.id,
|
||||||
|
name=payload.name.strip(),
|
||||||
|
kind=payload.kind,
|
||||||
|
icon=payload.icon,
|
||||||
|
color=payload.color,
|
||||||
|
position=_next_position(db, CategoryGroup, owner_id=user.id),
|
||||||
|
)
|
||||||
|
db.add(g)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(g)
|
||||||
|
return group_out(g)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/groups/{group_id}", response_model=CategoryGroupOut)
|
||||||
|
def update_group(
|
||||||
|
group_id: int,
|
||||||
|
payload: CategoryGroupUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
g = get_owned(db, CategoryGroup, group_id, user)
|
||||||
|
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
if value is not None:
|
||||||
|
setattr(g, key, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(g)
|
||||||
|
return group_out(g)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_group(
|
||||||
|
group_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
g = get_owned(db, CategoryGroup, group_id, user)
|
||||||
|
db.delete(g)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kategorien ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_category(
|
||||||
|
payload: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
group = get_owned(db, CategoryGroup, payload.group_id, user)
|
||||||
|
cat = Category(
|
||||||
|
owner_id=user.id,
|
||||||
|
group_id=group.id,
|
||||||
|
name=payload.name.strip(),
|
||||||
|
type=group.kind, # erbt die Art der Gruppe
|
||||||
|
icon=payload.icon,
|
||||||
|
color=payload.color,
|
||||||
|
position=_next_position(db, Category, owner_id=user.id, group_id=group.id),
|
||||||
|
)
|
||||||
|
db.add(cat)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cat)
|
||||||
|
return CategoryOut.model_validate(cat)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{category_id}", response_model=CategoryOut)
|
||||||
|
def update_category(
|
||||||
|
category_id: int,
|
||||||
|
payload: CategoryUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cat = get_owned(db, Category, category_id, user)
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
new_group_id = data.pop("group_id", None)
|
||||||
|
if new_group_id is not None:
|
||||||
|
group = get_owned(db, CategoryGroup, new_group_id, user)
|
||||||
|
cat.group_id = group.id
|
||||||
|
cat.type = group.kind # Art an die neue Gruppe angleichen
|
||||||
|
for key, value in data.items():
|
||||||
|
if value is not None or key == "is_archived":
|
||||||
|
setattr(cat, key, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cat)
|
||||||
|
return CategoryOut.model_validate(cat)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_category(
|
||||||
|
category_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
cat = get_owned(db, Category, category_id, user)
|
||||||
|
db.delete(cat)
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Wechselkurse: Status und Umrechnungs-Vorschau (für das Buchungsformular)."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user
|
||||||
|
from ..models import ExchangeRate, User
|
||||||
|
from ..schemas import ConversionOut, RateStatus
|
||||||
|
from ..services import currency
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rates", tags=["rates"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=list[RateStatus])
|
||||||
|
def status(db: Session = Depends(get_db), _: User = Depends(get_current_user)):
|
||||||
|
out: list[RateStatus] = []
|
||||||
|
currencies = [
|
||||||
|
row[0] for row in db.query(ExchangeRate.currency).distinct().order_by(ExchangeRate.currency)
|
||||||
|
]
|
||||||
|
for ccy in currencies:
|
||||||
|
agg = db.query(
|
||||||
|
func.count(ExchangeRate.date),
|
||||||
|
func.min(ExchangeRate.date),
|
||||||
|
func.max(ExchangeRate.date),
|
||||||
|
).filter(ExchangeRate.currency == ccy).one()
|
||||||
|
latest = (
|
||||||
|
db.query(ExchangeRate)
|
||||||
|
.filter(ExchangeRate.currency == ccy)
|
||||||
|
.order_by(ExchangeRate.date.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
RateStatus(
|
||||||
|
currency=ccy,
|
||||||
|
count=agg[0] or 0,
|
||||||
|
min_date=agg[1],
|
||||||
|
max_date=agg[2],
|
||||||
|
latest_rate=float(latest.rate_per_eur) if latest else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/convert", response_model=ConversionOut)
|
||||||
|
def convert(
|
||||||
|
amount_cents: int,
|
||||||
|
from_currency: str,
|
||||||
|
to_currency: str,
|
||||||
|
on: date | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
on = on or date.today()
|
||||||
|
from_currency = from_currency.upper()
|
||||||
|
to_currency = to_currency.upper()
|
||||||
|
converted = currency.convert(db, amount_cents, from_currency, to_currency, on)
|
||||||
|
# Anzeige-Kurs: EZB-Kurs der Nicht-EUR-Währung (Einheiten je 1 EUR).
|
||||||
|
non_eur = to_currency if to_currency != "EUR" else from_currency
|
||||||
|
rate = currency.rate_on(db, non_eur, on)
|
||||||
|
return ConversionOut(
|
||||||
|
amount_cents=amount_cents,
|
||||||
|
from_currency=from_currency,
|
||||||
|
to_currency=to_currency,
|
||||||
|
date=on,
|
||||||
|
converted_cents=converted,
|
||||||
|
rate=float(rate) if rate is not None else None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Transaktionen: Ausgabe, Einnahme, interner Transfer. Owner-gescoped.
|
||||||
|
|
||||||
|
Beim Speichern wird der eingefrorene EUR-Wert (``amount_eur_cents``) zum
|
||||||
|
Buchungsdatum berechnet (Pivot für Budget/Dashboard). Ein manuell gesetzter
|
||||||
|
Kurs (``fx_rate``) hat Vorrang vor dem EZB-Kurs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, get_owned
|
||||||
|
from ..models import Account, Category, Transaction, TxnType, User
|
||||||
|
from ..schemas import TransactionCreate, TransactionOut, TransactionUpdate
|
||||||
|
from ..services import currency
|
||||||
|
from .accounts import base_currency
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||||
|
|
||||||
|
|
||||||
|
def _fx(txn: Transaction) -> float | None:
|
||||||
|
return float(txn.fx_rate) if txn.fx_rate is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _recompute(db: Session, txn: Transaction, from_acc: Account, to_acc: Account | None) -> None:
|
||||||
|
"""Setzt amount_eur_cents und (bei Transfers) to_amount_cents neu."""
|
||||||
|
txn.amount_eur_cents = currency.compute_amount_eur_cents(
|
||||||
|
db, txn.amount_cents, from_acc.currency, txn.date, _fx(txn)
|
||||||
|
)
|
||||||
|
if txn.type == TxnType.transfer and to_acc is not None:
|
||||||
|
if txn.to_amount_cents is None:
|
||||||
|
if to_acc.currency == from_acc.currency:
|
||||||
|
txn.to_amount_cents = txn.amount_cents
|
||||||
|
else:
|
||||||
|
conv = currency.convert(
|
||||||
|
db, txn.amount_cents, from_acc.currency, to_acc.currency, txn.date, _fx(txn)
|
||||||
|
)
|
||||||
|
txn.to_amount_cents = conv if conv is not None else txn.amount_cents
|
||||||
|
else:
|
||||||
|
txn.to_amount_cents = None
|
||||||
|
|
||||||
|
|
||||||
|
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str]) -> TransactionOut:
|
||||||
|
acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
|
||||||
|
if base == "EUR":
|
||||||
|
amount_base = txn.amount_eur_cents
|
||||||
|
else:
|
||||||
|
conv = currency.from_eur_cents(db, txn.amount_eur_cents, base, txn.date)
|
||||||
|
amount_base = conv if conv is not None else txn.amount_eur_cents
|
||||||
|
return TransactionOut(
|
||||||
|
id=txn.id,
|
||||||
|
type=txn.type,
|
||||||
|
date=txn.date,
|
||||||
|
time=txn.time,
|
||||||
|
account_id=txn.account_id,
|
||||||
|
to_account_id=txn.to_account_id,
|
||||||
|
account_currency=acc_ccy,
|
||||||
|
amount_cents=txn.amount_cents,
|
||||||
|
to_amount_cents=txn.to_amount_cents,
|
||||||
|
fx_rate=_fx(txn),
|
||||||
|
amount_eur_cents=txn.amount_eur_cents,
|
||||||
|
amount_base_cents=amount_base,
|
||||||
|
category_id=txn.category_id,
|
||||||
|
budget_treatment=txn.budget_treatment,
|
||||||
|
budget_category_id=txn.budget_category_id,
|
||||||
|
payee=txn.payee,
|
||||||
|
description=txn.description,
|
||||||
|
notes=txn.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _currency_map(db: Session, user: User) -> dict[int, str]:
|
||||||
|
rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all()
|
||||||
|
return {row[0]: row[1] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_category(db: Session, user: User, category_id: int | None) -> None:
|
||||||
|
if category_id is not None:
|
||||||
|
get_owned(db, Category, category_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TransactionOut])
|
||||||
|
def list_transactions(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
account_id: int | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
|
type: TxnType | None = None,
|
||||||
|
date_from: date | None = None,
|
||||||
|
date_to: date | None = None,
|
||||||
|
limit: int = Query(default=200, le=1000),
|
||||||
|
offset: int = 0,
|
||||||
|
):
|
||||||
|
q = db.query(Transaction).filter(Transaction.owner_id == user.id)
|
||||||
|
if account_id is not None:
|
||||||
|
q = q.filter(
|
||||||
|
(Transaction.account_id == account_id) | (Transaction.to_account_id == account_id)
|
||||||
|
)
|
||||||
|
if category_id is not None:
|
||||||
|
q = q.filter(Transaction.category_id == category_id)
|
||||||
|
if type is not None:
|
||||||
|
q = q.filter(Transaction.type == type)
|
||||||
|
if date_from is not None:
|
||||||
|
q = q.filter(Transaction.date >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
q = q.filter(Transaction.date <= date_to)
|
||||||
|
rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
|
||||||
|
base = base_currency(db, user)
|
||||||
|
ccy = _currency_map(db, user)
|
||||||
|
return [to_out(db, t, base, ccy) for t in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_transaction(
|
||||||
|
payload: TransactionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
from_acc = get_owned(db, Account, payload.account_id, user)
|
||||||
|
to_acc = None
|
||||||
|
category_id = payload.category_id
|
||||||
|
|
||||||
|
if payload.type == TxnType.transfer:
|
||||||
|
if payload.to_account_id is None or payload.to_account_id == payload.account_id:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Transfer braucht ein anderes Zielkonto")
|
||||||
|
to_acc = get_owned(db, Account, payload.to_account_id, user)
|
||||||
|
category_id = None # Transfers werden nicht regulär kategorisiert
|
||||||
|
else:
|
||||||
|
if payload.to_account_id is not None:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Nur Transfers haben ein Zielkonto")
|
||||||
|
_validate_category(db, user, category_id)
|
||||||
|
|
||||||
|
_validate_category(db, user, payload.budget_category_id)
|
||||||
|
|
||||||
|
txn = Transaction(
|
||||||
|
owner_id=user.id,
|
||||||
|
type=payload.type,
|
||||||
|
date=payload.date,
|
||||||
|
time=payload.time,
|
||||||
|
account_id=from_acc.id,
|
||||||
|
to_account_id=to_acc.id if to_acc else None,
|
||||||
|
amount_cents=payload.amount_cents,
|
||||||
|
to_amount_cents=payload.to_amount_cents,
|
||||||
|
fx_rate=payload.fx_rate,
|
||||||
|
category_id=category_id,
|
||||||
|
budget_treatment=payload.budget_treatment,
|
||||||
|
budget_category_id=payload.budget_category_id,
|
||||||
|
payee=(payload.payee or None),
|
||||||
|
description=(payload.description or None),
|
||||||
|
notes=(payload.notes or None),
|
||||||
|
)
|
||||||
|
_recompute(db, txn, from_acc, to_acc)
|
||||||
|
db.add(txn)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(txn)
|
||||||
|
return to_out(db, txn, base_currency(db, user), _currency_map(db, user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{txn_id}", response_model=TransactionOut)
|
||||||
|
def get_transaction(
|
||||||
|
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
txn = get_owned(db, Transaction, txn_id, user)
|
||||||
|
return to_out(db, txn, base_currency(db, user), _currency_map(db, user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{txn_id}", response_model=TransactionOut)
|
||||||
|
def update_transaction(
|
||||||
|
txn_id: int,
|
||||||
|
payload: TransactionUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
txn = get_owned(db, Transaction, txn_id, user)
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
for key in ("type", "date", "time", "amount_cents", "to_amount_cents", "fx_rate",
|
||||||
|
"budget_treatment", "payee", "description", "notes"):
|
||||||
|
if key in data:
|
||||||
|
setattr(txn, key, data[key])
|
||||||
|
|
||||||
|
if "account_id" in data and data["account_id"] is not None:
|
||||||
|
get_owned(db, Account, data["account_id"], user)
|
||||||
|
txn.account_id = data["account_id"]
|
||||||
|
if "to_account_id" in data:
|
||||||
|
if data["to_account_id"] is not None:
|
||||||
|
get_owned(db, Account, data["to_account_id"], user)
|
||||||
|
txn.to_account_id = data["to_account_id"]
|
||||||
|
if "category_id" in data:
|
||||||
|
_validate_category(db, user, data["category_id"])
|
||||||
|
txn.category_id = data["category_id"]
|
||||||
|
if "budget_category_id" in data:
|
||||||
|
_validate_category(db, user, data["budget_category_id"])
|
||||||
|
txn.budget_category_id = data["budget_category_id"]
|
||||||
|
|
||||||
|
# Konsistenz je nach Typ + Neuberechnung der abgeleiteten Werte.
|
||||||
|
from_acc = get_owned(db, Account, txn.account_id, user)
|
||||||
|
to_acc = None
|
||||||
|
if txn.type == TxnType.transfer:
|
||||||
|
if txn.to_account_id is None or txn.to_account_id == txn.account_id:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Transfer braucht ein anderes Zielkonto")
|
||||||
|
to_acc = get_owned(db, Account, txn.to_account_id, user)
|
||||||
|
txn.category_id = None
|
||||||
|
else:
|
||||||
|
txn.to_account_id = None
|
||||||
|
_recompute(db, txn, from_acc, to_acc)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(txn)
|
||||||
|
return to_out(db, txn, base_currency(db, user), _currency_map(db, user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_transaction(
|
||||||
|
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
txn = get_owned(db, Transaction, txn_id, user)
|
||||||
|
db.delete(txn)
|
||||||
|
db.commit()
|
||||||
@@ -8,6 +8,7 @@ from ..deps import require_admin
|
|||||||
from ..models import Role, User
|
from ..models import Role, User
|
||||||
from ..schemas import UserCreate, UserOut, UserUpdate
|
from ..schemas import UserCreate, UserOut, UserUpdate
|
||||||
from ..security import hash_password
|
from ..security import hash_password
|
||||||
|
from ..seed import ensure_starter_categories
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"], dependencies=[Depends(require_admin)])
|
router = APIRouter(prefix="/users", tags=["users"], dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ def create_user(payload: UserCreate, db: Session = Depends(get_db)) -> User:
|
|||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
# Neuen Nutzern die Startkategorien mitgeben.
|
||||||
|
ensure_starter_categories(db, user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+217
-3
@@ -1,10 +1,26 @@
|
|||||||
"""Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen)."""
|
"""Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen)."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import date as date_t, datetime, time as time_t
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
from .models import Role
|
from .models import (
|
||||||
|
AccountType,
|
||||||
|
BudgetTreatment,
|
||||||
|
CategoryKind,
|
||||||
|
IdentifierKind,
|
||||||
|
Role,
|
||||||
|
TxnType,
|
||||||
|
)
|
||||||
|
|
||||||
|
ALLOWED_CURRENCIES = {"EUR", "CHF"}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_currency(v: str) -> str:
|
||||||
|
v = v.upper()
|
||||||
|
if v not in ALLOWED_CURRENCIES:
|
||||||
|
raise ValueError("Währung muss EUR oder CHF sein")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class UserOut(BaseModel):
|
||||||
@@ -41,3 +57,201 @@ class PasswordChange(BaseModel):
|
|||||||
class SettingOut(BaseModel):
|
class SettingOut(BaseModel):
|
||||||
key: str
|
key: str
|
||||||
value: str | None = None
|
value: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Konten ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AccountIdentifierIn(BaseModel):
|
||||||
|
kind: IdentifierKind = IdentifierKind.iban
|
||||||
|
value: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountIdentifierOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
kind: IdentifierKind
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class AccountCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
type: AccountType = AccountType.girokonto
|
||||||
|
currency: str = "EUR"
|
||||||
|
icon: str = "wallet"
|
||||||
|
color: str = "#3f51b5"
|
||||||
|
opening_balance_cents: int = 0
|
||||||
|
opening_date: date_t | None = None
|
||||||
|
identifiers: list[AccountIdentifierIn] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def _cur(cls, v: str) -> str:
|
||||||
|
return _check_currency(v)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
type: AccountType | None = None
|
||||||
|
currency: str | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
color: str | None = None
|
||||||
|
opening_balance_cents: int | None = None
|
||||||
|
opening_date: date_t | None = None
|
||||||
|
is_archived: bool | None = None
|
||||||
|
position: int | None = None
|
||||||
|
# Wenn gesetzt, ersetzt die komplette Identifikator-Liste.
|
||||||
|
identifiers: list[AccountIdentifierIn] | None = None
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def _cur(cls, v: str | None) -> str | None:
|
||||||
|
return _check_currency(v) if v is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
type: AccountType
|
||||||
|
currency: str
|
||||||
|
icon: str
|
||||||
|
color: str
|
||||||
|
opening_balance_cents: int
|
||||||
|
opening_date: date_t | None
|
||||||
|
is_archived: bool
|
||||||
|
position: int
|
||||||
|
identifiers: list[AccountIdentifierOut]
|
||||||
|
balance_cents: int
|
||||||
|
balance_base_cents: int
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kategorien ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
group_id: int
|
||||||
|
name: str
|
||||||
|
type: CategoryKind
|
||||||
|
icon: str
|
||||||
|
color: str
|
||||||
|
position: int
|
||||||
|
is_builtin: bool
|
||||||
|
is_archived: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryGroupOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
kind: CategoryKind
|
||||||
|
icon: str
|
||||||
|
color: str
|
||||||
|
position: int
|
||||||
|
is_builtin: bool
|
||||||
|
categories: list[CategoryOut]
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryGroupCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
kind: CategoryKind = CategoryKind.expense
|
||||||
|
icon: str = "list"
|
||||||
|
color: str = "#6b7480"
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryGroupUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
icon: str | None = None
|
||||||
|
color: str | None = None
|
||||||
|
position: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryCreate(BaseModel):
|
||||||
|
group_id: int
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
icon: str = "more-horizontal"
|
||||||
|
color: str = "#6b7480"
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
group_id: int | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
color: str | None = None
|
||||||
|
position: int | None = None
|
||||||
|
is_archived: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Transaktionen ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionCreate(BaseModel):
|
||||||
|
type: TxnType
|
||||||
|
date: date_t
|
||||||
|
time: time_t | None = None
|
||||||
|
account_id: int
|
||||||
|
to_account_id: int | None = None
|
||||||
|
amount_cents: int = Field(gt=0)
|
||||||
|
to_amount_cents: int | None = Field(default=None, gt=0)
|
||||||
|
fx_rate: float | None = Field(default=None, gt=0)
|
||||||
|
category_id: int | None = None
|
||||||
|
budget_treatment: BudgetTreatment | None = None
|
||||||
|
budget_category_id: int | None = None
|
||||||
|
payee: str | None = Field(default=None, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=255)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionUpdate(BaseModel):
|
||||||
|
type: TxnType | None = None
|
||||||
|
date: date_t | None = None
|
||||||
|
time: time_t | None = None
|
||||||
|
account_id: int | None = None
|
||||||
|
to_account_id: int | None = None
|
||||||
|
amount_cents: int | None = Field(default=None, gt=0)
|
||||||
|
to_amount_cents: int | None = Field(default=None, gt=0)
|
||||||
|
fx_rate: float | None = Field(default=None, gt=0)
|
||||||
|
category_id: int | None = None
|
||||||
|
budget_treatment: BudgetTreatment | None = None
|
||||||
|
budget_category_id: int | None = None
|
||||||
|
payee: str | None = Field(default=None, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=255)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
type: TxnType
|
||||||
|
date: date_t
|
||||||
|
time: time_t | None
|
||||||
|
account_id: int
|
||||||
|
to_account_id: int | None
|
||||||
|
account_currency: str
|
||||||
|
amount_cents: int
|
||||||
|
to_amount_cents: int | None
|
||||||
|
fx_rate: float | None
|
||||||
|
amount_eur_cents: int
|
||||||
|
amount_base_cents: int
|
||||||
|
category_id: int | None
|
||||||
|
budget_treatment: BudgetTreatment | None
|
||||||
|
budget_category_id: int | None
|
||||||
|
payee: str | None
|
||||||
|
description: str | None
|
||||||
|
notes: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class RateStatus(BaseModel):
|
||||||
|
currency: str
|
||||||
|
count: int
|
||||||
|
min_date: date_t | None
|
||||||
|
max_date: date_t | None
|
||||||
|
latest_rate: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionOut(BaseModel):
|
||||||
|
amount_cents: int
|
||||||
|
from_currency: str
|
||||||
|
to_currency: str
|
||||||
|
date: date_t
|
||||||
|
converted_cents: int | None
|
||||||
|
rate: float | None
|
||||||
|
|||||||
+62
-1
@@ -6,9 +6,68 @@ Fachliche Startdaten (Standard-Kategorien usw.) folgen in späteren Phasen.
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .models import Role, User
|
from .models import Category, CategoryGroup, CategoryKind, Role, User
|
||||||
from .security import hash_password
|
from .security import hash_password
|
||||||
|
|
||||||
|
# Vorgefertigte, gruppierte Startkategorien je Benutzer. Alles frei änderbar;
|
||||||
|
# wer eine Gruppe/Kategorie löscht, bekommt sie nicht automatisch zurück.
|
||||||
|
# (Gruppe, Art, Gruppen-Icon, Gruppen-Farbe, [(Kategoriename, Icon), ...])
|
||||||
|
STARTER_CATEGORIES: list[tuple[str, CategoryKind, str, str, list[tuple[str, str]]]] = [
|
||||||
|
("Fixkosten", CategoryKind.expense, "landmark", "#3f51b5", [
|
||||||
|
("Wohnen", "home"),
|
||||||
|
("Versicherungen", "shield"),
|
||||||
|
("Telefon & Internet", "wifi"),
|
||||||
|
("Abos & Streaming", "refresh-ccw"),
|
||||||
|
]),
|
||||||
|
("Lebenshaltung", CategoryKind.expense, "shopping-cart", "#2e7d55", [
|
||||||
|
("Lebensmittel", "shopping-cart"),
|
||||||
|
("Restaurant", "utensils"),
|
||||||
|
("Mobilität", "car"),
|
||||||
|
("Gesundheit", "heart-pulse"),
|
||||||
|
("Kleidung", "shirt"),
|
||||||
|
]),
|
||||||
|
("Freizeit & Persönliches", CategoryKind.expense, "film", "#b06f14", [
|
||||||
|
("Freizeit", "film"),
|
||||||
|
("Reisen", "plane"),
|
||||||
|
("Sport & Fitness", "dumbbell"),
|
||||||
|
("Bildung", "graduation-cap"),
|
||||||
|
("Haustiere", "paw-print"),
|
||||||
|
("Geschenke & Spenden", "gift"),
|
||||||
|
]),
|
||||||
|
("Finanzen", CategoryKind.expense, "piggy-bank", "#2a78d6", [
|
||||||
|
("Sparen & Investments", "piggy-bank"),
|
||||||
|
("Steuern & Gebühren", "percent"),
|
||||||
|
]),
|
||||||
|
("Sonstiges", CategoryKind.expense, "more-horizontal", "#6b7480", [
|
||||||
|
("Sonstiges", "more-horizontal"),
|
||||||
|
]),
|
||||||
|
("Einnahmen", CategoryKind.income, "coins", "#2e7d55", [
|
||||||
|
("Gehalt", "banknote"),
|
||||||
|
("Nebeneinkünfte", "coins"),
|
||||||
|
("Erstattungen", "receipt"),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_starter_categories(db: Session, user: User) -> None:
|
||||||
|
"""Legt die Startkategorien für einen Benutzer an – nur, wenn er noch keine hat."""
|
||||||
|
exists = db.query(CategoryGroup).filter(CategoryGroup.owner_id == user.id).first()
|
||||||
|
if exists is not None:
|
||||||
|
return
|
||||||
|
for gpos, (gname, kind, gicon, gcolor, cats) in enumerate(STARTER_CATEGORIES):
|
||||||
|
group = CategoryGroup(
|
||||||
|
owner_id=user.id, name=gname, kind=kind, icon=gicon, color=gcolor,
|
||||||
|
position=gpos, is_builtin=True,
|
||||||
|
)
|
||||||
|
db.add(group)
|
||||||
|
db.flush()
|
||||||
|
for cpos, (cname, cicon) in enumerate(cats):
|
||||||
|
db.add(Category(
|
||||||
|
owner_id=user.id, group_id=group.id, name=cname, type=kind,
|
||||||
|
icon=cicon, color=gcolor, position=cpos, is_builtin=True,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def ensure_first_admin(db: Session) -> None:
|
def ensure_first_admin(db: Session) -> None:
|
||||||
"""Legt einen Admin an, solange noch gar kein Benutzer existiert."""
|
"""Legt einen Admin an, solange noch gar kein Benutzer existiert."""
|
||||||
@@ -24,3 +83,5 @@ def ensure_first_admin(db: Session) -> None:
|
|||||||
)
|
)
|
||||||
db.add(admin)
|
db.add(admin)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
db.refresh(admin)
|
||||||
|
ensure_starter_categories(db, admin)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Kontostände (in Kontowährung) und Umrechnung in die Hauptwährung.
|
||||||
|
|
||||||
|
Saldo = Anfangssaldo + Einnahmen - Ausgaben - Transfers-raus + Transfers-rein.
|
||||||
|
Transfers werden in der jeweiligen Kontowährung verrechnet (bei währungsüber-
|
||||||
|
greifenden Transfers am Zielkonto der gutgeschriebene Betrag ``to_amount_cents``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Account, Transaction, TxnType
|
||||||
|
from . import currency
|
||||||
|
|
||||||
|
|
||||||
|
def _sum(query) -> int:
|
||||||
|
return int(query.scalar() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def account_balance_cents(db: Session, account: Account) -> int:
|
||||||
|
income = _sum(
|
||||||
|
db.query(func.sum(Transaction.amount_cents)).filter(
|
||||||
|
Transaction.account_id == account.id, Transaction.type == TxnType.income
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expense = _sum(
|
||||||
|
db.query(func.sum(Transaction.amount_cents)).filter(
|
||||||
|
Transaction.account_id == account.id, Transaction.type == TxnType.expense
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transfer_out = _sum(
|
||||||
|
db.query(func.sum(Transaction.amount_cents)).filter(
|
||||||
|
Transaction.account_id == account.id, Transaction.type == TxnType.transfer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transfer_in = _sum(
|
||||||
|
db.query(
|
||||||
|
func.sum(func.coalesce(Transaction.to_amount_cents, Transaction.amount_cents))
|
||||||
|
).filter(
|
||||||
|
Transaction.to_account_id == account.id, Transaction.type == TxnType.transfer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return account.opening_balance_cents + income - expense - transfer_out + transfer_in
|
||||||
|
|
||||||
|
|
||||||
|
def account_balance_base_cents(
|
||||||
|
db: Session, account: Account, base_currency: str, on: date
|
||||||
|
) -> int:
|
||||||
|
"""Saldo in die Hauptwährung umgerechnet (aktueller Kurs). Fällt auf den
|
||||||
|
nativen Wert zurück, wenn kein Kurs verfügbar ist."""
|
||||||
|
native = account_balance_cents(db, account)
|
||||||
|
if account.currency == base_currency:
|
||||||
|
return native
|
||||||
|
conv = currency.convert(db, native, account.currency, base_currency, on)
|
||||||
|
return conv if conv is not None else native
|
||||||
|
|
||||||
|
|
||||||
|
def net_worth_cents(db: Session, accounts: list[Account], base_currency: str, on: date) -> int:
|
||||||
|
return sum(
|
||||||
|
account_balance_base_cents(db, a, base_currency, on)
|
||||||
|
for a in accounts
|
||||||
|
if not a.is_archived
|
||||||
|
)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Währungsumrechnung EUR<->CHF über die lokal gecachten EZB-Referenzkurse.
|
||||||
|
|
||||||
|
Kurse werden als ``rate_per_eur`` gespeichert: wie viele Einheiten der Währung
|
||||||
|
ein Euro wert war (EUR ist implizit 1.0). Umgerechnet wird immer mit dem Kurs zum
|
||||||
|
Buchungsdatum; fehlt ein Kurs für genau diesen Tag (Wochenende/Feiertag), wird der
|
||||||
|
letzte verfügbare Börsentag davor genommen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import ExchangeRate
|
||||||
|
|
||||||
|
|
||||||
|
def rate_on(db: Session, currency: str, on: date) -> Decimal | None:
|
||||||
|
"""Kurs (Währungseinheiten je 1 EUR) am Tag ``on`` oder dem letzten davor."""
|
||||||
|
if currency == "EUR":
|
||||||
|
return Decimal(1)
|
||||||
|
row = (
|
||||||
|
db.query(ExchangeRate)
|
||||||
|
.filter(ExchangeRate.currency == currency, ExchangeRate.date <= on)
|
||||||
|
.order_by(ExchangeRate.date.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return Decimal(str(row.rate_per_eur)) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _round_cents(value: Decimal) -> int:
|
||||||
|
return int(value.quantize(Decimal(1), rounding=ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
|
def to_eur_cents(
|
||||||
|
db: Session, amount_cents: int, currency: str, on: date, fx_rate: float | None = None
|
||||||
|
) -> int | None:
|
||||||
|
"""Rechnet einen Betrag in Kontowährung in EUR-Cent um.
|
||||||
|
|
||||||
|
``fx_rate`` (Kontowährung je 1 EUR) hat Vorrang vor dem EZB-Kurs. Gibt None
|
||||||
|
zurück, wenn kein Kurs verfügbar ist (Aufrufer entscheidet über Fallback)."""
|
||||||
|
if currency == "EUR":
|
||||||
|
return amount_cents
|
||||||
|
rate = Decimal(str(fx_rate)) if fx_rate else rate_on(db, currency, on)
|
||||||
|
if not rate or rate == 0:
|
||||||
|
return None
|
||||||
|
return _round_cents(Decimal(amount_cents) / rate)
|
||||||
|
|
||||||
|
|
||||||
|
def from_eur_cents(db: Session, eur_cents: int, currency: str, on: date) -> int | None:
|
||||||
|
"""Rechnet EUR-Cent in die Zielwährung um (Kurs zum Tag ``on``)."""
|
||||||
|
if currency == "EUR":
|
||||||
|
return eur_cents
|
||||||
|
rate = rate_on(db, currency, on)
|
||||||
|
if rate is None:
|
||||||
|
return None
|
||||||
|
return _round_cents(Decimal(eur_cents) * rate)
|
||||||
|
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
db: Session,
|
||||||
|
amount_cents: int,
|
||||||
|
from_ccy: str,
|
||||||
|
to_ccy: str,
|
||||||
|
on: date,
|
||||||
|
fx_rate: float | None = None,
|
||||||
|
) -> int | None:
|
||||||
|
"""Rechnet zwischen zwei Währungen um (über EUR als Pivot)."""
|
||||||
|
if from_ccy == to_ccy:
|
||||||
|
return amount_cents
|
||||||
|
eur = to_eur_cents(db, amount_cents, from_ccy, on, fx_rate)
|
||||||
|
if eur is None:
|
||||||
|
return None
|
||||||
|
return from_eur_cents(db, eur, to_ccy, on)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_amount_eur_cents(
|
||||||
|
db: Session, amount_cents: int, currency: str, on: date, fx_rate: float | None = None
|
||||||
|
) -> int:
|
||||||
|
"""Eingefrorener EUR-Wert für eine Transaktion. Fällt notfalls (kein Kurs
|
||||||
|
verfügbar) auf 1:1 zurück, damit das Speichern nie scheitert."""
|
||||||
|
eur = to_eur_cents(db, amount_cents, currency, on, fx_rate)
|
||||||
|
return eur if eur is not None else amount_cents
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""EZB-Referenzkurse laden und lokal cachen.
|
||||||
|
|
||||||
|
Die EZB veröffentlicht die Euro-Referenzkurse werktäglich als XML: ein Tages-Feed,
|
||||||
|
ein 90-Tage-Feed und die volle Historie seit 1999. Wir brauchen nur CHF. Historische
|
||||||
|
Kurse ändern sich nicht – vorhandene Zeilen werden daher nicht überschrieben.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import ExchangeRate
|
||||||
|
|
||||||
|
FEED_90D = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml"
|
||||||
|
FEED_FULL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml"
|
||||||
|
|
||||||
|
# Welche Währungen wir speichern (Hauptwährungen des Nutzers: EUR ist Pivot).
|
||||||
|
CURRENCIES = {"CHF"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(xml_bytes: bytes) -> list[tuple[date, str, str]]:
|
||||||
|
"""Extrahiert (Datum, Währung, Kurs) aus dem EZB-XML (namespace-tolerant)."""
|
||||||
|
root = ET.fromstring(xml_bytes)
|
||||||
|
rows: list[tuple[date, str, str]] = []
|
||||||
|
for elem in root.iter():
|
||||||
|
t = elem.attrib.get("time")
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
d = date.fromisoformat(t)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
for child in elem:
|
||||||
|
ccy = child.attrib.get("currency")
|
||||||
|
rate = child.attrib.get("rate")
|
||||||
|
if ccy in CURRENCIES and rate:
|
||||||
|
rows.append((d, ccy, rate))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(url: str, timeout: float = 15.0) -> bytes:
|
||||||
|
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
||||||
|
resp = client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert(db: Session, rows: list[tuple[date, str, str]]) -> int:
|
||||||
|
added = 0
|
||||||
|
for d, ccy, rate in rows:
|
||||||
|
if db.get(ExchangeRate, (d, ccy)) is None:
|
||||||
|
db.add(ExchangeRate(date=d, currency=ccy, rate_per_eur=Decimal(rate), source="ecb"))
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
db.commit()
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_recent(db: Session) -> int:
|
||||||
|
"""Lädt die letzten ~90 Tage (schnell) und ergänzt fehlende Tage."""
|
||||||
|
return _upsert(db, _parse(_fetch(FEED_90D)))
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_full(db: Session) -> int:
|
||||||
|
"""Lädt die komplette Historie (einmalig, größer)."""
|
||||||
|
return _upsert(db, _parse(_fetch(FEED_FULL)))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_rates_on_startup(db: Session, enabled: bool) -> None:
|
||||||
|
"""Beim App-Start best-effort die aktuellen Kurse nachladen.
|
||||||
|
|
||||||
|
Blockiert nie hart: Ohne Internet läuft die App weiter, nur ohne frische Kurse.
|
||||||
|
Die volle Historie holt das Install-Skript / der systemd-Timer separat."""
|
||||||
|
if not enabled:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
refresh_recent(db)
|
||||||
|
except Exception: # noqa: BLE001 – bewusst tolerant (Netz weg etc.)
|
||||||
|
pass
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Konten, Identifikatoren, Kategorien(-gruppen), Transaktionen, Wechselkurse.
|
||||||
|
|
||||||
|
Revision ID: 0002
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2026-08-19
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0002"
|
||||||
|
down_revision = "0001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"accounts",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("type", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("currency", sa.String(length=3), nullable=False),
|
||||||
|
sa.Column("icon", sa.String(length=40), nullable=False, server_default="wallet"),
|
||||||
|
sa.Column("color", sa.String(length=9), nullable=False, server_default="#3f51b5"),
|
||||||
|
sa.Column("opening_balance_cents", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("opening_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_accounts_owner_id", "accounts", ["owner_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"account_identifiers",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("value", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("value_norm", sa.String(length=64), nullable=False),
|
||||||
|
sa.UniqueConstraint("account_id", "kind", "value_norm", name="uq_ident_account_kind_value"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_account_identifiers_account_id", "account_identifiers", ["account_id"])
|
||||||
|
op.create_index("ix_account_identifiers_value_norm", "account_identifiers", ["value_norm"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"category_groups",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("icon", sa.String(length=40), nullable=False, server_default="list"),
|
||||||
|
sa.Column("color", sa.String(length=9), nullable=False, server_default="#6b7480"),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_category_groups_owner_id", "category_groups", ["owner_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"categories",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("group_id", sa.Integer(), sa.ForeignKey("category_groups.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("type", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("icon", sa.String(length=40), nullable=False, server_default="more-horizontal"),
|
||||||
|
sa.Column("color", sa.String(length=9), nullable=False, server_default="#6b7480"),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_categories_owner_id", "categories", ["owner_id"])
|
||||||
|
op.create_index("ix_categories_group_id", "categories", ["group_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"transactions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("type", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("time", sa.Time(), nullable=True),
|
||||||
|
sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("to_account_id", sa.Integer(), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("amount_cents", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("to_amount_cents", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("fx_rate", sa.Numeric(18, 8), nullable=True),
|
||||||
|
sa.Column("amount_eur_cents", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("category_id", sa.Integer(), sa.ForeignKey("categories.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("budget_treatment", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("budget_category_id", sa.Integer(), sa.ForeignKey("categories.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("payee", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("description", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_transactions_owner_id", "transactions", ["owner_id"])
|
||||||
|
op.create_index("ix_transactions_date", "transactions", ["date"])
|
||||||
|
op.create_index("ix_transactions_account_id", "transactions", ["account_id"])
|
||||||
|
op.create_index("ix_transactions_to_account_id", "transactions", ["to_account_id"])
|
||||||
|
op.create_index("ix_transactions_category_id", "transactions", ["category_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"exchange_rates",
|
||||||
|
sa.Column("date", sa.Date(), primary_key=True),
|
||||||
|
sa.Column("currency", sa.String(length=3), primary_key=True),
|
||||||
|
sa.Column("rate_per_eur", sa.Numeric(18, 8), nullable=False),
|
||||||
|
sa.Column("source", sa.String(length=16), nullable=False, server_default="ecb"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("exchange_rates")
|
||||||
|
for ix in (
|
||||||
|
"ix_transactions_category_id",
|
||||||
|
"ix_transactions_to_account_id",
|
||||||
|
"ix_transactions_account_id",
|
||||||
|
"ix_transactions_date",
|
||||||
|
"ix_transactions_owner_id",
|
||||||
|
):
|
||||||
|
op.drop_index(ix, table_name="transactions")
|
||||||
|
op.drop_table("transactions")
|
||||||
|
op.drop_index("ix_categories_group_id", table_name="categories")
|
||||||
|
op.drop_index("ix_categories_owner_id", table_name="categories")
|
||||||
|
op.drop_table("categories")
|
||||||
|
op.drop_index("ix_category_groups_owner_id", table_name="category_groups")
|
||||||
|
op.drop_table("category_groups")
|
||||||
|
op.drop_index("ix_account_identifiers_value_norm", table_name="account_identifiers")
|
||||||
|
op.drop_index("ix_account_identifiers_account_id", table_name="account_identifiers")
|
||||||
|
op.drop_table("account_identifiers")
|
||||||
|
op.drop_index("ix_accounts_owner_id", table_name="accounts")
|
||||||
|
op.drop_table("accounts")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = tests
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""EZB-Wechselkurse laden (aus dem backend/-Verzeichnis: ``python -m scripts.backfill_rates``).
|
||||||
|
|
||||||
|
Ohne Argument: volle Historie. Mit ``--recent``: nur die letzten ~90 Tage
|
||||||
|
(für den täglichen systemd-Timer).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services import ecb_rates
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
recent = "--recent" in sys.argv
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if recent:
|
||||||
|
n = ecb_rates.refresh_recent(db)
|
||||||
|
print(f"{n} neue Kurs-Tage geladen (90-Tage-Feed).")
|
||||||
|
else:
|
||||||
|
n = ecb_rates.backfill_full(db)
|
||||||
|
print(f"{n} neue Kurs-Tage geladen (volle Historie).")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Test-Setup: frische SQLite-DB je Testlauf, kein EZB-Netzabruf."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Umgebung setzen, BEVOR app importiert wird (config wird beim Import gelesen).
|
||||||
|
os.environ.setdefault("APP_DATA_DIR", tempfile.mkdtemp(prefix="cf-test-"))
|
||||||
|
os.environ.setdefault("APP_ECB_FETCH_ON_START", "false")
|
||||||
|
os.environ.setdefault("APP_ADMIN_USERNAME", "admin")
|
||||||
|
os.environ.setdefault("APP_ADMIN_PASSWORD", "adminpass")
|
||||||
|
os.environ.setdefault("APP_SESSION_SECRET", "test-secret")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin(client):
|
||||||
|
r = client.post("/api/auth/login", data={"username": "admin", "password": "adminpass"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def new_user(admin_client) -> tuple[str, str]:
|
||||||
|
"""Legt (als Admin) einen frischen Nutzer an und gibt (username, passwort) zurück."""
|
||||||
|
username = "u_" + secrets.token_hex(4)
|
||||||
|
password = "pw_" + secrets.token_hex(4)
|
||||||
|
r = admin_client.post("/api/users", json={"username": username, "password": password, "role": "user"})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return username, password
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_recent_rates(rate: str = "0.95") -> None:
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import ExchangeRate
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
today = date.today()
|
||||||
|
for delta in range(0, 7):
|
||||||
|
day = today - timedelta(days=delta)
|
||||||
|
if db.get(ExchangeRate, (day, "CHF")) is None:
|
||||||
|
db.add(ExchangeRate(date=day, currency="CHF", rate_per_eur=Decimal(rate)))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_account(admin, **kw):
|
||||||
|
body = {"name": "A", "type": "girokonto", "currency": "EUR", "opening_balance_cents": 0}
|
||||||
|
body.update(kw)
|
||||||
|
r = admin.post("/api/accounts", json=body)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_starter_categories_present(admin):
|
||||||
|
groups = admin.get("/api/categories").json()
|
||||||
|
assert len(groups) >= 6
|
||||||
|
names = {c["name"] for g in groups for c in g["categories"]}
|
||||||
|
assert {"Gehalt", "Lebensmittel", "Sparen & Investments"} <= names
|
||||||
|
|
||||||
|
|
||||||
|
def test_currency_validation(admin):
|
||||||
|
r = admin.post("/api/accounts", json={"name": "X", "currency": "USD"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_identifiers_stored_and_deduped(admin):
|
||||||
|
acc = _mk_account(
|
||||||
|
admin,
|
||||||
|
name="Mit IBAN",
|
||||||
|
identifiers=[
|
||||||
|
{"kind": "iban", "value": "DE12 3456"},
|
||||||
|
{"kind": "iban", "value": "de123456"}, # normiert identisch -> dedupliziert
|
||||||
|
{"kind": "card_no", "value": "1234"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
kinds = sorted((i["kind"], i["value"]) for i in acc["identifiers"])
|
||||||
|
assert len(acc["identifiers"]) == 2
|
||||||
|
assert ("card_no", "1234") in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_balance_and_cross_currency_transfer(admin):
|
||||||
|
_seed_recent_rates("0.95")
|
||||||
|
d = date.today()
|
||||||
|
eur = _mk_account(admin, name="Giro", currency="EUR", opening_balance_cents=100000)
|
||||||
|
chf = _mk_account(admin, name="Spar", type="sparkonto", currency="CHF", opening_balance_cents=0)
|
||||||
|
|
||||||
|
admin.post("/api/transactions", json={"type": "income", "date": str(d), "account_id": eur["id"], "amount_cents": 50000})
|
||||||
|
admin.post("/api/transactions", json={"type": "expense", "date": str(d), "account_id": eur["id"], "amount_cents": 20000})
|
||||||
|
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 130000
|
||||||
|
|
||||||
|
# Transfer 100 EUR -> CHF (0.95) = 95 CHF gutgeschrieben.
|
||||||
|
r = admin.post("/api/transactions", json={
|
||||||
|
"type": "transfer", "date": str(d), "account_id": eur["id"],
|
||||||
|
"to_account_id": chf["id"], "amount_cents": 10000,
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
assert r.json()["to_amount_cents"] == 9500
|
||||||
|
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 120000
|
||||||
|
assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 9500
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_requires_other_account(admin):
|
||||||
|
eur = _mk_account(admin, name="Solo", currency="EUR")
|
||||||
|
d = date.today()
|
||||||
|
r = admin.post("/api/transactions", json={
|
||||||
|
"type": "transfer", "date": str(d), "account_id": eur["id"],
|
||||||
|
"to_account_id": eur["id"], "amount_cents": 100,
|
||||||
|
})
|
||||||
|
assert r.status_code == 400
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_rate(day: date, rate: str) -> None:
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import ExchangeRate
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if db.get(ExchangeRate, (day, "CHF")) is None:
|
||||||
|
db.add(ExchangeRate(date=day, currency="CHF", rate_per_eur=Decimal(rate)))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversion_and_override(admin):
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services import currency
|
||||||
|
|
||||||
|
d = date(2026, 1, 15)
|
||||||
|
_ensure_rate(d, "0.95")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
assert currency.to_eur_cents(db, 9500, "CHF", d) == 10000 # 9500 / 0.95
|
||||||
|
assert currency.from_eur_cents(db, 10000, "CHF", d) == 9500
|
||||||
|
assert currency.convert(db, 10000, "EUR", "CHF", d) == 9500
|
||||||
|
assert currency.to_eur_cents(db, 5000, "EUR", d) == 5000 # EUR bleibt EUR
|
||||||
|
# Manuell gesetzter Kurs hat Vorrang vor dem EZB-Kurs.
|
||||||
|
assert currency.to_eur_cents(db, 10000, "CHF", d, fx_rate=2.0) == 5000
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekend_fallback(admin):
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services import currency
|
||||||
|
|
||||||
|
friday = date(2026, 3, 6)
|
||||||
|
sunday = date(2026, 3, 8) # kein Kurs -> Freitag gilt
|
||||||
|
_ensure_rate(friday, "0.90")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
assert currency.rate_on(db, "CHF", sunday) == Decimal("0.90")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_rate_returns_none(admin):
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services import currency
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Weit in der Vergangenheit gibt es keinen Kurs -> None.
|
||||||
|
assert currency.to_eur_cents(db, 1000, "CHF", date(1990, 1, 1)) is None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import secrets
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
def test_users_are_isolated(admin):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
# Admin legt ein Konto an.
|
||||||
|
acc = admin.post("/api/accounts", json={"name": "AdminKonto", "currency": "EUR"}).json()
|
||||||
|
|
||||||
|
# Frischen Nutzer anlegen und getrennt einloggen.
|
||||||
|
username = "iso_" + secrets.token_hex(4)
|
||||||
|
password = "pw_" + secrets.token_hex(4)
|
||||||
|
assert admin.post("/api/users", json={"username": username, "password": password, "role": "user"}).status_code == 201
|
||||||
|
|
||||||
|
other = TestClient(app)
|
||||||
|
assert other.post("/api/auth/login", data={"username": username, "password": password}).status_code == 200
|
||||||
|
|
||||||
|
# Sieht keine fremden Konten/Buchungen.
|
||||||
|
assert other.get("/api/accounts").json() == []
|
||||||
|
assert other.get("/api/transactions").json() == []
|
||||||
|
# Zugriff auf fremdes Konto -> 404 (nicht 403: existiert für ihn schlicht nicht).
|
||||||
|
assert other.get(f"/api/accounts/{acc['id']}").status_code == 404
|
||||||
|
# Buchung auf fremdes Konto -> 404.
|
||||||
|
r = other.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": str(date.today()), "account_id": acc["id"], "amount_cents": 100,
|
||||||
|
})
|
||||||
|
assert r.status_code == 404
|
||||||
|
# Eigene Startkategorien sind vorhanden.
|
||||||
|
assert len(other.get("/api/categories").json()) >= 6
|
||||||
|
# Kein Admin: darf keine Nutzer listen.
|
||||||
|
assert other.get("/api/users").status_code == 403
|
||||||
@@ -7,6 +7,9 @@ import BrandMark from "./components/BrandMark";
|
|||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import ChangePassword from "./pages/ChangePassword";
|
import ChangePassword from "./pages/ChangePassword";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
|
import Accounts from "./pages/Accounts";
|
||||||
|
import Categories from "./pages/Categories";
|
||||||
|
import Transactions from "./pages/Transactions";
|
||||||
import Users from "./pages/Users";
|
import Users from "./pages/Users";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
|
|
||||||
@@ -38,6 +41,9 @@ function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
<NavItem to="/" end icon="dashboard" label={t("nav.overview")} />
|
<NavItem to="/" end icon="dashboard" label={t("nav.overview")} />
|
||||||
|
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
|
||||||
|
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
||||||
|
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
||||||
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
|
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
@@ -119,6 +125,9 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="/passwort-aendern" element={<ChangePassword />} />
|
<Route path="/passwort-aendern" element={<ChangePassword />} />
|
||||||
<Route path="/" element={<Protected wide><Dashboard /></Protected>} />
|
<Route path="/" element={<Protected wide><Dashboard /></Protected>} />
|
||||||
|
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
|
||||||
|
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
||||||
|
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
||||||
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
||||||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -124,4 +124,42 @@ export const api = {
|
|||||||
listSettings: () => request("/settings"),
|
listSettings: () => request("/settings"),
|
||||||
setSetting: (key, value) =>
|
setSetting: (key, value) =>
|
||||||
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
|
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
|
||||||
|
|
||||||
|
// Konten
|
||||||
|
listAccounts: () => request("/accounts"),
|
||||||
|
getAccount: (id) => request(`/accounts/${id}`),
|
||||||
|
createAccount: (body) => request("/accounts", { method: "POST", body }),
|
||||||
|
updateAccount: (id, body) => request(`/accounts/${id}`, { method: "PATCH", body }),
|
||||||
|
deleteAccount: (id) => request(`/accounts/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Kategorien (Gruppen + Kategorien)
|
||||||
|
listCategories: () => request("/categories"),
|
||||||
|
createGroup: (body) => request("/categories/groups", { method: "POST", body }),
|
||||||
|
updateGroup: (id, body) => request(`/categories/groups/${id}`, { method: "PATCH", body }),
|
||||||
|
deleteGroup: (id) => request(`/categories/groups/${id}`, { method: "DELETE" }),
|
||||||
|
createCategory: (body) => request("/categories", { method: "POST", body }),
|
||||||
|
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
|
||||||
|
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Transaktionen
|
||||||
|
listTransactions: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v !== undefined && v !== null && v !== "") qs.set(k, v);
|
||||||
|
}
|
||||||
|
const s = qs.toString();
|
||||||
|
return request(`/transactions${s ? `?${s}` : ""}`);
|
||||||
|
},
|
||||||
|
getTransaction: (id) => request(`/transactions/${id}`),
|
||||||
|
createTransaction: (body) => request("/transactions", { method: "POST", body }),
|
||||||
|
updateTransaction: (id, body) => request(`/transactions/${id}`, { method: "PATCH", body }),
|
||||||
|
deleteTransaction: (id) => request(`/transactions/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Wechselkurse
|
||||||
|
rateStatus: () => request("/rates/status"),
|
||||||
|
convertRate: (amountCents, from, to, on) => {
|
||||||
|
const qs = new URLSearchParams({ amount_cents: amountCents, from_currency: from, to_currency: to });
|
||||||
|
if (on) qs.set("on", on);
|
||||||
|
return request(`/rates/convert?${qs.toString()}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -153,6 +153,154 @@ const PATHS = {
|
|||||||
<path d="m16.71 13.88.7.71-2.82 2.82" />
|
<path d="m16.71 13.88.7.71-2.82 2.82" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// --- Kontotyp-Icons -----------------------------------------------------
|
||||||
|
"credit-card": (
|
||||||
|
<>
|
||||||
|
<rect width="20" height="14" x="2" y="5" rx="2" />
|
||||||
|
<line x1="2" x2="22" y1="10" y2="10" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"trending-up": (
|
||||||
|
<>
|
||||||
|
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
|
||||||
|
<polyline points="16 7 22 7 22 13" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"piggy-bank": (
|
||||||
|
<>
|
||||||
|
<path d="M19 5c-1.5 0-2.8 1.4-3 2-3.5-1.5-11-.3-11 5 0 1.8 0 3 2 4.5V20h4v-2h3v2h4v-4c1-.5 1.7-1 2-2h2v-4h-2c0-1-.5-1.5-1-2V5z" />
|
||||||
|
<path d="M2 9v1c0 1.1.9 2 2 2h1" />
|
||||||
|
<path d="M16 11h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
banknote: (
|
||||||
|
<>
|
||||||
|
<rect width="20" height="12" x="2" y="6" rx="2" />
|
||||||
|
<circle cx="12" cy="12" r="2" />
|
||||||
|
<path d="M6 12h.01M18 12h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- Kategorie-Icons ----------------------------------------------------
|
||||||
|
home: (
|
||||||
|
<>
|
||||||
|
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
shield: (
|
||||||
|
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||||
|
),
|
||||||
|
wifi: (
|
||||||
|
<>
|
||||||
|
<path d="M12 20h.01" />
|
||||||
|
<path d="M2 8.82a15 15 0 0 1 20 0" />
|
||||||
|
<path d="M5 12.859a10 10 0 0 1 14 0" />
|
||||||
|
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"shopping-cart": (
|
||||||
|
<>
|
||||||
|
<circle cx="8" cy="21" r="1" />
|
||||||
|
<circle cx="19" cy="21" r="1" />
|
||||||
|
<path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
utensils: (
|
||||||
|
<>
|
||||||
|
<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2" />
|
||||||
|
<path d="M7 2v20" />
|
||||||
|
<path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
car: (
|
||||||
|
<>
|
||||||
|
<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2" />
|
||||||
|
<circle cx="7" cy="17" r="2" />
|
||||||
|
<path d="M9 17h6" />
|
||||||
|
<circle cx="17" cy="17" r="2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"heart-pulse": (
|
||||||
|
<>
|
||||||
|
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
|
||||||
|
<path d="M3.22 12H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
shirt: (
|
||||||
|
<path d="M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z" />
|
||||||
|
),
|
||||||
|
film: (
|
||||||
|
<>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<path d="M7 3v18" />
|
||||||
|
<path d="M3 7.5h4" />
|
||||||
|
<path d="M3 12h18" />
|
||||||
|
<path d="M3 16.5h4" />
|
||||||
|
<path d="M17 3v18" />
|
||||||
|
<path d="M17 7.5h4" />
|
||||||
|
<path d="M17 16.5h4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
plane: (
|
||||||
|
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z" />
|
||||||
|
),
|
||||||
|
dumbbell: (
|
||||||
|
<>
|
||||||
|
<path d="m6.5 6.5 11 11" />
|
||||||
|
<path d="m21 21-1-1" />
|
||||||
|
<path d="m3 3 1 1" />
|
||||||
|
<path d="m18 22 4-4" />
|
||||||
|
<path d="m2 6 4-4" />
|
||||||
|
<path d="m3 10 7-7" />
|
||||||
|
<path d="m14 21 7-7" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"graduation-cap": (
|
||||||
|
<>
|
||||||
|
<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" />
|
||||||
|
<path d="M22 10v6" />
|
||||||
|
<path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"paw-print": (
|
||||||
|
<>
|
||||||
|
<circle cx="11" cy="4" r="2" />
|
||||||
|
<circle cx="18" cy="8" r="2" />
|
||||||
|
<circle cx="20" cy="16" r="2" />
|
||||||
|
<path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
gift: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="8" width="18" height="4" rx="1" />
|
||||||
|
<path d="M12 8v13" />
|
||||||
|
<path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7" />
|
||||||
|
<path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
percent: (
|
||||||
|
<>
|
||||||
|
<line x1="19" x2="5" y1="5" y2="19" />
|
||||||
|
<circle cx="6.5" cy="6.5" r="2.5" />
|
||||||
|
<circle cx="17.5" cy="17.5" r="2.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"more-horizontal": (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="1" />
|
||||||
|
<circle cx="19" cy="12" r="1" />
|
||||||
|
<circle cx="5" cy="12" r="1" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
receipt: (
|
||||||
|
<>
|
||||||
|
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z" />
|
||||||
|
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8" />
|
||||||
|
<path d="M12 17.5v-11" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) {
|
export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
// Farbiges Icon-Plättchen für Konten und Kategorien: getönter Hintergrund,
|
||||||
|
// Icon in der Akzentfarbe. Farbe kommt aus dem Datensatz (hex).
|
||||||
|
export default function IconChip({ name, color = "#6b7480", size = 34 }) {
|
||||||
|
const inner = Math.round(size * 0.56);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="icon-chip"
|
||||||
|
style={{ width: size, height: size, color, background: `${color}22` }}
|
||||||
|
>
|
||||||
|
<Icon name={name} size={inner} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
export const ACCOUNT_ICONS = [
|
||||||
|
"wallet", "landmark", "piggy-bank", "credit-card", "trending-up", "banknote", "coins",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CATEGORY_ICONS = [
|
||||||
|
"home", "shopping-cart", "utensils", "car", "shield", "heart-pulse", "film",
|
||||||
|
"refresh-ccw", "shirt", "wifi", "plane", "gift", "graduation-cap", "dumbbell",
|
||||||
|
"paw-print", "piggy-bank", "percent", "more-horizontal", "banknote", "coins",
|
||||||
|
"receipt", "list", "landmark", "wallet", "trending-up", "credit-card",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Auswahlgitter für ein Icon. `value` ist der aktuell gewählte Icon-Name.
|
||||||
|
export default function IconPicker({ icons = CATEGORY_ICONS, value, onChange, color = "#3f51b5" }) {
|
||||||
|
return (
|
||||||
|
<div className="icon-picker">
|
||||||
|
{icons.map((name) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
className={`icon-picker-item${value === name ? " selected" : ""}`}
|
||||||
|
style={value === name ? { color, borderColor: color } : undefined}
|
||||||
|
onClick={() => onChange(name)}
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
<Icon name={name} size={18} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
// Einfacher, gestalteter Dialog (nutzt die modal-Klassen der Vorlage).
|
||||||
|
export default function Modal({ title, onClose, children, footer, wide = false }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-backdrop"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
>
|
||||||
|
<div className="modal" role="dialog" aria-modal="true" style={{ maxWidth: wide ? 640 : 480 }}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<h2 style={{ margin: 0 }}>{title}</h2>
|
||||||
|
<button className="btn-icon" style={{ marginLeft: "auto" }} onClick={onClose} title="Schließen">
|
||||||
|
<Icon name="close" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">{children}</div>
|
||||||
|
{footer && <div className="btn-pair">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { formatMoney } from "../format";
|
||||||
|
|
||||||
|
// Zeigt einen Betrag in Kontowährung und – falls abweichend – zusätzlich den in
|
||||||
|
// die Hauptwährung umgerechneten Wert. `sign` färbt +/− (grün/rot).
|
||||||
|
export default function Money({
|
||||||
|
cents,
|
||||||
|
currency = "EUR",
|
||||||
|
baseCents = null,
|
||||||
|
baseCurrency = "EUR",
|
||||||
|
sign = null, // "pos" | "neg" | null
|
||||||
|
showConverted = true,
|
||||||
|
}) {
|
||||||
|
const cls = sign ? `amount ${sign}` : "amount";
|
||||||
|
const prefix = sign === "pos" ? "+" : sign === "neg" ? "−" : "";
|
||||||
|
const showBase =
|
||||||
|
showConverted && baseCents != null && currency !== baseCurrency;
|
||||||
|
return (
|
||||||
|
<span className="money">
|
||||||
|
<span className={cls}>
|
||||||
|
{prefix}
|
||||||
|
{formatMoney(Math.abs(cents), currency)}
|
||||||
|
</span>
|
||||||
|
{showBase && (
|
||||||
|
<span className="amount-converted">≈ {formatMoney(Math.abs(baseCents), baseCurrency)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,3 +63,201 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
margin-bottom: var(--sp-3);
|
margin-bottom: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Farbiges Icon-Plättchen (Konten/Kategorien). */
|
||||||
|
.icon-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Betrag + umgerechneter Wert (rechtsbündig gestapelt). */
|
||||||
|
.money {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.money .amount {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 560;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon-Auswahlgitter. */
|
||||||
|
.icon-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.icon-picker-item {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .12s, color .12s, background .12s;
|
||||||
|
}
|
||||||
|
.icon-picker-item:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.icon-picker-item.selected {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Farbwähler-Zeile. */
|
||||||
|
.color-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.color-row input[type="color"] {
|
||||||
|
width: 44px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Name + Icon nebeneinander (Listen). */
|
||||||
|
.named {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.named .named-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.named .named-sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kategorie-Gruppe (Verwaltung). */
|
||||||
|
.cat-group + .cat-group {
|
||||||
|
margin-top: var(--sp-4);
|
||||||
|
}
|
||||||
|
.cat-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-bottom: var(--sp-2);
|
||||||
|
}
|
||||||
|
.cat-group-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.cat-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.cat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.cat-row .grow {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seitenkopf mit Titel + Aktion rechts. */
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
.page-head h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Konten. */
|
||||||
|
.account-total {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: var(--sp-3);
|
||||||
|
margin-bottom: var(--sp-2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.account-total strong {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.account-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.account-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.account-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filterleiste. */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
.filter-bar select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 160px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segmentierte Umschaltung (Ausgabe/Einnahme/Transfer). */
|
||||||
|
.seg {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
.seg-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 560;
|
||||||
|
cursor: pointer;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.seg-btn:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
.seg-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,3 +31,26 @@ export function formatDate(iso, locale = "de") {
|
|||||||
if (Number.isNaN(d.getTime())) return String(iso);
|
if (Number.isNaN(d.getTime())) return String(iso);
|
||||||
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE").format(d);
|
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE").format(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eingabetext ("12,34" oder "12.34") in Ganzzahl-Cent; null bei leer/ungültig. */
|
||||||
|
export function parseAmountToCents(str) {
|
||||||
|
if (str == null) return null;
|
||||||
|
const cleaned = String(str).trim().replace(/\s/g, "").replace(",", ".");
|
||||||
|
if (cleaned === "") return null;
|
||||||
|
const value = Number.parseFloat(cleaned);
|
||||||
|
if (Number.isNaN(value)) return null;
|
||||||
|
return Math.round(value * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cent-Betrag als bearbeitbarer Eingabetext ("1234" -> "12,34"). */
|
||||||
|
export function centsToInput(cents) {
|
||||||
|
if (cents == null) return "";
|
||||||
|
return (cents / 100).toFixed(2).replace(".", ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heutiges Datum als YYYY-MM-DD (lokal). */
|
||||||
|
export function todayIso() {
|
||||||
|
const d = new Date();
|
||||||
|
const z = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,5 +81,75 @@
|
|||||||
"server": {
|
"server": {
|
||||||
"unreachable": "Server nicht erreichbar.",
|
"unreachable": "Server nicht erreichbar.",
|
||||||
"sessionKept": "Deine Anmeldung ist noch gültig – sobald der Server wieder antwortet, geht es weiter."
|
"sessionKept": "Deine Anmeldung ist noch gültig – sobald der Server wieder antwortet, geht es weiter."
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"title": "Konten",
|
||||||
|
"new": "Neues Konto",
|
||||||
|
"edit": "Konto bearbeiten",
|
||||||
|
"name": "Name",
|
||||||
|
"type": "Typ",
|
||||||
|
"currency": "Währung",
|
||||||
|
"icon": "Symbol",
|
||||||
|
"color": "Farbe",
|
||||||
|
"openingBalance": "Anfangssaldo",
|
||||||
|
"openingDate": "Stichtag",
|
||||||
|
"identifiers": "Erkennungs-Nummern (IBAN etc.)",
|
||||||
|
"addIdentifier": "Nummer hinzufügen",
|
||||||
|
"identShort": "Nr.",
|
||||||
|
"total": "Gesamt",
|
||||||
|
"empty": "Noch keine Konten. Lege dein erstes Konto an.",
|
||||||
|
"deleteConfirm": "Dieses Konto und alle zugehörigen Buchungen löschen?"
|
||||||
|
},
|
||||||
|
"accountTypes": {
|
||||||
|
"girokonto": "Girokonto",
|
||||||
|
"sparkonto": "Sparkonto",
|
||||||
|
"bargeld": "Bargeld",
|
||||||
|
"kreditkarte": "Kreditkarte",
|
||||||
|
"depot": "Depot / Investment",
|
||||||
|
"sonstiges": "Sonstiges"
|
||||||
|
},
|
||||||
|
"identifierKinds": {
|
||||||
|
"iban": "IBAN",
|
||||||
|
"account_no": "Kontonummer",
|
||||||
|
"card_no": "Kartennummer"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"title": "Kategorien",
|
||||||
|
"newGroup": "Neue Gruppe",
|
||||||
|
"editGroup": "Gruppe bearbeiten",
|
||||||
|
"newCategory": "Neue Kategorie",
|
||||||
|
"editCategory": "Kategorie bearbeiten",
|
||||||
|
"name": "Name",
|
||||||
|
"kind": "Art",
|
||||||
|
"income": "Einnahme",
|
||||||
|
"expense": "Ausgabe",
|
||||||
|
"emptyGroup": "Keine Kategorien in dieser Gruppe.",
|
||||||
|
"deleteConfirm": "Diese Kategorie löschen?",
|
||||||
|
"deleteGroupConfirm": "Diese Gruppe samt aller Kategorien löschen?"
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"title": "Buchungen",
|
||||||
|
"new": "Neue Buchung",
|
||||||
|
"edit": "Buchung bearbeiten",
|
||||||
|
"expense": "Ausgabe",
|
||||||
|
"income": "Einnahme",
|
||||||
|
"transfer": "Transfer",
|
||||||
|
"date": "Datum",
|
||||||
|
"account": "Konto",
|
||||||
|
"fromAccount": "Von Konto",
|
||||||
|
"toAccount": "Auf Konto",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"amount": "Betrag",
|
||||||
|
"toAmount": "Gutschrift",
|
||||||
|
"payee": "Empfänger",
|
||||||
|
"description": "Verwendungszweck",
|
||||||
|
"uncategorized": "Ohne Kategorie",
|
||||||
|
"empty": "Noch keine Buchungen.",
|
||||||
|
"needAccount": "Lege zuerst ein Konto an, um Buchungen zu erfassen.",
|
||||||
|
"allAccounts": "Alle Konten",
|
||||||
|
"allTypes": "Alle Typen",
|
||||||
|
"deleteConfirm": "Diese Buchung löschen?",
|
||||||
|
"amountRequired": "Bitte einen Betrag eingeben.",
|
||||||
|
"accountRequired": "Bitte ein Konto wählen."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,5 +81,75 @@
|
|||||||
"server": {
|
"server": {
|
||||||
"unreachable": "Server unreachable.",
|
"unreachable": "Server unreachable.",
|
||||||
"sessionKept": "Your session is still valid – once the server responds again, it will continue."
|
"sessionKept": "Your session is still valid – once the server responds again, it will continue."
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"title": "Accounts",
|
||||||
|
"new": "New account",
|
||||||
|
"edit": "Edit account",
|
||||||
|
"name": "Name",
|
||||||
|
"type": "Type",
|
||||||
|
"currency": "Currency",
|
||||||
|
"icon": "Icon",
|
||||||
|
"color": "Color",
|
||||||
|
"openingBalance": "Opening balance",
|
||||||
|
"openingDate": "As of",
|
||||||
|
"identifiers": "Identifiers (IBAN etc.)",
|
||||||
|
"addIdentifier": "Add identifier",
|
||||||
|
"identShort": "id(s)",
|
||||||
|
"total": "Total",
|
||||||
|
"empty": "No accounts yet. Create your first account.",
|
||||||
|
"deleteConfirm": "Delete this account and all its transactions?"
|
||||||
|
},
|
||||||
|
"accountTypes": {
|
||||||
|
"girokonto": "Checking",
|
||||||
|
"sparkonto": "Savings",
|
||||||
|
"bargeld": "Cash",
|
||||||
|
"kreditkarte": "Credit card",
|
||||||
|
"depot": "Brokerage / Investment",
|
||||||
|
"sonstiges": "Other"
|
||||||
|
},
|
||||||
|
"identifierKinds": {
|
||||||
|
"iban": "IBAN",
|
||||||
|
"account_no": "Account number",
|
||||||
|
"card_no": "Card number"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"title": "Categories",
|
||||||
|
"newGroup": "New group",
|
||||||
|
"editGroup": "Edit group",
|
||||||
|
"newCategory": "New category",
|
||||||
|
"editCategory": "Edit category",
|
||||||
|
"name": "Name",
|
||||||
|
"kind": "Kind",
|
||||||
|
"income": "Income",
|
||||||
|
"expense": "Expense",
|
||||||
|
"emptyGroup": "No categories in this group.",
|
||||||
|
"deleteConfirm": "Delete this category?",
|
||||||
|
"deleteGroupConfirm": "Delete this group and all its categories?"
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"title": "Transactions",
|
||||||
|
"new": "New transaction",
|
||||||
|
"edit": "Edit transaction",
|
||||||
|
"expense": "Expense",
|
||||||
|
"income": "Income",
|
||||||
|
"transfer": "Transfer",
|
||||||
|
"date": "Date",
|
||||||
|
"account": "Account",
|
||||||
|
"fromAccount": "From account",
|
||||||
|
"toAccount": "To account",
|
||||||
|
"category": "Category",
|
||||||
|
"amount": "Amount",
|
||||||
|
"toAmount": "Credited",
|
||||||
|
"payee": "Payee",
|
||||||
|
"description": "Description",
|
||||||
|
"uncategorized": "Uncategorized",
|
||||||
|
"empty": "No transactions yet.",
|
||||||
|
"needAccount": "Create an account first to record transactions.",
|
||||||
|
"allAccounts": "All accounts",
|
||||||
|
"allTypes": "All types",
|
||||||
|
"deleteConfirm": "Delete this transaction?",
|
||||||
|
"amountRequired": "Please enter an amount.",
|
||||||
|
"accountRequired": "Please choose an account."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { useSettings } from "../settings";
|
||||||
|
import { useConfirm } from "../confirm";
|
||||||
|
import { useToast } from "../toast";
|
||||||
|
import Icon from "../components/Icon";
|
||||||
|
import IconChip from "../components/IconChip";
|
||||||
|
import Money from "../components/Money";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker";
|
||||||
|
import { centsToInput, formatMoney, parseAmountToCents } from "../format";
|
||||||
|
|
||||||
|
const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"];
|
||||||
|
const TYPE_ICON = {
|
||||||
|
girokonto: "landmark",
|
||||||
|
sparkonto: "piggy-bank",
|
||||||
|
bargeld: "wallet",
|
||||||
|
kreditkarte: "credit-card",
|
||||||
|
depot: "trending-up",
|
||||||
|
sonstiges: "wallet",
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyForm() {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
type: "girokonto",
|
||||||
|
currency: "EUR",
|
||||||
|
icon: "landmark",
|
||||||
|
color: "#3f51b5",
|
||||||
|
opening_balance: "",
|
||||||
|
opening_date: "",
|
||||||
|
identifiers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Accounts() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { baseCurrency } = useSettings();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [accounts, setAccounts] = useState([]);
|
||||||
|
const [editing, setEditing] = useState(null); // Account-Objekt oder null
|
||||||
|
const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen)
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
try {
|
||||||
|
setAccounts(await api.listAccounts());
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { laden(); }, []);
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
}
|
||||||
|
function openEdit(acc) {
|
||||||
|
setEditing(acc);
|
||||||
|
setForm({
|
||||||
|
name: acc.name,
|
||||||
|
type: acc.type,
|
||||||
|
currency: acc.currency,
|
||||||
|
icon: acc.icon,
|
||||||
|
color: acc.color,
|
||||||
|
opening_balance: centsToInput(acc.opening_balance_cents),
|
||||||
|
opening_date: acc.opening_date || "",
|
||||||
|
identifiers: acc.identifiers.map((i) => ({ kind: i.kind, value: i.value })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setType(type) {
|
||||||
|
// Icon mitziehen, solange der Nutzer das Standard-Icon des alten Typs hatte.
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
type,
|
||||||
|
icon: f.icon === TYPE_ICON[f.type] ? TYPE_ICON[type] : f.icon,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speichern(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
const body = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
type: form.type,
|
||||||
|
currency: form.currency,
|
||||||
|
icon: form.icon,
|
||||||
|
color: form.color,
|
||||||
|
opening_balance_cents: parseAmountToCents(form.opening_balance) ?? 0,
|
||||||
|
opening_date: form.opening_date || null,
|
||||||
|
identifiers: form.identifiers
|
||||||
|
.filter((i) => i.value.trim())
|
||||||
|
.map((i) => ({ kind: i.kind, value: i.value.trim() })),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editing) await api.updateAccount(editing.id, body);
|
||||||
|
else await api.createAccount(body);
|
||||||
|
setForm(null);
|
||||||
|
toast(t("settings.saved"));
|
||||||
|
laden();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loeschen(acc) {
|
||||||
|
if (!(await confirm({ title: t("common.delete"), message: t("accounts.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||||
|
try {
|
||||||
|
await api.deleteAccount(acc.id);
|
||||||
|
laden();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = accounts.reduce((s, a) => s + a.balance_base_cents, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>{t("accounts.title")}</h1>
|
||||||
|
<button className="btn primary" onClick={openNew}>
|
||||||
|
<Icon name="plus" size={16} /> {t("accounts.new")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<div className="card"><div className="empty-state">{t("accounts.empty")}</div></div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<div className="account-total">
|
||||||
|
<span className="muted">{t("accounts.total")}</span>
|
||||||
|
<strong>{formatMoney(total, baseCurrency)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="account-list">
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<div className="account-row" key={a.id}>
|
||||||
|
<IconChip name={a.icon} color={a.color} />
|
||||||
|
<div className="named-text grow">
|
||||||
|
<div><strong>{a.name}</strong></div>
|
||||||
|
<div className="named-sub">
|
||||||
|
{t(`accountTypes.${a.type}`)} · {a.currency}
|
||||||
|
{a.identifiers.length > 0 && ` · ${a.identifiers.length} ${t("accounts.identShort")}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Money cents={a.balance_cents} currency={a.currency}
|
||||||
|
baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
|
||||||
|
<div className="row-actions">
|
||||||
|
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(a)}>
|
||||||
|
<Icon name="edit" size={15} />
|
||||||
|
</button>
|
||||||
|
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(a)}>
|
||||||
|
<Icon name="trash" size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form && (
|
||||||
|
<Modal
|
||||||
|
title={editing ? t("accounts.edit") : t("accounts.new")}
|
||||||
|
onClose={() => setForm(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn" onClick={() => setForm(null)}>{t("common.cancel")}</button>
|
||||||
|
<button className="btn primary" form="account-form" type="submit" disabled={busy}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="account-form" onSubmit={speichern}>
|
||||||
|
<label>
|
||||||
|
<span>{t("accounts.name")}</span>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||||
|
<input autoFocus value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
</label>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
<span>{t("accounts.type")}</span>
|
||||||
|
<select value={form.type} onChange={(e) => setType(e.target.value)}>
|
||||||
|
{ACCOUNT_TYPES.map((tp) => (
|
||||||
|
<option key={tp} value={tp}>{t(`accountTypes.${tp}`)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={{ minWidth: 120 }}>
|
||||||
|
<span>{t("accounts.currency")}</span>
|
||||||
|
<select value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })}>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
<option value="CHF">CHF</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
<span>{t("accounts.openingBalance")} ({form.currency})</span>
|
||||||
|
<input inputMode="decimal" value={form.opening_balance}
|
||||||
|
onChange={(e) => setForm({ ...form, opening_balance: e.target.value })} placeholder="0,00" />
|
||||||
|
</label>
|
||||||
|
<label style={{ minWidth: 150 }}>
|
||||||
|
<span>{t("accounts.openingDate")}</span>
|
||||||
|
<input type="date" value={form.opening_date}
|
||||||
|
onChange={(e) => setForm({ ...form, opening_date: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>{t("accounts.icon")}</span>
|
||||||
|
<IconPicker icons={ACCOUNT_ICONS} value={form.icon} color={form.color}
|
||||||
|
onChange={(name) => setForm({ ...form, icon: name })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("accounts.color")}</span>
|
||||||
|
<div className="color-row">
|
||||||
|
<input type="color" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
|
||||||
|
<IconChip name={form.icon} color={form.color} />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ marginBottom: 6 }}><span>{t("accounts.identifiers")}</span></label>
|
||||||
|
{form.identifiers.map((ident, idx) => (
|
||||||
|
<div className="row" key={idx} style={{ marginBottom: 6 }}>
|
||||||
|
<select style={{ minWidth: 130 }} value={ident.kind}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...form.identifiers];
|
||||||
|
next[idx] = { ...ident, kind: e.target.value };
|
||||||
|
setForm({ ...form, identifiers: next });
|
||||||
|
}}>
|
||||||
|
<option value="iban">IBAN</option>
|
||||||
|
<option value="account_no">{t("identifierKinds.account_no")}</option>
|
||||||
|
<option value="card_no">{t("identifierKinds.card_no")}</option>
|
||||||
|
</select>
|
||||||
|
<input className="grow" value={ident.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...form.identifiers];
|
||||||
|
next[idx] = { ...ident, value: e.target.value };
|
||||||
|
setForm({ ...form, identifiers: next });
|
||||||
|
}} />
|
||||||
|
<button type="button" className="btn sm ghost"
|
||||||
|
onClick={() => setForm({ ...form, identifiers: form.identifiers.filter((_, i) => i !== idx) })}>
|
||||||
|
<Icon name="trash" size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn sm"
|
||||||
|
onClick={() => setForm({ ...form, identifiers: [...form.identifiers, { kind: "iban", value: "" }] })}>
|
||||||
|
<Icon name="plus" size={15} /> {t("accounts.addIdentifier")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { useConfirm } from "../confirm";
|
||||||
|
import { useToast } from "../toast";
|
||||||
|
import Icon from "../components/Icon";
|
||||||
|
import IconChip from "../components/IconChip";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker";
|
||||||
|
|
||||||
|
export default function Categories() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const toast = useToast();
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [modal, setModal] = useState(null); // {type:'group'|'category', editing, groupId, form}
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
try {
|
||||||
|
setGroups(await api.listCategories());
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { laden(); }, []);
|
||||||
|
|
||||||
|
function openGroup(editing = null) {
|
||||||
|
setModal({
|
||||||
|
type: "group",
|
||||||
|
editing,
|
||||||
|
form: editing
|
||||||
|
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
|
||||||
|
: { name: "", kind: "expense", icon: "list", color: "#3f51b5" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function openCategory(groupId, editing = null) {
|
||||||
|
setModal({
|
||||||
|
type: "category",
|
||||||
|
editing,
|
||||||
|
groupId,
|
||||||
|
form: editing
|
||||||
|
? { name: editing.name, icon: editing.icon, color: editing.color }
|
||||||
|
: { name: "", icon: "more-horizontal", color: "#6b7480" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speichern(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const { type, editing, groupId, form } = modal;
|
||||||
|
try {
|
||||||
|
if (type === "group") {
|
||||||
|
if (editing) await api.updateGroup(editing.id, { name: form.name.trim(), icon: form.icon, color: form.color });
|
||||||
|
else await api.createGroup({ name: form.name.trim(), kind: form.kind, icon: form.icon, color: form.color });
|
||||||
|
} else {
|
||||||
|
if (editing) await api.updateCategory(editing.id, { name: form.name.trim(), icon: form.icon, color: form.color });
|
||||||
|
else await api.createCategory({ group_id: groupId, name: form.name.trim(), icon: form.icon, color: form.color });
|
||||||
|
}
|
||||||
|
setModal(null);
|
||||||
|
laden();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loeschenGruppe(g) {
|
||||||
|
if (!(await confirm({ title: t("common.delete"), message: t("categories.deleteGroupConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||||
|
try { await api.deleteGroup(g.id); laden(); } catch (err) { toast(err.message, "error"); }
|
||||||
|
}
|
||||||
|
async function loeschenKategorie(c) {
|
||||||
|
if (!(await confirm({ title: t("common.delete"), message: t("categories.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||||
|
try { await api.deleteCategory(c.id); laden(); } catch (err) { toast(err.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>{t("categories.title")}</h1>
|
||||||
|
<button className="btn primary" onClick={() => openGroup()}>
|
||||||
|
<Icon name="plus" size={16} /> {t("categories.newGroup")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div className="card cat-group" key={g.id}>
|
||||||
|
<div className="cat-group-head">
|
||||||
|
<IconChip name={g.icon} color={g.color} />
|
||||||
|
<h2 className="grow">{g.name}</h2>
|
||||||
|
<span className={`badge ${g.kind === "income" ? "ok" : ""}`}>
|
||||||
|
{g.kind === "income" ? t("categories.income") : t("categories.expense")}
|
||||||
|
</span>
|
||||||
|
<button className="btn sm ghost" title={t("categories.newCategory")} onClick={() => openCategory(g.id)}>
|
||||||
|
<Icon name="plus" size={15} />
|
||||||
|
</button>
|
||||||
|
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openGroup(g)}>
|
||||||
|
<Icon name="edit" size={15} />
|
||||||
|
</button>
|
||||||
|
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschenGruppe(g)}>
|
||||||
|
<Icon name="trash" size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="cat-list">
|
||||||
|
{g.categories.map((c) => (
|
||||||
|
<div className="cat-row" key={c.id}>
|
||||||
|
<IconChip name={c.icon} color={c.color} size={28} />
|
||||||
|
<span className="grow">{c.name}</span>
|
||||||
|
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openCategory(g.id, c)}>
|
||||||
|
<Icon name="edit" size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschenKategorie(c)}>
|
||||||
|
<Icon name="trash" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{g.categories.length === 0 && <div className="muted small">{t("categories.emptyGroup")}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
modal.type === "group"
|
||||||
|
? (modal.editing ? t("categories.editGroup") : t("categories.newGroup"))
|
||||||
|
: (modal.editing ? t("categories.editCategory") : t("categories.newCategory"))
|
||||||
|
}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn" onClick={() => setModal(null)}>{t("common.cancel")}</button>
|
||||||
|
<button className="btn primary" form="cat-form" type="submit">{t("common.save")}</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="cat-form" onSubmit={speichern}>
|
||||||
|
<label>
|
||||||
|
<span>{t("categories.name")}</span>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||||
|
<input autoFocus value={modal.form.name}
|
||||||
|
onChange={(e) => setModal({ ...modal, form: { ...modal.form, name: e.target.value } })} required />
|
||||||
|
</label>
|
||||||
|
{modal.type === "group" && !modal.editing && (
|
||||||
|
<label>
|
||||||
|
<span>{t("categories.kind")}</span>
|
||||||
|
<select value={modal.form.kind}
|
||||||
|
onChange={(e) => setModal({ ...modal, form: { ...modal.form, kind: e.target.value } })}>
|
||||||
|
<option value="expense">{t("categories.expense")}</option>
|
||||||
|
<option value="income">{t("categories.income")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
<span>{t("accounts.icon")}</span>
|
||||||
|
<IconPicker icons={CATEGORY_ICONS} value={modal.form.icon} color={modal.form.color}
|
||||||
|
onChange={(name) => setModal({ ...modal, form: { ...modal.form, icon: name } })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("accounts.color")}</span>
|
||||||
|
<div className="color-row">
|
||||||
|
<input type="color" value={modal.form.color}
|
||||||
|
onChange={(e) => setModal({ ...modal, form: { ...modal.form, color: e.target.value } })} />
|
||||||
|
<IconChip name={modal.form.icon} color={modal.form.color} />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { useSettings } from "../settings";
|
||||||
|
import { useConfirm } from "../confirm";
|
||||||
|
import { useToast } from "../toast";
|
||||||
|
import Icon from "../components/Icon";
|
||||||
|
import IconChip from "../components/IconChip";
|
||||||
|
import Money from "../components/Money";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import { centsToInput, parseAmountToCents, todayIso } from "../format";
|
||||||
|
|
||||||
|
const TYPES = ["expense", "income", "transfer"];
|
||||||
|
|
||||||
|
function emptyForm() {
|
||||||
|
return {
|
||||||
|
type: "expense",
|
||||||
|
date: todayIso(),
|
||||||
|
account_id: "",
|
||||||
|
to_account_id: "",
|
||||||
|
category_id: "",
|
||||||
|
amount: "",
|
||||||
|
to_amount: "",
|
||||||
|
payee: "",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Transactions() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { baseCurrency, formatDate } = useSettings();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [txns, setTxns] = useState([]);
|
||||||
|
const [accounts, setAccounts] = useState([]);
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [filter, setFilter] = useState({ account_id: "", type: "" });
|
||||||
|
const [form, setForm] = useState(null);
|
||||||
|
const [editing, setEditing] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const accountById = useMemo(() => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]);
|
||||||
|
const categoryById = useMemo(() => {
|
||||||
|
const m = {};
|
||||||
|
for (const g of groups) for (const c of g.categories) m[c.id] = c;
|
||||||
|
return m;
|
||||||
|
}, [groups]);
|
||||||
|
|
||||||
|
async function ladenStamm() {
|
||||||
|
try {
|
||||||
|
const [accs, cats] = await Promise.all([api.listAccounts(), api.listCategories()]);
|
||||||
|
setAccounts(accs);
|
||||||
|
setGroups(cats);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function ladenBuchungen() {
|
||||||
|
try {
|
||||||
|
setTxns(await api.listTransactions({ account_id: filter.account_id, type: filter.type }));
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { ladenStamm(); }, []);
|
||||||
|
useEffect(() => { ladenBuchungen(); }, [filter.account_id, filter.type]);
|
||||||
|
|
||||||
|
const fromAcc = form && accountById[Number(form.account_id)];
|
||||||
|
const toAcc = form && accountById[Number(form.to_account_id)];
|
||||||
|
const crossCurrency = form?.type === "transfer" && fromAcc && toAcc && fromAcc.currency !== toAcc.currency;
|
||||||
|
|
||||||
|
// Bei währungsübergreifenden Transfers den Zielbetrag per EZB-Kurs vorschlagen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!crossCurrency) return;
|
||||||
|
const cents = parseAmountToCents(form.amount);
|
||||||
|
if (!cents || form.to_amount) return;
|
||||||
|
let abbruch = false;
|
||||||
|
api.convertRate(cents, fromAcc.currency, toAcc.currency, form.date)
|
||||||
|
.then((r) => {
|
||||||
|
if (!abbruch && r.converted_cents != null) {
|
||||||
|
setForm((f) => (f && !f.to_amount ? { ...f, to_amount: centsToInput(r.converted_cents) } : f));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { abbruch = true; };
|
||||||
|
}, [crossCurrency, form?.amount, form?.account_id, form?.to_account_id, form?.date]);
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
setEditing(null);
|
||||||
|
setForm({ ...emptyForm(), account_id: accounts[0]?.id ? String(accounts[0].id) : "" });
|
||||||
|
}
|
||||||
|
function openEdit(tx) {
|
||||||
|
setEditing(tx);
|
||||||
|
setForm({
|
||||||
|
type: tx.type,
|
||||||
|
date: tx.date,
|
||||||
|
account_id: String(tx.account_id),
|
||||||
|
to_account_id: tx.to_account_id ? String(tx.to_account_id) : "",
|
||||||
|
category_id: tx.category_id ? String(tx.category_id) : "",
|
||||||
|
amount: centsToInput(tx.amount_cents),
|
||||||
|
to_amount: tx.to_amount_cents ? centsToInput(tx.to_amount_cents) : "",
|
||||||
|
payee: tx.payee || "",
|
||||||
|
description: tx.description || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const catOptions = useMemo(() => {
|
||||||
|
if (!form) return [];
|
||||||
|
const kind = form.type === "income" ? "income" : "expense";
|
||||||
|
return groups.filter((g) => g.kind === kind);
|
||||||
|
}, [form, groups]);
|
||||||
|
|
||||||
|
async function speichern(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const cents = parseAmountToCents(form.amount);
|
||||||
|
if (!cents || cents <= 0) { toast(t("transactions.amountRequired"), "error"); return; }
|
||||||
|
if (!form.account_id) { toast(t("transactions.accountRequired"), "error"); return; }
|
||||||
|
setBusy(true);
|
||||||
|
const body = {
|
||||||
|
type: form.type,
|
||||||
|
date: form.date,
|
||||||
|
account_id: Number(form.account_id),
|
||||||
|
amount_cents: cents,
|
||||||
|
payee: form.payee.trim() || null,
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
};
|
||||||
|
if (form.type === "transfer") {
|
||||||
|
body.to_account_id = Number(form.to_account_id);
|
||||||
|
const toCents = parseAmountToCents(form.to_amount);
|
||||||
|
if (toCents) body.to_amount_cents = toCents;
|
||||||
|
} else {
|
||||||
|
body.category_id = form.category_id ? Number(form.category_id) : null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editing) await api.updateTransaction(editing.id, body);
|
||||||
|
else await api.createTransaction(body);
|
||||||
|
setForm(null);
|
||||||
|
toast(t("settings.saved"));
|
||||||
|
ladenBuchungen();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loeschen(tx) {
|
||||||
|
if (!(await confirm({ title: t("common.delete"), message: t("transactions.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||||
|
try { await api.deleteTransaction(tx.id); ladenBuchungen(); } catch (err) { toast(err.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowLabel(tx) {
|
||||||
|
if (tx.type === "transfer") {
|
||||||
|
const from = accountById[tx.account_id]?.name || "?";
|
||||||
|
const to = accountById[tx.to_account_id]?.name || "?";
|
||||||
|
return { icon: "arrow-left-right", color: "#6b7480", title: `${from} → ${to}`, sub: tx.description || t("transactions.transfer") };
|
||||||
|
}
|
||||||
|
const cat = tx.category_id ? categoryById[tx.category_id] : null;
|
||||||
|
return {
|
||||||
|
icon: cat?.icon || "more-horizontal",
|
||||||
|
color: cat?.color || "#6b7480",
|
||||||
|
title: cat?.name || t("transactions.uncategorized"),
|
||||||
|
sub: tx.payee || tx.description || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>{t("transactions.title")}</h1>
|
||||||
|
<button className="btn primary" onClick={openNew} disabled={accounts.length === 0}>
|
||||||
|
<Icon name="plus" size={16} /> {t("transactions.new")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<div className="card"><div className="empty-state">{t("transactions.needAccount")}</div></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="card filter-bar">
|
||||||
|
<select value={filter.account_id} onChange={(e) => setFilter({ ...filter, account_id: e.target.value })}>
|
||||||
|
<option value="">{t("transactions.allAccounts")}</option>
|
||||||
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={filter.type} onChange={(e) => setFilter({ ...filter, type: e.target.value })}>
|
||||||
|
<option value="">{t("transactions.allTypes")}</option>
|
||||||
|
<option value="expense">{t("transactions.expense")}</option>
|
||||||
|
<option value="income">{t("transactions.income")}</option>
|
||||||
|
<option value="transfer">{t("transactions.transfer")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{txns.length === 0 ? (
|
||||||
|
<div className="empty-state">{t("transactions.empty")}</div>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("transactions.date")}</th>
|
||||||
|
<th>{t("transactions.category")}</th>
|
||||||
|
<th>{t("transactions.account")}</th>
|
||||||
|
<th style={{ textAlign: "right" }}>{t("transactions.amount")}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{txns.map((tx) => {
|
||||||
|
const l = rowLabel(tx);
|
||||||
|
const acc = accountById[tx.account_id];
|
||||||
|
const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null;
|
||||||
|
return (
|
||||||
|
<tr key={tx.id}>
|
||||||
|
<td className="nowrap">{formatDate(tx.date)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="named">
|
||||||
|
<IconChip name={l.icon} color={l.color} size={28} />
|
||||||
|
<div className="named-text">
|
||||||
|
<div>{l.title}</div>
|
||||||
|
{l.sub && <div className="named-sub">{l.sub}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="nowrap">{acc?.name}</td>
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
<Money cents={tx.amount_cents} currency={acc?.currency || "EUR"}
|
||||||
|
baseCents={tx.amount_base_cents} baseCurrency={baseCurrency} sign={sign} />
|
||||||
|
</td>
|
||||||
|
<td className="nowrap" style={{ textAlign: "right" }}>
|
||||||
|
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(tx)}>
|
||||||
|
<Icon name="edit" size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tx)}>
|
||||||
|
<Icon name="trash" size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form && (
|
||||||
|
<Modal
|
||||||
|
title={editing ? t("transactions.edit") : t("transactions.new")}
|
||||||
|
onClose={() => setForm(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn" onClick={() => setForm(null)}>{t("common.cancel")}</button>
|
||||||
|
<button className="btn primary" form="txn-form" type="submit" disabled={busy}>{t("common.save")}</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="txn-form" onSubmit={speichern}>
|
||||||
|
<div className="seg">
|
||||||
|
{TYPES.map((tp) => (
|
||||||
|
<button key={tp} type="button"
|
||||||
|
className={`seg-btn${form.type === tp ? " active" : ""}`}
|
||||||
|
onClick={() => setForm({ ...form, type: tp, category_id: "", to_account_id: "", to_amount: "" })}>
|
||||||
|
{t(`transactions.${tp}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
<span>{form.type === "transfer" ? t("transactions.fromAccount") : t("transactions.account")}</span>
|
||||||
|
<select value={form.account_id} onChange={(e) => setForm({ ...form, account_id: e.target.value })} required>
|
||||||
|
<option value="" disabled>—</option>
|
||||||
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.currency})</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={{ minWidth: 150 }}>
|
||||||
|
<span>{t("transactions.date")}</span>
|
||||||
|
<input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.type === "transfer" ? (
|
||||||
|
<label>
|
||||||
|
<span>{t("transactions.toAccount")}</span>
|
||||||
|
<select value={form.to_account_id} onChange={(e) => setForm({ ...form, to_account_id: e.target.value })} required>
|
||||||
|
<option value="" disabled>—</option>
|
||||||
|
{accounts.filter((a) => String(a.id) !== form.account_id).map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name} ({a.currency})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label>
|
||||||
|
<span>{t("transactions.category")}</span>
|
||||||
|
<select value={form.category_id} onChange={(e) => setForm({ ...form, category_id: e.target.value })}>
|
||||||
|
<option value="">{t("transactions.uncategorized")}</option>
|
||||||
|
{catOptions.map((g) => (
|
||||||
|
<optgroup key={g.id} label={g.name}>
|
||||||
|
{g.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
<span>{t("transactions.amount")} {fromAcc ? `(${fromAcc.currency})` : ""}</span>
|
||||||
|
<input inputMode="decimal" value={form.amount} placeholder="0,00"
|
||||||
|
onChange={(e) => setForm({ ...form, amount: e.target.value })} required />
|
||||||
|
</label>
|
||||||
|
{crossCurrency && (
|
||||||
|
<label className="grow">
|
||||||
|
<span>{t("transactions.toAmount")} ({toAcc.currency})</span>
|
||||||
|
<input inputMode="decimal" value={form.to_amount} placeholder="0,00"
|
||||||
|
onChange={(e) => setForm({ ...form, to_amount: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.type !== "transfer" && (
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
<span>{t("transactions.payee")}</span>
|
||||||
|
<input value={form.payee} onChange={(e) => setForm({ ...form, payee: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
<span>{t("transactions.description")}</span>
|
||||||
|
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user