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
+41
View File
@@ -0,0 +1,41 @@
# Alembic-Konfiguration. Die DSN kommt aus der Umgebung (siehe alembic/env.py).
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
timezone = Europe/Berlin
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+66
View File
@@ -0,0 +1,66 @@
"""Alembic-Umgebung nutzt die Async-Engine der Anwendung."""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.core.config import settings
from app.models import Base
config = context.config
config.set_main_option("sqlalchemy.url", str(settings.database_url))
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Migrationen ohne DB-Verbindung als SQL ausgeben."""
context.configure(
url=str(settings.database_url),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
View File
@@ -0,0 +1,290 @@
"""initial schema
Revision ID: 2bc4ab55dbe9
Revises:
Create Date: 2026-09-09 13:07:31.239925+02:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = '2bc4ab55dbe9'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('account',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('type', sa.Enum('checking', 'credit_card', 'savings', 'cash', name='account_type'), nullable=False),
sa.Column('iban_last4', sa.String(length=4), nullable=True),
sa.Column('opening_balance', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('opening_balance_date', sa.Date(), nullable=False),
sa.Column('color', sa.String(length=9), nullable=False),
sa.Column('icon', sa.String(length=64), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_account')),
sa.UniqueConstraint('name', name='uq_account_name')
)
op.create_index('ix_account_is_active', 'account', ['is_active'], unique=False)
op.create_table('app_user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=120), nullable=False),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('password_hash', sa.Text(), nullable=True),
sa.Column('must_change_password', sa.Boolean(), nullable=False),
sa.Column('external_subject', sa.String(length=255), nullable=True),
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint('password_hash IS NOT NULL OR external_subject IS NOT NULL', name=op.f('ck_app_user_local_or_external_login')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_app_user')),
sa.UniqueConstraint('external_subject', name=op.f('uq_app_user_external_subject')),
sa.UniqueConstraint('username', name=op.f('uq_app_user_username'))
)
op.create_table('category',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('parent_id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('kind', sa.Enum('expense', 'income', name='entry_kind'), nullable=False),
sa.Column('color', sa.String(length=9), nullable=False),
sa.Column('icon', sa.String(length=64), nullable=False),
sa.Column('is_fixed_cost', sa.Boolean(), nullable=False),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('is_archived', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['category.id'], name=op.f('fk_category_parent_id_category'), ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_category')),
sa.UniqueConstraint('parent_id', 'name', name='uq_category_parent_id')
)
op.create_index('ix_category_kind', 'category', ['kind'], unique=False)
op.create_table('logo_asset',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sha256', sa.String(length=64), nullable=False),
sa.Column('mime', sa.String(length=64), nullable=False),
sa.Column('width', sa.Integer(), nullable=True),
sa.Column('height', sa.Integer(), nullable=True),
sa.Column('file_path', sa.String(length=255), nullable=False),
sa.Column('source_url', sa.Text(), nullable=True),
sa.Column('fetched_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_logo_asset')),
sa.UniqueConstraint('sha256', name=op.f('uq_logo_asset_sha256'))
)
op.create_table('notification_rule',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('type', sa.Enum('due_soon', 'notice_deadline', 'budget_exceeded', 'contract_renewal', name='notification_type'), nullable=False),
sa.Column('lead_days', sa.Integer(), nullable=False),
sa.Column('channel', sa.Enum('smtp', 'apprise', name='notification_channel'), nullable=False),
sa.Column('target', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_notification_rule'))
)
op.create_table('budget',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('period_month', sa.Date(), nullable=False),
sa.Column('limit_amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('rollover', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint("date_trunc('month', period_month) = period_month", name=op.f('ck_budget_month_start')),
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_budget_category_id_category'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_budget')),
sa.UniqueConstraint('category_id', 'period_month', name='uq_budget_category_id')
)
op.create_table('budget_template',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('valid_from', sa.Date(), nullable=False),
sa.Column('valid_until', sa.Date(), nullable=True),
sa.Column('limit_amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('rollover', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint("date_trunc('month', valid_from) = valid_from", name=op.f('ck_budget_template_month_start')),
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_budget_template_category_id_category'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_budget_template')),
sa.UniqueConstraint('category_id', 'valid_from', name='uq_budget_template_category_id')
)
op.create_table('merchant',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=160), nullable=False),
sa.Column('normalized_name', sa.String(length=160), nullable=False),
sa.Column('domain', sa.String(length=255), nullable=True),
sa.Column('aliases', postgresql.ARRAY(sa.Text()), server_default='{}', nullable=False),
sa.Column('logo_asset_id', sa.Integer(), nullable=True),
sa.Column('brand_color', sa.String(length=9), nullable=True),
sa.Column('brand_color_dark', sa.String(length=9), nullable=True),
sa.Column('logo_source', sa.Enum('simple-icons', 'logodev', 'brandfetch', 'favicon', 'upload', 'generated', name='logo_source'), nullable=True),
sa.Column('logo_status', sa.Enum('pending', 'resolved', 'failed', 'manual', name='logo_status'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['logo_asset_id'], ['logo_asset.id'], name=op.f('fk_merchant_logo_asset_id_logo_asset'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_merchant')),
sa.UniqueConstraint('normalized_name', name=op.f('uq_merchant_normalized_name'))
)
op.create_index('ix_merchant_normalized_name', 'merchant', ['normalized_name'], unique=False)
op.create_table('notification_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('rule_id', sa.Integer(), nullable=False),
sa.Column('ref_type', sa.String(length=40), nullable=False),
sa.Column('ref_id', sa.String(length=80), nullable=False),
sa.Column('dedupe_day', sa.Date(), nullable=False),
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('status', sa.Enum('sent', 'failed', name='notification_status'), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['rule_id'], ['notification_rule.id'], name=op.f('fk_notification_log_rule_id_notification_rule'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_notification_log')),
sa.UniqueConstraint('rule_id', 'ref_type', 'ref_id', 'dedupe_day', name='uq_notification_log_rule_id')
)
op.create_index('ix_notification_log_sent_at', 'notification_log', ['sent_at'], unique=False)
op.create_table('savings_goal',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=160), nullable=False),
sa.Column('target_amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('target_date', sa.Date(), nullable=True),
sa.Column('current_amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=True),
sa.Column('monthly_contribution', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('color', sa.String(length=9), nullable=False),
sa.Column('icon', sa.String(length=64), nullable=False),
sa.Column('is_archived', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_savings_goal_account_id_account'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_savings_goal')),
sa.UniqueConstraint('name', name='uq_savings_goal_name')
)
op.create_table('recurrence',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('kind', sa.Enum('expense', 'income', name='entry_kind'), nullable=False),
sa.Column('title', sa.String(length=160), nullable=False),
sa.Column('merchant_id', sa.Integer(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('is_variable', sa.Boolean(), nullable=False),
sa.Column('currency', sa.String(length=3), nullable=False),
sa.Column('rrule', sa.Text(), nullable=False),
sa.Column('dtstart', sa.Date(), nullable=False),
sa.Column('until', sa.Date(), nullable=True),
sa.Column('business_day_shift', sa.Enum('none', 'next', 'previous', name='business_day_shift'), server_default='next', nullable=False),
sa.Column('holiday_region', sa.String(length=8), server_default='DE-NW', nullable=False),
sa.Column('installments_total', sa.Integer(), nullable=True),
sa.Column('principal_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('contract_start', sa.Date(), nullable=True),
sa.Column('contract_min_term_months', sa.Integer(), nullable=True),
sa.Column('contract_notice_period_days', sa.Integer(), nullable=True),
sa.Column('contract_auto_renew_months', sa.Integer(), nullable=True),
sa.Column('contract_cancelled_at', sa.Date(), nullable=True),
sa.Column('reserve_enabled', sa.Boolean(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('tags', postgresql.ARRAY(sa.Text()), server_default='{}', nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint('installments_total IS NULL OR installments_total > 0', name=op.f('ck_recurrence_installments')),
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_recurrence_account_id_account'), ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_recurrence_category_id_category'), ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['merchant_id'], ['merchant.id'], name=op.f('fk_recurrence_merchant_id_merchant'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_recurrence'))
)
op.create_index('ix_recurrence_category_id', 'recurrence', ['category_id'], unique=False)
op.create_index('ix_recurrence_kind_active', 'recurrence', ['kind', 'is_active'], unique=False)
op.create_index('ix_recurrence_merchant_id', 'recurrence', ['merchant_id'], unique=False)
op.create_table('transaction',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('kind', sa.Enum('expense', 'income', name='entry_kind'), nullable=False),
sa.Column('title', sa.String(length=160), nullable=False),
sa.Column('merchant_id', sa.Integer(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('booking_date', sa.Date(), nullable=False),
sa.Column('note', sa.Text(), nullable=True),
sa.Column('tags', postgresql.ARRAY(sa.Text()), server_default='{}', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_transaction_account_id_account'), ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['category_id'], ['category.id'], name=op.f('fk_transaction_category_id_category'), ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['merchant_id'], ['merchant.id'], name=op.f('fk_transaction_merchant_id_merchant'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_transaction'))
)
op.create_index('ix_transaction_booking_date', 'transaction', ['booking_date'], unique=False)
op.create_index('ix_transaction_category_id', 'transaction', ['category_id'], unique=False)
op.create_table('amount_version',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('recurrence_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('valid_from', sa.Date(), nullable=False),
sa.Column('note', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['recurrence_id'], ['recurrence.id'], name=op.f('fk_amount_version_recurrence_id_recurrence'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_amount_version')),
sa.UniqueConstraint('recurrence_id', 'valid_from', name='uq_amount_version_recurrence_id')
)
op.create_table('occurrence',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('recurrence_id', sa.Integer(), nullable=False),
sa.Column('occurrence_date', sa.Date(), nullable=False),
sa.Column('status', sa.Enum('planned', 'confirmed', 'skipped', name='occurrence_status'), nullable=False),
sa.Column('planned_amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('actual_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('actual_date', sa.Date(), nullable=True),
sa.Column('account_id', sa.Integer(), nullable=True),
sa.Column('note', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['account.id'], name=op.f('fk_occurrence_account_id_account'), ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['recurrence_id'], ['recurrence.id'], name=op.f('fk_occurrence_recurrence_id_recurrence'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_occurrence')),
sa.UniqueConstraint('recurrence_id', 'occurrence_date', name='uq_occurrence_recurrence_id')
)
op.create_index('ix_occurrence_date', 'occurrence', ['occurrence_date'], unique=False)
op.create_table('reserve_ledger',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('recurrence_id', sa.Integer(), nullable=False),
sa.Column('period_month', sa.Date(), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('is_released', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint("date_trunc('month', period_month) = period_month", name=op.f('ck_reserve_ledger_month_start')),
sa.ForeignKeyConstraint(['recurrence_id'], ['recurrence.id'], name=op.f('fk_reserve_ledger_recurrence_id_recurrence'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_reserve_ledger')),
sa.UniqueConstraint('recurrence_id', 'period_month', name='uq_reserve_ledger_recurrence_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('reserve_ledger')
op.drop_index('ix_occurrence_date', table_name='occurrence')
op.drop_table('occurrence')
op.drop_table('amount_version')
op.drop_index('ix_transaction_category_id', table_name='transaction')
op.drop_index('ix_transaction_booking_date', table_name='transaction')
op.drop_table('transaction')
op.drop_index('ix_recurrence_merchant_id', table_name='recurrence')
op.drop_index('ix_recurrence_kind_active', table_name='recurrence')
op.drop_index('ix_recurrence_category_id', table_name='recurrence')
op.drop_table('recurrence')
op.drop_table('savings_goal')
op.drop_index('ix_notification_log_sent_at', table_name='notification_log')
op.drop_table('notification_log')
op.drop_index('ix_merchant_normalized_name', table_name='merchant')
op.drop_table('merchant')
op.drop_table('budget_template')
op.drop_table('budget')
op.drop_table('notification_rule')
op.drop_table('logo_asset')
op.drop_index('ix_category_kind', table_name='category')
op.drop_table('category')
op.drop_table('app_user')
op.drop_index('ix_account_is_active', table_name='account')
op.drop_table('account')
# ### end Alembic commands ###
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
+60
View File
@@ -0,0 +1,60 @@
[project]
name = "moneyfy-backend"
version = "0.1.0"
description = "moneyfy Planung monatlicher Kosten und Einkünfte"
requires-python = ">=3.12,<3.13"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"sqlalchemy[asyncio]>=2.0.36",
"asyncpg>=0.30",
"alembic>=1.14",
"pydantic>=2.10",
"pydantic-settings>=2.7",
"python-dateutil>=2.9",
"holidays>=0.63",
"apscheduler>=3.11",
"argon2-cffi>=23.1",
"pyjwt>=2.10",
"httpx>=0.28",
"pillow>=11.0",
"openpyxl>=3.1",
"apprise>=1.9",
"python-multipart>=0.0.20",
"email-validator>=2.2",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"ruff>=0.8",
"psycopg[binary]>=3.2",
"freezegun>=1.5",
]
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]
[tool.ruff]
line-length = 100
target-version = "py312"
extend-exclude = ["alembic/versions"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "C4", "N", "RUF"]
ignore = ["B008", "RUF001", "RUF002", "RUF003"]
[tool.ruff.format]
quote-style = "double"
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
filterwarnings = ["ignore::DeprecationWarning"]
View File
+78
View File
@@ -0,0 +1,78 @@
"""Gemeinsame Test-Fixtures. Nutzt eine separate Testdatenbank."""
import os
# Muss vor dem ersten Import der Anwendung gesetzt sein, da die Settings gecacht werden.
os.environ.setdefault(
"DATABASE_URL",
os.environ.get(
"TEST_DATABASE_URL",
"postgresql+asyncpg://moneyfy:moneyfy@127.0.0.1:5432/moneyfy_test",
),
)
os.environ.setdefault("SECRET_KEY", "test-secret-key")
os.environ.setdefault("ENVIRONMENT", "test")
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.core.config import settings
from app.db.session import get_session
from app.main import app as fastapi_app
from app.models import Base
@pytest.fixture(scope="session")
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture(scope="session")
async def engine():
"""Legt das Schema einmal pro Testlauf frisch an."""
test_engine = create_async_engine(str(settings.database_url), poolclass=None)
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield test_engine
await test_engine.dispose()
@pytest.fixture
async def session(engine) -> AsyncGenerator[AsyncSession, None]:
"""Eine Session je Test, am Ende wird zurückgerollt."""
connection = await engine.connect()
transaction = await connection.begin()
# `create_savepoint` sorgt dafür, dass session.commit() nur den Savepoint freigibt
# und der abschließende Rollback wirklich alle Testdaten entfernt.
maker = async_sessionmaker(
bind=connection,
expire_on_commit=False,
autoflush=False,
join_transaction_mode="create_savepoint",
)
async with maker() as db_session:
yield db_session
await transaction.rollback()
await connection.close()
@pytest.fixture
async def client(session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
"""HTTP-Client gegen die App, alle Requests laufen in der Test-Transaktion."""
async def _override() -> AsyncGenerator[AsyncSession, None]:
yield session
fastapi_app.dependency_overrides[get_session] = _override
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
fastapi_app.dependency_overrides.clear()
+57
View File
@@ -0,0 +1,57 @@
"""Tests des Stammdaten-Seeds."""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Category, NotificationRule
from app.models.enums import EntryKind
from app.services.seed import seed_all
async def test_seed_erzeugt_kategoriebaum(session: AsyncSession) -> None:
await seed_all(session)
total = (await session.execute(select(func.count()).select_from(Category))).scalar_one()
assert total == 35
roots = (
(await session.execute(select(Category).where(Category.parent_id.is_(None))))
.scalars()
.all()
)
assert {root.name for root in roots} == {
"Wohnen",
"Versicherungen",
"Abos & Medien",
"Mobilität",
"Lebenshaltung",
"Finanzen",
"Einkünfte",
}
einkuenfte = next(root for root in roots if root.name == "Einkünfte")
assert einkuenfte.kind is EntryKind.INCOME
miete = (await session.execute(select(Category).where(Category.name == "Miete"))).scalar_one()
assert miete.is_fixed_cost is True
assert miete.kind is EntryKind.EXPENSE
assert miete.parent_id is not None
async def test_seed_ist_idempotent(session: AsyncSession) -> None:
first = await seed_all(session)
second = await seed_all(session)
assert first["categories"] == 35
assert second["categories"] == 0
assert second["notification_rules"] == 0
total = (await session.execute(select(func.count()).select_from(Category))).scalar_one()
assert total == 35
async def test_seed_legt_benachrichtigungsregeln_an(session: AsyncSession) -> None:
await seed_all(session)
rules = (await session.execute(select(NotificationRule))).scalars().all()
assert len(rules) == 4
assert all(rule.is_active is False for rule in rules)
+26
View File
@@ -0,0 +1,26 @@
"""Tests der Systemendpunkte und des Fehlerformats."""
from httpx import AsyncClient
async def test_health_meldet_datenbank_ok(client: AsyncClient) -> None:
response = await client.get("/api/health")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
assert body["database"] == "ok"
# Zeitstempel muss in Europe/Berlin geliefert werden (+01:00 oder +02:00).
assert body["time"].endswith(("+01:00", "+02:00"))
async def test_version_liefert_stammdaten(client: AsyncClient) -> None:
body = (await client.get("/api/version")).json()
assert body["name"] == "moneyfy"
assert body["timezone"] == "Europe/Berlin"
assert body["currency"] == "EUR"
async def test_unbekannte_route_liefert_fehlerformat(client: AsyncClient) -> None:
response = await client.get("/api/gibt-es-nicht")
assert response.status_code == 404
assert response.json() == {"detail": "Not Found", "code": "not_found"}