feat: Projektfundament mit Datenmodell, Migrationen und Seed

- Repo-Struktur für Backend, Frontend, Dokumentation und Gitea-Workflows
- FastAPI-Skeleton mit pydantic-settings und RFC-7807-artigem Fehlerformat
- Vollständiges Datenmodell (15 Tabellen) als SQLAlchemy-2.0-Modelle
- Alembic gegen die Async-Engine inklusive Erstmigration
- Idempotenter Seed für den deutschen Kategoriebaum und Standardregeln
- Endpunkte /api/health und /api/version
- pytest-Infrastruktur mit transaktionsisolierten Fixtures

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 13:12:08 +02:00
co-authored by Claude Opus 5
commit 0b06775be3
40 changed files with 2236 additions and 0 deletions
View File
View File
+8
View File
@@ -0,0 +1,8 @@
"""Sammelrouter für alle /api-Endpunkte."""
from fastapi import APIRouter
from app.api.routes import system
api_router = APIRouter(prefix="/api")
api_router.include_router(system.router)
View File
+54
View File
@@ -0,0 +1,54 @@
"""Systemendpunkte: Health-Check und Versionsinformation (ohne Authentifizierung)."""
from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.session import get_session
router = APIRouter(tags=["system"])
class HealthResponse(BaseModel):
status: str = Field(description="'ok' wenn Anwendung und Datenbank erreichbar sind.")
database: str = Field(description="'ok' oder 'unavailable'.")
time: datetime = Field(description="Aktuelle Serverzeit in Europe/Berlin.")
class VersionResponse(BaseModel):
name: str
version: str
environment: str
timezone: str
currency: str
@router.get("/health", response_model=HealthResponse, summary="Health-Check")
async def health(session: AsyncSession = Depends(get_session)) -> HealthResponse:
"""Prüft die Datenbankverbindung. Wird vom Docker-Healthcheck verwendet."""
database = "ok"
try:
await session.execute(text("SELECT 1"))
except Exception:
database = "unavailable"
return HealthResponse(
status="ok" if database == "ok" else "degraded",
database=database,
time=datetime.now(ZoneInfo(settings.timezone)),
)
@router.get("/version", response_model=VersionResponse, summary="Versionsinformation")
async def version() -> VersionResponse:
return VersionResponse(
name=settings.app_name,
version=settings.app_version,
environment=settings.environment,
timezone=settings.timezone,
currency=settings.default_currency,
)
View File
+113
View File
@@ -0,0 +1,113 @@
"""Zentrale Anwendungskonfiguration über Umgebungsvariablen."""
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, PostgresDsn, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Alle Einstellungen stammen aus der Umgebung bzw. einer .env-Datei."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# --- Allgemein -----------------------------------------------------------
app_name: str = "moneyfy"
app_version: str = "0.1.0"
environment: Literal["development", "production", "test"] = "development"
debug: bool = False
timezone: str = "Europe/Berlin"
holiday_region: str = "DE-NW"
default_currency: str = "EUR"
# --- Datenbank -----------------------------------------------------------
database_url: PostgresDsn = Field(
default="postgresql+asyncpg://moneyfy:moneyfy@localhost:5432/moneyfy",
description="Async-DSN (asyncpg) für SQLAlchemy.",
)
# --- Sicherheit ----------------------------------------------------------
secret_key: str = Field(default="change-me-in-production", min_length=8)
access_token_ttl_minutes: int = 30
refresh_token_ttl_days: int = 14
cookie_secure: bool = True
cookie_domain: str | None = None
# Erst-Start: Admin-Benutzer
moneyfy_admin_user: str = "admin"
moneyfy_admin_password: str | None = None
# --- OIDC (vorbereitet, nicht implementiert) -----------------------------
oidc_enabled: bool = False
oidc_issuer: str | None = None
oidc_client_id: str | None = None
oidc_client_secret: str | None = None
oidc_scopes: str = "openid profile email"
# --- Logo-Service --------------------------------------------------------
logo_storage_dir: Path = Path("/data/logos")
logodev_api_key: str | None = None
brandfetch_api_key: str | None = None
logo_http_timeout_seconds: float = 5.0
logo_http_retries: int = 2
logo_max_upload_bytes: int = 1_048_576
# --- Benachrichtigungen --------------------------------------------------
notifications_enabled: bool = True
scheduler_enabled: bool = True
notification_hour: int = 7
notification_minute: int = 0
smtp_host: str | None = None
smtp_port: int = 587
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from: str | None = None
smtp_use_tls: bool = True
smtp_use_ssl: bool = False
apprise_urls: str | None = None
public_base_url: str = "http://localhost:8087"
# --- CORS ----------------------------------------------------------------
cors_origins: str = ""
@field_validator("database_url", mode="before")
@classmethod
def _force_async_driver(cls, value: object) -> object:
"""Erlaubt sowohl `postgresql://` als auch `postgresql+asyncpg://` in der Umgebung."""
if isinstance(value, str) and value.startswith("postgresql://"):
return value.replace("postgresql://", "postgresql+asyncpg://", 1)
return value
@property
def sync_database_url(self) -> str:
"""Synchrone Variante der DSN wird von Alembic benötigt."""
return str(self.database_url).replace("+asyncpg", "+psycopg")
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@property
def apprise_url_list(self) -> list[str]:
if not self.apprise_urls:
return []
return [url.strip() for url in self.apprise_urls.split(",") if url.strip()]
@lru_cache
def get_settings() -> Settings:
"""Gecachte Settings-Instanz."""
return Settings()
settings = get_settings()
+108
View File
@@ -0,0 +1,108 @@
"""Einheitliche Fehlerdarstellung im RFC-7807-Stil: {"detail": ..., "code": ...}."""
from typing import Any
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
# Starlette benennt die 422-Konstante gerade um fester Wert vermeidet die Abhängigkeit.
HTTP_422 = 422
class AppError(Exception):
"""Basisklasse für fachliche Fehler mit stabilem Fehlercode."""
status_code: int = status.HTTP_400_BAD_REQUEST
code: str = "bad_request"
def __init__(self, detail: str, *, code: str | None = None, status_code: int | None = None):
super().__init__(detail)
self.detail = detail
if code is not None:
self.code = code
if status_code is not None:
self.status_code = status_code
class NotFoundError(AppError):
status_code = status.HTTP_404_NOT_FOUND
code = "not_found"
class ConflictError(AppError):
status_code = status.HTTP_409_CONFLICT
code = "conflict"
class ValidationError(AppError):
status_code = HTTP_422
code = "validation_error"
class AuthError(AppError):
status_code = status.HTTP_401_UNAUTHORIZED
code = "unauthorized"
def problem(
status_code: int, detail: str, code: str, extra: dict[str, Any] | None = None
) -> JSONResponse:
body: dict[str, Any] = {"detail": detail, "code": code}
if extra:
body.update(extra)
return JSONResponse(status_code=status_code, content=body)
# Zuordnung der Standard-HTTP-Statuscodes auf sprechende Fehlercodes.
_STATUS_CODES = {
400: "bad_request",
401: "unauthorized",
403: "forbidden",
404: "not_found",
405: "method_not_allowed",
409: "conflict",
413: "payload_too_large",
415: "unsupported_media_type",
422: "validation_error",
429: "too_many_requests",
500: "internal_error",
}
def register_exception_handlers(app: FastAPI) -> None:
"""Registriert alle Handler, damit jede Fehlerantwort dasselbe Format hat."""
@app.exception_handler(AppError)
async def _app_error(_: Request, exc: AppError) -> JSONResponse:
return problem(exc.status_code, exc.detail, exc.code)
@app.exception_handler(StarletteHTTPException)
async def _http_error(_: Request, exc: StarletteHTTPException) -> JSONResponse:
code = _STATUS_CODES.get(exc.status_code, "error")
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
return problem(exc.status_code, detail, code)
@app.exception_handler(RequestValidationError)
async def _validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
return problem(
HTTP_422,
"Die Anfrage enthält ungültige Felder.",
"validation_error",
{"errors": _serialise_errors(exc.errors())},
)
def _serialise_errors(errors: list[Any]) -> list[dict[str, Any]]:
"""Pydantic-Fehler auf JSON-serialisierbare Felder reduzieren."""
result = []
for error in errors:
result.append(
{
"loc": [str(part) for part in error.get("loc", [])],
"msg": error.get("msg", ""),
"type": error.get("type", ""),
}
)
return result
View File
+51
View File
@@ -0,0 +1,51 @@
"""Deklarative Basisklasse und gemeinsame Spaltentypen."""
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy import DateTime, MetaData, Numeric
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
# Feste Namenskonventionen, damit Alembic stabile Constraint-Namen erzeugt.
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
# Alle Geldbeträge einheitlich als NUMERIC(12,2) -> Decimal.
Money = Numeric(12, 2, asdecimal=True)
def utcnow() -> datetime:
"""Zeitstempel in UTC die Anzeige rechnet nach Europe/Berlin um."""
return datetime.now(UTC)
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
type_annotation_map = { # noqa: RUF012
Decimal: Money,
}
class TimestampMixin:
"""created_at/updated_at für Tabellen mit Änderungsverfolgung."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
class CreatedAtMixin:
"""Nur created_at für rein additive Tabellen."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, nullable=False
)
+110
View File
@@ -0,0 +1,110 @@
"""Deutscher Standard-Kategoriebaum für den Erst-Seed."""
from dataclasses import dataclass, field
from app.models.enums import EntryKind
@dataclass(frozen=True)
class SeedCategory:
"""Ein Knoten des Seed-Baums. Unterkategorien erben `kind` vom Elternknoten."""
name: str
icon: str = "circle"
color: str = "#64748b"
is_fixed_cost: bool = False
children: tuple["SeedCategory", ...] = field(default_factory=tuple)
# Ausgaben ----------------------------------------------------------------------
EXPENSE_TREE: tuple[SeedCategory, ...] = (
SeedCategory(
name="Wohnen",
icon="house",
color="#f97316",
is_fixed_cost=True,
children=(
SeedCategory("Miete", "key-round", "#f97316", True),
SeedCategory("Nebenkosten", "droplets", "#fb923c", True),
SeedCategory("Strom", "zap", "#facc15", True),
SeedCategory("Internet", "wifi", "#38bdf8", True),
SeedCategory("Rundfunkbeitrag", "radio", "#a78bfa", True),
),
),
SeedCategory(
name="Versicherungen",
icon="shield",
color="#0ea5e9",
is_fixed_cost=True,
children=(
SeedCategory("Haftpflicht", "shield-check", "#0ea5e9", True),
SeedCategory("Hausrat", "shield-half", "#22d3ee", True),
SeedCategory("Kfz", "car-front", "#38bdf8", True),
SeedCategory("Kranken", "heart-pulse", "#f472b6", True),
SeedCategory("BU", "briefcase-medical", "#818cf8", True),
),
),
SeedCategory(
name="Abos & Medien",
icon="repeat",
color="#a855f7",
is_fixed_cost=True,
children=(
SeedCategory("Streaming", "clapperboard", "#e11d48", True),
SeedCategory("Software", "app-window", "#8b5cf6", True),
SeedCategory("Cloud", "cloud", "#60a5fa", True),
SeedCategory("Zeitungen", "newspaper", "#94a3b8", True),
),
),
SeedCategory(
name="Mobilität",
icon="car",
color="#14b8a6",
children=(
SeedCategory("Kfz-Steuer", "landmark", "#14b8a6", True),
SeedCategory("Sprit", "fuel", "#f59e0b"),
SeedCategory("ÖPNV", "bus", "#10b981", True),
SeedCategory("Werkstatt", "wrench", "#64748b"),
),
),
SeedCategory(
name="Lebenshaltung",
icon="shopping-basket",
color="#84cc16",
children=(
SeedCategory("Lebensmittel", "shopping-cart", "#84cc16"),
SeedCategory("Drogerie", "sparkles", "#22c55e"),
SeedCategory("Restaurant", "utensils", "#f97316"),
),
),
SeedCategory(
name="Finanzen",
icon="banknote",
color="#6366f1",
children=(
SeedCategory("Kredite", "hand-coins", "#ef4444", True),
SeedCategory("Sparen", "piggy-bank", "#10b981", True),
SeedCategory("Gebühren", "receipt", "#94a3b8", True),
),
),
)
# Einkünfte ---------------------------------------------------------------------
INCOME_TREE: tuple[SeedCategory, ...] = (
SeedCategory(
name="Einkünfte",
icon="wallet",
color="#22c55e",
children=(
SeedCategory("Gehalt", "briefcase", "#22c55e"),
SeedCategory("Nebeneinkünfte", "laptop", "#4ade80"),
SeedCategory("Erstattungen", "undo-2", "#34d399"),
SeedCategory("Zinsen", "percent", "#a3e635"),
),
),
)
SEED_TREE: tuple[tuple[EntryKind, tuple[SeedCategory, ...]], ...] = (
(EntryKind.EXPENSE, EXPENSE_TREE),
(EntryKind.INCOME, INCOME_TREE),
)
+30
View File
@@ -0,0 +1,30 @@
"""Async-Engine und Session-Factory."""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
engine = create_async_engine(
str(settings.database_url),
echo=settings.debug,
pool_pre_ping=True,
)
SessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI-Dependency: eine Session pro Request."""
async with SessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise
+55
View File
@@ -0,0 +1,55 @@
"""Einstiegspunkt der moneyfy-Anwendung."""
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import settings
from app.core.errors import register_exception_handlers
logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
logger = logging.getLogger("moneyfy")
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
"""Start- und Stopplogik der Anwendung."""
settings.logo_storage_dir.mkdir(parents=True, exist_ok=True)
logger.info("moneyfy %s gestartet (%s)", settings.app_version, settings.environment)
yield
logger.info("moneyfy wird beendet")
def create_app() -> FastAPI:
app = FastAPI(
title="moneyfy",
version=settings.app_version,
description="Planung monatlicher Kosten und Einkünfte.",
openapi_url="/api/openapi.json",
docs_url="/api/docs",
redoc_url=None,
lifespan=lifespan,
)
if settings.cors_origin_list:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
register_exception_handlers(app)
app.include_router(api_router)
return app
app = create_app()
+54
View File
@@ -0,0 +1,54 @@
"""SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig."""
from app.db.base import Base
from app.models.core import Account, AppUser, Category, LogoAsset, Merchant
from app.models.enums import (
AccountType,
BusinessDayShift,
EntryKind,
LogoSource,
LogoStatus,
NotificationChannel,
NotificationStatus,
NotificationType,
OccurrenceStatus,
)
from app.models.finance import (
AmountVersion,
Budget,
BudgetTemplate,
Occurrence,
Recurrence,
ReserveLedger,
SavingsGoal,
Transaction,
)
from app.models.notification import NotificationLog, NotificationRule
__all__ = [
"Account",
"AccountType",
"AmountVersion",
"AppUser",
"Base",
"Budget",
"BudgetTemplate",
"BusinessDayShift",
"Category",
"EntryKind",
"LogoAsset",
"LogoSource",
"LogoStatus",
"Merchant",
"NotificationChannel",
"NotificationLog",
"NotificationRule",
"NotificationStatus",
"NotificationType",
"Occurrence",
"OccurrenceStatus",
"Recurrence",
"ReserveLedger",
"SavingsGoal",
"Transaction",
]
+150
View File
@@ -0,0 +1,150 @@
"""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 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",
),
)
+77
View File
@@ -0,0 +1,77 @@
"""Aufzählungstypen des Datenmodells (werden als native PostgreSQL-Enums angelegt)."""
from enum import StrEnum
from sqlalchemy import Enum as SAEnum
class AccountType(StrEnum):
CHECKING = "checking"
CREDIT_CARD = "credit_card"
SAVINGS = "savings"
CASH = "cash"
class EntryKind(StrEnum):
"""Richtung eines Postens: Ausgabe oder Einnahme."""
EXPENSE = "expense"
INCOME = "income"
class BusinessDayShift(StrEnum):
"""Verschiebung, wenn ein Fälligkeitstag auf Wochenende/Feiertag fällt."""
NONE = "none"
NEXT = "next"
PREVIOUS = "previous"
class OccurrenceStatus(StrEnum):
PLANNED = "planned"
CONFIRMED = "confirmed"
SKIPPED = "skipped"
class LogoSource(StrEnum):
SIMPLE_ICONS = "simple-icons"
LOGODEV = "logodev"
BRANDFETCH = "brandfetch"
FAVICON = "favicon"
UPLOAD = "upload"
GENERATED = "generated"
class LogoStatus(StrEnum):
PENDING = "pending"
RESOLVED = "resolved"
FAILED = "failed"
MANUAL = "manual"
class NotificationType(StrEnum):
DUE_SOON = "due_soon"
NOTICE_DEADLINE = "notice_deadline"
BUDGET_EXCEEDED = "budget_exceeded"
CONTRACT_RENEWAL = "contract_renewal"
class NotificationChannel(StrEnum):
SMTP = "smtp"
APPRISE = "apprise"
class NotificationStatus(StrEnum):
SENT = "sent"
FAILED = "failed"
def pg_enum(enum_cls: type[StrEnum], name: str) -> SAEnum:
"""Erzeugt einen nativen PostgreSQL-Enum-Typ mit den String-Werten der Enum-Klasse."""
return SAEnum(
enum_cls,
name=name,
native_enum=True,
values_callable=lambda cls: [member.value for member in cls],
validate_strings=True,
)
+271
View File
@@ -0,0 +1,271 @@
"""Bewegungsdaten: Recurrences, Preishistorie, Occurrences, Buchungen, Budgets, Ziele."""
from datetime import date
from decimal import Decimal
from sqlalchemy import (
Boolean,
CheckConstraint,
Date,
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.core import Account, Category, Merchant
from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus, pg_enum
class Recurrence(Base, TimestampMixin):
"""Wiederkehrende Zahlung oder Einkunft, beschrieben durch eine RFC-5545-RRULE."""
__tablename__ = "recurrence"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
title: Mapped[str] = mapped_column(String(160), nullable=False)
merchant_id: Mapped[int | None] = mapped_column(
ForeignKey("merchant.id", ondelete="SET NULL"), nullable=True
)
category_id: Mapped[int] = mapped_column(
ForeignKey("category.id", ondelete="RESTRICT"), nullable=False
)
account_id: Mapped[int] = mapped_column(
ForeignKey("account.id", ondelete="RESTRICT"), nullable=False
)
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
is_variable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
# RRULE ohne DTSTART der Startzeitpunkt steht separat in `dtstart`.
rrule: Mapped[str] = mapped_column(Text, nullable=False)
dtstart: Mapped[date] = mapped_column(Date, nullable=False)
until: Mapped[date | None] = mapped_column(Date, nullable=True)
business_day_shift: Mapped[BusinessDayShift] = mapped_column(
pg_enum(BusinessDayShift, "business_day_shift"),
nullable=False,
default=BusinessDayShift.NEXT,
server_default="next",
)
holiday_region: Mapped[str] = mapped_column(
String(8), nullable=False, default="DE-NW", server_default="DE-NW"
)
# Ratenzahlung / Kredit
installments_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
principal_amount: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
# Vertragsdaten
contract_start: Mapped[date | None] = mapped_column(Date, nullable=True)
contract_min_term_months: Mapped[int | None] = mapped_column(Integer, nullable=True)
contract_notice_period_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
contract_auto_renew_months: Mapped[int | None] = mapped_column(Integer, nullable=True)
contract_cancelled_at: Mapped[date | None] = mapped_column(Date, nullable=True)
reserve_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list, server_default="{}"
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
merchant: Mapped[Merchant | None] = relationship(lazy="selectin")
category: Mapped[Category] = relationship(lazy="selectin")
account: Mapped[Account] = relationship(lazy="selectin")
amount_versions: Mapped[list["AmountVersion"]] = relationship(
back_populates="recurrence",
cascade="all, delete-orphan",
order_by="AmountVersion.valid_from",
lazy="selectin",
)
occurrences: Mapped[list["Occurrence"]] = relationship(
back_populates="recurrence",
cascade="all, delete-orphan",
order_by="Occurrence.occurrence_date",
)
__table_args__ = (
CheckConstraint(
"installments_total IS NULL OR installments_total > 0", name="installments"
),
Index("ix_recurrence_kind_active", "kind", "is_active"),
Index("ix_recurrence_category_id", "category_id"),
Index("ix_recurrence_merchant_id", "merchant_id"),
)
class AmountVersion(Base, CreatedAtMixin):
"""Preishistorie: gültig ist die Version mit dem größten `valid_from <= Fälligkeitsdatum`."""
__tablename__ = "amount_version"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
recurrence_id: Mapped[int] = mapped_column(
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
)
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
valid_from: Mapped[date] = mapped_column(Date, nullable=False)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
recurrence: Mapped[Recurrence] = relationship(back_populates="amount_versions")
__table_args__ = (
UniqueConstraint("recurrence_id", "valid_from", name="uq_amount_version_recurrence_id"),
)
class Occurrence(Base, TimestampMixin):
"""Materialisierte Einzelfälligkeit nur bei Abweichung, Bestätigung oder Auslassung."""
__tablename__ = "occurrence"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
recurrence_id: Mapped[int] = mapped_column(
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
)
# Immer das nominale (unverschobene) Datum dadurch bleibt die Zuordnung stabil.
occurrence_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[OccurrenceStatus] = mapped_column(
pg_enum(OccurrenceStatus, "occurrence_status"),
nullable=False,
default=OccurrenceStatus.PLANNED,
)
planned_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
actual_amount: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("account.id", ondelete="RESTRICT"), nullable=True
)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
recurrence: Mapped[Recurrence] = relationship(back_populates="occurrences")
__table_args__ = (
UniqueConstraint("recurrence_id", "occurrence_date", name="uq_occurrence_recurrence_id"),
Index("ix_occurrence_date", "occurrence_date"),
)
class Transaction(Base, CreatedAtMixin):
"""Einmalige Buchung ohne Wiederholung."""
__tablename__ = "transaction"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
title: Mapped[str] = mapped_column(String(160), nullable=False)
merchant_id: Mapped[int | None] = mapped_column(
ForeignKey("merchant.id", ondelete="SET NULL"), nullable=True
)
category_id: Mapped[int] = mapped_column(
ForeignKey("category.id", ondelete="RESTRICT"), nullable=False
)
account_id: Mapped[int] = mapped_column(
ForeignKey("account.id", ondelete="RESTRICT"), nullable=False
)
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
booking_date: Mapped[date] = mapped_column(Date, nullable=False)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list, server_default="{}"
)
merchant: Mapped[Merchant | None] = relationship(lazy="selectin")
category: Mapped[Category] = relationship(lazy="selectin")
account: Mapped[Account] = relationship(lazy="selectin")
__table_args__ = (
Index("ix_transaction_booking_date", "booking_date"),
Index("ix_transaction_category_id", "category_id"),
)
class Budget(Base, CreatedAtMixin):
"""Monatsbudget je Kategorie. `period_month` ist immer der Monatserste."""
__tablename__ = "budget"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
category_id: Mapped[int] = mapped_column(
ForeignKey("category.id", ondelete="CASCADE"), nullable=False
)
period_month: Mapped[date] = mapped_column(Date, nullable=False)
limit_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
rollover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
category: Mapped[Category] = relationship(lazy="selectin")
__table_args__ = (
UniqueConstraint("category_id", "period_month", name="uq_budget_category_id"),
CheckConstraint("date_trunc('month', period_month) = period_month", name="month_start"),
)
class BudgetTemplate(Base, TimestampMixin):
"""Dauerhaftes Budget ab einem Monat erspart die Pflege jedes einzelnen Monats."""
__tablename__ = "budget_template"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
category_id: Mapped[int] = mapped_column(
ForeignKey("category.id", ondelete="CASCADE"), nullable=False
)
valid_from: Mapped[date] = mapped_column(Date, nullable=False)
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
limit_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
rollover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
category: Mapped[Category] = relationship(lazy="selectin")
__table_args__ = (
UniqueConstraint("category_id", "valid_from", name="uq_budget_template_category_id"),
CheckConstraint("date_trunc('month', valid_from) = valid_from", name="month_start"),
)
class SavingsGoal(Base, TimestampMixin):
"""Sparziel mit Fortschritt und optionaler Zielrate."""
__tablename__ = "savings_goal"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160), nullable=False)
target_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
target_date: Mapped[date | None] = mapped_column(Date, nullable=True)
current_amount: Mapped[Decimal] = mapped_column(Money, nullable=False, default=Decimal("0.00"))
account_id: Mapped[int | None] = mapped_column(
ForeignKey("account.id", ondelete="SET NULL"), nullable=True
)
monthly_contribution: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#10b981")
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="piggy-bank")
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
__table_args__ = (UniqueConstraint("name", name="uq_savings_goal_name"),)
class ReserveLedger(Base, CreatedAtMixin):
"""Monatliche Rücklagenbildung für nicht-monatliche Posten."""
__tablename__ = "reserve_ledger"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
recurrence_id: Mapped[int] = mapped_column(
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
)
period_month: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
is_released: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
__table_args__ = (
UniqueConstraint("recurrence_id", "period_month", name="uq_reserve_ledger_recurrence_id"),
CheckConstraint("date_trunc('month', period_month) = period_month", name="month_start"),
)
+73
View File
@@ -0,0 +1,73 @@
"""Benachrichtigungsregeln und Versandprotokoll."""
from datetime import date, datetime
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, CreatedAtMixin
from app.models.enums import (
NotificationChannel,
NotificationStatus,
NotificationType,
pg_enum,
)
class NotificationRule(Base, CreatedAtMixin):
"""Eine Regel pro Ereignistyp und Kanal."""
__tablename__ = "notification_rule"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
type: Mapped[NotificationType] = mapped_column(
pg_enum(NotificationType, "notification_type"), nullable=False
)
lead_days: Mapped[int] = mapped_column(Integer, nullable=False, default=7)
channel: Mapped[NotificationChannel] = mapped_column(
pg_enum(NotificationChannel, "notification_channel"), nullable=False
)
target: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
logs: Mapped[list["NotificationLog"]] = relationship(
back_populates="rule", cascade="all, delete-orphan"
)
class NotificationLog(Base):
"""Versandprotokoll. `dedupe_day` verhindert Doppelversand am selben Tag."""
__tablename__ = "notification_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
rule_id: Mapped[int] = mapped_column(
ForeignKey("notification_rule.id", ondelete="CASCADE"), nullable=False
)
ref_type: Mapped[str] = mapped_column(String(40), nullable=False)
ref_id: Mapped[str] = mapped_column(String(80), nullable=False)
dedupe_day: Mapped[date] = mapped_column(Date, nullable=False)
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
status: Mapped[NotificationStatus] = mapped_column(
pg_enum(NotificationStatus, "notification_status"), nullable=False
)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
rule: Mapped[NotificationRule] = relationship(back_populates="logs")
__table_args__ = (
UniqueConstraint(
"rule_id", "ref_type", "ref_id", "dedupe_day", name="uq_notification_log_rule_id"
),
Index("ix_notification_log_sent_at", "sent_at"),
)
View File
View File
+20
View File
@@ -0,0 +1,20 @@
"""CLI: Stammdaten seeden `python -m app.scripts.seed`."""
import asyncio
from app.db.session import SessionLocal, engine
from app.services.seed import seed_all
async def main() -> None:
async with SessionLocal() as session:
result = await seed_all(session)
await engine.dispose()
print(
f"Seed fertig: {result['categories']} Kategorien, "
f"{result['notification_rules']} Benachrichtigungsregeln neu angelegt."
)
if __name__ == "__main__":
asyncio.run(main())
View File
+97
View File
@@ -0,0 +1,97 @@
"""Idempotenter Seed der Stammdaten (Kategoriebaum, Benachrichtigungsregeln)."""
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.seed_data import SEED_TREE, SeedCategory
from app.models import Category, NotificationRule
from app.models.enums import EntryKind, NotificationChannel, NotificationType
logger = logging.getLogger(__name__)
# Standardregeln: Vorlaufzeiten gemäß Fachspezifikation, zunächst inaktiv per SMTP.
DEFAULT_RULES: tuple[tuple[NotificationType, int], ...] = (
(NotificationType.DUE_SOON, 3),
(NotificationType.NOTICE_DEADLINE, 30),
(NotificationType.BUDGET_EXCEEDED, 0),
(NotificationType.CONTRACT_RENEWAL, 30),
)
async def seed_categories(session: AsyncSession) -> int:
"""Legt den Standardbaum an. Bereits vorhandene Kategorien bleiben unverändert."""
created = 0
for kind, nodes in SEED_TREE:
for order, node in enumerate(nodes):
parent, was_created = await _upsert(session, node, kind, None, order)
created += int(was_created)
for child_order, child in enumerate(node.children):
_, child_created = await _upsert(session, child, kind, parent.id, child_order)
created += int(child_created)
return created
async def _upsert(
session: AsyncSession,
node: SeedCategory,
kind: EntryKind,
parent_id: int | None,
sort_order: int,
) -> tuple[Category, bool]:
"""Sucht die Kategorie über (parent_id, name) und legt sie bei Bedarf an."""
stmt = select(Category).where(Category.name == node.name)
stmt = stmt.where(
Category.parent_id.is_(None) if parent_id is None else Category.parent_id == parent_id
)
existing = (await session.execute(stmt)).scalar_one_or_none()
if existing is not None:
return existing, False
category = Category(
parent_id=parent_id,
name=node.name,
kind=kind,
color=node.color,
icon=node.icon,
is_fixed_cost=node.is_fixed_cost,
sort_order=sort_order,
)
session.add(category)
await session.flush()
return category, True
async def seed_notification_rules(session: AsyncSession) -> int:
"""Legt je Ereignistyp eine inaktive SMTP-Standardregel an."""
created = 0
for rule_type, lead_days in DEFAULT_RULES:
stmt = select(NotificationRule).where(
NotificationRule.type == rule_type,
NotificationRule.channel == NotificationChannel.SMTP,
)
if (await session.execute(stmt)).scalar_one_or_none() is not None:
continue
session.add(
NotificationRule(
type=rule_type,
lead_days=lead_days,
channel=NotificationChannel.SMTP,
is_active=False,
)
)
created += 1
await session.flush()
return created
async def seed_all(session: AsyncSession) -> dict[str, int]:
"""Führt alle Seeds aus und liefert die Anzahl neu angelegter Datensätze."""
result = {
"categories": await seed_categories(session),
"notification_rules": await seed_notification_rules(session),
}
await session.commit()
logger.info("Seed abgeschlossen: %s", result)
return result