"""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, 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 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) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) 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 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) 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) group: Mapped[CategoryGroup] = relationship(back_populates="categories") class Transaction(Base): __tablename__ = "transactions" id: Mapped[int] = mapped_column(Integer, primary_key=True) owner_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) type: Mapped[TxnType] = mapped_column(_enum(TxnType), nullable=False) date: Mapped[date_t] = mapped_column(Date, nullable=False, index=True) time: Mapped[time_t | None] = mapped_column(Time, nullable=True) account_id: Mapped[int] = mapped_column( ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True ) to_account_id: Mapped[int | None] = mapped_column( ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True, index=True ) # Betrag in Kontowährung (bei Transfers: abgebuchter Betrag vom Quellkonto). amount_cents: Mapped[int] = mapped_column(Integer, nullable=False) # Nur bei währungsübergreifenden Transfers: gutgeschriebener Betrag am Zielkonto. to_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) # Manuell gesetzter Kurs (Kontowährung je 1 EUR); überschreibt den EZB-Kurs. fx_rate: Mapped[float | None] = mapped_column(Numeric(18, 8), nullable=True) # Eingefrorener EUR-Wert von amount_cents zum Buchungsdatum (Pivot für Budget/Dashboard). amount_eur_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) category_id: Mapped[int | None] = mapped_column( ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True ) # Übersteuert die Standard-Regel für Sparen/Depot-Transfers (budgeted|neutral). budget_treatment: Mapped[BudgetTreatment | None] = mapped_column(_enum(BudgetTreatment), nullable=True) # Kategorie, gegen die ein budgetierter Transfer zählt (Sparen/Investment). budget_category_id: Mapped[int | None] = mapped_column( ForeignKey("categories.id", ondelete="SET NULL"), nullable=True ) payee: Mapped[str | None] = mapped_column(String(255), nullable=True) description: Mapped[str | None] = mapped_column(String(255), nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True) # 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) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) class ExchangeRate(Base): """EZB-Referenzkurs: wie viele Einheiten der Währung ein Euro wert war. EUR ist implizit 1.0 (keine Zeile). In der Praxis nur CHF. Nicht owner-gescoped (öffentliche Referenzdaten).""" __tablename__ = "exchange_rates" date: Mapped[date_t] = mapped_column(Date, primary_key=True) currency: Mapped[str] = mapped_column(String(3), primary_key=True) rate_per_eur: Mapped[float] = mapped_column(Numeric(18, 8), nullable=False) source: Mapped[str] = mapped_column(String(16), nullable=False, default="ecb") # --------------------------------------------------------------------------- # 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) __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) 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. Bewusst eng gehalten. Eine 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. """ quick_add = "quick_add" 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 ) # 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 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 {}