Die Farbauswahl der App lag dreimal gleich im Code; sie steht jetzt einmal in Farbwahl, mit dem Markengruen an erster Stelle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1352 lines
63 KiB
Python
1352 lines
63 KiB
Python
"""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="#3f9c72")
|
||
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="#3f9c72")
|
||
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="#3f9c72")
|
||
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"),
|
||
)
|