Ein Uebertrag legte auf dem Zielkonto nichts an. Der Duplikatschutz konnte die Gegenseite deshalb nicht sehen – er vergleicht import_hash, den eine Uebertrags-Buchung nicht hat, und Eingangs-Eintraege, die es dort nicht gab. Die schon vorliegende Gegenseite liess sich ein zweites Mal buchen, die spaeter von der Bank gelieferte kam ungeprueft an: das Geld zweimal auf dem Zielkonto. Jetzt entsteht auf dem Zielkonto ein Eingangs-Eintrag, der auf DIESELBE Buchung zeigt. Liegt dort schon ein passender offener Eintrag, wird dieser verbraucht statt einen zweiten anzulegen – ueber denselben Abgleich wie beim Import, nicht ueber eine zweite Vergleichslogik. Damit greift auch der vorhandene Schutz gegen die spaeter eintreffende Gegenseite, ohne dass etwas Neues dafuer gebaut werden musste. Die Uebertrags-Logik liegt dafuer in einem eigenen Dienst. Zwei Wege fuehren hinein – ein offener Vorschlag und eine schon gebuchte Buchung – und beide brauchen dieselben vier Regeln. Der PATCH-Weg des Buchungs-Routers taete es nicht: Er dreht die Richtung bei einer Einnahme nicht um und legt keine Gegenseite an. Die Sperre richtet sich nicht mehr nach der Waehrung, sondern danach, ob der Betrag der Gegenseite BEKANNT oder geschaetzt ist. Bei gleicher Waehrung ist er exakt, die Gegenseite wird sicher wiedererkannt – dann geht ein Uebertrag auf ein angebundenes Konto ohne Nachfrage durch. Ueber Waehrungen hinweg bleibt die Warnung, weil der EZB-Kurs nicht der Kurs der Bank ist. Und Verwerfen raeumt beide Seiten: Bei einem Uebertrag zeigen zwei Eintraege auf dieselbe Buchung, und der Fremdschluessel haette die andere als 'gebucht' ohne Buchung zurueckgelassen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
957 lines
43 KiB
Python
957 lines
43 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,
|
||
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 {}
|