Eine Saeule 3a ist gesperrt – bis zur Rente, zur Selbstaendigkeit oder zur Auswanderung. Bisher zaehlte ihr Stand in dieselbe Summe wie das Girokonto, und die Antwort auf "wie viel habe ich" war um genau diesen Betrag zu hoch. Das Geld steht da und ist doch nicht da. Konten lassen sich jetzt als gebunden kennzeichnen. "Gesamt" zeigt dann nur noch, was wirklich ausgegeben werden kann; darunter steht "zusaetzlich CHF 2'004.61 gebunden" – aufklappbar, mit jedem Konto einzeln. Eine Summe, die man nicht nachrechnen kann, muss man glauben. Verschwiegen wird nichts: Der Saldo bleibt am Konto stehen, und Vermoegensverlauf wie Nettovermoegen rechnen weiter damit. Es nicht ausgeben zu koennen heisst nicht, es nicht zu besitzen – das ist der ganze Unterschied, und ein Test haelt ihn fest. Ein eigenes Kennzeichen, keine Ableitung aus dem Kontotyp: Ein frei verkaeufliches Depot und eine gesperrte Saeule 3a kommen beide als "depot" herein. Am Typ laesst sich das nicht ablesen – am ausdruecklichen Eintrag schon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
851 lines
25 KiB
Python
851 lines
25 KiB
Python
"""Pydantic-Schemas für Anfragen/Antworten (Phase 0: Auth, Benutzer, Einstellungen)."""
|
||
|
||
from datetime import date as date_t, datetime, time as time_t
|
||
from decimal import Decimal
|
||
from typing import Literal
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||
|
||
from . import waehrungen
|
||
from .models import (
|
||
AccountType,
|
||
AmountMode,
|
||
BudgetTreatment,
|
||
CategoryKind,
|
||
IdentifierKind,
|
||
ImportStatus,
|
||
Role,
|
||
TxnType,
|
||
)
|
||
|
||
ALLOWED_CURRENCIES = waehrungen.ERLAUBT
|
||
|
||
|
||
def _check_currency(v: str) -> str:
|
||
v = v.upper()
|
||
if v not in ALLOWED_CURRENCIES:
|
||
raise ValueError(
|
||
f"'{v}' ist keine bekannte Währung. Möglich sind die Währungen mit "
|
||
f"EZB-Referenzkurs – ohne Kurs liesse sich der Betrag nie umrechnen."
|
||
)
|
||
return v
|
||
|
||
|
||
class UserOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
id: int
|
||
username: str
|
||
role: Role
|
||
display_name: str | None = None
|
||
is_active: bool
|
||
must_change_password: bool
|
||
must_change_password_reason: str | None = None
|
||
totp_enabled: bool = False
|
||
created_at: datetime
|
||
# Kein Datenbankfeld: „Richtlinie verlangt MFA und dieser Benutzer hat
|
||
# keins". Wird beim Ausliefern gesetzt (siehe deps.user_out), damit das
|
||
# Frontend ohne zweite Abfrage weiss, wohin es umleiten muss.
|
||
mfa_setup_required: bool = False
|
||
|
||
|
||
class UserCreate(BaseModel):
|
||
username: str = Field(min_length=1, max_length=64)
|
||
password: str = Field(min_length=1, max_length=1024)
|
||
role: Role = Role.user
|
||
display_name: str | None = Field(default=None, max_length=120)
|
||
|
||
|
||
class UserUpdate(BaseModel):
|
||
# Umbenennen ist erlaubt: Der Benutzername ist ein ETIKETT, kein Schluessel.
|
||
# Alles Fachliche haengt an der Nummer des Nutzers – aendert sich der Name
|
||
# beim Anmeldedienst, muss Certain folgen koennen, ohne dass Daten
|
||
# umziehen.
|
||
username: str | None = Field(default=None, min_length=1, max_length=64)
|
||
password: str | None = Field(default=None, min_length=1, max_length=1024)
|
||
role: Role | None = None
|
||
display_name: str | None = Field(default=None, max_length=120)
|
||
is_active: bool | None = None
|
||
|
||
|
||
class PasswordChange(BaseModel):
|
||
current_password: str = Field(min_length=1, max_length=1024)
|
||
new_password: str = Field(min_length=1, max_length=1024)
|
||
|
||
|
||
class SettingOut(BaseModel):
|
||
key: str
|
||
value: str | None = None
|
||
|
||
|
||
# --- Passwortrichtlinie -----------------------------------------------------
|
||
|
||
|
||
class PolicyOut(BaseModel):
|
||
min_length: int
|
||
require_upper: bool
|
||
require_lower: bool
|
||
require_digit: bool
|
||
require_special: bool
|
||
mfa_required: bool
|
||
|
||
|
||
class PolicyIn(BaseModel):
|
||
min_length: int = Field(ge=8, le=128)
|
||
require_upper: bool = False
|
||
require_lower: bool = False
|
||
require_digit: bool = False
|
||
require_special: bool = False
|
||
mfa_required: bool = False
|
||
|
||
|
||
# --- Anmeldung / Zwei-Faktor ------------------------------------------------
|
||
|
||
|
||
class LoginOut(BaseModel):
|
||
"""Antwort auf ``POST /auth/login``.
|
||
|
||
``status`` ist ``"ok"`` (angemeldet, ``user`` gesetzt) oder ``"2fa_required"``
|
||
(Passwort stimmt, es fehlt noch der Code – ``user`` bleibt leer)."""
|
||
|
||
status: str = "ok"
|
||
user: UserOut | None = None
|
||
|
||
|
||
class TwoFactorVerify(BaseModel):
|
||
code: str = Field(min_length=1, max_length=64)
|
||
recovery: bool = False
|
||
|
||
|
||
class TwoFactorSetupOut(BaseModel):
|
||
secret: str
|
||
otpauth_uri: str
|
||
qr_png: str # data:-URI, direkt in <img src=...> verwendbar
|
||
|
||
|
||
class TwoFactorActivate(BaseModel):
|
||
code: str = Field(min_length=1, max_length=16)
|
||
|
||
|
||
class TwoFactorPassword(BaseModel):
|
||
password: str = Field(min_length=1, max_length=1024)
|
||
|
||
|
||
class RecoveryCodesOut(BaseModel):
|
||
codes: list[str]
|
||
|
||
|
||
class TwoFactorStatusOut(BaseModel):
|
||
enabled: bool
|
||
recovery_codes_left: int
|
||
|
||
|
||
# --- 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
|
||
# Was die Karte ausgeben darf – kein Guthaben. Nur bei Kreditkarten sinnvoll.
|
||
credit_limit_cents: int | None = Field(default=None, ge=0)
|
||
# Gesperrtes Geld (z. B. Saeule 3a): zaehlt nicht als verfuegbar.
|
||
is_locked: bool = False
|
||
# Ueberweisungen hierher zaehlen als Sparen gegen diese Ausgabe-Kategorie.
|
||
savings_category_id: int | None = None
|
||
identifiers: list[AccountIdentifierIn] = Field(default_factory=list)
|
||
|
||
@field_validator("currency")
|
||
@classmethod
|
||
def _cur(cls, v: str) -> str:
|
||
return _check_currency(v)
|
||
|
||
|
||
class AccountOrder(BaseModel):
|
||
"""Konto-IDs in der gewünschten Reihenfolge."""
|
||
|
||
ids: list[int]
|
||
|
||
|
||
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
|
||
credit_limit_cents: int | None = Field(default=None, ge=0)
|
||
is_locked: bool | None = None
|
||
is_archived: bool | None = None
|
||
position: int | None = None
|
||
savings_category_id: 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
|
||
credit_limit_cents: int | None = None
|
||
is_locked: bool = False
|
||
is_archived: bool
|
||
position: int
|
||
savings_category_id: int | None = None
|
||
identifiers: list[AccountIdentifierOut]
|
||
balance_cents: int
|
||
balance_base_cents: int
|
||
# Saldo, wie ihn die Bank fuehrt – nur bei angebundenen Konten gesetzt. Er
|
||
# ersetzt die eigene Rechnung nicht, er prueft sie.
|
||
bank_balance_cents: int | None = None
|
||
bank_balance_at: datetime | None = None
|
||
# Nur ein GEBUCHTER Saldo ist mit Certains Rechnung vergleichbar. Ein
|
||
# verfuegbarer enthaelt Vorgemerktes, das hier bewusst nicht gebucht wird.
|
||
bank_balance_vergleichbar: bool = False
|
||
|
||
|
||
# --- Abos & Verträge --------------------------------------------------------
|
||
|
||
# Was als Intervall erlaubt ist. Offen genug für einen zweimonatlichen Vertrag,
|
||
# eng genug, dass niemand "0" oder "37" einträgt.
|
||
INTERVALL_MONATE = (1, 2, 3, 6, 12)
|
||
|
||
|
||
class SubscriptionIn(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
icon: str = "refresh-ccw"
|
||
color: str = "#6b7480"
|
||
amount_cents: int = Field(ge=0)
|
||
currency: str = "EUR"
|
||
interval_months: int = 1
|
||
account_id: int | None = None
|
||
category_id: int | None = None
|
||
next_due: date_t | None = None
|
||
payee_pattern: str | None = Field(default=None, max_length=255)
|
||
savings_account_id: int | None = None
|
||
is_active: bool = True
|
||
cancelled_at: date_t | None = None
|
||
notes: str | None = None
|
||
|
||
@field_validator("currency")
|
||
@classmethod
|
||
def _cur(cls, v: str) -> str:
|
||
return _check_currency(v)
|
||
|
||
@field_validator("interval_months")
|
||
@classmethod
|
||
def _iv(cls, v: int) -> int:
|
||
if v not in INTERVALL_MONATE:
|
||
raise ValueError(f"Intervall muss eines von {INTERVALL_MONATE} Monaten sein")
|
||
return v
|
||
|
||
|
||
class SubscriptionUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
icon: str | None = None
|
||
color: str | None = None
|
||
amount_cents: int | None = Field(default=None, ge=0)
|
||
currency: str | None = None
|
||
interval_months: int | None = None
|
||
account_id: int | None = None
|
||
category_id: int | None = None
|
||
next_due: date_t | None = None
|
||
payee_pattern: str | None = Field(default=None, max_length=255)
|
||
savings_account_id: int | None = None
|
||
is_active: bool | None = None
|
||
cancelled_at: date_t | None = None
|
||
notes: str | 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
|
||
|
||
@field_validator("interval_months")
|
||
@classmethod
|
||
def _iv(cls, v: int | None) -> int | None:
|
||
if v is not None and v not in INTERVALL_MONATE:
|
||
raise ValueError(f"Intervall muss eines von {INTERVALL_MONATE} Monaten sein")
|
||
return v
|
||
|
||
|
||
class SubscriptionOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
id: int
|
||
name: str
|
||
icon: str
|
||
color: str
|
||
amount_cents: int
|
||
currency: str
|
||
interval_months: int
|
||
account_id: int | None
|
||
category_id: int | None
|
||
next_due: date_t | None
|
||
payee_pattern: str | None
|
||
savings_account_id: int | None
|
||
is_active: bool
|
||
cancelled_at: date_t | None
|
||
notes: str | None
|
||
position: int
|
||
# Gerechnet, nicht gespeichert – in der Währung des Vertrags.
|
||
monatlich_cents: int
|
||
# Und derselbe Wert in der Hauptwährung, damit sich Zeilen vergleichen und
|
||
# summieren lassen. None, wenn für die Währung kein Kurs vorliegt.
|
||
monatlich_base_cents: int | None = None
|
||
# Wie viele Tage bis zur nächsten Abbuchung (negativ = überfällig).
|
||
tage_bis_faellig: int | None = None
|
||
# Ob ein Markenlogo hinterlegt ist. Nur die Angabe, nicht das Bild – das
|
||
# holt die Anzeige einzeln, sonst hinge die Liste voller Base64.
|
||
has_logo: bool = False
|
||
# Ändert sich beim Austauschen und überholt so den Zwischenspeicher des
|
||
# Browsers – ohne das zeigte er nach dem Wechsel weiter das alte Bild.
|
||
logo_version: int | None = None
|
||
|
||
|
||
class SubscriptionSummary(BaseModel):
|
||
"""Was am Kopf der Seite steht – die Zahl, wegen der man sie öffnet."""
|
||
|
||
currency: str
|
||
monatlich_cents: int
|
||
jaehrlich_cents: int
|
||
anzahl_aktiv: int
|
||
# Verträge, deren Währung sich mangels Kurs nicht umrechnen liess. Lieber
|
||
# nennen als stillschweigend zu klein summieren.
|
||
ohne_kurs: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class SubscriptionListOut(BaseModel):
|
||
subscriptions: list[SubscriptionOut]
|
||
summary: SubscriptionSummary
|
||
|
||
|
||
# --- 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
|
||
excluded_from_stats: bool = False
|
||
spending_is_good: bool = False
|
||
# Anzahl hinterlegter Schlagwörter. Nur in der Liste gefüllt, damit die
|
||
# Übersicht ohne einen Abruf je Kategorie auskommt.
|
||
keyword_count: int = 0
|
||
|
||
|
||
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"
|
||
excluded_from_stats: bool = False
|
||
spending_is_good: bool = False
|
||
|
||
|
||
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
|
||
excluded_from_stats: bool | None = None
|
||
spending_is_good: 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
|
||
# Budget-Monat (YYYY-MM). Leer heisst: aus dem Datum ableiten.
|
||
budget_period: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}$")
|
||
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
|
||
# Budget-Monat (YYYY-MM). Leer heisst: aus dem Datum ableiten.
|
||
budget_period: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}$")
|
||
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
|
||
# Der Vertrag, zu dem diese Buchung gehoert – ein Etikett, von der
|
||
# Erkennung gesetzt. Steht in der AUSGABE, nicht in der Eingabe: Beim
|
||
# Anlegen gibt man es nicht mit.
|
||
subscription_id: int | None = None
|
||
# Der Budget-Monat, in dem die Buchung zaehlt – immer gesetzt. Ob er von
|
||
# Hand gewaehlt wurde oder aus dem Datum stammt, sagt das zweite Feld.
|
||
budget_period: str
|
||
budget_period_manuell: bool = False
|
||
# Woher die Buchung stammt, fuer die Arbeitsliste: Ist sie gesetzt,
|
||
# kam die Buchung aus einem Datei-Import.
|
||
import_batch_id: int | None = None
|
||
# Woher die Buchung stammt: bank, mail, shortcut, transfer, csv, manuell.
|
||
herkunft: str = "manuell"
|
||
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
|
||
|
||
|
||
# --- Budget: geplante Einnahmen --------------------------------------------
|
||
|
||
|
||
class PlannedIncomeCreate(BaseModel):
|
||
label: str = Field(min_length=1, max_length=120)
|
||
category_id: int | None = None
|
||
target_account_id: int | None = None
|
||
planned_cents: int = Field(ge=0)
|
||
|
||
|
||
class PlannedIncomeUpdate(BaseModel):
|
||
label: str | None = Field(default=None, min_length=1, max_length=120)
|
||
category_id: int | None = None
|
||
target_account_id: int | None = None
|
||
planned_cents: int | None = Field(default=None, ge=0)
|
||
|
||
|
||
class PlannedIncomeOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
id: int
|
||
label: str
|
||
category_id: int | None
|
||
target_account_id: int | None
|
||
planned_cents: int
|
||
position: int
|
||
|
||
|
||
# --- CSV-Import ------------------------------------------------------------
|
||
|
||
|
||
class ImportTemplateCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
delimiter: str = ";"
|
||
decimal_sep: str = ","
|
||
date_format: str = "%d.%m.%Y"
|
||
encoding: str = "utf-8"
|
||
has_header: bool = True
|
||
skip_rows: int = 0
|
||
amount_mode: AmountMode = AmountMode.signed
|
||
column_map: dict[str, int | None] = Field(default_factory=dict)
|
||
filter_column: int | None = None
|
||
filter_value: str | None = None
|
||
|
||
|
||
class ImportTemplateUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
delimiter: str | None = None
|
||
decimal_sep: str | None = None
|
||
date_format: str | None = None
|
||
encoding: str | None = None
|
||
has_header: bool | None = None
|
||
skip_rows: int | None = None
|
||
amount_mode: AmountMode | None = None
|
||
column_map: dict[str, int | None] | None = None
|
||
filter_column: int | None = None
|
||
filter_value: str | None = None
|
||
|
||
|
||
class ImportTemplateOut(BaseModel):
|
||
id: int
|
||
name: str
|
||
delimiter: str
|
||
decimal_sep: str
|
||
date_format: str
|
||
encoding: str
|
||
has_header: bool
|
||
skip_rows: int
|
||
amount_mode: AmountMode
|
||
column_map: dict[str, int | None]
|
||
filter_column: int | None = None
|
||
filter_value: str | None = None
|
||
|
||
|
||
class ImportBatchOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
id: int
|
||
account_id: int
|
||
template_id: int | None
|
||
preset: str | None
|
||
filename: str
|
||
row_count: int
|
||
imported_count: int
|
||
status: ImportStatus
|
||
created_at: datetime
|
||
# Wie viele Uebertraege beim Buchen erkannt wurden. Nicht gespeichert –
|
||
# nur die Rueckmeldung eines Vorgangs. Sonst erfaehrt niemand, dass eine
|
||
# Kartenrechnung gerade still mit ihrer Gegenbuchung verbunden wurde.
|
||
uebertraege_verknuepft: int = 0
|
||
|
||
|
||
class ImportCommitIn(BaseModel):
|
||
account_id: int
|
||
template_id: int | None = None
|
||
preset: str | None = None
|
||
filename: str = ""
|
||
rows: list[dict] = Field(default_factory=list)
|
||
|
||
|
||
# --- Schlagwörter (manuelle Kategorisierungs-Regeln) -----------------------
|
||
|
||
|
||
class KeywordCreate(BaseModel):
|
||
word: str = Field(min_length=1, max_length=255)
|
||
amount_cents: int | None = Field(default=None, gt=0)
|
||
|
||
|
||
class KeywordUpdate(BaseModel):
|
||
word: str = Field(min_length=1, max_length=255)
|
||
amount_cents: int | None = Field(default=None, gt=0)
|
||
|
||
|
||
class KeywordOut(BaseModel):
|
||
id: int
|
||
category_id: int
|
||
word: str
|
||
amount_cents: int | None = None
|
||
|
||
|
||
class TransferKeywordCreate(BaseModel):
|
||
word: str = Field(min_length=1, max_length=255)
|
||
amount_cents: int | None = Field(default=None, gt=0)
|
||
target_account_id: int | None = None
|
||
|
||
|
||
class TransferKeywordOut(BaseModel):
|
||
id: int
|
||
word: str
|
||
amount_cents: int | None = None
|
||
target_account_id: int | None = None
|
||
|
||
|
||
class FlaggedKeywordOut(BaseModel):
|
||
id: int
|
||
category_id: int | None = None
|
||
category_name: str
|
||
word: str
|
||
applied_count: int
|
||
overridden_count: int
|
||
|
||
|
||
# --- Gerätemarken und Eingang ------------------------------------------------
|
||
|
||
|
||
class ApiTokenCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=80)
|
||
default_account_id: int | None = None
|
||
|
||
|
||
class ApiTokenOut(BaseModel):
|
||
id: int
|
||
name: str
|
||
default_account_id: int | None
|
||
last_used_at: datetime | None
|
||
created_at: datetime | None
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
class ApiTokenCreated(ApiTokenOut):
|
||
"""Antwort beim Anlegen – der Klartext ist hier das einzige Mal zu sehen."""
|
||
|
||
token: str
|
||
|
||
|
||
class QuickBookingIn(BaseModel):
|
||
"""Was ein Kurzbefehl schickt. Bewusst genügsam: Betrag reicht."""
|
||
|
||
amount: Decimal
|
||
payee: str | None = Field(default=None, max_length=255)
|
||
date: date_t | None = None
|
||
account_id: int | None = None
|
||
note: str | None = None
|
||
# Kennung des Kurzbefehls, damit ein doppelt ausgelöster Aufruf nicht
|
||
# zweimal bucht (schlechtes Netz, versehentlicher Doppeltipp).
|
||
external_id: str | None = Field(default=None, max_length=255)
|
||
kind: Literal["expense", "income"] = "expense"
|
||
|
||
|
||
class InboxItemOut(BaseModel):
|
||
id: int
|
||
source: str
|
||
status: str
|
||
account_id: int | None
|
||
date: date_t
|
||
amount_cents: int
|
||
currency: str
|
||
payee: str | None
|
||
note: str | None
|
||
category_id: int | None
|
||
transaction_id: int | None
|
||
# Das jeweils ANDERE Konto, wenn die Buchung ein Kontoübertrag ist. Ohne
|
||
# das kann die Oberfläche einen Übertrag nicht von einer Ausgabe
|
||
# unterscheiden und fragt weiter nach einer Kategorie.
|
||
transfer_account_id: int | None = None
|
||
# Was die Bank ueber die Umrechnung mitteilt – ihr Kurs schlaegt die EZB.
|
||
fx_rate: float | None = None
|
||
counter_amount_cents: int | None = None
|
||
counter_currency: str | None = None
|
||
created_at: datetime | None
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
class QuickBookingOut(BaseModel):
|
||
"""Kurze Rückmeldung – der Kurzbefehl zeigt sie als Mitteilung an."""
|
||
|
||
ok: bool = True
|
||
message: str
|
||
item: InboxItemOut
|
||
|
||
|
||
# --- Postfächer für den Mail-Abruf -------------------------------------------
|
||
|
||
|
||
class MailSourceIn(BaseModel):
|
||
name: str = Field(default="", max_length=80)
|
||
host: str = Field(min_length=1, max_length=255)
|
||
port: int = 993
|
||
security: Literal["ssl", "starttls", "none"] = "ssl"
|
||
username: str = Field(min_length=1, max_length=255)
|
||
# Beim Ändern weglassen, um das gespeicherte Passwort zu behalten.
|
||
password: str | None = None
|
||
folder: str = "INBOX"
|
||
done_folder: str | None = None
|
||
mark_seen: bool = True
|
||
is_active: bool = True
|
||
poll_minutes: int = Field(default=15, ge=5, le=1440)
|
||
|
||
|
||
class MailSourceOut(BaseModel):
|
||
id: int
|
||
name: str
|
||
host: str
|
||
port: int
|
||
security: str
|
||
username: str
|
||
folder: str
|
||
done_folder: str | None
|
||
mark_seen: bool
|
||
is_active: bool
|
||
poll_minutes: int
|
||
last_poll_at: datetime | None
|
||
last_error: str | None
|
||
# Das Passwort verlässt den Server nie – nur die Tatsache, dass eines da ist.
|
||
password_set: bool = True
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
class MailSenderIn(BaseModel):
|
||
name: str = Field(default="", max_length=80)
|
||
from_contains: str | None = Field(default=None, max_length=255)
|
||
subject_contains: str | None = Field(default=None, max_length=255)
|
||
currency: str | None = Field(default=None, max_length=3)
|
||
account_id: int | None = None
|
||
|
||
|
||
class MailSenderOut(MailSenderIn):
|
||
id: int
|
||
source_id: int
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
# --- Bankanbindung (PSD2) ----------------------------------------------------
|
||
|
||
|
||
class BankAppIn(BaseModel):
|
||
application_id: str = Field(min_length=1, max_length=64)
|
||
# Inhalt der .pem-Datei aus dem Control Panel.
|
||
private_key: str = Field(min_length=1)
|
||
# Öffentliche Adresse dieser Installation; die Bank leitet dorthin zurück.
|
||
base_url: str = Field(min_length=1, max_length=255)
|
||
|
||
@field_validator("base_url")
|
||
@classmethod
|
||
def nur_https(cls, v: str) -> str:
|
||
"""Ohne HTTPS lehnt die Bank die Rückleitung ab.
|
||
|
||
Bewusst eingetragen statt erkannt: Hinter einem Proxy sieht der Dienst
|
||
selbst „http", obwohl der Browser HTTPS spricht – automatisches Raten
|
||
würde hier zuverlässig das Falsche liefern.
|
||
"""
|
||
v = v.strip().rstrip("/")
|
||
if not v.lower().startswith("https://"):
|
||
raise ValueError(
|
||
"Die Adresse muss mit https:// beginnen. Banken leiten nicht "
|
||
"auf eine unverschlüsselte Adresse zurück."
|
||
)
|
||
return v
|
||
|
||
|
||
class BankAppOut(BaseModel):
|
||
id: int
|
||
application_id: str
|
||
base_url: str
|
||
created_at: datetime | None
|
||
# Der Schlüssel verlässt den Server nie – nur die Tatsache, dass einer da ist.
|
||
key_set: bool = True
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
class BankConnectionOut(BaseModel):
|
||
id: int
|
||
aspsp_name: str
|
||
aspsp_country: str
|
||
valid_until: datetime | None
|
||
last_sync_at: datetime | None
|
||
# Takt des automatischen Abrufs in Minuten, 0 = aus.
|
||
poll_minutes: int = 0
|
||
last_error: str | None
|
||
expires_soon: bool = False
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
class BankAccountLinkOut(BaseModel):
|
||
id: int
|
||
connection_id: int
|
||
remote_uid: str
|
||
iban: str | None
|
||
remote_name: str | None
|
||
product: str | None = None
|
||
other_id: str | None = None
|
||
bank_felder: dict = {}
|
||
currency: str
|
||
balance_cents: int | None = None
|
||
balance_type: str | None = None
|
||
balance_vergleichbar: bool = False
|
||
balance_at: datetime | None = None
|
||
last_sync_at: datetime | None = None
|
||
account_id: int | None
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
class BankConnectStart(BaseModel):
|
||
aspsp_name: str = Field(min_length=1, max_length=120)
|
||
country: str = Field(min_length=2, max_length=2)
|
||
psu_type: Literal["personal", "business"] = "personal"
|