Wer nach dem Gehaltseingang plant, stellt unter Einstellungen den Tag ein, ab dem ein neuer Monat zählt. Der Zeitraum läuft dann vom Gehaltstag bis zum Vortag des nächsten und trägt den Namen des Monats, in dem er beginnt: Mit dem 25. umfasst „September 2026“ den 25.09. bis zum 24.10. Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten, sodass 31 verlässlich den letzten Tag des Monats meint. Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die Kategorienauswertung, der Monatsexport und die Benachrichtigung über überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben je Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der Schnitt verschiebt sich. Bestandsinstallationen bleiben beim Ersten. Die Einstellung liegt in einer einzeiligen Tabelle hinter GET/PUT /api/settings; die Monatsauswertungen liefern zusätzlich period_start und period_end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
192 lines
7.5 KiB
Python
192 lines
7.5 KiB
Python
"""Stammdaten: Konten, Kategorien, Firmen, Logos, Benutzer."""
|
||
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import (
|
||
Boolean,
|
||
CheckConstraint,
|
||
Date,
|
||
DateTime,
|
||
ForeignKey,
|
||
Index,
|
||
Integer,
|
||
String,
|
||
Text,
|
||
UniqueConstraint,
|
||
)
|
||
from sqlalchemy.dialects.postgresql import ARRAY
|
||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||
|
||
from app.db.base import Base, CreatedAtMixin, Money, TimestampMixin
|
||
from app.models.enums import (
|
||
AccountType,
|
||
EntryKind,
|
||
LogoSource,
|
||
LogoStatus,
|
||
pg_enum,
|
||
)
|
||
|
||
|
||
class Account(Base, TimestampMixin):
|
||
"""Ein Konto.
|
||
|
||
Der Saldo wird aus Eröffnungssaldo plus allen bestätigten Buchungen fortgeschrieben.
|
||
"""
|
||
|
||
__tablename__ = "account"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||
type: Mapped[AccountType] = mapped_column(
|
||
pg_enum(AccountType, "account_type"), nullable=False, default=AccountType.CHECKING
|
||
)
|
||
iban_last4: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||
opening_balance: Mapped[Decimal] = mapped_column(Money, nullable=False, default=Decimal("0.00"))
|
||
opening_balance_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3b82f6")
|
||
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="wallet")
|
||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint("name", name="uq_account_name"),
|
||
Index("ix_account_is_active", "is_active"),
|
||
)
|
||
|
||
|
||
class Category(Base):
|
||
"""Zweistufiger Kategoriebaum – Unterkategorien haben genau einen Elternknoten."""
|
||
|
||
__tablename__ = "category"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
parent_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("category.id", ondelete="RESTRICT"), nullable=True
|
||
)
|
||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
|
||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#64748b")
|
||
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="circle")
|
||
is_fixed_cost: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
|
||
parent: Mapped["Category | None"] = relationship(
|
||
remote_side="Category.id", back_populates="children"
|
||
)
|
||
children: Mapped[list["Category"]] = relationship(
|
||
back_populates="parent", cascade="save-update, merge", order_by="Category.sort_order"
|
||
)
|
||
|
||
__table_args__ = (
|
||
UniqueConstraint("parent_id", "name", name="uq_category_parent_id"),
|
||
Index("ix_category_kind", "kind"),
|
||
)
|
||
|
||
|
||
class LogoAsset(Base):
|
||
"""Im Volume abgelegtes Logo. Auslieferung erfolgt ausschließlich lokal."""
|
||
|
||
__tablename__ = "logo_asset"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
sha256: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||
mime: Mapped[str] = mapped_column(String(64), nullable=False)
|
||
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||
file_path: Mapped[str] = mapped_column(String(255), nullable=False)
|
||
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||
|
||
|
||
class Merchant(Base, CreatedAtMixin):
|
||
"""Firma bzw. Zahlungsempfänger inklusive Markenlogo und -farbe."""
|
||
|
||
__tablename__ = "merchant"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||
normalized_name: Mapped[str] = mapped_column(String(160), nullable=False, unique=True)
|
||
domain: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||
aliases: Mapped[list[str]] = mapped_column(
|
||
ARRAY(Text), nullable=False, default=list, server_default="{}"
|
||
)
|
||
logo_asset_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("logo_asset.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
brand_color: Mapped[str | None] = mapped_column(String(9), nullable=True)
|
||
brand_color_dark: Mapped[str | None] = mapped_column(String(9), nullable=True)
|
||
logo_source: Mapped[LogoSource | None] = mapped_column(
|
||
pg_enum(LogoSource, "logo_source"), nullable=True
|
||
)
|
||
logo_status: Mapped[LogoStatus] = mapped_column(
|
||
pg_enum(LogoStatus, "logo_status"), nullable=False, default=LogoStatus.PENDING
|
||
)
|
||
|
||
logo_asset: Mapped[LogoAsset | None] = relationship(lazy="joined")
|
||
|
||
__table_args__ = (Index("ix_merchant_normalized_name", "normalized_name"),)
|
||
|
||
|
||
class AppSetting(Base, TimestampMixin):
|
||
"""Anwendungsweite Einstellungen. Es gibt genau eine Zeile mit `id = 1`.
|
||
|
||
`month_start_day` legt den Gehaltstag fest: Ab diesem Tag rechnet moneyfy
|
||
einen neuen Monat. Ein Wert jenseits der Monatslänge trifft den Monatsletzten.
|
||
"""
|
||
|
||
__tablename__ = "app_setting"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||
month_start_day: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||
|
||
__table_args__ = (
|
||
CheckConstraint("id = 1", name="single_row"),
|
||
CheckConstraint("month_start_day BETWEEN 1 AND 31", name="month_start_day_range"),
|
||
)
|
||
|
||
|
||
class AppUser(Base, CreatedAtMixin):
|
||
"""Single-User-Betrieb; `external_subject` ist für eine spätere OIDC-Anbindung vorgesehen."""
|
||
|
||
__tablename__ = "app_user"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
username: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
|
||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||
external_subject: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||
|
||
__table_args__ = (
|
||
CheckConstraint(
|
||
"password_hash IS NOT NULL OR external_subject IS NOT NULL",
|
||
name="local_or_external_login",
|
||
),
|
||
)
|
||
|
||
|
||
class RefreshToken(Base, CreatedAtMixin):
|
||
"""Ausgegebenes Refresh-Token.
|
||
|
||
Für die Rotation beim Refresh wird serverseitiger Zustand benötigt: Ein Token
|
||
ist nur gültig, solange seine `jti` hier ungesperrt hinterlegt ist. Beim Refresh
|
||
wird der alte Eintrag gesperrt und ein neuer angelegt.
|
||
"""
|
||
|
||
__tablename__ = "refresh_token"
|
||
|
||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||
jti: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||
user_id: Mapped[int] = mapped_column(
|
||
ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||
|
||
user: Mapped[AppUser] = relationship()
|
||
|
||
__table_args__ = (Index("ix_refresh_token_user_id", "user_id"),)
|