Files
certain/backend/app/models.py
T
ScarriffleandClaude Opus 5 8a7545bf5b feat: Eine Geraetemarke kann jetzt auch als Anmeldung fuer eine App gelten
Die API kannte zwei Zustaende: angemeldet per Sitzungs-Cookie, oder eine enge
Kurzbefehl-Marke, die nur erfassen darf. Eine native App passt in keinen davon
- sie kann kein Cookie-Formular ausfuellen und soll das Passwort nicht auf dem
Geraet halten.

Neu ist die Reichweite "app": Sie gilt im Header X-Certain-Token wie eine
Anmeldung, laeuft nicht nach 30 Tagen ab und laesst sich einzeln widerrufen.

Die enge Marke bleibt eng - das ist der Punkt, an dem die Tests am genauesten
hinsehen. Und eine App-Marke darf keine weitere ausstellen: Sonst stellte sich
ein Dieb Ersatzschluessel aus und das Widerrufen liefe ins Leere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 16:06:41 +02:00

1352 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SQLAlchemy-Modelle.
Konventionen (wie in der Vorlage): ``Mapped[...] = mapped_column(...)``, Geld als
Ganzzahl in Cent/Rappen, Enums als kurze VARCHAR-Strings (``native_enum=False``,
ohne DB-CHECK) – das hält Alembic auf SQLite einfach.
Phase 0 umfasst nur Benutzer, Sitzungen und Benutzer-Einstellungen. Fachliche
Tabellen (Konten, Kategorien, Transaktionen, Budget …) kommen in späteren Phasen
hinzu und werden dann alle über ``owner_id`` je Benutzer getrennt.
"""
from __future__ import annotations
import enum
import json
# 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 (
Boolean,
Date,
DateTime,
Enum as SAEnum,
ForeignKey,
Index,
Integer,
LargeBinary,
Numeric,
String,
Text,
Time,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
def _enum(enum_cls: type[enum.Enum]):
"""Enum als VARCHAR ohne DB-CHECK – Alembic-freundlich auf SQLite."""
return SAEnum(enum_cls, native_enum=False, create_constraint=False, length=32)
class Role(str, enum.Enum):
admin = "admin"
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"
text = "text" # freier Erkennungs-Text im Buchungstext (Zielkonto bei Transfers)
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 AmountMode(str, enum.Enum):
"""Wie der Betrag in der CSV steht."""
signed = "signed" # eine Spalte, mit Vorzeichen (− = Ausgabe)
debit_credit = "debit_credit" # getrennte Soll-/Haben-Spalten
abs_expense = "abs_expense" # Betrag ohne Vorzeichen, immer Ausgabe
# Eine Spalte mit Vorzeichen, nur andersherum: + = Ausgabe.
#
# So fuehren Kreditkarten ihre Auszuege, und das ist aus ihrer Sicht
# richtig: Ein Einkauf erhoeht die Schuld, die Rechnungszahlung senkt sie.
# Gemeint ist derselbe Vorgang wie bei "signed", nur aus der anderen
# Richtung erzaehlt.
#
# abs_expense ist dafuer KEIN Ersatz – es machte auch die
# Rechnungszahlung und jede Gutschrift zur Ausgabe.
signed_inverted = "signed_inverted"
class MatchField(str, enum.Enum):
payee = "payee"
purpose = "purpose"
any = "any" # trifft, wenn das Muster im Empfänger ODER im Zweck vorkommt
class RuleSource(str, enum.Enum):
learned = "learned" # automatisch aus bestätigten Zuordnungen gelernt
manual = "manual" # vom Nutzer als Schlagwort angelegt
class ImportStatus(str, enum.Enum):
committed = "committed"
undone = "undone"
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
# Leer bei reinen SSO-Nutzern: Wer sich nur ueber einen fremden
# Anmeldedienst anmeldet, hat hier nie ein Passwort gesetzt. NULL heisst
# ausdruecklich "kein Passwort-Login", nicht "leeres Passwort" – die
# Anmeldung prueft das, bevor sie ueberhaupt vergleicht.
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
role: Mapped[Role] = mapped_column(_enum(Role), nullable=False, default=Role.user)
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Vom Admin mit Temporär-Passwort angelegt: beim ersten Login ändern lassen.
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Warum der Wechsel fällig ist: "temp" (Admin-Passwort) oder "policy"
# (Richtlinie wurde verschärft). Steuert nur den Hinweistext.
must_change_password_reason: Mapped[str | None] = mapped_column(String(16), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
# --- Zwei-Faktor (TOTP) ---
# Das Secret liegt im Klartext. Verschlüsseln brächte hier nichts: der
# Schlüssel läge auf derselben Maschine wie die Datenbank. Wer die DB-Datei
# hat, hätte dann auch den Schlüssel. Schutz kommt aus Dateirechten.
totp_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
totp_confirmed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Zuletzt verbrauchtes 30-Sekunden-Zeitfenster – verhindert, dass derselbe
# Code innerhalb seiner Gültigkeit ein zweites Mal benutzt wird.
totp_last_step: Mapped[int | None] = mapped_column(Integer, nullable=True)
sessions: Mapped[list[Session]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
settings: Mapped[list[UserSetting]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
recovery_codes: Mapped[list[RecoveryCode]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class UserIdentity(Base):
"""Ein Konto bei einem fremden Anmeldedienst, das zu diesem Nutzer gehoert.
Eigene Tabelle und nicht zwei Spalten am Nutzer: Jemand kann sich ueber
mehrere Dienste anmelden, und die Kennung (``subject``) gilt immer nur
beim jeweiligen Anbieter. Zwei Nutzer duerfen dieselbe Kennung haben,
solange sie von verschiedenen Anbietern kommt.
``subject`` ist die ``sub``-Angabe aus dem ID-Token: die einzige, die der
Anbieter garantiert stabil haelt. Die E-Mail steht nur zur Anzeige dabei –
sie kann sich aendern, ohne dass es ein anderer Mensch wird.
"""
__tablename__ = "user_identities"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
last_login_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
# Eine Kennung gehoert zu genau EINEM Nutzer. Ohne diese Regel koennte
# ein zweiter Nutzer dieselbe Kennung bekommen, und wer sich anmeldet,
# landete mal hier und mal dort.
UniqueConstraint("provider", "subject", name="uq_identity_provider_subject"),
)
class OidcState(Base):
"""Ein begonnener Anmeldeversuch, bis der Nutzer zurueckkommt.
Muss serverseitig liegen und nicht im Cookie: Der Nutzer verlaesst die
Seite und kommt ueber eine fremde Adresse zurueck – bei ``SameSite=lax``
kaeme ein Cookie zwar mit, aber ``code_verifier`` und ``nonce`` gehoeren
ohnehin nicht in den Browser.
Wird beim Einloesen GELOESCHT, nicht nur als benutzt markiert: Ein Code
darf genau einmal eingeloest werden.
"""
__tablename__ = "oidc_states"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
state: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
nonce: Mapped[str] = mapped_column(String(64), nullable=False)
code_verifier: Mapped[str] = mapped_column(String(128), nullable=False)
redirect_uri: Mapped[str] = mapped_column(String(500), nullable=False)
# Gesetzt, wenn ein bereits angemeldeter Nutzer sein Konto VERKNUEPFEN will
# statt sich anzumelden. Dann darf am Ende kein fremdes Konto entstehen.
link_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
# Wohin es in der Oberflaeche danach weitergeht.
weiter: Mapped[str | None] = mapped_column(String(255), nullable=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class Session(Base):
"""Server-seitige Sitzung. Im Cookie liegt nur das Klartext-Token, hier der Hash."""
__tablename__ = "sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Passwort stimmt, der zweite Faktor fehlt noch. Solche Sitzungen gelten
# nirgends als angemeldet (siehe services/sessions.validate) und laufen
# nach wenigen Minuten ab.
pending_2fa: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
failed_2fa: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
user: Mapped[User] = relationship(back_populates="sessions")
class RecoveryCode(Base):
"""Einmal-Wiederherstellungscode für den Fall eines verlorenen Authenticators.
Gespeichert wird nur der SHA-256-Hash. Bewusst nicht Argon2: die Codes sind
zufällig und hochentrop, und pro Prüfung stünden bis zu zehn Kandidaten an.
"""
__tablename__ = "recovery_codes"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
code_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
user: Mapped[User] = relationship(back_populates="recovery_codes")
class AppSetting(Base):
"""Globale, vom Admin gepflegte Einstellungen – im Gegensatz zu
``user_settings`` bewusst ohne ``owner_id``. Beherbergt heute die
Passwortrichtlinie (siehe ``services/policy``)."""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str | None] = mapped_column(String(255), nullable=True)
class UserSetting(Base):
"""Freie Schlüssel-Wert-Einstellungen je Benutzer.
Bekannte Schlüssel: ``base_currency`` (EUR|CHF), ``budget_start_day`` (int),
``locale`` (de|en), ``theme`` (system|light|dark), ``date_format``.
"""
__tablename__ = "user_settings"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str | None] = mapped_column(String(255), nullable=True)
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)
# Was die Karte ausgeben DARF – nicht, was auf ihr liegt.
#
# Eine Kreditkarte ist kein Guthaben. Abbezahlt steht sie auf null, jede
# Ausgabe drueckt sie ins Minus, die Rechnungszahlung holt sie zurueck.
# Der Rahmen ist eine Eigenschaft des Vertrags mit der Bank und hat im
# Saldo nichts zu suchen – wer ihn dort eintraegt, sieht Geld, das er
# nicht hat.
#
# Deshalb ein eigenes Feld: Die Zahl ist es wert, aufgehoben zu werden
# („noch 1.900 von 2.500 frei"), nur eben nicht als Vermoegen.
#
# Optional, und nur bei Kreditkarten sinnvoll. Ohne Eintrag aendert sich
# nichts.
credit_limit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Wer hierher ueberweist, spart – und zwar gegen DIESE Kategorie.
#
# Sparen ist eine Entscheidung ueber ein KONTO, nicht ueber jede einzelne
# Buchung: Ein Sparkonto ist zum Sparen da, sonst waere es keins. Die
# Angabe gehoert deshalb hierher und nicht an jeden Uebertrag – einmal
# gesagt, gilt sie fuer alles, was danach kommt.
#
# Sie steht ueber der Vorgabe nach Kontotyp (siehe SAVINGS_ACCOUNT_TYPES).
# Das ist der Punkt: Ein Revolut-Pocket ist bei der Bank ein ganz normales
# Konto und kommt als „girokonto" herein. Am Typ laesst sich nicht ablesen,
# dass man darauf spart – am ausdruecklichen Eintrag hier schon.
#
# SET NULL: Verschwindet die Kategorie, faellt nur die Zuordnung weg. Ein
# Konto zu loeschen, weil man eine Kategorie aufraeumt, waere absurd.
savings_category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
# Geld, an das man nicht herankommt.
#
# Eine Saeule 3a ist gesperrt bis zur Rente, zur Selbstaendigkeit oder zur
# Auswanderung. Es als Teil dessen zu zaehlen, was man ausgeben kann, ist
# schlicht falsch – der Betrag steht da und ist doch nicht da.
#
# Es bleibt Vermoegen: Der Verlauf und das Nettovermoegen rechnen es weiter
# mit. Nur die Summe, die beantwortet „wie viel habe ich zur Verfuegung",
# laesst es aussen vor und weist es daneben aus.
#
# Deshalb ein eigenes Kennzeichen und keine Ableitung aus dem Kontotyp: Ein
# Depot ist frei verkaeuflich, eine Saeule 3a nicht, und beide kommen als
# „depot" herein. Am Typ laesst sich das nicht ablesen.
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
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)
# Umbuchungen zwischen eigenen Konten sind keine Ausgabe: Das Geld ist nicht
# weg, es liegt woanders. Solche Kategorien bleiben aus Budget, Dashboard und
# Auswertungen heraus – die Buchung selbst zählt aber weiter auf den Saldo.
excluded_from_stats: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Diese Ausgabe ist kein Verlust. Sparen, Rücklagen, Altersvorsorge: Das Geld
# ist ausgegeben im Sinne des Budgets – man kann es diesen Monat nicht mehr
# verbrauchen –, aber es ist nicht weg.
#
# Zwei Folgen, und die zweite ist die wichtigere:
# 1. Beträge stehen neutral statt rot. NICHT grün: Grün heisst in Certain
# überall "Geld kommt rein", und CHF 1'010 in Grün liest sich als
# Einnahme. Grau sagt genau das Richtige – kein Verlust, kein Gewinn.
# 2. Mehr als geplant ist keine Warnung. Wer 1'000 statt 800 zurücklegt,
# hat nichts überzogen; ihn deswegen rot anzuschreien ist schlicht
# falsch herum.
spending_is_good: 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
)
# Budget-Monat (YYYY-MM), wenn er NICHT der aus dem Datum abgeleitete ist.
# Leer heisst: aus dem Datum. Das ist der Normalfall; gesetzt wird das Feld
# nur fuer den Ausnahmefall, dass eine Zahlung sichtbar in den einen Monat
# faellt, aber zum anderen gehoert – die Miete, die am 24. abgeht, oder die
# Rechnung, die man verspaetet begleicht.
budget_period: Mapped[str | None] = mapped_column(String(7), nullable=True, index=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)
# „Sieht gleich aus, ist aber richtig so."
#
# Drei Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche
# Einkaeufe koennen es auch sein. Wer das einmal entschieden hat, soll
# nicht bei jedem Blick in die Liste erneut gefragt werden – und schon gar
# nicht zum Loeschen gedraengt, nur damit die Meldung verschwindet.
#
# Die Buchung bleibt unveraendert; das ist nur ein Haken an der Frage.
duplicate_ok: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Import (Phase 4): Herkunft für Undo und Duplikaterkennung.
import_batch_id: Mapped[int | None] = mapped_column(
ForeignKey("import_batches.id", ondelete="SET NULL"), nullable=True, index=True
)
import_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# Die Kennung, unter der die Quelle diese Zahlung fuehrt – bei der Bank die
# des Umsatzes, mit der Quelle davor ("bank:…", "mail:…", "shortcut:…").
#
# Sie steht bewusst HIER und nicht nur am Eingangs-Eintrag. Der Eintrag ist
# eine Zwischenstation: Er kann verschwinden, weil ein Konto neu verbunden
# wurde, weil ein aelteres Backup ihn nicht kannte, weil jemand aufgeraeumt
# hat. Die Buchung bleibt. Solange die Kennung an der Buchung haengt, kann
# dieselbe Zahlung kein zweites Mal hereinkommen – und zwar unabhaengig
# davon, ob der pruefende Code davor daran gedacht hat: Die Eindeutigkeit
# weiter unten laesst es gar nicht zu.
#
# Leer bleibt sie bei allem, was keine Kennung mitbringt: von Hand erfasst,
# aus einer CSV-Datei. Dort schuetzt weiterhin nur ``import_hash``, und das
# ist eine Aehnlichkeit, keine Kennung.
#
# 320 statt 255: Die Kennung der Quelle darf selbst schon 255 lang sein,
# der Name der Quelle davor kostet ein paar Zeichen mehr. Abschneiden waere
# hier die falsche Sparsamkeit – zwei abgeschnittene Kennungen saehen
# gleich aus, und dann verschluckt eine Zahlung die andere.
source_uid: Mapped[str | None] = mapped_column(String(320), nullable=True)
# Der Vertrag, zu dem diese Buchung gehoert – Netflix, Handy, Versicherung.
#
# Ein ETIKETT, wie die Uebertrags-Verknuepfung: An der Buchung selbst
# aendert sich nichts, sie behaelt Betrag, Konto und Kategorie. Hier steht
# nur, dass die beiden zusammengehoeren. Zieht man es ab, bleibt eine
# gewoehnliche Buchung zurueck.
#
# SET NULL: Verschwindet der Vertrag, verschwindet das Etikett – nie die
# Buchung. Wer ein Abo kuendigt und loescht, will nicht seine
# Zahlungshistorie mitloeschen.
subscription_id: Mapped[int | None] = mapped_column(
ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True, index=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)
__table_args__ = (
# Ein eindeutiger INDEX, keine UNIQUE-Spaltenregel: Letztere zwingt
# SQLite dazu, die Tabelle neu zu bauen – und dabei reisst das
# implizite DELETE die abhaengigen Zeilen ueber ON DELETE CASCADE mit
# (Uebertrags-Verknuepfungen, abgelehnte Vorschlaege). Siehe
# tests/test_migrations.py. Der Index haelt dasselbe durch, ohne die
# Tabelle anzufassen.
#
# NULL zaehlt in SQL nicht als gleich – Buchungen ohne Kennung stehen
# sich also nicht gegenseitig im Weg. Genau das ist gewollt: Die Regel
# greift nur dort, wo es wirklich eine Kennung gibt.
Index("uq_txn_owner_source_uid", "owner_id", "source_uid", unique=True),
)
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")
# ---------------------------------------------------------------------------
# Phase 2: Budget (ein Standard-Budget als Vorlage + Monats-Overrides)
# Planned-Beträge sind in der Hauptwährung des Nutzers.
# ---------------------------------------------------------------------------
class BudgetTemplateLine(Base):
"""Standard-Budget je Kategorie (die editierbare Vorlage)."""
__tablename__ = "budget_template_lines"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
category_id: Mapped[int] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
)
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Wie der Soll-Betrag zustande kommt.
#
# "fest" – die Zahl in ``planned_cents``, wie eh und je.
# "berechnet" – Summe der Vertraege dieser Kategorie PLUS ``zuschlag_cents``.
#
# Der Zuschlag ist der Punkt: Wer fuer Streaming 80 plant, weil 58 fest
# gebunden sind und 22 Luft sein sollen, will beim naechsten Preisaufschlag
# nicht nachrechnen muessen – und seine 22 auch nicht verlieren.
#
# ``planned_cents`` bleibt bei "berechnet" stehen, wird aber nicht gelesen.
# So findet man seinen alten Wert wieder, wenn man zurueckschaltet.
modus: Mapped[str] = mapped_column(String(16), nullable=False, default="fest")
zuschlag_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
__table_args__ = (
UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"),
)
class PlannedIncomeLine(Base):
"""Geplante Einnahme (z. B. Gehalt) mit optionaler Kategorie und Zielkonto."""
__tablename__ = "planned_income_lines"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
label: Mapped[str] = mapped_column(String(120), nullable=False)
category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
target_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
class BudgetPeriod(Base):
"""Ein Budget-Monat. Existiert erst, sobald der Monat angepasst wurde."""
__tablename__ = "budget_periods"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
label: Mapped[str] = mapped_column(String(7), nullable=False) # YYYY-MM (End-/Hauptmonat)
customized: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
lines: Mapped[list[BudgetPeriodLine]] = relationship(
back_populates="period", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint("owner_id", "label", name="uq_period_owner_label"),
)
class BudgetPeriodLine(Base):
"""Monatsspezifischer Budget-Wert (Snapshot-on-Customize)."""
__tablename__ = "budget_period_lines"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
budget_period_id: Mapped[int] = mapped_column(
ForeignKey("budget_periods.id", ondelete="CASCADE"), nullable=False, index=True
)
category_id: Mapped[int] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
)
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Wie in der Vorlage – der angepasste Monat erbt die Betriebsart, sonst
# friert er beim Anpassen heimlich auf einer Zahl ein.
modus: Mapped[str] = mapped_column(String(16), nullable=False, default="fest")
zuschlag_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
period: Mapped[BudgetPeriod] = relationship(back_populates="lines")
__table_args__ = (
UniqueConstraint("budget_period_id", "category_id", name="uq_periodline_period_category"),
)
# ---------------------------------------------------------------------------
# Phase 3: Dashboard-Layout (frei anordbare Karten, JSON, je Benutzer)
# ---------------------------------------------------------------------------
class DashboardLayout(Base):
__tablename__ = "dashboard_layouts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True
)
# JSON-Array: [{i, type, x, y, w, h, props}]
layout: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
# ---------------------------------------------------------------------------
# Phase 4: CSV-Import (Templates, Batches für Undo, lernende Kategorie-Regeln)
# ---------------------------------------------------------------------------
class ImportTemplate(Base):
__tablename__ = "import_templates"
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)
delimiter: Mapped[str] = mapped_column(String(2), nullable=False, default=";")
decimal_sep: Mapped[str] = mapped_column(String(1), nullable=False, default=",")
date_format: Mapped[str] = mapped_column(String(32), nullable=False, default="%d.%m.%Y")
encoding: Mapped[str] = mapped_column(String(16), nullable=False, default="utf-8")
has_header: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
skip_rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
amount_mode: Mapped[AmountMode] = mapped_column(_enum(AmountMode), nullable=False, default=AmountMode.signed)
# JSON: Feld -> 0-basierter Spaltenindex (oder null). Felder: date, time,
# amount, debit, credit, currency, payee, purpose, counterparty_iban.
column_map: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# Zeilenfilter: nur Zeilen übernehmen, in denen Spalte `filter_column` den
# Wert `filter_value` hat. Revolut legt alle Pockets („Giro", „Erspartes" …)
# mit je eigenem Saldo-Verlauf in EINE Datei – ohne Filter landete alles auf
# demselben Konto und zählte doppelt.
filter_column: Mapped[int | None] = mapped_column(Integer, nullable=True)
filter_value: Mapped[str | None] = mapped_column(String(120), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class ImportBatch(Base):
__tablename__ = "import_batches"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
account_id: Mapped[int] = mapped_column(
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
template_id: Mapped[int | None] = mapped_column(
ForeignKey("import_templates.id", ondelete="SET NULL"), nullable=True
)
# Name der Bank-Vorlage (Raiffeisen, N26 …). Eigene Vorlagen stehen in
# template_id; die fest eingebauten haben keine ID und wuerden sonst
# vergessen, sobald der Import durch ist.
preset: Mapped[str | None] = mapped_column(String(64), nullable=True)
filename: Mapped[str] = mapped_column(String(255), nullable=False, default="")
row_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
imported_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[ImportStatus] = mapped_column(_enum(ImportStatus), nullable=False, default=ImportStatus.committed)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class CategorizationRule(Base):
"""Regel: Empfänger/Zweck enthält Muster -> Kategorie.
Zwei Quellen (``source``): ``learned`` wird beim Import automatisch aus
bestätigten Zuordnungen gelernt; ``manual`` legt der Nutzer als Schlagwort je
Kategorie an. ``applied_count``/``overridden_count`` zählen, wie oft die Regel
zutraf und wie oft ihr Vorschlag wegkorrigiert wurde – daraus wird ein
manuelles Schlagwort ggf. als „stimmt vielleicht nicht" markiert."""
__tablename__ = "categorization_rules"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
match_field: Mapped[MatchField] = mapped_column(_enum(MatchField), nullable=False, default=MatchField.payee)
pattern: Mapped[str] = mapped_column(String(255), nullable=False) # normalisiert (lowercase)
category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=True
)
# Optionale Zusatzbedingung: Regel greift nur bei genau diesem (absoluten) Betrag.
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Wenn True: passende Buchung wird zum Transfer (category_id bleibt leer).
is_transfer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Optionales festes Zielkonto für Transfer-Regeln. Ohne -> Zielkonto wird aus
# dem Buchungstext (Konto-Erkennungs-Texte) erraten.
target_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
# Art der Regel: "expense" | "income" | "transfer". Erlaubt, dasselbe Wort je
# einmal für eine Ausgabe- UND eine Einnahme-Kategorie zu verwenden (das
# Matching filtert ohnehin nach Buchungsart).
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="expense")
source: Mapped[RuleSource] = mapped_column(_enum(RuleSource), nullable=False, default=RuleSource.learned)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
applied_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
overridden_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# „Behalten, nicht mehr fragen" – unterdrückt das Flaggen trotz Korrekturen.
confirmed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
# Der Betrag gehört dazu: dasselbe Wort darf mehrere Preise haben
# („sv = 5.00" und „sv = 7.50"), ohne Betrag ist es der Auffang.
UniqueConstraint("owner_id", "match_field", "pattern", "kind", "amount_cents",
name="uq_rule_owner_field_pattern_kind_amount"),
)
class TokenScope(str, enum.Enum):
"""Was eine Gerätemarke darf.
``quick_add`` ist die enge Form und die Voreinstellung. Eine solche Marke
liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt. Sie darf
deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist kein
Datenabfluss möglich.
``app`` ist die Marke der nativen App und gilt wie eine Anmeldung – eine
App muss alles können, was die Weboberfläche kann, und die API kennt
sonst nur das Sitzungs-Cookie. Der Klartext liegt allein im Schlüsselbund
des Geräts; widerrufen wird die Marke in den Einstellungen. Vergeben darf
sie nur eine echte Anmeldung, nie eine Marke selbst: Sonst hätte ein Dieb
sich beliebig viele Ersatzschlüssel ausgestellt und das Widerrufen wäre
wirkungslos.
"""
quick_add = "quick_add"
app = "app"
class ApiToken(Base):
"""Zugangsmerkmal für Geräte, die sich nicht anmelden können (Kurzbefehle)."""
__tablename__ = "api_tokens"
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(80), nullable=False)
# Nur der Hash wird abgelegt; den Klartext sieht man einmal beim Anlegen.
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
scope: Mapped[TokenScope] = mapped_column(
_enum(TokenScope), nullable=False, default=TokenScope.quick_add
)
# Vorbelegtes Konto, damit der Kurzbefehl ohne Kontoauswahl auskommt.
default_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class InboxSource(str, enum.Enum):
shortcut = "shortcut" # per Kurzbefehl vom Telefon erfasst
mail = "mail" # aus einer Benachrichtigungs-Mail gelesen
bank = "bank" # direkt von der Bank geholt (PSD2)
transfer = "transfer" # Gegenseite eines Kontouebertrags, von Certain angelegt
class InboxStatus(str, enum.Enum):
booked = "booked" # als Buchung übernommen
pending = "pending" # wartet auf Bestätigung
unclear = "unclear" # Erkennung unsicher, erst ansehen
discarded = "discarded" # keine Zahlung / verworfen
class InboxItem(Base):
"""Eine von aussen erfasste Zahlung, bevor der Kontoauszug sie bestätigt.
Der Kontoauszug kommt erst am Monatsende. Wer eine Zahlung gleich erfasst,
weiss noch, wofür sie war – auf dem Auszug steht später oft nur ein
nichtssagender Betreibername. Der Eintrag bleibt nach dem Buchen erhalten,
damit der spätere CSV-Import ihn wiedererkennt und die Zahlung nicht ein
zweites Mal verbucht.
"""
__tablename__ = "inbox_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
source: Mapped[InboxSource] = mapped_column(_enum(InboxSource), nullable=False)
status: Mapped[InboxStatus] = mapped_column(
_enum(InboxStatus), nullable=False, default=InboxStatus.pending
)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True, index=True
)
date: Mapped[date_t] = mapped_column(Date, nullable=False, index=True)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
# Die erzeugte Buchung. SET NULL, damit das Löschen einer Buchung den
# Eingangs-Eintrag nicht mitreisst – sonst käme die Zahlung beim nächsten
# Auszug unbemerkt ein zweites Mal herein.
transaction_id: Mapped[int | None] = mapped_column(
ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True
)
# Was die Bank ueber die Umrechnung mitteilt. Ihr eigener Kurs schlaegt die
# EZB-Naeherung – und nur mit dem exakten Gegenbetrag laesst sich die
# spaeter gelieferte Gegenseite eines Uebertrags wiedererkennen.
fx_rate: Mapped[float | None] = mapped_column(Numeric(18, 8), nullable=True)
counter_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
counter_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
# Kennung der Quelle (Message-ID der Mail, Kennung des Kurzbefehls):
# schützt davor, dieselbe Meldung zweimal zu erfassen.
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
raw_text: 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)
__table_args__ = (
UniqueConstraint("owner_id", "source", "external_id", name="uq_inbox_owner_source_external"),
)
class MailSource(Base):
"""Ein Postfach, aus dem Certain Zahlungs-Mails abholt."""
__tablename__ = "mail_sources"
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(80), nullable=False, default="")
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False, default=993)
# "ssl" = TLS ab der ersten Zeile (Port 993, der Normalfall),
# "starttls" = im Klartext verbinden und dann verschlüsseln (Port 143),
# "none" = unverschlüsselt (nur sinnvoll innerhalb desselben Rechners).
security: Mapped[str] = mapped_column(String(16), nullable=False, default="ssl")
username: Mapped[str] = mapped_column(String(255), nullable=False)
# Verschlüsselt, nicht gehasht: Certain muss es dem Mailserver vorlegen.
password_enc: Mapped[str] = mapped_column(Text, nullable=False)
folder: Mapped[str] = mapped_column(String(255), nullable=False, default="INBOX")
# Nach dem Auswerten: dorthin verschieben (leer = nicht verschieben).
done_folder: Mapped[str | None] = mapped_column(String(255), nullable=True)
mark_seen: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
poll_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
last_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class MailSender(Base):
"""Welcher Absender auf welches Konto bucht.
Ohne Zuordnung weiss Certain nicht, wohin eine erkannte Zahlung gehört –
dieselbe Mailadresse kann je nach Konto etwas anderes bedeuten.
"""
__tablename__ = "mail_senders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("mail_sources.id", ondelete="CASCADE"), nullable=False, index=True
)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
# Teilstrings, gegen Absender und Betreff geprüft. Leer = jede Mail.
from_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
subject_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Nur Zahlungen dieser Währung. Leer = jede. Revolut schickt für EUR und CHF
# dieselbe Mail vom selben Absender – erst die Währung sagt, welches Konto
# gemeint ist.
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class PendingUpload(Base):
"""Eine vom Telefon abgelieferte Datei, die noch niemand geprüft hat.
Am Telefon lässt sich ein Auszug nicht sinnvoll durchsehen – hunderte
Zeilen mit Kategorie-Auswahl sind auf dem Bildschirm keine Erleichterung.
Die Datei wird deshalb nur entgegengenommen; geprüft und gebucht wird
später in der normalen Import-Ansicht, mit Saldo-Abgleich und allem.
"""
__tablename__ = "pending_uploads"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
account_id: Mapped[int] = mapped_column(
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
filename: Mapped[str] = mapped_column(String(255), nullable=False, default="")
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class TransferLink(Base):
"""Zwei Buchungen, die dieselbe Bewegung sind – ein Übertrag zwischen
eigenen Konten.
Bewusst eine SCHICHT OBENDRAUF: An den Buchungen selbst wird nichts
geändert. Sie behalten Betrag, Konto, Kategorie, Import-Schlüssel und
Stapel-Zugehörigkeit. Hier steht nur, dass sie zusammengehören.
Der frühere Ansatz führte beide zu einer Übertrags-Buchung zusammen und
löschte die Originale. Daran hing eine Kette von Folgeproblemen – der
Duplikatschutz verlor einen der beiden Import-Schlüssel, die Rücknahme
eines Imports riss die Gegenseite mit, und eine falsche Paarung liess sich
ohne vollständige Kopie nicht zurückholen. Nichts zu löschen lässt all das
gar nicht erst entstehen: Eine falsche Verknüpfung ist ein Etikett, das man
abzieht.
"""
__tablename__ = "transfer_links"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# CASCADE ist hier richtig: Verschwindet eine Buchung, ist die Verknüpfung
# gegenstandslos. Es verschwindet das Etikett, nie eine Buchung.
from_txn_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
to_txn_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
automatisch: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("from_txn_id", name="uq_link_from"),
UniqueConstraint("to_txn_id", name="uq_link_to"),
)
class LinkIgnored(Base):
"""Ein abgelehnter Vorschlag. Damit er nicht bei jedem Suchlauf wiederkehrt."""
__tablename__ = "link_ignored"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
a_txn_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
b_txn_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("a_txn_id", "b_txn_id", name="uq_ignored_pair"),
)
class Subscription(Base):
"""Ein Abo oder Vertrag: wiederkehrende Zahlung mit bekanntem Betrag.
Netflix, Handyvertrag, Krankenkasse, Halbtax – dieselbe Form. Deshalb
heisst der Bereich "Abos & Vertraege": Ein Handyvertrag ist kein Abo, und
niemand soll ueberlegen muessen, wo die Versicherung hingehoert.
Der Monatswert wird NICHT gespeichert, sondern gerechnet
(``amount_cents / interval_months``). Gespeichert veraltete er beim
naechsten Preisaufschlag stillschweigend.
"""
__tablename__ = "subscriptions"
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)
# Wie bei Konten und Kategorien: Am Symbol findet man die Zeile schneller
# wieder als am Namen, wenn dreissig davon untereinander stehen.
icon: Mapped[str] = mapped_column(String(40), nullable=False, default="refresh-ccw")
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Eigene Waehrung, nicht die des Kontos: Ein deutscher Handyvertrag laeuft
# in Euro, auch wenn er von einem Franken-Konto bezahlt wird.
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
# 1 = monatlich, 3 = vierteljaehrlich, 6 = halbjaehrlich, 12 = jaehrlich.
# Als Zahl und nicht als Aufzaehlung: Der Monatswert ist dann eine Division
# statt einer Tabelle, und ein zweimonatlicher Vertrag braucht keine
# Migration.
interval_months: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# Womit bezahlt wird. Manche Abos laufen ueber die Kreditkarte, andere per
# eBill direkt vom Konto – ohne diese Angabe findet man den Abbucher nicht.
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
# Naechste Verlaengerung/Abbuchung.
next_due: Mapped[date_t | None] = mapped_column(Date, nullable=True)
# Woran die Abbuchung im Kontoauszug zu erkennen ist. Aus dem Namen
# vorbelegt, aenderbar – auf dem Auszug steht selten "Netflix".
payee_pattern: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Nur bei laengerem Intervall: das Konto, auf das monatlich zurueckgelegt
# wird. Die Jahresrechnung wird dann daraus bezahlt, statt einen Monat zu
# sprengen.
savings_account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Gekuendigtes wird nicht geloescht: Der Verlauf ist die Antwort auf
# "was habe ich dafuer insgesamt bezahlt".
cancelled_at: Mapped[date_t | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
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)
@property
def monatlich_cents(self) -> int:
"""Was der Vertrag im Monat kostet – in SEINER Waehrung.
Kaufmaennisch gerundet. Die Summe ueber viele Vertraege weicht dadurch
um ein paar Rappen von der exakten ab; das ist der Preis dafuer, dass
jede einzelne Zeile eine Zahl zeigt, die man nachrechnen kann.
"""
n = self.interval_months or 1
return round(self.amount_cents / n)
class SubscriptionLogo(Base):
"""Das Markenlogo eines Vertrags.
Eigene Tabelle und nicht eine Spalte an ``subscriptions``: Sonst zoege
jede Listenabfrage saemtliche Bilder mit aus der Datenbank, obwohl die
Liste sie einzeln nachlaedt. Ein Blob in einer Zeile, die man oft und
vollstaendig liest, ist teuer.
Gespeichert wird immer PNG, klein gerechnet – hochgeladen wird, was das
Telefon hergibt, und das sind schnell drei Megabyte fuer ein Bildchen von
dreissig Pixeln Kantenlaenge.
"""
__tablename__ = "subscription_logos"
subscription_id: Mapped[int] = mapped_column(
ForeignKey("subscriptions.id", ondelete="CASCADE"), primary_key=True
)
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
class BankApp(Base):
"""Die bei Enable Banking registrierte Anwendung dieser Installation.
Es gibt keinen API-Schlüssel zum Eintippen: Enable Banking gibt eine
Application ID und eine private Schlüsseldatei aus, mit der jede Anfrage
einzeln signiert wird (JWT, RS256). Der Schlüssel liegt deshalb hier –
verschlüsselt wie die IMAP-Passwörter, denn er ist genauso schützenswert.
Wer Certain selbst hostet, registriert eine eigene Anwendung; mitliefern
lässt sich das nicht, weil der Schlüssel an die Registrierung gebunden ist.
"""
__tablename__ = "bank_apps"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
application_id: Mapped[str] = mapped_column(String(64), nullable=False)
private_key_enc: Mapped[str] = mapped_column(Text, nullable=False)
# Öffentliche Adresse dieser Installation, an die die Bank zurückleitet.
# Muss HTTPS sein und im Control Panel hinterlegt werden. Bewusst
# eingetragen statt geraten: Hinter einem Proxy sieht der Dienst „http",
# obwohl der Browser HTTPS spricht.
base_url: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class BankConnection(Base):
"""Eine autorisierte Verbindung zu einer Bank – das Ergebnis von /sessions."""
__tablename__ = "bank_connections"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
aspsp_name: Mapped[str] = mapped_column(String(120), nullable=False)
aspsp_country: Mapped[str] = mapped_column(String(2), nullable=False)
session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Der Zeitpunkt, ab dem neu autorisiert werden muss (bis zu 180 Tage).
# Sichtbar anzeigen: Läuft er ab, hört der Abruf sonst stumm auf.
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Solange die Autorisierung läuft, steht hier der state-Wert aus /auth.
pending_state: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Takt des automatischen Abrufs in Minuten, 0 = aus. Wie beim Postfach –
# nur ist die Vorgabe hier bewusst aus, denn ein Abruf bucht.
poll_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class BankAccountLink(Base):
"""Ein Konto bei der Bank, zugeordnet zu einem Konto in Certain.
Ohne Zuordnung wird nichts abgerufen – dieselbe Regel wie bei den
Mail-Absendern: Certain rät nicht, wohin eine Bewegung gehört.
"""
__tablename__ = "bank_account_links"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
connection_id: Mapped[int] = mapped_column(
ForeignKey("bank_connections.id", ondelete="CASCADE"), nullable=False, index=True
)
# Kennung des Kontos bei Enable Banking; damit werden Salden und Umsätze geholt.
remote_uid: Mapped[str] = mapped_column(String(64), nullable=False)
iban: Mapped[str | None] = mapped_column(String(64), nullable=True)
remote_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Produktbezeichnung der Bank. Bei Revolut unterscheidet sie das Konto von
# seinen Pockets – die haben weder eigene IBAN noch eigenen Namen.
product: Mapped[str | None] = mapped_column(String(120), nullable=True)
# Kennung fuer Konten ohne IBAN (PSD2 fuehrt sie unter ``other``).
other_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
# Der uebermittelte Datensatz, wie er kam. Damit laesst sich nachsehen,
# statt zu raten, wenn zwei Konten gleich aussehen.
raw_json: Mapped[str | None] = mapped_column(Text, nullable=True)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
# Saldo, wie ihn die Bank fuehrt. Er ersetzt Certains eigene Rechnung
# nicht, er prueft sie – weichen beide ab, fehlt oder doppelt eine Buchung.
balance_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
balance_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
balance_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
)
# Wann DIESES Konto zuletzt abgerufen wurde. An der Verbindung waere der
# Stempel falsch, sobald man ein einzelnes Konto abruft: Er schoebe das
# Startdatum der uebrigen mit vor, und deren Umsaetze kaemen nie an.
last_sync_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("owner_id", "remote_uid", name="uq_bank_link_remote"),
)
@property
def bank_felder(self) -> dict:
"""Der uebermittelte Datensatz als Feld-Liste.
Wird in den Einstellungen aufklappbar gezeigt. Wenn zwei Konten gleich
aussehen, steht der Unterschied hier – nachsehen schlaegt raten.
"""
if not self.raw_json:
return {}
try:
daten = json.loads(self.raw_json)
except ValueError:
return {}
return daten if isinstance(daten, dict) else {}
class Tag(Base):
"""Ein frei vergebenes Etikett fuer Buchungen, mit eigener Farbe.
Neben der Kategorie, nicht statt ihr: Die Kategorie beantwortet „wofuer war
das Geld", ein Tag alles andere, was sich quer dazu legt – „Urlaub Island",
„steuerlich absetzbar", „zurueckfordern". Deshalb mehrere je Buchung und
keine Pflicht, genau eines zu waehlen.
Die Farbe traegt der Tag selbst, wie bei Kategorie und Gruppe: In einer
langen Liste erkennt man sie am Fleck, bevor man den Text liest.
"""
__tablename__ = "tags"
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(60), nullable=False)
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
)
class TransactionTag(Base):
"""Welcher Tag haengt an welcher Buchung.
Eine eigene Tabelle und kein ``secondary``-Verweis, wie ueberall hier
(``TransferLink``, ``LinkIgnored``): Sie traegt ihre eigene ``owner_id`` und
ist damit vom Backup und von der Mandanten-Pruefung erfasst wie jede andere.
CASCADE auf beiden Seiten. Verschwindet die Buchung oder der Tag, ist die
Zuordnung gegenstandslos – es verschwindet immer nur das Etikett, nie eine
Buchung.
"""
__tablename__ = "transaction_tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
transaction_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
)