From 0b06775be3efd431d8c731d2c18d8798b569a894 Mon Sep 17 00:00:00 2001 From: moneyfy Date: Wed, 9 Sep 2026 13:12:08 +0200 Subject: [PATCH] feat: Projektfundament mit Datenmodell, Migrationen und Seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH --- .env.example | 72 +++++ .gitignore | 27 ++ CHANGELOG.md | 24 ++ LICENSE | 21 ++ Makefile | 49 +++ README.md | 89 ++++++ backend/alembic.ini | 41 +++ backend/alembic/env.py | 66 ++++ backend/alembic/script.py.mako | 24 ++ backend/alembic/versions/.gitkeep | 0 .../versions/20260909_1307_initial_schema.py | 290 ++++++++++++++++++ backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/router.py | 8 + backend/app/api/routes/__init__.py | 0 backend/app/api/routes/system.py | 54 ++++ backend/app/core/__init__.py | 0 backend/app/core/config.py | 113 +++++++ backend/app/core/errors.py | 108 +++++++ backend/app/db/__init__.py | 0 backend/app/db/base.py | 51 +++ backend/app/db/seed_data.py | 110 +++++++ backend/app/db/session.py | 30 ++ backend/app/main.py | 55 ++++ backend/app/models/__init__.py | 54 ++++ backend/app/models/core.py | 150 +++++++++ backend/app/models/enums.py | 77 +++++ backend/app/models/finance.py | 271 ++++++++++++++++ backend/app/models/notification.py | 73 +++++ backend/app/schemas/__init__.py | 0 backend/app/scripts/__init__.py | 0 backend/app/scripts/seed.py | 20 ++ backend/app/services/__init__.py | 0 backend/app/services/seed.py | 97 ++++++ backend/pyproject.toml | 60 ++++ backend/tests/__init__.py | 0 backend/tests/conftest.py | 78 +++++ backend/tests/test_seed.py | 57 ++++ backend/tests/test_system.py | 26 ++ docker-compose.override.yml.example | 41 +++ 40 files changed, 2236 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/.gitkeep create mode 100644 backend/alembic/versions/20260909_1307_initial_schema.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/router.py create mode 100644 backend/app/api/routes/__init__.py create mode 100644 backend/app/api/routes/system.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/errors.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/seed_data.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/core.py create mode 100644 backend/app/models/enums.py create mode 100644 backend/app/models/finance.py create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/scripts/__init__.py create mode 100644 backend/app/scripts/seed.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/seed.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_seed.py create mode 100644 backend/tests/test_system.py create mode 100644 docker-compose.override.yml.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd92b2e --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03444a2 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ffecf65 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..06232b9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..33469d2 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..49cb884 --- /dev/null +++ b/README.md @@ -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) diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..4909365 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..e80bbfa --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..dc8cca5 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/alembic/versions/20260909_1307_initial_schema.py b/backend/alembic/versions/20260909_1307_initial_schema.py new file mode 100644 index 0000000..d0604ed --- /dev/null +++ b/backend/alembic/versions/20260909_1307_initial_schema.py @@ -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 ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..cfd9993 --- /dev/null +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/system.py b/backend/app/api/routes/system.py new file mode 100644 index 0000000..c0746f4 --- /dev/null +++ b/backend/app/api/routes/system.py @@ -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, + ) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..163a763 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 0000000..d8f80ff --- /dev/null +++ b/backend/app/core/errors.py @@ -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 diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..567c033 --- /dev/null +++ b/backend/app/db/base.py @@ -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 + ) diff --git a/backend/app/db/seed_data.py b/backend/app/db/seed_data.py new file mode 100644 index 0000000..8757c46 --- /dev/null +++ b/backend/app/db/seed_data.py @@ -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), +) diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..c981529 --- /dev/null +++ b/backend/app/db/session.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..c89e4fb --- /dev/null +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..274d772 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/core.py b/backend/app/models/core.py new file mode 100644 index 0000000..233b746 --- /dev/null +++ b/backend/app/models/core.py @@ -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", + ), + ) diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py new file mode 100644 index 0000000..d1dacc6 --- /dev/null +++ b/backend/app/models/enums.py @@ -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, + ) diff --git a/backend/app/models/finance.py b/backend/app/models/finance.py new file mode 100644 index 0000000..0981a16 --- /dev/null +++ b/backend/app/models/finance.py @@ -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"), + ) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..2e16e97 --- /dev/null +++ b/backend/app/models/notification.py @@ -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"), + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/scripts/__init__.py b/backend/app/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/scripts/seed.py b/backend/app/scripts/seed.py new file mode 100644 index 0000000..affdd31 --- /dev/null +++ b/backend/app/scripts/seed.py @@ -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()) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/seed.py b/backend/app/services/seed.py new file mode 100644 index 0000000..294c0c8 --- /dev/null +++ b/backend/app/services/seed.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..ee6f5b0 --- /dev/null +++ b/backend/pyproject.toml @@ -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"] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..5b1a53f --- /dev/null +++ b/backend/tests/conftest.py @@ -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() diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py new file mode 100644 index 0000000..651e1bf --- /dev/null +++ b/backend/tests/test_seed.py @@ -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) diff --git a/backend/tests/test_system.py b/backend/tests/test_system.py new file mode 100644 index 0000000..79172cf --- /dev/null +++ b/backend/tests/test_system.py @@ -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"} diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example new file mode 100644 index 0000000..5195ad6 --- /dev/null +++ b/docker-compose.override.yml.example @@ -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"