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
+72
View File
@@ -0,0 +1,72 @@
# =============================================================================
# moneyfy Beispielkonfiguration
# Kopieren nach `.env` und anpassen. Niemals echte Secrets committen.
# =============================================================================
# --- Allgemein ---------------------------------------------------------------
ENVIRONMENT=production # development | production | test
DEBUG=false
TIMEZONE=Europe/Berlin # Durchgängige Zeitzone der Anwendung
HOLIDAY_REGION=DE-NW # Feiertagsregion für Werktagsverschiebungen
DEFAULT_CURRENCY=EUR
# Öffentliche Basis-URL, u. a. für Links in Benachrichtigungen
PUBLIC_BASE_URL=https://moneyfy.menzel.center
# Image-Tag, den docker-compose zieht (main, 0.1.0, latest, sha-abc1234)
TAG=latest
# --- Datenbank ---------------------------------------------------------------
POSTGRES_DB=moneyfy
POSTGRES_USER=moneyfy
POSTGRES_PASSWORD=bitte-aendern
# Vom Backend genutzte DSN. `postgresql://` wird automatisch auf asyncpg umgestellt.
DATABASE_URL=postgresql+asyncpg://moneyfy:bitte-aendern@moneyfy-db:5432/moneyfy
# --- Sicherheit --------------------------------------------------------------
# Signaturschlüssel für JWTs. Erzeugen mit: openssl rand -hex 32
SECRET_KEY=bitte-aendern
ACCESS_TOKEN_TTL_MINUTES=30
REFRESH_TOKEN_TTL_DAYS=14
COOKIE_SECURE=true # Hinter HTTPS-Proxy true, für lokales HTTP false
COOKIE_DOMAIN=
# Wird beim ersten Start angelegt (danach mit erzwungenem Passwortwechsel).
MONEYFY_ADMIN_USER=admin
MONEYFY_ADMIN_PASSWORD=bitte-aendern
# --- OIDC (vorbereitet, noch nicht implementiert) ----------------------------
OIDC_ENABLED=false
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_SCOPES=openid profile email
# --- Logo-Service ------------------------------------------------------------
# Verzeichnis für den lokalen Logo-Cache (im Container auf das Volume gemountet).
LOGO_STORAGE_DIR=/data/logos
# Optionale Provider. Ohne Key wird der jeweilige Adapter übersprungen.
LOGODEV_API_KEY=
BRANDFETCH_API_KEY=
LOGO_HTTP_TIMEOUT_SECONDS=5
LOGO_HTTP_RETRIES=2
LOGO_MAX_UPLOAD_BYTES=1048576
# --- Benachrichtigungen ------------------------------------------------------
NOTIFICATIONS_ENABLED=true
SCHEDULER_ENABLED=true
NOTIFICATION_HOUR=7 # Täglicher Lauf um 07:00 Europe/Berlin
NOTIFICATION_MINUTE=0
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=moneyfy@example.com
SMTP_USE_TLS=true # STARTTLS
SMTP_USE_SSL=false # Implizites TLS (Port 465)
# Komma-separierte Apprise-URLs, z. B. ntfy://host/topic,discord://...
APPRISE_URLS=
# --- CORS --------------------------------------------------------------------
# Nur für getrennte Frontend-Entwicklung nötig, z. B. http://localhost:5173
CORS_ORIGINS=
+27
View File
@@ -0,0 +1,27 @@
# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.ruff_cache/
*.egg-info/
# Node
node_modules/
dist/
.vite/
# Lokale Konfiguration und Daten
.env
.env.local
docker-compose.override.yml
backend/.data/
data/
# Werkzeuge
.coverage
htmlcov/
.DS_Store
*.swp
.idea/
.vscode/
+24
View File
@@ -0,0 +1,24 @@
# Changelog
Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Das Format orientiert sich an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased]
### Hinzugefügt
- Projektgerüst mit getrennten Bereichen für Backend, Frontend, Dokumentation und CI.
- FastAPI-Backend mit Konfiguration über `pydantic-settings` und einheitlichem
Fehlerformat (`{"detail": ..., "code": ...}`).
- Vollständiges Datenmodell als SQLAlchemy-2.0-Modelle: Konten, Kategorien, Firmen,
Logo-Assets, Recurrences, Preishistorie, Occurrences, Buchungen, Budgets samt
Vorlagen, Sparziele, Rücklagen, Benachrichtigungsregeln und -protokoll, Benutzer.
- Alembic-Setup gegen die Async-Engine samt Erstmigration des kompletten Schemas.
- Idempotenter Seed für den deutschen Standard-Kategoriebaum (35 Kategorien) und
die Standard-Benachrichtigungsregeln.
- Endpunkte `GET /api/health` und `GET /api/version`.
- Testinfrastruktur mit pytest, httpx und transaktionsisolierten Fixtures.
- `.env.example`, `docker-compose.override.yml.example` und Makefile für die
lokale Entwicklung.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jonas Menzel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+49
View File
@@ -0,0 +1,49 @@
# Entwicklungs-Helfer für moneyfy.
.DEFAULT_GOAL := help
BACKEND := backend
PY := $(BACKEND)/.venv/bin/python
UV := uv
.PHONY: help
help: ## Verfügbare Ziele anzeigen
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: ## Backend-Abhängigkeiten in .venv installieren
$(UV) venv --python 3.12 $(BACKEND)/.venv
cd $(BACKEND) && ../$(BACKEND)/.venv/bin/python -m ensurepip --version >/dev/null 2>&1 || true
cd $(BACKEND) && $(UV) pip install -e ".[dev]"
.PHONY: migrate
migrate: ## Alembic-Migrationen auf den aktuellen Stand bringen
cd $(BACKEND) && .venv/bin/alembic upgrade head
.PHONY: revision
revision: ## Neue Migration erzeugen: make revision m="beschreibung"
cd $(BACKEND) && .venv/bin/alembic revision --autogenerate -m "$(m)"
.PHONY: seed
seed: ## Stammdaten (Kategoriebaum, Benachrichtigungsregeln) anlegen
cd $(BACKEND) && .venv/bin/python -m app.scripts.seed
.PHONY: dev
dev: ## Backend mit Hot Reload starten
cd $(BACKEND) && .venv/bin/uvicorn app.main:app --reload --port 8000
.PHONY: test
test: ## Backend-Tests ausführen
cd $(BACKEND) && .venv/bin/python -m pytest
.PHONY: lint
lint: ## ruff check und Formatprüfung
cd $(BACKEND) && .venv/bin/ruff check .
cd $(BACKEND) && .venv/bin/ruff format --check .
.PHONY: format
format: ## Code formatieren und automatisch behebbare Regelverstöße korrigieren
cd $(BACKEND) && .venv/bin/ruff check --fix .
cd $(BACKEND) && .venv/bin/ruff format .
.PHONY: check
check: lint test ## Lint und Tests zusammen
+89
View File
@@ -0,0 +1,89 @@
# moneyfy
Selbst gehostete Web-Anwendung zur Planung monatlicher Kosten und Einkünfte.
Single-User, deutschsprachige Oberfläche, ausgelegt für den Betrieb im Homelab
hinter einem Reverse Proxy.
> **Status:** in Entwicklung. Siehe [CHANGELOG.md](CHANGELOG.md).
## Screenshots
_Platzhalter werden ergänzt, sobald die Oberfläche steht._
| Dashboard | Cashflow-Kalender | Firmen |
|---|---|---|
| _folgt_ | _folgt_ | _folgt_ |
## Funktionsumfang
- Wiederkehrende Zahlungen und Einkünfte über vollständige RFC-5545-RRULEs,
inklusive Werktagsverschiebung nach NRW-Feiertagen.
- Preishistorie je Vertrag, sodass vergangene Monate betragstreu bleiben.
- Verträge mit Mindestlaufzeit, Kündigungsfrist und automatischer Verlängerung.
- Ratenzahlungen mit Restschuld- und Restratenberechnung.
- Rücklagenbildung für nicht-monatliche Posten.
- Firmenlogos und Markenfarben, lokal zwischengespeichert.
- Auswertungen: Monatsübersicht, Cashflow-Kalender, 12-Monats-Forecast,
Kategorien, Abo-Übersicht, Jahresvergleich, Budgets, Sparziele.
- Benachrichtigungen per SMTP und Apprise.
## Technischer Stack
| Bereich | Technologie |
|---|---|
| Backend | Python 3.12, FastAPI, SQLAlchemy 2 (async), Alembic, Pydantic v2 |
| Datenbank | PostgreSQL 17 |
| Scheduler | APScheduler (in-process) |
| Frontend | React 18, TypeScript, Vite, TailwindCSS |
| Charts | Recharts |
| Tests | pytest + httpx, vitest + testing-library |
## Schnellstart (lokale Entwicklung)
Voraussetzungen: Python 3.12, [uv](https://docs.astral.sh/uv/), Node 20+,
eine erreichbare PostgreSQL-17-Instanz.
```bash
git clone https://git.menzel.center/menzeljonas/moneyfy.git
cd moneyfy
cp .env.example backend/.env # DATABASE_URL und SECRET_KEY anpassen
make install # Backend-Abhängigkeiten in backend/.venv
make migrate # Schema anlegen
make seed # Kategoriebaum und Standardregeln
make dev # http://localhost:8000/api/docs
```
Nützliche Ziele: `make test`, `make lint`, `make format`, `make check`.
## Konfiguration
Alle Einstellungen kommen aus Umgebungsvariablen; `.env.example` enthält die
vollständige, kommentierte Liste. Die wichtigsten:
| Variable | Standard | Bedeutung |
|---|---|---|
| `DATABASE_URL` | | PostgreSQL-DSN, `postgresql://` wird auf asyncpg umgestellt |
| `SECRET_KEY` | | Signaturschlüssel für JWTs (`openssl rand -hex 32`) |
| `MONEYFY_ADMIN_USER` / `MONEYFY_ADMIN_PASSWORD` | `admin` / | Beim Erststart angelegter Benutzer |
| `TIMEZONE` | `Europe/Berlin` | Zeitzone der gesamten Anwendung |
| `HOLIDAY_REGION` | `DE-NW` | Feiertagsregion für Werktagsverschiebungen |
| `LOGO_STORAGE_DIR` | `/data/logos` | Verzeichnis des Logo-Caches |
| `LOGODEV_API_KEY` / `BRANDFETCH_API_KEY` | leer | Optionale Logo-Provider |
| `SMTP_*` | leer | Mailversand für Benachrichtigungen |
| `APPRISE_URLS` | leer | Komma-separierte Apprise-Ziele |
| `COOKIE_SECURE` | `true` | Hinter HTTPS `true`, für lokales HTTP `false` |
| `SCHEDULER_ENABLED` | `true` | Täglicher Benachrichtigungslauf um 07:00 |
## Projektstruktur
```
backend/ FastAPI-Anwendung, Modelle, Services, Alembic-Migrationen, Tests
frontend/ React-Oberfläche (Vite)
docs/ Betriebs- und Runner-Dokumentation
.gitea/ Gitea-Actions-Workflows
```
## Lizenz
[MIT](LICENSE)
+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"}
+41
View File
@@ -0,0 +1,41 @@
# Lokale Entwicklung: Hot Reload und offene Ports.
# Kopieren nach `docker-compose.override.yml` die Datei wird nicht eingecheckt.
services:
moneyfy-db:
# Datenbank direkt erreichbar, z. B. für psql oder einen SQL-Client.
ports:
- "127.0.0.1:5432:5432"
moneyfy-backend:
build:
context: ./backend
target: development
image: moneyfy-backend:dev
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
environment:
ENVIRONMENT: development
DEBUG: "true"
COOKIE_SECURE: "false"
SCHEDULER_ENABLED: "false"
CORS_ORIGINS: http://localhost:5173
volumes:
# Quellcode einblenden, damit --reload greift.
- ./backend/app:/app/app:ro
- ./backend/alembic:/app/alembic:ro
ports:
- "127.0.0.1:8000:8000"
moneyfy-frontend:
build:
context: ./frontend
target: development
image: moneyfy-frontend:dev
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
environment:
VITE_API_BASE_URL: http://localhost:8000
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
- ./frontend/index.html:/app/index.html:ro
ports:
- "127.0.0.1:5173:5173"