Compare commits
57 Commits
309b1039c5
...
8f6f1a6e0e
| Author | SHA256 | Date | |
|---|---|---|---|
| 8f6f1a6e0e | |||
| c2b5f6eaa3 | |||
| 1b888307e9 | |||
| 7a0f71aa8c | |||
| ea4858c4de | |||
| e2d713e960 | |||
| f6a53b7e56 | |||
| 41d556bcde | |||
| 6b02fa3d85 | |||
| 1fc87001d6 | |||
| 18b738738b | |||
| 06392c0fec | |||
| e7855a7bda | |||
| d71c22adce | |||
| 0bad69bf42 | |||
| 543bb648b7 | |||
| 94eb80506e | |||
| f57edc5fcd | |||
| bba04db663 | |||
| e40442b73b | |||
| d5103a0fb3 | |||
| 82f278ca00 | |||
| 87f8bbc400 | |||
| 36d3e4fc59 | |||
| f27eda88f4 | |||
| 597f7ba552 | |||
| 917797bb16 | |||
| cf9282c716 | |||
| b3b40a5982 | |||
| 1338bd66d5 | |||
| 557928ac3f | |||
| a1d8e91cbc | |||
| fca11f551c | |||
| 43e8e8e8f9 | |||
| df6615c8f7 | |||
| afbbcbe9a5 | |||
| 3847398055 | |||
| a6009c47e3 | |||
| e8ea948b38 | |||
| b0cb4883ea | |||
| 8ca563cec1 | |||
| 19d870cf20 | |||
| 0576b396f1 | |||
| 320abd6542 | |||
| 193bbc450c | |||
| a10f4d0b57 | |||
| 101ee01af1 | |||
| 78d94a583b | |||
| d91bb2e331 | |||
| 877cba4964 | |||
| 020f41e9f2 | |||
| d41db2e10d | |||
| 82800ea7d0 | |||
| 9f50435df9 | |||
| d37cb230c4 | |||
| 53ffd65104 | |||
| d4a98e117f |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
example_create_pod_langflow.sh
|
||||
336
create_pod_finance.sh
Executable file
336
create_pod_finance.sh
Executable file
@@ -0,0 +1,336 @@
|
||||
#!/bin/bash
|
||||
|
||||
# To be run by user wlfb to create pod for the Finanzberatungs-Tool
|
||||
# (API + Postgres + Grafana).
|
||||
|
||||
set -e
|
||||
|
||||
# Environment variables
|
||||
POD_NAME='finance_pod'
|
||||
DB_CTR_NAME='finance-db_ctr'
|
||||
API_CTR_NAME='finance-api_ctr'
|
||||
GRAFANA_CTR_NAME='finance-grafana_ctr'
|
||||
|
||||
# All image tags are pinned to fixed, immutable versions (no floating tags
|
||||
# such as :latest, :3 or :16). Verified against the upstream registries on
|
||||
# 2026-07-17 via `podman pull` (both tags exist as-is; no substitution
|
||||
# needed).
|
||||
POSTGRES_IMAGE='docker.io/library/postgres:17.10'
|
||||
GRAFANA_IMAGE='docker.io/grafana/grafana-oss:12.1.0'
|
||||
FINANCE_DIR="$HOME/bin/finance"
|
||||
API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"
|
||||
|
||||
HOST_LOCAL_IP='127.0.0.1'
|
||||
# Expose API on 8096 -> 8000
|
||||
API_HOST_PORT='8096'
|
||||
API_CONTAINER_PORT='8000'
|
||||
# Expose Grafana on 8097 -> 3000
|
||||
GRAFANA_HOST_PORT='8097'
|
||||
GRAFANA_CONTAINER_PORT='3000'
|
||||
# Postgres is only reachable inside the pod (localhost:5432 from API/Grafana)
|
||||
|
||||
BIND_DIR="$HOME/.local/share/$POD_NAME"
|
||||
POSTGRES_DATA_DIR="$BIND_DIR/postgres-data"
|
||||
GRAFANA_DATA_DIR="$BIND_DIR/grafana-data"
|
||||
# inbox and uploads must live under one shared bind mount: process_pdf()
|
||||
# moves a file from inbox to uploads via Path.replace() (os.replace/rename),
|
||||
# which fails with EXDEV ("Invalid cross-device link") across two separate
|
||||
# bind mounts even when both point at the same underlying host filesystem
|
||||
# (Linux permits a filesystem to be mounted at multiple points, but rename()
|
||||
# does not work across different mount points - see `man 2 rename`).
|
||||
DATA_DIR="$BIND_DIR/data"
|
||||
INBOX_DIR="$DATA_DIR/inbox"
|
||||
UPLOADS_DIR="$DATA_DIR/uploads"
|
||||
USER_SYSTEMD_DIR="$HOME/.config/systemd/user"
|
||||
|
||||
GRAFANA_PROVISIONING_DIR="$FINANCE_DIR/grafana/provisioning"
|
||||
GRAFANA_DASHBOARDS_DIR="$FINANCE_DIR/grafana/dashboards"
|
||||
|
||||
# --- Secrets bootstrap -------------------------------------------------------
|
||||
# Secrets live under BIND_DIR (not the code checkout in FINANCE_DIR). For
|
||||
# disaster recovery you need BOTH: a backup of BIND_DIR (data + secrets) AND
|
||||
# the repo checkout ~/bin (this script, finance/ for the image build, Grafana
|
||||
# provisioning). BIND_DIR must exist and be private before .env is written.
|
||||
mkdir -p "$BIND_DIR" && chmod 700 "$BIND_DIR"
|
||||
|
||||
ENV_FILE="$BIND_DIR/.env"
|
||||
LEGACY_ENV="$HOME/bin/finance/.env"
|
||||
if [ ! -f "$ENV_FILE" ] && [ -f "$LEGACY_ENV" ]; then
|
||||
mv "$LEGACY_ENV" "$ENV_FILE" && chmod 600 "$ENV_FILE"
|
||||
echo "MIGRIERT: .env nach $ENV_FILE verschoben"
|
||||
fi
|
||||
|
||||
# On first run, generate all secrets into $ENV_FILE (gitignored) and print
|
||||
# the shared GUI/Grafana login password once. On subsequent runs the
|
||||
# existing .env is reused, so re-running this script never rotates secrets
|
||||
# under existing data - except FB_GUI_PASSWORD_HASH, which is kept in sync
|
||||
# with FB_PASSWORD on every run further below. That is what makes the
|
||||
# password-change procedure a one-liner: edit FB_PASSWORD in $ENV_FILE, run
|
||||
# this script again - the GUI hash (below) and Grafana's admin password
|
||||
# (Grafana-sync retry loop, further down near the Grafana container) both
|
||||
# follow automatically, with no separate hash-regeneration step.
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
# Compute all values into variables first, then write them SINGLE-QUOTED.
|
||||
# The GUI password hash has the form salt$digest; if written unquoted, the
|
||||
# later `set -a; . "$ENV_FILE"` would shell-expand the "$digest" part and
|
||||
# corrupt the hash, breaking GUI login. Single quotes make every value a
|
||||
# literal string on sourcing.
|
||||
FB_PASSWORD_VAL=$(openssl rand -base64 12)
|
||||
POSTGRES_PASSWORD_VAL=$(openssl rand -hex 16)
|
||||
FINANCE_READ_PASSWORD_VAL=$(openssl rand -hex 16)
|
||||
FB_API_KEY_VAL=$(openssl rand -hex 32)
|
||||
FB_SESSION_SECRET_VAL=$(openssl rand -hex 32)
|
||||
FB_GUI_PASSWORD_HASH_VAL=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD_VAL")
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
POSTGRES_PASSWORD='$POSTGRES_PASSWORD_VAL'
|
||||
FINANCE_READ_PASSWORD='$FINANCE_READ_PASSWORD_VAL'
|
||||
FB_API_KEY='$FB_API_KEY_VAL'
|
||||
FB_SESSION_SECRET='$FB_SESSION_SECRET_VAL'
|
||||
FB_GUI_USER='admin'
|
||||
FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH_VAL'
|
||||
FB_PASSWORD='$FB_PASSWORD_VAL'
|
||||
EOF
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "NEU ERZEUGT: GUI-/Grafana-Login admin / $FB_PASSWORD_VAL (jetzt notieren!)"
|
||||
elif grep -q '^FB_GUI_PASSWORD_HASH=' "$ENV_FILE" && ! grep -q '^FB_PASSWORD=' "$ENV_FILE"; then
|
||||
# Migrate a pre-existing .env that predates the shared GUI/Grafana
|
||||
# password (independent GUI hash + GRAFANA_ADMIN_PASSWORD) to one shared
|
||||
# FB_PASSWORD. The old GUI password was only ever stored as a salted
|
||||
# hash, so its plaintext cannot be recovered - a new password is
|
||||
# unavoidable here. The stale hash and any GRAFANA_ADMIN_PASSWORD line
|
||||
# (superseded by FB_PASSWORD) are dropped; all other keys (API key,
|
||||
# session secret, DB passwords, ...) are left untouched.
|
||||
FB_PASSWORD_VAL=$(openssl rand -base64 12)
|
||||
FB_GUI_PASSWORD_HASH_VAL=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD_VAL")
|
||||
sed -i "/^FB_GUI_PASSWORD_HASH=/d; /^GRAFANA_ADMIN_PASSWORD=/d" "$ENV_FILE"
|
||||
{
|
||||
echo "FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH_VAL'"
|
||||
echo "FB_PASSWORD='$FB_PASSWORD_VAL'"
|
||||
} >> "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "MIGRATION: gemeinsames Passwort fuer GUI+Grafana eingefuehrt (altes GUI-Passwort war nur als Hash gespeichert und nicht rekonstruierbar)."
|
||||
echo "MIGRATION: GUI-/Grafana-Login admin / $FB_PASSWORD_VAL (jetzt notieren!)"
|
||||
fi
|
||||
set -a; . "$ENV_FILE"; set +a
|
||||
|
||||
# Keep FB_GUI_PASSWORD_HASH in sync with FB_PASSWORD on every run (creation
|
||||
# and migration above already produce a matching pair, so this is a no-op
|
||||
# for them; it only rewrites $ENV_FILE when a user has edited FB_PASSWORD by
|
||||
# hand since the last run - see the password-change procedure comment
|
||||
# above).
|
||||
if ! python3 -c "
|
||||
import hashlib, sys
|
||||
pw, stored = sys.argv[1], sys.argv[2]
|
||||
salt, digest = stored.split('\$', 1)
|
||||
sys.exit(0 if hashlib.pbkdf2_hmac('sha256', pw.encode(), salt.encode(), 200000).hex() == digest else 1)
|
||||
" "$FB_PASSWORD" "$FB_GUI_PASSWORD_HASH" 2>/dev/null; then
|
||||
FB_GUI_PASSWORD_HASH=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD")
|
||||
sed -i "s|^FB_GUI_PASSWORD_HASH=.*|FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH'|" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "FB_PASSWORD-Aenderung erkannt: FB_GUI_PASSWORD_HASH in $ENV_FILE neu abgeleitet."
|
||||
fi
|
||||
|
||||
# Sanity check: the GUI password hash must survive shell sourcing intact. An
|
||||
# unquoted value containing a literal '$' gets mangled by parameter expansion,
|
||||
# which would silently break GUI login. Require the canonical salt$digest form
|
||||
# (32 hex chars, '$', 64 hex chars).
|
||||
if ! printf '%s' "$FB_GUI_PASSWORD_HASH" | grep -Eq '^[0-9a-f]{32}\$[0-9a-f]{64}$'; then
|
||||
echo "ERROR: FB_GUI_PASSWORD_HASH is malformed after sourcing $ENV_FILE." >&2
|
||||
echo " All values in $ENV_FILE must be single-quoted." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop existing systemd-managed pod if present, to avoid conflicts on rerun
|
||||
echo "Stopping systemd-managed pod 'pod-$POD_NAME.service' if it exists..."
|
||||
if systemctl --user list-units --type=service --all 2>/dev/null | \
|
||||
grep -q "pod-$POD_NAME.service"; then
|
||||
systemctl --user stop "pod-$POD_NAME.service" || true
|
||||
fi
|
||||
|
||||
# Prepare directories
|
||||
mkdir -p "$POSTGRES_DATA_DIR" "$GRAFANA_DATA_DIR" "$INBOX_DIR" "$UPLOADS_DIR" \
|
||||
"$USER_SYSTEMD_DIR"
|
||||
|
||||
# The Grafana image runs as a non-root user (uid 472) that belongs to group 0
|
||||
# (root). Its data dir is bind-mounted from the host, where it is owned by the
|
||||
# (rootless) container root. Make the dir group-writable with the setgid bit
|
||||
# so the non-root Grafana process can write its sqlite db/plugins (same
|
||||
# "arbitrary uid + gid 0" pattern as example_create_pod_langflow.sh's
|
||||
# LANGFLOW_DATA_DIR fix).
|
||||
chmod g+rwxs "$GRAFANA_DATA_DIR"
|
||||
|
||||
# Build the API image (Task 15 Containerfile)
|
||||
podman build -t "$API_IMAGE" "$FINANCE_DIR"
|
||||
|
||||
# Create pod if not yet existing
|
||||
if ! podman pod exists "$POD_NAME"; then
|
||||
podman pod create -n "$POD_NAME" \
|
||||
-p "$HOST_LOCAL_IP:$API_HOST_PORT:$API_CONTAINER_PORT" \
|
||||
-p "$HOST_LOCAL_IP:$GRAFANA_HOST_PORT:$GRAFANA_CONTAINER_PORT"
|
||||
echo "Pod '$POD_NAME' created (rc=$?)"
|
||||
else
|
||||
echo "Pod '$POD_NAME' already exists."
|
||||
fi
|
||||
|
||||
# Remove any old containers (ignore errors if they don't exist)
|
||||
podman rm -f "$DB_CTR_NAME" || true
|
||||
podman rm -f "$API_CTR_NAME" || true
|
||||
podman rm -f "$GRAFANA_CTR_NAME" || true
|
||||
|
||||
# Postgres container
|
||||
podman run -d --name "$DB_CTR_NAME" --pod "$POD_NAME" \
|
||||
-e POSTGRES_USER=finance \
|
||||
-e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
|
||||
-e POSTGRES_DB=finance \
|
||||
-v "$POSTGRES_DATA_DIR:/var/lib/postgresql/data:Z" \
|
||||
"$POSTGRES_IMAGE"
|
||||
echo "Container '$DB_CTR_NAME' started (rc=$?)"
|
||||
|
||||
# Wait for Postgres to be ready before starting API / Grafana
|
||||
echo "Waiting for Postgres to be ready (pg_isready)..."
|
||||
for attempt in $(seq 1 30); do
|
||||
if podman exec "$DB_CTR_NAME" pg_isready -q >/dev/null 2>&1; then
|
||||
echo "Postgres is ready."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [ "$attempt" -eq 30 ]; then
|
||||
echo "ERROR: Postgres did not become ready in time." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Create the read-only role used by the Grafana datasource, idempotently.
|
||||
echo "Ensuring read-only role 'finance_read' exists..."
|
||||
podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \
|
||||
"DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='finance_read')
|
||||
THEN CREATE ROLE finance_read LOGIN PASSWORD '$FINANCE_READ_PASSWORD'; END IF; END \$\$;
|
||||
GRANT CONNECT ON DATABASE finance TO finance_read;
|
||||
GRANT USAGE ON SCHEMA public TO finance_read;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO finance_read;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO finance_read;"
|
||||
echo "Role 'finance_read' is ready."
|
||||
|
||||
# API container (runs alembic upgrade head on start via entrypoint.sh)
|
||||
podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \
|
||||
-e FB_DATABASE_URL="postgresql+psycopg://finance:$POSTGRES_PASSWORD@localhost:5432/finance" \
|
||||
-e FB_API_KEY \
|
||||
-e FB_SESSION_SECRET \
|
||||
-e FB_GUI_USER \
|
||||
-e FB_GUI_PASSWORD_HASH \
|
||||
-e FB_INBOX_DIR=/data/inbox \
|
||||
-e FB_UPLOADS_DIR=/data/uploads \
|
||||
-v "$DATA_DIR:/data:Z" \
|
||||
"$API_IMAGE"
|
||||
echo "Container '$API_CTR_NAME' started (rc=$?)"
|
||||
|
||||
# Grafana container (provisioning + dashboards mounted read-only; datasource
|
||||
# yaml interpolates FINANCE_READ_PASSWORD). GF_SECURITY_ALLOW_EMBEDDING
|
||||
# permits the GUI's iframe to embed dashboards; anonymous access stays OFF
|
||||
# (no GF_AUTH_ANONYMOUS_* vars are set) - only the iframe X-Frame-Options
|
||||
# restriction is relaxed, authentication is unaffected.
|
||||
podman run -d --name "$GRAFANA_CTR_NAME" --pod "$POD_NAME" \
|
||||
-e GF_SECURITY_ADMIN_PASSWORD="$FB_PASSWORD" \
|
||||
-e GF_SECURITY_ALLOW_EMBEDDING=true \
|
||||
-e GF_SECURITY_COOKIE_SAMESITE=lax \
|
||||
-e GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/finanzen.json \
|
||||
-e FINANCE_READ_PASSWORD \
|
||||
-v "$GRAFANA_PROVISIONING_DIR:/etc/grafana/provisioning:Z,ro" \
|
||||
-v "$GRAFANA_DASHBOARDS_DIR:/var/lib/grafana/dashboards:Z,ro" \
|
||||
-v "$GRAFANA_DATA_DIR:/var/lib/grafana:Z" \
|
||||
"$GRAFANA_IMAGE"
|
||||
echo "Container '$GRAFANA_CTR_NAME' started (rc=$?)"
|
||||
|
||||
# GF_SECURITY_ADMIN_PASSWORD above only takes effect on a FRESH Grafana data
|
||||
# dir (initial admin bootstrap); an existing install keeps its previously
|
||||
# set password. So sync Grafana's admin password to FB_PASSWORD explicitly
|
||||
# on every script run via the CLI (idempotent: resetting to the same
|
||||
# password is harmless). grafana-oss:12.1.0 ships the unified `grafana`
|
||||
# binary, which exposes this as `grafana cli admin reset-admin-password`
|
||||
# (verified in-image: `grafana cli admin reset-admin-password --help`
|
||||
# succeeds); the legacy standalone `grafana-cli` binary is kept as a
|
||||
# fallback in case of a differently-built image. Retry like the pg_isready
|
||||
# wait above, since Grafana needs a moment after container start to
|
||||
# initialize its SQLite db before the CLI can operate on it. This runs
|
||||
# against the just-started container (before the stop/recreate-via-systemd
|
||||
# further below); the result persists because GRAFANA_DATA_DIR is a
|
||||
# bind-mounted directory shared with the systemd-managed restart.
|
||||
echo "Syncing Grafana admin password with FB_PASSWORD..."
|
||||
for attempt in $(seq 1 30); do
|
||||
if podman exec "$GRAFANA_CTR_NAME" grafana cli admin reset-admin-password "$FB_PASSWORD" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "Grafana admin password synced (grafana cli)."
|
||||
break
|
||||
fi
|
||||
if podman exec "$GRAFANA_CTR_NAME" grafana-cli admin reset-admin-password "$FB_PASSWORD" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "Grafana admin password synced (grafana-cli fallback)."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [ "$attempt" -eq 30 ]; then
|
||||
echo "ERROR: could not sync Grafana admin password in time." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate systemd service files
|
||||
cd "$USER_SYSTEMD_DIR"
|
||||
podman generate systemd --name --new --files "$POD_NAME"
|
||||
echo "Generated systemd service files (rc=$?)"
|
||||
|
||||
# The --new units embed the full `podman run` commands, including container
|
||||
# environment secrets (POSTGRES_PASSWORD, GF_SECURITY_ADMIN_PASSWORD,
|
||||
# FB_GUI_PASSWORD_HASH, ...). Post-process each generated unit file:
|
||||
# 1. chmod 600 so only the owner can read the embedded secrets.
|
||||
# 2. Collapse '$$' -> '$' on Environment= lines. `podman generate systemd`
|
||||
# escapes every '$' as '$$' (correct for ExecStart command lines, where
|
||||
# systemd performs variable expansion). But systemd does NOT un-escape
|
||||
# '$$' inside Environment= *values* - it passes them through literally.
|
||||
# The GUI password hash has the form salt$digest, so without this fix the
|
||||
# container receives salt$$digest and GUI login fails. Restrict the
|
||||
# substitution to Environment= lines so ExecStart escaping stays intact.
|
||||
for svc in "pod-${POD_NAME}.service" "container-${DB_CTR_NAME}.service" \
|
||||
"container-${API_CTR_NAME}.service" "container-${GRAFANA_CTR_NAME}.service"; do
|
||||
[ -f "$svc" ] || continue
|
||||
chmod 600 "$svc"
|
||||
sed -i '/^Environment=/ s/\$\$/$/g' "$svc"
|
||||
done
|
||||
|
||||
# Stop & remove live pod and containers
|
||||
podman pod stop --ignore --time 15 "$POD_NAME"
|
||||
podman pod rm -f --ignore "$POD_NAME"
|
||||
if podman pod exists "$POD_NAME"; then
|
||||
echo "ERROR: Pod $POD_NAME still exists." >&2
|
||||
exit 1
|
||||
else
|
||||
echo "Stopped & removed live pod $POD_NAME and containers."
|
||||
fi
|
||||
|
||||
# Enable systemd user services
|
||||
systemctl --user daemon-reload
|
||||
# pod service (creates pod + containers)
|
||||
systemctl --user enable --now "pod-${POD_NAME}.service"
|
||||
systemctl --user is-enabled "pod-${POD_NAME}.service"
|
||||
systemctl --user is-active "pod-${POD_NAME}.service"
|
||||
echo "Enabled systemd service pod-${POD_NAME}.service (rc=$?)"
|
||||
echo "To view status: systemctl --user status pod-${POD_NAME}.service"
|
||||
echo "To view logs: journalctl --user -u pod-${POD_NAME}.service -f"
|
||||
|
||||
# Wait for API and Grafana readiness
|
||||
CHECK_URL_API="http://$HOST_LOCAL_IP:$API_HOST_PORT/login"
|
||||
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT/api/health"
|
||||
for attempt in $(seq 1 30); do
|
||||
API_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_API" || true)
|
||||
GRAFANA_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_GRAFANA" || true)
|
||||
if [ "$API_CODE" = "200" ] && [ "$GRAFANA_CODE" = "200" ]; then
|
||||
echo "API is reachable at $CHECK_URL_API (200)."
|
||||
echo "Grafana is reachable at $CHECK_URL_GRAFANA (200)."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [ "$attempt" -eq 30 ]; then
|
||||
echo "timeout error (API=$API_CODE, Grafana=$GRAFANA_CODE)." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
310
docs/superpowers/plans/2026-07-19-ausbaustufe-2.md
Normal file
310
docs/superpowers/plans/2026-07-19-ausbaustufe-2.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Ausbaustufe 2 — Implementierungsplan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** Parser-Qualität auf Fable-Abnahmeniveau bringen, Konto-Namen und Betrag-Spalte verbessern, Version + Hilfe-Seite in die GUI, alle Anwenderdaten (inkl. Secrets) unter `~/.local/share/finance_pod`, ein gemeinsames Passwort für GUI und Grafana mit funktionierendem Dashboard-Embedding.
|
||||
|
||||
**Architecture:** Bestehendes System (FastAPI + Jinja2/HTMX, Postgres, Grafana, rootless Podman) wird erweitert, nicht umgebaut. Secrets wandern in das Bind-Verzeichnis des Pods, damit Backup+`create_pod_finance.sh` allein für Disaster-Recovery reichen. Grafana-Admin-Passwort wird bei jedem Skript-Lauf aus dem gemeinsamen `FB_PASSWORD` synchronisiert.
|
||||
|
||||
**Tech Stack:** unverändert (Python 3.12 im Container, venv 3.11 lokal, pytest, pdfplumber, Grafana OSS 12.1.0).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Arbeitsverzeichnis `/home/wlfb/bin`, Tool-Code unter `finance/`; Phase „.env-Umzug" ändert zusätzlich Repo `/home/wlfb/fb` (dort separat committen).
|
||||
- Geldbeträge `Decimal`; Nutzertexte Deutsch; vor jedem Commit `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` grün (Basis: 61 passed).
|
||||
- Commit-Messages enden mit `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
|
||||
- DATENSCHUTZ: `tests/fixtures/*.pdf` sind echte Kontoauszüge. Keine echten Daten (Beträge, IBANs, Namen, Verwendungszwecke, Datumsangaben aus Auszügen) in Commits, Berichten, Code-Kommentaren oder Test-Dateien. Erwartungswerte für Tests liegen NUR in gitignorten `tests/fixtures/expected_*.json`.
|
||||
- Parser-Abnahme erfolgt durch den Controller (Fable) persönlich, nicht durch Subagenten-Selbsteinschätzung.
|
||||
- Deployment-Änderungen (Tasks 5–7) erst nach den App-Tasks; Redeploy gesammelt in Task 7.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Parser-Audit-Werkzeug + DKB-Parser-Korrektur (Fable-Abnahme)
|
||||
|
||||
**Files:**
|
||||
- Create: `finance/scripts/parser_audit.py`
|
||||
- Modify: `finance/app/parsers/dkb.py` (nach Audit-Befund; `vr.py`/`hvb.py` nur falls Audit strukturgleiche Fehler zeigt)
|
||||
- Modify: `finance/tests/test_bank_parsers.py` (Noise-Checks + expected-Datei-Tests)
|
||||
- Modify: `finance/.gitignore` (Zeile `tests/fixtures/expected_*.json` ergänzen)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `parser_audit.py` CLI: `python scripts/parser_audit.py tests/fixtures/dkb_beispiel.pdf` → gibt pro Transaktion eine nummerierte Zeile `#i | booking | value | amount | counterparty | purpose` aus, danach den Rohtext (via bestehender Extraktion). Nur lokale Nutzung; Ausgabe enthält echte Daten und darf nirgends persistiert werden außer im gitignorten `.superpowers/`-Workspace.
|
||||
- Produces: Testschema `tests/fixtures/expected_<bank>.json` (gitignored):
|
||||
```json
|
||||
{"transaction_count": 0,
|
||||
"spot_checks": [{"index": 0, "booking_date": "YYYY-MM-DD",
|
||||
"amount": "0.00", "counterparty_contains": "...",
|
||||
"purpose_contains": "..."}]}
|
||||
```
|
||||
|
||||
- [x] **Step 1: Audit-Skript schreiben**
|
||||
|
||||
`finance/scripts/parser_audit.py`:
|
||||
```python
|
||||
"""Parser-Audit: geparste Transaktionen + Rohtext einer Fixture anzeigen.
|
||||
|
||||
NUR lokal verwenden - Ausgabe enthaelt echte Kontodaten und darf nicht
|
||||
in Commits, Reports oder Tickets uebernommen werden.
|
||||
Aufruf: python scripts/parser_audit.py tests/fixtures/dkb_beispiel.pdf
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from app.parsers.registry import parse_pdf
|
||||
from app.parsers.validate import balance_difference
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
stmt = parse_pdf(path)
|
||||
print(f"bank={stmt.bank} iban={stmt.iban} "
|
||||
f"period={stmt.period_start}..{stmt.period_end}")
|
||||
print(f"opening={stmt.opening_balance} closing={stmt.closing_balance} "
|
||||
f"diff={balance_difference(stmt)} n_tx={len(stmt.transactions)}")
|
||||
print("-" * 100)
|
||||
for i, t in enumerate(stmt.transactions):
|
||||
print(f"#{i:3d} | {t.booking_date} | {t.value_date} | {t.amount:>12} "
|
||||
f"| {t.counterparty[:40]:40} | {t.purpose[:60]}")
|
||||
print("=" * 100)
|
||||
with pdfplumber.open(path) as pdf:
|
||||
for n, page in enumerate(pdf.pages, 1):
|
||||
print(f"--- Seite {n} ---")
|
||||
print(page.extract_text() or "(kein Text)")
|
||||
```
|
||||
|
||||
- [x] **Step 2: Controller-Audit (Fable, kein Subagent)** — Der Controller führt das Skript für `dkb_beispiel.pdf` aus, vergleicht JEDE geparste Transaktion feldweise mit dem Rohtext (Buchungs-/Wertstellungsdatum, Betrag, Empfänger, Verwendungszweck, keine Seitenkopf-/Übertrag-/Saldo-Reste, keine verschluckten oder zusammengeklebten Buchungen) und schreibt eine Mängelliste nach `.superpowers/sdd/parser-audit-dkb.md` (gitignorter Workspace — Rohdaten-Zitate nur dort). Danach dasselbe kompakt für `vr_beispiel.pdf` und `hvb_beispiel.pdf` (Strukturprüfung; Detailkorrektur nur bei Befund).
|
||||
|
||||
- [x] **Step 3: Parser fixen (Subagent, Mängelliste als Brief)** — Nur die im Audit benannten Defekte beheben; Interface `parse(path) -> ParsedStatement` und Modulstruktur unverändert. Nach jedem Fix: `pytest tests/test_bank_parsers.py -v` (Saldo-Gate bleibt Pflicht).
|
||||
|
||||
- [x] **Step 4: Tests härten**
|
||||
|
||||
In `finance/tests/test_bank_parsers.py` ergänzen (committebar, ohne echte Daten):
|
||||
```python
|
||||
import json
|
||||
import re
|
||||
|
||||
NOISE = re.compile(
|
||||
r"(Kontostand|Übertrag|alter Saldo|neuer Saldo|Seite \d|Blatt \d)", re.I)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,bank", CASES)
|
||||
def test_no_noise_in_parsed_fields(filename, bank):
|
||||
path = FIXTURES / filename
|
||||
if not path.exists():
|
||||
pytest.skip(f"Fixture {filename} fehlt")
|
||||
stmt = parse_pdf(path)
|
||||
for t in stmt.transactions:
|
||||
assert not NOISE.search(t.purpose), f"Noise im Verwendungszweck: #{stmt.transactions.index(t)}"
|
||||
assert not NOISE.search(t.counterparty), f"Noise im Empfänger: #{stmt.transactions.index(t)}"
|
||||
assert stmt.period_start <= t.booking_date <= stmt.period_end
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,bank", CASES)
|
||||
def test_expected_values(filename, bank):
|
||||
path = FIXTURES / filename
|
||||
exp_path = FIXTURES / f"expected_{bank}.json"
|
||||
if not path.exists() or not exp_path.exists():
|
||||
pytest.skip("Fixture oder expected-Datei fehlt")
|
||||
stmt = parse_pdf(path)
|
||||
exp = json.loads(exp_path.read_text())
|
||||
assert len(stmt.transactions) == exp["transaction_count"]
|
||||
for c in exp["spot_checks"]:
|
||||
t = stmt.transactions[c["index"]]
|
||||
assert str(t.booking_date) == c["booking_date"]
|
||||
assert str(t.amount) == c["amount"]
|
||||
assert c["counterparty_contains"].lower() in t.counterparty.lower()
|
||||
assert c["purpose_contains"].lower() in t.purpose.lower()
|
||||
```
|
||||
|
||||
`expected_dkb.json` (und bei Befund `expected_vr.json`/`expected_hvb.json`) erstellt der CONTROLLER aus dem Audit (mind. 5 Spot-Checks über den Auszug verteilt, inkl. erster + letzter Buchung). Datei bleibt gitignored — `finance/.gitignore` um `tests/fixtures/expected_*.json` ergänzen.
|
||||
|
||||
- [x] **Step 5: Fable-Abnahme** — Controller wiederholt Step 2 auf dem gefixten Stand; Abnahme erst, wenn feldweise keine Mängel mehr bestehen und die volle Suite grün ist.
|
||||
|
||||
- [x] **Step 6: Commit** — `fix: DKB-Parser feldweise korrigiert, Audit-Werkzeug und gehaertete Parser-Tests` (Mängel im Commit-Body nur GENERISCH beschreiben, ohne echte Daten).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Konto-Namen + Betrag-Spalte
|
||||
|
||||
**Files:**
|
||||
- Modify: `finance/app/routers/accounts.py` (PATCH-Route), `finance/app/routers/gui.py` (Konten in Übersicht-Kontext), `finance/app/templates/index.html` (Umbenennen-Formular), `finance/app/templates/transactions.html` (Spaltenkopf „Betrag (€)", Zellen ohne €, rechtsbündig), `finance/app/static/style.css` (`.amount { text-align: right }`, `td.account { word-break: break-all; max-width: 12rem }`)
|
||||
- Test: `finance/tests/test_crud_api.py` (PATCH-Tests)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `PATCH /api/accounts/{id}` mit Body `{"name": str}` (nur `name`, 1–100 Zeichen, führende/abschließende Leerzeichen gestrippt; leer → 422) → 200 mit AccountOut; 404 bei unbekanntem Konto.
|
||||
- GUI: In der Übersicht je Konto-Zeile Inline-Formular (Textfeld vorbefüllt mit aktuellem Namen, Button „Umbenennen", `hx-patch` via bestehender json-form-Extension auf `/api/accounts/{id}`, danach Reload/Refresh der Tabelle). Buchungsliste zeigt weiterhin `account_names`-Mapping — profitiert automatisch.
|
||||
|
||||
- [x] **Step 1: Failing Tests** — in `finance/tests/test_crud_api.py`:
|
||||
```python
|
||||
def test_patch_account_name(client):
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()
|
||||
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"name": "DKB Giro"})
|
||||
assert r.status_code == 200 and r.json()["name"] == "DKB Giro"
|
||||
assert client.patch("/api/accounts/9999", headers=H,
|
||||
json={"name": "x"}).status_code == 404
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"name": " "}).status_code == 422
|
||||
```
|
||||
- [x] **Step 2: Ausführen — FAIL** · **Step 3: Implementieren** (Route mit `AccountPatch(BaseModel)`; `field_validator` strippt und verwirft leere Namen) · **Step 4: Templates/CSS anpassen** · **Step 5: Suite grün** · **Step 6: Commit** — `feat: Konten umbenennbar, Betrag-Spalte mit Euro im Kopf`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Versionsanzeige
|
||||
|
||||
**Files:**
|
||||
- Create: `finance/VERSION` (Inhalt: `0.2.0`), `finance/app/version.py`
|
||||
- Modify: `finance/app/templates/base.html` (Footer), `finance/app/routers/gui.py` (Jinja-Global), `finance/app/main.py` (`GET /api/version`), `finance/Containerfile` (`COPY VERSION .`), `create_pod_finance.sh` (`API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"`)
|
||||
- Test: `finance/tests/test_gui.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `app.version.get_version() -> str` (liest `VERSION` neben dem `app`-Paket, Fallback `"0.0.0-dev"`); `GET /api/version` (mit `require_auth`) → `{"version": "0.2.0"}`; Footer jeder GUI-Seite: `Finanzberatungs-Tool v0.2.0`.
|
||||
|
||||
- [x] **Step 1: Failing Test**
|
||||
```python
|
||||
def test_version_visible(client):
|
||||
r = client.get("/api/version", headers={"Authorization": "Bearer test-key"})
|
||||
assert r.status_code == 200 and r.json()["version"] == "0.2.0"
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
assert "v0.2.0" in client.get("/").text
|
||||
```
|
||||
- [x] **Step 2: FAIL** · **Step 3: Implementieren**
|
||||
|
||||
`finance/app/version.py`:
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
f = Path(__file__).resolve().parent.parent / "VERSION"
|
||||
try:
|
||||
return f.read_text().strip()
|
||||
except OSError:
|
||||
return "0.0.0-dev"
|
||||
```
|
||||
In `gui.py`: `templates.env.globals["app_version"] = get_version()`; in `base.html` vor `</body>`: `<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>`.
|
||||
- [x] **Step 4: PASS, Suite grün** · **Step 5: Commit** — `feat: Versionsanzeige (VERSION-Datei, Footer, /api/version, Image-Tag)`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Hilfe-Seite
|
||||
|
||||
**Files:**
|
||||
- Create: `finance/app/templates/hilfe.html`
|
||||
- Modify: `finance/app/routers/gui.py` (Route `GET /hilfe` mit `gui_session`), `finance/app/templates/base.html` (Nav-Link „Hilfe"), `finance/tests/test_gui.py` (Route in beide bestehende Seiten-Tests aufnehmen)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `GET /hilfe` (Session-pflichtig, 302 → /login ohne Session).
|
||||
|
||||
- [x] **Step 1: Tests erweitern** — `/hilfe` in `test_pages_require_login` und `test_pages_render_after_login` aufnehmen; zusätzlich `assert "Gebrauchsanleitung" in r.text`.
|
||||
- [x] **Step 2: FAIL** · **Step 3: Implementieren** — `hilfe.html` erbt von `base.html`; Inhalt (vollständig, Deutsch — Feinschliff der Formulierungen erlaubt, Struktur bindend):
|
||||
|
||||
```
|
||||
# Gebrauchsanleitung
|
||||
|
||||
## Was dieses Tool ist
|
||||
Liquiditätsplanung auf Basis echter Kontoauszüge: Ist-Stand aus importierten
|
||||
Auszügen, Zukunft aus gepflegten Planposten und durchgerechneten Szenarien.
|
||||
|
||||
## Konzepte
|
||||
- Konten: entstehen automatisch beim ersten Import (per IBAN). Über die
|
||||
Übersicht umbenennbar (z. B. "DKB Giro").
|
||||
- Kontoauszug-Import: PDF in die Drop-Zone auf der Import-Seite ziehen (oder
|
||||
in die Inbox ~/.local/share/finance_pod/data/inbox/ legen und "Inbox
|
||||
scannen"). Jeder Import wird geprüft (Anfangssaldo + Buchungen = Endsaldo)
|
||||
und landet als Entwurf: erst nach Kontrolle der Vorschau "Übernehmen"
|
||||
klicken. Duplikate werden erkannt und nicht doppelt übernommen.
|
||||
- Kategorien & Regeln: Buchungen lassen sich kategorisieren; eine Regel
|
||||
("Muster im Text → Kategorie") kategorisiert künftige Importe automatisch.
|
||||
Regel direkt aus einer Buchung erzeugen: Button in der Buchungsliste.
|
||||
- Wiederkehrende Posten: Miete, Gehalt, Abos - mit Rhythmus (monatlich/
|
||||
quartalsweise/jährlich) und Fälligkeitstag. Der Vorschlags-Knopf erkennt
|
||||
Kandidaten aus mind. 3 Monaten gleichartiger Buchungen.
|
||||
- Einmalposten: einzelne künftige Zahlungen (z. B. Steuernachzahlung).
|
||||
- Kredite: Annuität oder endfällig; Tilgungsplan aufklappbar. Ein Kredit
|
||||
wirkt erst, wenn er einem Szenario zugeordnet ist.
|
||||
- Szenarien: eine "Was-wäre-wenn"-Rechnung. Basis = alle wiederkehrenden
|
||||
Posten + Einmalposten. Varianten entstehen durch Zuordnen von Krediten und
|
||||
Modifikatoren (Kategorie oder Posten prozentual/absolut kürzen oder ganz
|
||||
streichen). "Durchrechnen" liefert: tiefster Kontostand mit Datum, erstes
|
||||
Datum unter 0 und unter der Warnschwelle.
|
||||
- Grafana: Kontostand-Verläufe, Monatsausgaben nach Kategorie und der
|
||||
Szenario-Vergleich als Kurven (einmal mit demselben Passwort wie hier
|
||||
anmelden, dann erscheint das Dashboard auch in der Übersicht).
|
||||
|
||||
## Empfohlener Arbeitsablauf
|
||||
1. Monatlich: neue Auszüge importieren, Vorschau prüfen, übernehmen.
|
||||
2. Unkategorisierte Buchungen durchsehen; für Wiederkehrendes Regeln anlegen.
|
||||
3. Planung pflegen: Vorschläge prüfen, Einmalposten eintragen.
|
||||
4. Fragestellung ("Können wir uns X leisten?") als Szenario-Varianten
|
||||
abbilden und durchrechnen; Entscheidung anhand Tiefpunkt und
|
||||
Unterschreitungsdaten treffen, Kurven in Grafana vergleichen.
|
||||
|
||||
## Grundregeln
|
||||
- Bestätigte Buchungen sind die Wahrheit - nie ändern, nur kategorisieren.
|
||||
- Zukunft ausschließlich über Szenarien planen.
|
||||
- Empfehlungen immer mit durchgerechneten Zahlen begründen.
|
||||
```
|
||||
- [x] **Step 4: PASS, Suite grün** · **Step 5: Commit** — `feat: Hilfe-Seite mit Gebrauchsanleitung`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Secrets-Umzug nach `$BIND_DIR/.env`
|
||||
|
||||
**Files:**
|
||||
- Modify: `create_pod_finance.sh`
|
||||
- Modify (Repo `/home/wlfb/fb`): `CLAUDE.md`, `.claude/skills/finanz-api/SKILL.md`, `.claude/skills/finanzberatung/SKILL.md`, `.claude/skills/auszug-import/SKILL.md` (überall Pfad `/home/wlfb/bin/finance/.env` → `$HOME/.local/share/finance_pod/.env`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ENV_FILE="$BIND_DIR/.env"`; `$BIND_DIR` mit `chmod 700`; Migration: existiert `$HOME/bin/finance/.env` und `$ENV_FILE` nicht → `mv` + Meldung. Disaster-Recovery-Garantie: `$BIND_DIR` aus Backup + Skript-Lauf = lauffähig.
|
||||
|
||||
- [x] **Step 1: Skript ändern** — Reihenfolge: `mkdir -p "$BIND_DIR" && chmod 700 "$BIND_DIR"` VOR dem `.env`-Block; dann:
|
||||
```bash
|
||||
ENV_FILE="$BIND_DIR/.env"
|
||||
LEGACY_ENV="$HOME/bin/finance/.env"
|
||||
if [ ! -f "$ENV_FILE" ] && [ -f "$LEGACY_ENV" ]; then
|
||||
mv "$LEGACY_ENV" "$ENV_FILE" && chmod 600 "$ENV_FILE"
|
||||
echo "MIGRIERT: .env nach $ENV_FILE verschoben"
|
||||
fi
|
||||
```
|
||||
- [x] **Step 2: fb-Repo anpassen** — alle Pfad-Referenzen, inkl. Key-Extraktions-Muster: `grep '^FB_API_KEY=' $HOME/.local/share/finance_pod/.env | cut -d= -f2 | tr -d "'"` (Werte sind single-quoted!).
|
||||
- [x] **Step 3: Verifizieren (ohne Redeploy, nur Trockenlauf)** — `bash -n create_pod_finance.sh`; Migration + Livegang erst in Task 7.
|
||||
- [x] **Step 4: Commits** — `~/bin`: `feat: Secrets unter ~/.local/share/finance_pod (Backup-vollstaendig)`; `~/fb`: `docs: .env-Pfad auf finance_pod-Datenverzeichnis umgestellt`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Ein Passwort für GUI + Grafana, Embedding aktivieren
|
||||
|
||||
**Files:**
|
||||
- Modify: `create_pod_finance.sh`, `finance/app/templates/index.html` (Hinweis am iframe)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `.env` führt `FB_PASSWORD='<klartext>'` (chmod 600; nötig, um Grafana synchron zu halten); `FB_GUI_PASSWORD_HASH` wird daraus abgeleitet; `GRAFANA_ADMIN_PASSWORD` entfällt. Grafana: `GF_SECURITY_ALLOW_EMBEDDING=true`, Admin-Passwort wird bei JEDEM Skript-Lauf per `grafana cli admin reset-admin-password` auf `$FB_PASSWORD` gesetzt (Invocation im Container verifizieren; `grafana-cli` als Fallback). Anonymer Zugriff bleibt AUS.
|
||||
- Passwort-Wechsel-Prozedur (in Skript-Kommentar dokumentieren): `FB_PASSWORD` in der `.env` ändern, Skript ausführen — Hash und Grafana ziehen nach.
|
||||
|
||||
- [x] **Step 1: Bootstrap-Block umbauen** — Neuerzeugung: ein `FB_PASSWORD` generieren, Hash daraus ableiten, beide single-quoted schreiben; einmalige Terminal-Ausgabe des Passworts. Migration bestehender `.env` (hat `FB_GUI_PASSWORD_HASH`, aber kein `FB_PASSWORD`): neues `FB_PASSWORD` generieren, Hash NEU ableiten, `GRAFANA_ADMIN_PASSWORD`-Zeile entfernen, Passwort einmalig ausgeben (Rotation unvermeidbar, da Klartext aus Hash nicht rekonstruierbar).
|
||||
- [x] **Step 2: Grafana-Container** — `-e GF_SECURITY_ALLOW_EMBEDDING=true`, `GF_SECURITY_ADMIN_PASSWORD="$FB_PASSWORD"`; nach Startwartezeit Sync-Kommando ausführen (idempotent, Fehler abfangen falls Grafana noch initialisiert — Retry-Schleife wie bei pg_isready).
|
||||
- [x] **Step 3: iframe-Hinweis** — unter dem iframe in `index.html`: kleiner Text „Kein Diagramm sichtbar? Einmal in <a href=...>Grafana anmelden</a> (gleiches Passwort wie hier)."
|
||||
- [x] **Step 4: `bash -n`, Suite grün (App unverändert bis auf Template)** · **Step 5: Commit** — `feat: gemeinsames Passwort fuer GUI und Grafana, Dashboard-Embedding`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Redeploy + Gesamt-Smoke + Disaster-Recovery-Probe
|
||||
|
||||
**Files:** keine neuen; führt Tasks 1–6 im Livesystem zusammen.
|
||||
|
||||
- [x] **Step 1: Image + Redeploy** — `podman build -t "localhost/finance-api:$(cat /home/wlfb/bin/finance/VERSION)" /home/wlfb/bin/finance && bash /home/wlfb/bin/create_pod_finance.sh`. Migrationsmeldungen (Env-Umzug, Passwort-Rotation) protokollieren; neues Passwort NUR als letzte Zeile der Abschlussmeldung an den Nutzer.
|
||||
- [x] **Step 2: Smoke** — Service aktiv; `/login` 200; GUI-Login mit `FB_PASSWORD` → 303+Cookie; `/`, `/buchungen`, `/planung`, `/import`, `/hilfe` 200; Footer zeigt `v0.2.0`; `/api/version` 200; Grafana-Login mit demselben Passwort (`curl -u admin:$FB_PASSWORD .../api/dashboards/uid/finanzen` → 200); Embedding-Header: `curl -sI http://127.0.0.1:8097/` enthält KEIN `X-Frame-Options: deny`; Datenbestand unverändert (1 Konto, 26 bestätigte Buchungen); anonymer Zugriff verweigert (`curl -s http://127.0.0.1:8097/api/dashboards/uid/finanzen` ohne Auth → 401/403).
|
||||
- [x] **Step 3: Disaster-Recovery-Probe** — `systemctl --user stop pod-finance_pod.service`; alle finance-Units disablen und aus `~/.config/systemd/user/` entfernen; `podman pod rm -f finance_pod`; NUR mit vorhandenem `$BIND_DIR` das Skript erneut ausführen → alles wieder da (Login funktioniert, Datenbestand unverändert, keine Passwort-Neuerzeugung).
|
||||
- [x] **Step 4: Ledger + Abschluss** — Ergebnisse in `.superpowers/sdd/progress.md`; offene Folgeentscheidung notieren: Neuimport des DKB-Auszugs (bestehende 26 Buchungen tragen ggf. alte Parser-Textfehler) — Nutzerentscheidung, nicht automatisch ausführen.
|
||||
|
||||
---
|
||||
|
||||
## Abschluss-Checkliste
|
||||
|
||||
- [x] Suite grün (>= 65 Tests inkl. neuer Parser-/PATCH-/Version-Tests; expected-Tests laufen lokal, skippen ohne expected-Dateien)
|
||||
- [x] Fable-Abnahme der Parser dokumentiert (`.superpowers/sdd/parser-audit-dkb.md`, ohne Commit)
|
||||
- [x] Live: v0.2.0 im Footer, ein Passwort für GUI+Grafana, Embedding aktiv ohne anonymen Zugriff, `.env` unter `$BIND_DIR`, DR-Probe bestanden
|
||||
- [x] Keine echten Kontodaten/Secrets in `git log -p` beider Repos
|
||||
- [x] Beide Repos committet; Plan-Häkchen gesetzt
|
||||
141
docs/superpowers/plans/2026-07-19-ausbaustufe-3.md
Normal file
141
docs/superpowers/plans/2026-07-19-ausbaustufe-3.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Ausbaustufe 3 — CSV-Import, Saldo-Anker, Salden-Seite, Parser-Tuning
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** CSV-Kontoumsatz-Import als primärer Import-Weg (DKB/VR/HVB-Exportformate), Konto-Saldo-Anker mit GUI-Pflege, neue Salden-Seite (Stichtag + Monatsübersicht), PDF-Parser per CSV-Ground-Truth verbessern; anschließend beaufsichtigte Vollmigration (PDF-Bestand → CSV-Basis 2025-01-01 bis heute).
|
||||
|
||||
**Architecture:** CSV-Dateien laufen durch die BESTEHENDE Import-Pipeline (Upload/Inbox → ParsedStatement → Statement/Draft → Vorschau → Confirm → Rollback). Neu: `parsers/csv_formats.py` erkennt die drei Bankformate am Header und liefert ParsedStatement. Saldenrechnung wird von Statement-closing auf **Konto-Anker** (Kontostand X am Datum Y, neue Account-Spalten via Alembic) umgestellt — rückwärts wie vorwärts vom Anker aus; Grafana-Views analog. Nutzerentscheidungen (2026-07-19): CSV primär; Anker GUI-pflegbar; Prüfschärfe je Format maximal (VR lückenlos, DKB gegen Datei-Kontostand-Anker, HVB ohne — gekennzeichnet); Migration führt der Controller beaufsichtigt durch.
|
||||
|
||||
**Tech Stack:** unverändert; Version → 0.4.0.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Repo `/home/wlfb/bin`, Code `finance/`; Geldbeträge `Decimal`; Nutzertexte Deutsch; Suite grün vor jedem Commit (Basis: 79 passed); Commit-Trailer `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
|
||||
- DATENSCHUTZ: `tests/fixtures/*.csv` und `*.pdf` sind echte Kontodaten (gitignored). Keine echten Daten in Commits/Reports/Code. Erwartungswerte nur in gitignorten `tests/fixtures/expected_*.json`-Dateien.
|
||||
- **TEST-VERIFIKATION (Nutzer-Vorgabe): Nach jeder Task-Implementierung führt ein SUBAGENT MIT FABLE-MODELL die Test-/Verifikationspassage aus** (Suite + task-spezifische Checks) und berichtet unabhängig; der Implementer-Selbstbericht genügt nicht.
|
||||
- UX-Regel (etabliert): Bedienelemente immer sichtbar, Nichtverfügbares disabled mit title.
|
||||
- Deployment erst in Task 6.
|
||||
|
||||
## CSV-Formatreferenz (aus Datei-Analyse 2026-07-19)
|
||||
|
||||
| | DKB_*.csv | VR_*.csv | HVB_*.csv |
|
||||
|---|---|---|---|
|
||||
| Encoding | UTF-8 mit BOM | UTF-8 mit BOM, CRLF | **UTF-16** |
|
||||
| Trenner | `;`, Werte in `"` | `;`, unquoted | `;` |
|
||||
| Kopf | 4 Metazeilen: Girokonto/IBAN; Zeitraum; „Kontostand vom TT.MM.JJJJ:" + Betrag mit €; Leerzeile — dann Spaltenkopf | Spaltenkopf in Zeile 1 | Spaltenkopf in Zeile 1 |
|
||||
| Spalten | Buchungsdatum;Wertstellung;Status;Zahlungspflichtige*r;Zahlungsempfänger*in;Verwendungszweck;Umsatztyp;IBAN;Betrag (€);Gläubiger-ID;Mandatsreferenz;Kundenreferenz | Bezeichnung Auftragskonto;IBAN Auftragskonto;BIC Auftragskonto;Bankname Auftragskonto;Buchungstag;Valutadatum;Name Zahlungsbeteiligter;IBAN Zahlungsbeteiligter;BIC (SWIFT-Code) Zahlungsbeteiligter;Buchungstext;Verwendungszweck;Betrag;Waehrung;Saldo nach Buchung;Bemerkung;Gekennzeichneter Umsatz;Glaeubiger ID;Mandatsreferenz | Kontonummer;Buchungsdatum;Valuta;Verwendungszweck;Betrag;Waehrung |
|
||||
| Datumsformat | TT.MM.JJ (zweistellig!) | TT.MM.JJJJ | TT.MM.JJJJ |
|
||||
| Sortierung | absteigend | absteigend | aufsteigend |
|
||||
| Konto-Zuordnung | IBAN aus Kopfzeile | IBAN Auftragskonto | Kontonummer → Konto per `iban.endswith(kontonummer)` |
|
||||
| counterparty | Betrag<0 → Zahlungsempfänger*in, sonst Zahlungspflichtige*r | Name Zahlungsbeteiligter | "" (steckt im Verwendungszweck) |
|
||||
| purpose | Umsatztyp + " " + Verwendungszweck | Buchungstext + " " + Verwendungszweck | Verwendungszweck |
|
||||
| value_date | Wertstellung | Valutadatum | Valuta |
|
||||
| Saldo-Prüfung | Datei-Kontostand als KONTO-ANKER übernehmen (Datum aus „Kontostand vom"); keine per-Datei-Prüfung (Kontostand = Exportzeitpunkt, nicht Periodenende) | Saldo nach Buchung: chronologisch je Zeile prüfen `saldo_n = saldo_{n-1} + betrag_n` → daraus opening/closing für ParsedStatement ableiten (balance_difference==0 greift); Anker = neuester Saldo | keine (opening/closing None, preview „Saldo-Prüfung: nicht verfügbar") |
|
||||
| Sonstiges | nur Zeilen mit Status "Gebucht" importieren | Abschluss-Zeilen (leerer Name) normal importieren | Betrag-Spalte ist maßgeblich (Text enthält Betrag redundant) |
|
||||
|
||||
Beträge deutsch formatiert (`1.234,56`, Minus führend) → `parse_german_amount` wiederverwenden (€-Zeichen/`EUR` vorher strippen).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Konto-Saldo-Anker (Modell, Migration, Saldenrechnung, GUI, Views)
|
||||
|
||||
**Files:**
|
||||
- Modify: `finance/app/models/tables.py` (Account: `anchor_date: Mapped[date | None]`, `anchor_balance: Mapped[Decimal | None]` = Numeric(12,2))
|
||||
- Create: `finance/alembic/versions/<autogen>_konto_anker.py` (autogenerate, add_column x2)
|
||||
- Modify: `finance/app/services/balances.py`, `finance/app/routers/accounts.py` (PATCH um anchor-Felder), `finance/app/templates/index.html` (Anker-Anzeige+Pflegeformular je Konto), `finance/app/models/views.py` (Views ankerbasiert)
|
||||
- Test: `finance/tests/test_crud_api.py`, `finance/tests/test_gui.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `balances.account_balance(session, account, at: date | None = None) -> Decimal` — `at` default heute. Mit Anker: `anchor_balance + Σ(tx: anchor_date < booking_date <= at) − Σ(tx: at < booking_date <= anchor_date)` (nur bestätigte; eine der Summen ist leer). Ohne Anker: `Σ(tx: booking_date <= at)`. Die bisherige Statement-closing-Logik ENTFÄLLT ersatzlos (Statements bleiben als Import-Einheiten bestehen).
|
||||
- `PATCH /api/accounts/{id}` zusätzlich: `anchor_date: date | None`, `anchor_balance: Decimal | None` (beide optional, model_fields_set-Semantik wie bei name; nur gemeinsam gesetzt oder gemeinsam null → sonst 422 „Anker braucht Datum und Betrag").
|
||||
- GUI Übersicht: je Konto Ankeranzeige („Anker: X € am TT.MM.JJJJ" oder „kein Anker") + Inline-Formular (Datum+Betrag, json-form hx-patch).
|
||||
- `views.py`: `v_balance_history`/`v_balance_total` seeden pro Konto mit `anchor_balance − Σ(tx > … bis anchor_date)` statt Statement-opening — konkret: Basis je Konto = `account_balance` zum frühesten Buchungsdatum − dessen Tagesumsatz; einfachste korrekte Form: CTE mit Anker-Join und kumulativer Fenstersumme relativ zum Anker. `v_projection` unverändert.
|
||||
|
||||
- [x] **Step 1: Failing Tests** — test_crud_api: (a) Konto mit Anker (Datum D, Betrag B) + bestätigte Buchungen vor/nach D → `account_balance(at=D)==B`, `at=D+2d` addiert spätere Buchung, `at=D−2d` subtrahiert dazwischenliegende; (b) PATCH setzt/löscht Anker, 422 bei nur-einem-Feld; test_gui: Übersicht zeigt Ankertext.
|
||||
- [x] **Step 2: FAIL** · **Step 3: Implementieren + Alembic-Autogenerate gegen Wegwerf-SQLite** (Muster Task 2 Ausbaustufe 1; Migration committen) · **Step 4: PASS, Suite grün**
|
||||
- [x] **Step 5: FABLE-TESTAGENT** verifiziert unabhängig (Suite + Anker-Randfälle: at==anchor_date, Buchung AM Ankertag zählt nicht zur Vorwärtssumme/wohl zur Rückwärtssumme — Konvention: Anker gilt per Tagesende).
|
||||
- [x] **Step 6: Commit** — `feat: Konto-Saldo-Anker mit ankerbasierter Saldenrechnung`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: CSV-Format-Parser
|
||||
|
||||
**Files:**
|
||||
- Create: `finance/app/parsers/csv_formats.py`, `finance/tests/test_csv_formats.py`
|
||||
- Modify: `finance/.gitignore` ist bereits um `tests/fixtures/*.csv` ergänzt (erledigt)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `csv_formats.detect_csv_format(head_bytes: bytes) -> str | None` („dkb_csv"|„vr_csv"|„hvb_csv"; erkennt Encoding inkl. UTF-16/BOM + Headersignatur); `csv_formats.parse_csv(path: Path) -> ParsedCsv` mit `ParsedCsv(statement: ParsedStatement, anchor: tuple[date, Decimal] | None, balance_checkable: bool)`. Transaktionen chronologisch aufsteigend im ParsedStatement, Mapping exakt gemäß Formatreferenz oben. VR: Saldo-Kette prüfen, bei Bruch `ParserError(„VR-CSV: Saldo-Kette inkonsistent bei Zeile N")`; opening/closing setzen (balance_checkable=True). DKB: anchor=(Datum aus „Kontostand vom", Betrag), opening/closing=None, nur „Gebucht". HVB: alles None/False.
|
||||
- ParserError bei unbekanntem Format/kaputter Struktur.
|
||||
|
||||
- [x] **Step 1: Failing Tests** — pro Format parametrisiert (skip wenn Datei fehlt): Zeilenzahl >0, Transaktionen aufsteigend, Beträge Decimal, VR: balance_difference(stmt)==0 und anchor gesetzt, DKB: anchor gesetzt + alle Status Gebucht + counterparty je Vorzeichen befüllt, HVB: balance_checkable False; plus `expected_csv_<fmt>.json` (gitignored, Schema wie expected_*: transaction_count + 5 spot_checks) — Dateien erstellt der CONTROLLER nach Implementierung aus Stichproben.
|
||||
- [x] **Step 2: FAIL** · **Step 3: Implementieren** (csv-Modul der stdlib; Encoding-Erkennung: BOM-Sniff + UTF-16-Heuristik über Nullbytes) · **Step 4: PASS**
|
||||
- [x] **Step 5: CONTROLLER erstellt expected-Dateien** (Stichproben aus den echten CSVs, nur lokal) · **Step 6: FABLE-TESTAGENT** verifiziert (Suite + eigene Stichproben-Gegenkontrolle CSV-Zeile↔geparste Transaktion für je 3 Zeilen pro Format, feldweise).
|
||||
- [x] **Step 7: Commit** — `feat: CSV-Parser fuer DKB/VR/HVB-Kontoumsatz-Exporte`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: CSV in die Import-Pipeline
|
||||
|
||||
**Files:**
|
||||
- Modify: `finance/app/services/importer.py` (Dateiendungs-Weiche: .csv → parse_csv; Anker-Autofill: bei confirm... nein: beim IMPORT `ParsedCsv.anchor` am Statement zwischenspeichern → einfacher: importer setzt Anker DIREKT am Konto beim process, nur wenn `anchor_date` neuer als vorhandener Anker oder keiner existiert), `finance/app/routers/imports.py` (Upload akzeptiert .csv; 400-Text anpassen „Nur PDF- oder CSV-Dateien"), Inbox-Scan `*.csv` zusätzlich, `finance/app/templates/_preview_table.html`/`_import_list.html` (balance_ok dreiwertig: True „Saldo plausibel" / False rot / None „Saldo-Prüfung: nicht verfügbar (Format ohne Saldodaten)"), `finance/app/templates/hilfe.html` (CSV als primärer Weg, PDF als Fallback)
|
||||
- Test: `finance/tests/test_import_api.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: preview-Response `balance_ok: bool | None`. Statement.bank erhält Formatkennung („dkb_csv" etc.). Duplikatschutz unverändert über dedup_hash (funktioniert für künftige überlappende CSV-Exporte, da identische Texte).
|
||||
- Anker-Regel: Import (bereits beim Draft-Anlegen) aktualisiert `account.anchor_*` nur, wenn CSV-Anker-Datum ≥ bestehendes Anker-Datum oder Konto ohne Anker; manuelles Überschreiben bleibt via PATCH möglich.
|
||||
|
||||
- [x] **Step 1: Failing Tests** — Upload einer synthetischen Mini-CSV je Format (im Test als Bytes konstruiert nach Formatreferenz — KEINE echten Daten): 201 draft, preview balance_ok-Wert je Format (VR True, HVB None), confirm übernimmt, DKB-Anker am Konto gesetzt; Upload .txt → 400; Inbox-Scan findet .csv.
|
||||
- [x] **Step 2: FAIL** · **Step 3: Implementieren** · **Step 4: PASS, Suite grün**
|
||||
- [x] **Step 5: FABLE-TESTAGENT** verifiziert (Suite + Upload der ECHTEN 6 Dateien gegen Test-Instanz in-memory: je 201+plausible Zeilenzahlen — Zahlen nur als Counts berichten).
|
||||
- [x] **Step 6: Commit** — `feat: CSV-Import ueber bestehende Pipeline mit dreiwertiger Saldo-Pruefung`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Salden-Seite
|
||||
|
||||
**Files:**
|
||||
- Create: `finance/app/templates/salden.html`
|
||||
- Modify: `finance/app/routers/gui.py` (GET /salden mit gui_session; Stichtag-Param lenient wie Filter), `finance/app/templates/base.html` (Nav „Salden" zwischen Buchungen und Planung), `finance/app/templates/hilfe.html` (Konzept-Absatz), `finance/tests/test_gui.py`
|
||||
|
||||
**Interfaces:**
|
||||
- GET /salden[?stichtag=YYYY-MM-DD] → Abschnitt 1 „Salden am <Datum>" (Default heute): Tabelle Konto→Saldo (account_balance(at=stichtag)) + Gesamtzeile; Datumsformular (GET). Abschnitt 2 „Monatsanfangs-Salden": Zeilen = 1. jedes Monats von frühestem Buchungsmonat bis heute (absteigend), Spalten = Konten + Gesamt; Werte = account_balance(at=Monatsersten). Beträge 2 Nachkommastellen, .neg-Klasse, Konten ohne Anker mit Fußnote „ohne Anker — relative Werte".
|
||||
|
||||
- [x] **Step 1: Failing Tests** — /salden in beide Seiten-Tests; mit Seed (Anker + Buchungen über 3 Monate): Stichtags-Tabelle zeigt erwarteten Kontosaldo, Monatsübersicht enthält 3 Monatszeilen mit korrektem Wert für einen definierten Monatsersten.
|
||||
- [x] **Step 2: FAIL** · **Step 3: Implementieren** · **Step 4: PASS** · **Step 5: FABLE-TESTAGENT** (Suite + Handrechnung eines Monatsanfangs-Werts gegen Seed-Daten) · **Step 6: Commit** — `feat: Salden-Seite mit Stichtag und Monatsuebersicht`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: PDF-Parser-Tuning per CSV-Ground-Truth (Fable-Audit)
|
||||
|
||||
**Files:**
|
||||
- Create: `finance/scripts/parser_vs_csv.py` (lokales Vergleichswerkzeug)
|
||||
- Modify: `finance/app/parsers/dkb.py` (ggf. vr.py/hvb.py), `finance/tests/fixtures/expected_*.json` (Controller, lokal)
|
||||
|
||||
**Interfaces:**
|
||||
- `parser_vs_csv.py <pdf> <csv...>`: matcht PDF-Transaktionen gegen CSV-Zeilen im Überlappungszeitraum über (booking_date, amount); gibt je Match Feldvergleich counterparty/purpose aus (Ähnlichkeit + Differenzen), am Ende Metrik: N matched, counterparty-Übereinstimmung x %, purpose-Kernübereinstimmung y %. Nur lokal, echte Daten nur im gitignorten Workspace.
|
||||
|
||||
- [x] **Step 1: Werkzeug bauen (Subagent)** · **Step 2: CONTROLLER-Audit (Fable)**: Werkzeug auf DKB/VR/HVB-Überlappung (Juni–Juli 2026) laufen lassen, konkrete strukturelle Verbesserungen identifizieren (v. a. DKB: Empfänger/Zweck-Trennung — CSV-Empfängerspalte zeigt, wo die Grenze liegt; erwartbar: Empfänger = Anfang der ersten Fortsetzungszeile bis zum ersten Schlüsselwort-Muster). Mängelliste in `.superpowers/sdd/parser-vs-csv-audit.md` (gitignored).
|
||||
- [x] **Step 3: Parser-Fixes (Subagent, Mängelliste als Brief)** — Saldo-Gates und bestehende expected-Tests bleiben Pflicht; expected_*.json passt der CONTROLLER an, wo sich purposes verbessern.
|
||||
- [x] **Step 4: FABLE-TESTAGENT**: Suite + Metrik-Vergleich vorher/nachher (Werkzeug erneut ausführen; Verbesserung dokumentieren, Zahlen ohne echte Daten). **Fable-Abnahme durch Controller.**
|
||||
- [x] **Step 5: Commit** — `fix: PDF-Parser per CSV-Ground-Truth verbessert` (generische Beschreibung). Hinweis: „möglichst gut" = Best effort; verbleibende strukturbedingte Grenzen (z. B. DKB-Fließtext) werden dokumentiert, nicht erzwungen.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Version 0.4.0, Redeploy, beaufsichtigte Vollmigration
|
||||
|
||||
**Files:** `finance/VERSION` → `0.4.0`; keine weiteren Code-Änderungen.
|
||||
|
||||
- [x] **Step 1:** VERSION bump + Commit `chore: Version 0.4.0` → Image-Build → `create_pod_finance.sh` (Alembic-Migration läuft im Entrypoint).
|
||||
- [x] **Step 2 (CONTROLLER, beaufsichtigt):** (a) 3 PDF-Importe zurückrollen (Rollback-Feature); (b) HVB-Anker setzen: PATCH anchor_date=2026-07-03, anchor_balance=4559.51 (aus PDF-Auszug); (c) die 6 CSVs importieren (Reihenfolge egal, je: Upload → Preview prüfen [Zeilenzahl plausibel; VR balance_ok True; DKB/HVB Kennzeichnung sichtbar] → Confirm); DKB/VR-Anker kommen automatisch aus den Dateien; (d) Endkontrolle: Buchungszahl gesamt = Summe der CSV-Zeilen (minus evtl. Nicht-Gebucht), Salden-Seite: DKB-Saldo heute == Datei-Kontostand, VR-Saldo == neuester Saldo nach Buchung, HVB plausibel gegen Anker; Grafana-Kurven ab 2025; Suite grün; Smoke der Kernseiten.
|
||||
- [x] **Step 3:** Ledger + Plan-Häkchen + Commit; Hilfe-/Memory-Konsistenz.
|
||||
|
||||
---
|
||||
|
||||
## Abschluss-Checkliste
|
||||
|
||||
- [x] Suite grün (>= 90 Tests erwartet); alle Task-Verifikationen durch FABLE-Testagenten dokumentiert
|
||||
- [x] Live: v0.4.0; Datenbestand vollständig aus 6 CSVs (2025-01-01 bis heute), Anker je Konto gesetzt, Salden-Seite konsistent mit CSV-Quellwerten
|
||||
- [x] Parser-Tuning-Metriken dokumentiert (vorher/nachher), Fable-Abnahme erteilt
|
||||
- [x] Keine echten Kontodaten/Secrets in git log beider Repos; CSV/PDF/expected-Dateien untracked
|
||||
- [x] Plan-Häkchen gesetzt, beide Repos committet
|
||||
6
finance/.containerignore
Normal file
6
finance/.containerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
tests/
|
||||
.env
|
||||
*.sqlite
|
||||
inbox/
|
||||
uploads/
|
||||
7
finance/.gitignore
vendored
Normal file
7
finance/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.sqlite
|
||||
tests/fixtures/*.pdf
|
||||
tests/fixtures/*.csv
|
||||
tests/fixtures/expected_*.json
|
||||
.venv/
|
||||
13
finance/Containerfile
Normal file
13
finance/Containerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM docker.io/library/python:3.12.11-slim
|
||||
|
||||
WORKDIR /srv/finance
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ app/
|
||||
COPY alembic/ alembic/
|
||||
COPY alembic.ini .
|
||||
COPY VERSION .
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
1
finance/VERSION
Normal file
1
finance/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
0.4.0
|
||||
141
finance/alembic.ini
Normal file
141
finance/alembic.ini
Normal file
@@ -0,0 +1,141 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[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
|
||||
1
finance/alembic/README
Normal file
1
finance/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
84
finance/alembic/env.py
Normal file
84
finance/alembic/env.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
finance/alembic/script.py.mako
Normal file
28
finance/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
34
finance/alembic/versions/1ef6a356f028_konto_anker.py
Normal file
34
finance/alembic/versions/1ef6a356f028_konto_anker.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""konto anker
|
||||
|
||||
Revision ID: 1ef6a356f028
|
||||
Revises: b2b1f5a18a74
|
||||
Create Date: 2026-07-19 21:18:41.888568
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1ef6a356f028'
|
||||
down_revision: Union[str, Sequence[str], None] = 'b2b1f5a18a74'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('accounts', sa.Column('anchor_date', sa.Date(), nullable=True))
|
||||
op.add_column('accounts', sa.Column('anchor_balance', sa.Numeric(precision=12, scale=2), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('accounts', 'anchor_balance')
|
||||
op.drop_column('accounts', 'anchor_date')
|
||||
# ### end Alembic commands ###
|
||||
177
finance/alembic/versions/b2b1f5a18a74_initial_schema.py
Normal file
177
finance/alembic/versions/b2b1f5a18a74_initial_schema.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: b2b1f5a18a74
|
||||
Revises:
|
||||
Create Date: 2026-07-17 17:08:30.212347
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b2b1f5a18a74'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('accounts',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('bank', sa.String(length=50), nullable=False),
|
||||
sa.Column('iban', sa.String(length=34), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('type', sa.String(length=20), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('iban')
|
||||
)
|
||||
op.create_table('categories',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_table('loans',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('principal', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('annual_rate_pct', sa.Numeric(precision=5, scale=2), nullable=False),
|
||||
sa.Column('term_months', sa.Integer(), nullable=False),
|
||||
sa.Column('payout_date', sa.Date(), nullable=False),
|
||||
sa.Column('repayment_type', sa.String(length=20), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('scenarios',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('include_recurring', sa.Boolean(), nullable=False),
|
||||
sa.Column('include_planned', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_table('category_rules',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('pattern', sa.String(length=200), nullable=False),
|
||||
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||
sa.Column('priority', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('planned_items',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('due', sa.Date(), nullable=False),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('projection_points',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('scenario_id', sa.Integer(), nullable=False),
|
||||
sa.Column('day', sa.Date(), nullable=False),
|
||||
sa.Column('balance', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.ForeignKeyConstraint(['scenario_id'], ['scenarios.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_projection_points_scenario_id'), 'projection_points', ['scenario_id'], unique=False)
|
||||
op.create_table('projection_results',
|
||||
sa.Column('scenario_id', sa.Integer(), nullable=False),
|
||||
sa.Column('computed_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('low_point_date', sa.Date(), nullable=False),
|
||||
sa.Column('low_point_balance', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('below_zero_date', sa.Date(), nullable=True),
|
||||
sa.Column('below_threshold_date', sa.Date(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['scenario_id'], ['scenarios.id'], ),
|
||||
sa.PrimaryKeyConstraint('scenario_id')
|
||||
)
|
||||
op.create_table('recurring_items',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('rhythm', sa.String(length=20), nullable=False),
|
||||
sa.Column('due_day', sa.Integer(), nullable=False),
|
||||
sa.Column('start_date', sa.Date(), nullable=True),
|
||||
sa.Column('end_date', sa.Date(), nullable=True),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('scenario_loans',
|
||||
sa.Column('scenario_id', sa.Integer(), nullable=False),
|
||||
sa.Column('loan_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['loan_id'], ['loans.id'], ),
|
||||
sa.ForeignKeyConstraint(['scenario_id'], ['scenarios.id'], ),
|
||||
sa.PrimaryKeyConstraint('scenario_id', 'loan_id')
|
||||
)
|
||||
op.create_table('scenario_modifiers',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('scenario_id', sa.Integer(), nullable=False),
|
||||
sa.Column('target_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('target_id', sa.Integer(), nullable=False),
|
||||
sa.Column('kind', sa.String(length=20), nullable=False),
|
||||
sa.Column('value', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.ForeignKeyConstraint(['scenario_id'], ['scenarios.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('statements',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('filename', sa.String(length=255), nullable=False),
|
||||
sa.Column('bank', sa.String(length=50), nullable=False),
|
||||
sa.Column('account_id', sa.Integer(), nullable=True),
|
||||
sa.Column('period_start', sa.Date(), nullable=True),
|
||||
sa.Column('period_end', sa.Date(), nullable=True),
|
||||
sa.Column('opening_balance', sa.Numeric(precision=12, scale=2), nullable=True),
|
||||
sa.Column('closing_balance', sa.Numeric(precision=12, scale=2), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('transactions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('account_id', sa.Integer(), nullable=False),
|
||||
sa.Column('statement_id', sa.Integer(), nullable=True),
|
||||
sa.Column('booking_date', sa.Date(), nullable=False),
|
||||
sa.Column('value_date', sa.Date(), nullable=True),
|
||||
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('purpose', sa.Text(), nullable=False),
|
||||
sa.Column('counterparty', sa.String(length=200), nullable=False),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('dedup_hash', sa.String(length=64), nullable=False),
|
||||
sa.Column('is_duplicate', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
|
||||
sa.ForeignKeyConstraint(['statement_id'], ['statements.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_transactions_dedup_hash'), 'transactions', ['dedup_hash'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_transactions_dedup_hash'), table_name='transactions')
|
||||
op.drop_table('transactions')
|
||||
op.drop_table('statements')
|
||||
op.drop_table('scenario_modifiers')
|
||||
op.drop_table('scenario_loans')
|
||||
op.drop_table('recurring_items')
|
||||
op.drop_table('projection_results')
|
||||
op.drop_index(op.f('ix_projection_points_scenario_id'), table_name='projection_points')
|
||||
op.drop_table('projection_points')
|
||||
op.drop_table('planned_items')
|
||||
op.drop_table('category_rules')
|
||||
op.drop_table('scenarios')
|
||||
op.drop_table('loans')
|
||||
op.drop_table('categories')
|
||||
op.drop_table('accounts')
|
||||
# ### end Alembic commands ###
|
||||
0
finance/app/__init__.py
Normal file
0
finance/app/__init__.py
Normal file
53
finance/app/auth.py
Normal file
53
finance/app/auth.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from itsdangerous import BadSignature, TimestampSigner
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
COOKIE = "fb_session"
|
||||
MAX_AGE = 60 * 60 * 12 # 12 h
|
||||
|
||||
|
||||
def hash_password(pw: str, salt: str | None = None) -> str:
|
||||
salt = salt or secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 200_000).hex()
|
||||
return f"{salt}${digest}"
|
||||
|
||||
|
||||
def verify_password(pw: str, stored: str) -> bool:
|
||||
try:
|
||||
salt, digest = stored.split("$", 1)
|
||||
except ValueError:
|
||||
return False
|
||||
return hmac.compare_digest(hash_password(pw, salt).split("$", 1)[1], digest)
|
||||
|
||||
|
||||
def _signer() -> TimestampSigner:
|
||||
return TimestampSigner(get_settings().session_secret)
|
||||
|
||||
|
||||
def make_session_token() -> str:
|
||||
return _signer().sign(b"gui").decode()
|
||||
|
||||
|
||||
def session_valid(token: str | None) -> bool:
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
_signer().unsign(token, max_age=MAX_AGE)
|
||||
return True
|
||||
except BadSignature:
|
||||
return False
|
||||
|
||||
|
||||
def require_auth(request: Request) -> None:
|
||||
settings = get_settings()
|
||||
header = request.headers.get("authorization", "")
|
||||
if settings.api_key and hmac.compare_digest(header, f"Bearer {settings.api_key}"):
|
||||
return
|
||||
if session_valid(request.cookies.get(COOKIE)):
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||||
34
finance/app/config.py
Normal file
34
finance/app/config.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
database_url: str
|
||||
api_key: str
|
||||
gui_user: str
|
||||
gui_password_hash: str
|
||||
session_secret: str
|
||||
inbox_dir: Path
|
||||
uploads_dir: Path
|
||||
warn_threshold: Decimal
|
||||
horizon_days: int
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
e = os.environ.get
|
||||
return Settings(
|
||||
database_url=e("FB_DATABASE_URL", "sqlite:///./fb.sqlite"),
|
||||
api_key=e("FB_API_KEY", ""),
|
||||
gui_user=e("FB_GUI_USER", "admin"),
|
||||
gui_password_hash=e("FB_GUI_PASSWORD_HASH", ""),
|
||||
session_secret=e("FB_SESSION_SECRET", "dev-secret"),
|
||||
inbox_dir=Path(e("FB_INBOX_DIR", "./inbox")),
|
||||
uploads_dir=Path(e("FB_UPLOADS_DIR", "./uploads")),
|
||||
warn_threshold=Decimal(e("FB_WARN_THRESHOLD", "0")),
|
||||
horizon_days=int(e("FB_HORIZON_DAYS", "548")),
|
||||
)
|
||||
23
finance/app/db.py
Normal file
23
finance/app/db.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
_engine = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(get_settings().database_url)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session():
|
||||
with Session(get_engine()) as session:
|
||||
yield session
|
||||
0
finance/app/engine/__init__.py
Normal file
0
finance/app/engine/__init__.py
Normal file
56
finance/app/engine/loans.py
Normal file
56
finance/app/engine/loans.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import calendar
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
CENT = Decimal("0.01")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Installment:
|
||||
due: date
|
||||
payment: Decimal
|
||||
interest: Decimal
|
||||
principal: Decimal
|
||||
remaining: Decimal
|
||||
|
||||
|
||||
def add_months(d: date, months: int) -> date:
|
||||
y, m0 = divmod(d.year * 12 + d.month - 1 + months, 12)
|
||||
m = m0 + 1
|
||||
return date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
|
||||
|
||||
|
||||
def annuity_payment(principal: Decimal, annual_rate_pct: Decimal, term_months: int) -> Decimal:
|
||||
i = annual_rate_pct / Decimal(100) / Decimal(12)
|
||||
if i == 0:
|
||||
return (principal / term_months).quantize(CENT, ROUND_HALF_UP)
|
||||
q = (Decimal(1) + i) ** term_months
|
||||
return (principal * i * q / (q - Decimal(1))).quantize(CENT, ROUND_HALF_UP)
|
||||
|
||||
|
||||
def loan_schedule(principal: Decimal, annual_rate_pct: Decimal, term_months: int,
|
||||
payout_date: date, repayment_type: str) -> list[Installment]:
|
||||
i = annual_rate_pct / Decimal(100) / Decimal(12)
|
||||
plan: list[Installment] = []
|
||||
remaining = principal
|
||||
if repayment_type == "bullet":
|
||||
for n in range(1, term_months + 1):
|
||||
interest = (remaining * i).quantize(CENT, ROUND_HALF_UP)
|
||||
principal_part = remaining if n == term_months else Decimal("0")
|
||||
remaining = remaining - principal_part
|
||||
plan.append(Installment(add_months(payout_date, n),
|
||||
-(interest + principal_part), interest,
|
||||
principal_part, remaining.quantize(CENT)))
|
||||
return plan
|
||||
rate = annuity_payment(principal, annual_rate_pct, term_months)
|
||||
for n in range(1, term_months + 1):
|
||||
interest = (remaining * i).quantize(CENT, ROUND_HALF_UP)
|
||||
principal_part = rate - interest
|
||||
if n == term_months or principal_part > remaining:
|
||||
principal_part = remaining
|
||||
remaining = remaining - principal_part
|
||||
plan.append(Installment(add_months(payout_date, n),
|
||||
-(interest + principal_part), interest,
|
||||
principal_part, remaining.quantize(CENT)))
|
||||
return plan
|
||||
39
finance/app/engine/projection.py
Normal file
39
finance/app/engine/projection.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Projection:
|
||||
series: list[tuple[date, Decimal]]
|
||||
low_point: tuple[date, Decimal]
|
||||
first_below_zero: date | None
|
||||
first_below_threshold: date | None
|
||||
|
||||
|
||||
def project(start_balance: Decimal, start_date: date,
|
||||
cashflows: list[tuple[date, Decimal]], horizon_days: int,
|
||||
threshold: Decimal = Decimal("0")) -> Projection:
|
||||
end = start_date + timedelta(days=horizon_days)
|
||||
by_day: dict[date, Decimal] = defaultdict(lambda: Decimal("0"))
|
||||
for d, amount in cashflows:
|
||||
if start_date < d <= end:
|
||||
by_day[d] += amount
|
||||
series: list[tuple[date, Decimal]] = []
|
||||
balance = start_balance
|
||||
low = (start_date, start_balance)
|
||||
below0: date | None = None
|
||||
below_t: date | None = None
|
||||
d = start_date + timedelta(days=1)
|
||||
while d <= end:
|
||||
balance += by_day.get(d, Decimal("0"))
|
||||
series.append((d, balance))
|
||||
if balance < low[1]:
|
||||
low = (d, balance)
|
||||
if below0 is None and balance < 0:
|
||||
below0 = d
|
||||
if below_t is None and balance < threshold:
|
||||
below_t = d
|
||||
d += timedelta(days=1)
|
||||
return Projection(series, low, below0, below_t)
|
||||
25
finance/app/engine/recurrence.py
Normal file
25
finance/app/engine/recurrence.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import calendar
|
||||
from datetime import date
|
||||
|
||||
STEP = {"monthly": 1, "quarterly": 3, "yearly": 12}
|
||||
|
||||
|
||||
def _clamp(y: int, m: int, day: int) -> date:
|
||||
return date(y, m, min(day, calendar.monthrange(y, m)[1]))
|
||||
|
||||
|
||||
def occurrences(rhythm: str, due_day: int, window_start: date, window_end: date,
|
||||
item_start: date | None = None, item_end: date | None = None) -> list[date]:
|
||||
step = STEP[rhythm]
|
||||
lo = max(window_start, item_start) if item_start else window_start
|
||||
hi = min(window_end, item_end) if item_end else window_end
|
||||
anchor = item_start or window_start
|
||||
d = _clamp(anchor.year, anchor.month, due_day)
|
||||
out: list[date] = []
|
||||
while d <= hi:
|
||||
if d >= lo:
|
||||
out.append(d)
|
||||
total = d.year * 12 + d.month - 1 + step
|
||||
y, m0 = divmod(total, 12)
|
||||
d = _clamp(y, m0 + 1, due_day)
|
||||
return out
|
||||
82
finance/app/engine/scenario.py
Normal file
82
finance/app/engine/scenario.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from app.engine.loans import Installment
|
||||
from app.engine.recurrence import occurrences
|
||||
|
||||
CENT = Decimal("0.01")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlainRecurring:
|
||||
id: int
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
due_day: int
|
||||
start_date: date | None
|
||||
end_date: date | None
|
||||
category_id: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlainPlanned:
|
||||
name: str
|
||||
amount: Decimal
|
||||
due: date
|
||||
category_id: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlainModifier:
|
||||
target_type: str
|
||||
target_id: int
|
||||
kind: str
|
||||
value: Decimal
|
||||
|
||||
|
||||
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
||||
modifiers: list[PlainModifier]) -> Decimal | None:
|
||||
for m in modifiers:
|
||||
hit = ((m.target_type == "category" and category_id == m.target_id)
|
||||
or (m.target_type == "recurring" and recurring_id == m.target_id))
|
||||
if not hit:
|
||||
continue
|
||||
if m.kind == "remove":
|
||||
return None
|
||||
if m.kind == "percent":
|
||||
amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP)
|
||||
elif m.kind == "absolute":
|
||||
if amount < 0:
|
||||
amount = min(Decimal("0"), amount + m.value)
|
||||
else:
|
||||
amount = max(Decimal("0"), amount - m.value)
|
||||
return amount
|
||||
|
||||
|
||||
def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned],
|
||||
loan_schedules: list[list[Installment]],
|
||||
loan_payouts: list[tuple[date, Decimal]],
|
||||
modifiers: list[PlainModifier],
|
||||
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
|
||||
flows: list[tuple[date, Decimal]] = []
|
||||
for r in recurring:
|
||||
amount = _modified(r.amount, r.category_id, r.id, modifiers)
|
||||
if amount is None:
|
||||
continue
|
||||
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
||||
r.start_date, r.end_date):
|
||||
flows.append((d, amount))
|
||||
for p in planned:
|
||||
amount = _modified(p.amount, p.category_id, None, modifiers)
|
||||
if amount is not None and window_start <= p.due <= window_end:
|
||||
flows.append((p.due, amount))
|
||||
for d, payout in loan_payouts:
|
||||
if window_start <= d <= window_end:
|
||||
flows.append((d, payout))
|
||||
for sched in loan_schedules:
|
||||
for inst in sched:
|
||||
if window_start <= inst.due <= window_end:
|
||||
flows.append((inst.due, inst.payment))
|
||||
return sorted(flows)
|
||||
64
finance/app/main.py
Normal file
64
finance/app/main.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import hmac
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import auth
|
||||
from app.auth import require_auth
|
||||
from app.config import get_settings
|
||||
from app.db import get_engine
|
||||
from app.models.views import create_views
|
||||
from app.routers import (accounts, categories, gui, imports, planning,
|
||||
scenarios, transactions)
|
||||
from app.routers.gui import templates
|
||||
from app.version import get_version
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
create_views(get_engine())
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Finanzberatungs-Tool", lifespan=lifespan)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.include_router(gui.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(transactions.router)
|
||||
app.include_router(categories.router)
|
||||
app.include_router(imports.router)
|
||||
app.include_router(planning.router)
|
||||
app.include_router(scenarios.router)
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
def login_form(request: Request):
|
||||
return templates.TemplateResponse(request, "login.html", {})
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login(username: str = Form(...), password: str = Form(...)):
|
||||
s = get_settings()
|
||||
if not (hmac.compare_digest(username, s.gui_user)
|
||||
and auth.verify_password(password, s.gui_password_hash)):
|
||||
return HTMLResponse("Login fehlgeschlagen", status_code=401)
|
||||
resp = RedirectResponse("/", status_code=303)
|
||||
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,
|
||||
max_age=auth.MAX_AGE, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
def logout():
|
||||
resp = RedirectResponse("/login", status_code=303)
|
||||
resp.delete_cookie(auth.COOKIE)
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/api/version", dependencies=[Depends(require_auth)])
|
||||
def api_version():
|
||||
return {"version": get_version()}
|
||||
1
finance/app/models/__init__.py
Normal file
1
finance/app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from app.models import tables # noqa: F401
|
||||
143
finance/app/models/tables.py
Normal file
143
finance/app/models/tables.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
MONEY = Numeric(12, 2)
|
||||
|
||||
|
||||
class Account(Base):
|
||||
__tablename__ = "accounts"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
bank: Mapped[str] = mapped_column(String(50))
|
||||
iban: Mapped[str] = mapped_column(String(34), unique=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
type: Mapped[str] = mapped_column(String(20), default="giro")
|
||||
# Konto-Saldo-Anker (Ausbaustufe 3 Task 1): ersetzt die bisherige
|
||||
# Statement-closing-Logik als Basis der Saldenrechnung. Beide Felder sind
|
||||
# nur gemeinsam gesetzt oder gemeinsam NULL (durchgesetzt in
|
||||
# app.routers.accounts.AccountPatch).
|
||||
anchor_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
anchor_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
|
||||
|
||||
class CategoryRule(Base):
|
||||
__tablename__ = "category_rules"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
pattern: Mapped[str] = mapped_column(String(200))
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
|
||||
priority: Mapped[int] = mapped_column(Integer, default=100)
|
||||
|
||||
|
||||
class Statement(Base):
|
||||
__tablename__ = "statements"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
bank: Mapped[str] = mapped_column(String(50))
|
||||
account_id: Mapped[int | None] = mapped_column(ForeignKey("accounts.id"), nullable=True)
|
||||
period_start: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
period_end: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
opening_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
|
||||
closing_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="draft")
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"))
|
||||
statement_id: Mapped[int | None] = mapped_column(ForeignKey("statements.id"), nullable=True)
|
||||
booking_date: Mapped[date] = mapped_column(Date)
|
||||
value_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||||
purpose: Mapped[str] = mapped_column(Text, default="")
|
||||
counterparty: Mapped[str] = mapped_column(String(200), default="")
|
||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="draft")
|
||||
dedup_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
is_duplicate: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class RecurringItem(Base):
|
||||
__tablename__ = "recurring_items"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||||
rhythm: Mapped[str] = mapped_column(String(20))
|
||||
due_day: Mapped[int] = mapped_column(Integer)
|
||||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
|
||||
|
||||
class PlannedItem(Base):
|
||||
__tablename__ = "planned_items"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||||
due: Mapped[date] = mapped_column(Date)
|
||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
|
||||
|
||||
class Loan(Base):
|
||||
__tablename__ = "loans"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
principal: Mapped[Decimal] = mapped_column(MONEY)
|
||||
annual_rate_pct: Mapped[Decimal] = mapped_column(Numeric(5, 2))
|
||||
term_months: Mapped[int] = mapped_column(Integer)
|
||||
payout_date: Mapped[date] = mapped_column(Date)
|
||||
repayment_type: Mapped[str] = mapped_column(String(20), default="annuity")
|
||||
|
||||
|
||||
class Scenario(Base):
|
||||
__tablename__ = "scenarios"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200), unique=True)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
include_recurring: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
include_planned: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class ScenarioLoan(Base):
|
||||
__tablename__ = "scenario_loans"
|
||||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), primary_key=True)
|
||||
loan_id: Mapped[int] = mapped_column(ForeignKey("loans.id"), primary_key=True)
|
||||
|
||||
|
||||
class ScenarioModifier(Base):
|
||||
__tablename__ = "scenario_modifiers"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"))
|
||||
target_type: Mapped[str] = mapped_column(String(20))
|
||||
target_id: Mapped[int] = mapped_column(Integer)
|
||||
kind: Mapped[str] = mapped_column(String(20))
|
||||
value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0"))
|
||||
|
||||
|
||||
class ProjectionPoint(Base):
|
||||
__tablename__ = "projection_points"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
|
||||
day: Mapped[date] = mapped_column(Date)
|
||||
balance: Mapped[Decimal] = mapped_column(MONEY)
|
||||
|
||||
|
||||
class ProjectionResult(Base):
|
||||
__tablename__ = "projection_results"
|
||||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), primary_key=True)
|
||||
computed_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
low_point_date: Mapped[date] = mapped_column(Date)
|
||||
low_point_balance: Mapped[Decimal] = mapped_column(MONEY)
|
||||
below_zero_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
below_threshold_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
71
finance/app/models/views.py
Normal file
71
finance/app/models/views.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
# Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the
|
||||
# same math as app.services.balances.account_balance, expressed as a single
|
||||
# constant "base" per account so it can be added to a plain (anchor-agnostic)
|
||||
# running sum of transaction amounts:
|
||||
#
|
||||
# balance(day) = anchor_balance + Sum(tx: anchor_date < booking_date <= day)
|
||||
# - Sum(tx: day < booking_date <= anchor_date)
|
||||
# = anchor_balance - cumsum(<=anchor_date) + cumsum(<=day)
|
||||
# = base + cumsum(<=day)
|
||||
#
|
||||
# ... which holds for `day` both before and after anchor_date, since the
|
||||
# subtracted/added transaction windows collapse into the same telescoping
|
||||
# sum. Accounts without an anchor get base=0 (via COALESCE below), matching
|
||||
# account_balance's "Ohne Anker: Summe ab 0" fallback.
|
||||
_ACCOUNT_OFFSET = """
|
||||
SELECT a.id AS account_id,
|
||||
a.anchor_balance - COALESCE((
|
||||
SELECT SUM(t2.amount) FROM transactions t2
|
||||
WHERE t2.account_id = a.id AND t2.status = 'confirmed'
|
||||
AND t2.booking_date <= a.anchor_date
|
||||
), 0) AS base
|
||||
FROM accounts a
|
||||
WHERE a.anchor_date IS NOT NULL AND a.anchor_balance IS NOT NULL
|
||||
"""
|
||||
|
||||
VIEWS: dict[str, str] = {
|
||||
"v_balance_history": f"""
|
||||
SELECT t.booking_date AS day, a.name AS account,
|
||||
COALESCE(b.base, 0)
|
||||
+ SUM(SUM(t.amount)) OVER (PARTITION BY a.id
|
||||
ORDER BY t.booking_date) AS balance
|
||||
FROM transactions t JOIN accounts a ON a.id = t.account_id
|
||||
LEFT JOIN ({_ACCOUNT_OFFSET}) b ON b.account_id = a.id
|
||||
WHERE t.status = 'confirmed'
|
||||
GROUP BY a.id, a.name, t.booking_date, b.base""",
|
||||
"v_balance_total": f"""
|
||||
SELECT booking_date AS day,
|
||||
(SELECT COALESCE(SUM(base), 0)
|
||||
FROM ({_ACCOUNT_OFFSET}) s)
|
||||
+ SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance
|
||||
FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""",
|
||||
"v_monthly_by_category": """
|
||||
SELECT date_trunc('month', t.booking_date) AS month,
|
||||
COALESCE(c.name, 'unkategorisiert') AS category,
|
||||
SUM(t.amount) AS total
|
||||
FROM transactions t LEFT JOIN categories c ON c.id = t.category_id
|
||||
WHERE t.status = 'confirmed' GROUP BY 1, 2""",
|
||||
"v_projection": """
|
||||
SELECT s.name AS scenario, p.day, p.balance
|
||||
FROM projection_points p JOIN scenarios s ON s.id = p.scenario_id""",
|
||||
}
|
||||
|
||||
|
||||
def create_views(engine: Engine) -> None:
|
||||
if engine.dialect.name != "postgresql":
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
for name, body in VIEWS.items():
|
||||
# CREATE OR REPLACE VIEW fails if the output column set changed
|
||||
# (name/type/order). Fall back to DROP + CREATE in that case so a
|
||||
# redeploy always applies the current definition.
|
||||
try:
|
||||
with conn.begin_nested():
|
||||
conn.execute(
|
||||
text(f"CREATE OR REPLACE VIEW {name} AS {body}"))
|
||||
except Exception:
|
||||
conn.execute(text(f"DROP VIEW IF EXISTS {name} CASCADE"))
|
||||
conn.execute(text(f"CREATE VIEW {name} AS {body}"))
|
||||
0
finance/app/parsers/__init__.py
Normal file
0
finance/app/parsers/__init__.py
Normal file
49
finance/app/parsers/base.py
Normal file
49
finance/app/parsers/base.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class ParserError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedTransaction:
|
||||
booking_date: date
|
||||
value_date: date | None
|
||||
amount: Decimal
|
||||
purpose: str
|
||||
counterparty: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedStatement:
|
||||
bank: str
|
||||
iban: str | None
|
||||
period_start: date | None
|
||||
period_end: date | None
|
||||
opening_balance: Decimal | None
|
||||
closing_balance: Decimal | None
|
||||
transactions: list[ParsedTransaction] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_german_amount(s: str) -> Decimal:
|
||||
s = s.strip().replace(" ", "").replace("\xa0", "")
|
||||
neg = s.endswith("-") or s.startswith("-") or s.endswith("S")
|
||||
s = s.strip("+-SH")
|
||||
value = Decimal(s.replace(".", "").replace(",", "."))
|
||||
return -value if neg else value
|
||||
|
||||
|
||||
def parse_german_date(s: str, default_year: int | None = None) -> date:
|
||||
m = re.fullmatch(r"(\d{2})\.(\d{2})\.(\d{4})?", s.strip())
|
||||
if not m:
|
||||
raise ParserError(f"Kein Datum: {s!r}")
|
||||
year = int(m.group(3)) if m.group(3) else default_year
|
||||
if year is None:
|
||||
raise ParserError(f"Jahr fehlt: {s!r}")
|
||||
try:
|
||||
return date(year, int(m.group(2)), int(m.group(1)))
|
||||
except ValueError:
|
||||
raise ParserError(f"Ungültiges Datum: {s!r}")
|
||||
245
finance/app/parsers/csv_formats.py
Normal file
245
finance/app/parsers/csv_formats.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Parser für Kontoumsatz-CSV-Exporte (DKB, VR, HVB).
|
||||
|
||||
Siehe „CSV-Formatreferenz" in docs/superpowers/plans/2026-07-19-ausbaustufe-3.md
|
||||
für die verbindliche Spalten-/Formatspezifikation je Bank.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
|
||||
parse_german_amount, parse_german_date)
|
||||
|
||||
RE_KONTOSTAND = re.compile(r"Kontostand vom (\d{2}\.\d{2}\.\d{4}):")
|
||||
RE_ZEITRAUM = re.compile(r"(\d{2}\.\d{2}\.\d{4})\s*-\s*(\d{2}\.\d{2}\.\d{4})")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedCsv:
|
||||
statement: ParsedStatement
|
||||
anchor: tuple[date, Decimal] | None
|
||||
balance_checkable: bool
|
||||
|
||||
|
||||
def _detect_encoding(raw: bytes) -> str:
|
||||
if raw.startswith(b"\xef\xbb\xbf"):
|
||||
return "utf-8-sig"
|
||||
if raw.startswith(b"\xff\xfe"):
|
||||
return "utf-16-le"
|
||||
if raw.startswith(b"\xfe\xff"):
|
||||
return "utf-16-be"
|
||||
sample = raw[:64]
|
||||
if b"\x00" in sample:
|
||||
even_zeros = sum(1 for i in range(0, len(sample), 2) if sample[i] == 0)
|
||||
odd_zeros = sum(1 for i in range(1, len(sample), 2) if i < len(sample) and sample[i] == 0)
|
||||
return "utf-16-be" if even_zeros > odd_zeros else "utf-16-le"
|
||||
return "utf-8"
|
||||
|
||||
|
||||
def _decode(raw: bytes) -> str:
|
||||
enc = _detect_encoding(raw)
|
||||
text = raw.decode(enc, errors="ignore")
|
||||
if text.startswith(""):
|
||||
text = text[1:]
|
||||
return text
|
||||
|
||||
|
||||
def _strip_currency(s: str) -> str:
|
||||
return s.replace("€", "").replace("EUR", "").replace("\xa0", " ").strip()
|
||||
|
||||
|
||||
def _parse_amount(s: str) -> Decimal:
|
||||
return parse_german_amount(_strip_currency(s))
|
||||
|
||||
|
||||
def _parse_two_digit_year_date(s: str) -> date:
|
||||
m = re.fullmatch(r"(\d{2})\.(\d{2})\.(\d{2})", s.strip())
|
||||
if not m:
|
||||
raise ParserError(f"DKB-CSV: Kein Datum im Format TT.MM.JJ: {s!r}")
|
||||
day, month, yy = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
year = 2000 + yy if yy <= 69 else 1900 + yy
|
||||
try:
|
||||
return date(year, month, day)
|
||||
except ValueError:
|
||||
raise ParserError(f"DKB-CSV: Ungueltiges Datum: {s!r}")
|
||||
|
||||
|
||||
def detect_csv_format(head_bytes: bytes) -> str | None:
|
||||
try:
|
||||
text = _decode(head_bytes)
|
||||
except (LookupError, UnicodeError):
|
||||
return None
|
||||
lines = text.splitlines()
|
||||
if not lines:
|
||||
return None
|
||||
first = lines[0]
|
||||
if first.startswith("Kontonummer;") and "Buchungsdatum" in first and "Valuta" in first \
|
||||
and "Betrag" in first:
|
||||
return "hvb_csv"
|
||||
if first.startswith("Bezeichnung Auftragskonto;") and "IBAN Auftragskonto" in first:
|
||||
return "vr_csv"
|
||||
head = "\n".join(lines[:6])
|
||||
if "Kontostand vom" in head and "Buchungsdatum" in head and "Zahlungsempf" in head:
|
||||
return "dkb_csv"
|
||||
return None
|
||||
|
||||
|
||||
def parse_csv(path: Path) -> ParsedCsv:
|
||||
raw = path.read_bytes()
|
||||
fmt = detect_csv_format(raw)
|
||||
if fmt is None:
|
||||
raise ParserError("CSV-Format nicht erkannt")
|
||||
text = _decode(raw)
|
||||
if fmt == "dkb_csv":
|
||||
return _parse_dkb(text)
|
||||
if fmt == "vr_csv":
|
||||
return _parse_vr(text)
|
||||
return _parse_hvb(text)
|
||||
|
||||
|
||||
def _rows(text: str) -> list[list[str]]:
|
||||
return list(csv.reader(io.StringIO(text), delimiter=";", quotechar='"'))
|
||||
|
||||
|
||||
def _parse_dkb(text: str) -> ParsedCsv:
|
||||
rows = _rows(text)
|
||||
if len(rows) < 2:
|
||||
raise ParserError("DKB-CSV: Datei zu kurz")
|
||||
iban = rows[0][1].strip() if len(rows[0]) > 1 else None
|
||||
|
||||
period_start = period_end = None
|
||||
kontostand_date: date | None = None
|
||||
kontostand_amount: Decimal | None = None
|
||||
header_idx = None
|
||||
for i, row in enumerate(rows):
|
||||
if row and row[0].strip() == "Zeitraum:" and len(row) > 1:
|
||||
m = RE_ZEITRAUM.search(row[1])
|
||||
if m:
|
||||
period_start = parse_german_date(m.group(1))
|
||||
period_end = parse_german_date(m.group(2))
|
||||
if row and RE_KONTOSTAND.search(row[0] or "") and len(row) > 1:
|
||||
m = RE_KONTOSTAND.search(row[0])
|
||||
kontostand_date = parse_german_date(m.group(1))
|
||||
kontostand_amount = _parse_amount(row[1])
|
||||
if row and row[0].strip() == "Buchungsdatum":
|
||||
header_idx = i
|
||||
break
|
||||
if header_idx is None:
|
||||
raise ParserError("DKB-CSV: Spaltenkopf nicht gefunden")
|
||||
if kontostand_date is None or kontostand_amount is None:
|
||||
raise ParserError("DKB-CSV: Kontostand-Zeile nicht gefunden")
|
||||
|
||||
header = [h.strip() for h in rows[header_idx]]
|
||||
idx = {name: i for i, name in enumerate(header)}
|
||||
required = ["Buchungsdatum", "Wertstellung", "Status", "Zahlungspflichtige*r",
|
||||
"Zahlungsempfänger*in", "Verwendungszweck", "Umsatztyp", "Betrag (€)"]
|
||||
for col in required:
|
||||
if col not in idx:
|
||||
raise ParserError(f"DKB-CSV: Spalte fehlt: {col}")
|
||||
|
||||
txs: list[ParsedTransaction] = []
|
||||
for row in rows[header_idx + 1:]:
|
||||
if not row or len(row) < len(header):
|
||||
continue
|
||||
if row[idx["Status"]].strip() != "Gebucht":
|
||||
continue
|
||||
amount = _parse_amount(row[idx["Betrag (€)"]])
|
||||
counterparty = (row[idx["Zahlungsempfänger*in"]] if amount < 0
|
||||
else row[idx["Zahlungspflichtige*r"]]).strip()
|
||||
purpose = f"{row[idx['Umsatztyp']].strip()} {row[idx['Verwendungszweck']].strip()}".strip()
|
||||
booking = _parse_two_digit_year_date(row[idx["Buchungsdatum"]])
|
||||
value = _parse_two_digit_year_date(row[idx["Wertstellung"]])
|
||||
txs.append(ParsedTransaction(booking_date=booking, value_date=value, amount=amount,
|
||||
purpose=purpose, counterparty=counterparty))
|
||||
txs.reverse() # file is sorted descending -> chronologically ascending
|
||||
|
||||
stmt = ParsedStatement(bank="dkb_csv", iban=iban, period_start=period_start,
|
||||
period_end=period_end, opening_balance=None,
|
||||
closing_balance=None, transactions=txs)
|
||||
return ParsedCsv(statement=stmt, anchor=(kontostand_date, kontostand_amount),
|
||||
balance_checkable=False)
|
||||
|
||||
|
||||
def _parse_vr(text: str) -> ParsedCsv:
|
||||
rows = _rows(text)
|
||||
if not rows:
|
||||
raise ParserError("VR-CSV: Datei leer")
|
||||
header = [h.strip() for h in rows[0]]
|
||||
idx = {name: i for i, name in enumerate(header)}
|
||||
required = ["IBAN Auftragskonto", "Buchungstag", "Valutadatum", "Name Zahlungsbeteiligter",
|
||||
"Buchungstext", "Verwendungszweck", "Betrag", "Saldo nach Buchung"]
|
||||
for col in required:
|
||||
if col not in idx:
|
||||
raise ParserError(f"VR-CSV: Spalte fehlt: {col}")
|
||||
|
||||
data_rows = [(i, row) for i, row in enumerate(rows[1:], start=2) if row and len(row) >= len(header)]
|
||||
if not data_rows:
|
||||
raise ParserError("VR-CSV: keine Buchungszeilen gefunden")
|
||||
|
||||
iban = rows[1][idx["IBAN Auftragskonto"]].strip() if len(rows) > 1 else None
|
||||
|
||||
# File is sorted descending (newest first) -> reverse for chronological ascending.
|
||||
asc = list(reversed(data_rows))
|
||||
|
||||
txs: list[ParsedTransaction] = []
|
||||
saldi: list[Decimal] = []
|
||||
prev_saldo: Decimal | None = None
|
||||
for line_no, row in asc:
|
||||
amount = _parse_amount(row[idx["Betrag"]])
|
||||
saldo = _parse_amount(row[idx["Saldo nach Buchung"]])
|
||||
if prev_saldo is not None and prev_saldo + amount != saldo:
|
||||
raise ParserError(f"VR-CSV: Saldo-Kette inkonsistent bei Zeile {line_no}")
|
||||
prev_saldo = saldo
|
||||
booking = parse_german_date(row[idx["Buchungstag"]])
|
||||
value = parse_german_date(row[idx["Valutadatum"]])
|
||||
purpose = f"{row[idx['Buchungstext']].strip()} {row[idx['Verwendungszweck']].strip()}".strip()
|
||||
counterparty = row[idx["Name Zahlungsbeteiligter"]].strip()
|
||||
txs.append(ParsedTransaction(booking_date=booking, value_date=value, amount=amount,
|
||||
purpose=purpose, counterparty=counterparty))
|
||||
saldi.append(saldo)
|
||||
|
||||
opening_balance = saldi[0] - txs[0].amount
|
||||
closing_balance = saldi[-1]
|
||||
anchor = (txs[-1].booking_date, closing_balance)
|
||||
|
||||
stmt = ParsedStatement(bank="vr_csv", iban=iban,
|
||||
period_start=txs[0].booking_date, period_end=txs[-1].booking_date,
|
||||
opening_balance=opening_balance, closing_balance=closing_balance,
|
||||
transactions=txs)
|
||||
return ParsedCsv(statement=stmt, anchor=anchor, balance_checkable=True)
|
||||
|
||||
|
||||
def _parse_hvb(text: str) -> ParsedCsv:
|
||||
rows = _rows(text)
|
||||
if not rows:
|
||||
raise ParserError("HVB-CSV: Datei leer")
|
||||
header = [h.strip() for h in rows[0]]
|
||||
idx = {name: i for i, name in enumerate(header)}
|
||||
required = ["Kontonummer", "Buchungsdatum", "Valuta", "Verwendungszweck", "Betrag"]
|
||||
for col in required:
|
||||
if col not in idx:
|
||||
raise ParserError(f"HVB-CSV: Spalte fehlt: {col}")
|
||||
|
||||
data_rows = [row for row in rows[1:] if row and len(row) >= len(header)]
|
||||
if not data_rows:
|
||||
raise ParserError("HVB-CSV: keine Buchungszeilen gefunden")
|
||||
|
||||
kontonummer = data_rows[0][idx["Kontonummer"]].strip()
|
||||
|
||||
txs: list[ParsedTransaction] = []
|
||||
for row in data_rows: # already chronologically ascending
|
||||
booking = parse_german_date(row[idx["Buchungsdatum"]])
|
||||
value = parse_german_date(row[idx["Valuta"]])
|
||||
amount = _parse_amount(row[idx["Betrag"]])
|
||||
purpose = row[idx["Verwendungszweck"]].strip()
|
||||
txs.append(ParsedTransaction(booking_date=booking, value_date=value, amount=amount,
|
||||
purpose=purpose, counterparty=""))
|
||||
|
||||
stmt = ParsedStatement(bank="hvb_csv", iban=kontonummer,
|
||||
period_start=txs[0].booking_date, period_end=txs[-1].booking_date,
|
||||
opening_balance=None, closing_balance=None, transactions=txs)
|
||||
return ParsedCsv(statement=stmt, anchor=None, balance_checkable=False)
|
||||
12
finance/app/parsers/detect.py
Normal file
12
finance/app/parsers/detect.py
Normal file
@@ -0,0 +1,12 @@
|
||||
MARKERS = [
|
||||
("vr", ["Volksbank", "Raiffeisenbank", "VR-Bank", "VR Bank"]),
|
||||
("hvb", ["HypoVereinsbank", "UniCredit", "Unicredit", "HYVEDEMM"]),
|
||||
("dkb", ["Deutsche Kreditbank", "DKB"]),
|
||||
]
|
||||
|
||||
|
||||
def detect_bank(text: str) -> str | None:
|
||||
for bank, needles in MARKERS:
|
||||
if any(n in text for n in needles):
|
||||
return bank
|
||||
return None
|
||||
184
finance/app/parsers/dkb.py
Normal file
184
finance/app/parsers/dkb.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Parser für DKB (Deutsche Kreditbank)-Kontoauszüge.
|
||||
|
||||
Realer Fixture-Abgleich (Task 7, Schritt 4): Der eigentliche Auszugszeitraum
|
||||
steht nicht als "vom X bis Y"-Satz im Text (dieser Wortlaut kommt nur in einem
|
||||
separaten quartalsweisen Zinsabschluss-Absatz mit abweichendem Zeitraum vor);
|
||||
er wird daher aus den Datumsangaben der beiden Kontostand-Zeilen abgeleitet.
|
||||
Buchungszeilen haben nur eine Datumsspalte, die ohne Leerzeichen direkt am
|
||||
Buchungstext klebt, und der Betrag trägt ein optionales führendes Minus statt
|
||||
eines nachgestellten S/H-Kürzels. Der Anfangssaldo trägt den Zusatz
|
||||
", Auszug Nr. N", der Endsaldo den Zusatz "um HH:MM Uhr" – beides
|
||||
unterscheidet die echten Saldo-Zeilen von einer abweichend formulierten
|
||||
Zwischensumme in einem quartalsweisen Rechnungsabschluss-Anhang.
|
||||
"""
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
|
||||
parse_german_amount, parse_german_date)
|
||||
|
||||
BANK = "dkb"
|
||||
RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b")
|
||||
# "Kontostand am 01.06.2020, Auszug Nr. 1 1.234,56"
|
||||
RE_OPENING = re.compile(r"Kontostand\s+am\s+(\d{2}\.\d{2}\.\d{4}),\s*Auszug\s+Nr\.\s*\d+\s+(-?[\d.,]+)", re.I)
|
||||
# "Kontostand am 30.06.2020 um 12:00 Uhr -2.345,67"
|
||||
RE_CLOSING = re.compile(r"Kontostand\s+am\s+(\d{2}\.\d{2}\.\d{4})\s+um\s+\d{2}:\d{2}\s*Uhr\s+(-?[\d.,]+)", re.I)
|
||||
# Buchungszeile: "01.06.2020Überweisung -123,45" (Datum direkt am Text, kein Leerzeichen)
|
||||
RE_TX_HEAD = re.compile(r"^(\d{2}\.\d{2}\.\d{4})(\S.*?)\s+(-?[\d.,]+)$")
|
||||
# Wiederkehrender Seitenfuß (Bank-Impressum) und Folgeseiten-Kopf; rein
|
||||
# strukturelle Muster (keine Kontodaten), damit dieser Block nicht als
|
||||
# Verwendungszweck der jeweils letzten Buchung einer Seite eingesammelt wird.
|
||||
RE_NOISE = re.compile(
|
||||
r"(?:Kreditbank AG" # Impressum-Zeile 1
|
||||
r"|Taubenstraße" # Impressum-Zeile 2 (Anschrift)
|
||||
r"|^\d{5}\s+Berlin\b" # Impressum-Zeile 3 (PLZ Ort)
|
||||
r"|Handelsregister" # Impressum-Zeile 4
|
||||
r"|^Ein Unternehmen" # Impressum-Zeile 5
|
||||
r"|Landesbank" # Impressum-Zeile 6
|
||||
r"|^Kontoauszug\s+\d+/\d+\s+Seite" # Folgeseiten-Kopf
|
||||
r"|^Girokonto\s+\d" # Folgeseiten-Kopf (Kontozeile)
|
||||
r"|^Datum\s+Erläuterung" # Spaltenüberschrift
|
||||
r")"
|
||||
)
|
||||
|
||||
|
||||
def _is_noise(line: str) -> bool:
|
||||
return bool(RE_NOISE.search(line))
|
||||
|
||||
|
||||
# --- Empfänger/Zweck-Split-Heuristik (D-T5-1/D-T5-2, siehe
|
||||
# .superpowers/sdd/parser-vs-csv-audit.md) --------------------------------
|
||||
# extras[0] enthält "<Empfänger><Leerzeichen><Beginn Verwendungszweck>" ohne
|
||||
# Trenner. Die folgenden Regeln bestimmen, wo der Empfänger endet.
|
||||
_LEGAL_FORM_TOKENS = {
|
||||
"ag", "gmbh", "ev", "eg", "kg", "bv", "sca", "sarl", "se",
|
||||
"ltd", "inc", "ek", "ohg", "gbr", "stiftung", "bank",
|
||||
}
|
||||
_CONNECTOR_TOKENS = {"et", "cie"}
|
||||
_REF_KEYWORDS = {"rechnung", "vertrag", "kundennummer", "kassenzeichen", "rg", "nr"}
|
||||
_REF_KEYWORD_PREFIXES = ("kd.-nr", "kdnr", "beitragsnr")
|
||||
_RE_TOKEN_DIGIT = re.compile(r"\d")
|
||||
_MAX_LEGAL_FORM_SCAN = 7
|
||||
|
||||
|
||||
def _normalize_token(tok: str) -> str:
|
||||
return tok.strip(",;").lower().replace(".", "")
|
||||
|
||||
|
||||
def _is_legal_form(tok: str) -> bool:
|
||||
return _normalize_token(tok) in _LEGAL_FORM_TOKENS
|
||||
|
||||
|
||||
def _is_connector(tok: str) -> bool:
|
||||
return _normalize_token(tok) in _CONNECTOR_TOKENS
|
||||
|
||||
|
||||
def _is_reference_keyword(tok: str) -> bool:
|
||||
norm = _normalize_token(tok)
|
||||
return norm in _REF_KEYWORDS or norm.startswith(_REF_KEYWORD_PREFIXES)
|
||||
|
||||
|
||||
def _split_counterparty(line: str) -> tuple[str, str]:
|
||||
"""Trennt Empfänger von Zweck-Beginn in der ersten Fortsetzungszeile
|
||||
(extras[0]) einer DKB-Buchung. Gibt (counterparty, purpose_prefix)
|
||||
zurück; purpose_prefix ist der abgeschnittene Rest (kann leer sein).
|
||||
|
||||
Reihenfolge (D-T5-1):
|
||||
1. Rechtsform-Grenze (erste ~7 Tokens; "et Cie"-Ketten bis zum letzten
|
||||
Rechtsform-Token in Folge).
|
||||
2. Referenz-Grenze (erstes Token mit Ziffer oder Referenz-Schlüsselwort,
|
||||
frühestens ab Token 1 -> counterparty nie leer).
|
||||
3. Kein Schnitt (reine Wort-Folge, semantisch nicht trennbar).
|
||||
|
||||
D-T5-2: "siehe Anlage"-Zeilen -> counterparty leer, ganze Zeile in den
|
||||
Zweck.
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return "", ""
|
||||
if line.lower().startswith("siehe anlage"):
|
||||
return "", line
|
||||
tokens = line.split()
|
||||
n = len(tokens)
|
||||
|
||||
for i in range(min(n, _MAX_LEGAL_FORM_SCAN)):
|
||||
if not _is_legal_form(tokens[i]):
|
||||
continue
|
||||
last = i
|
||||
j = i + 1
|
||||
while j < n and (_is_connector(tokens[j]) or _is_legal_form(tokens[j])):
|
||||
if _is_legal_form(tokens[j]):
|
||||
last = j
|
||||
j += 1
|
||||
cut = last + 1
|
||||
return " ".join(tokens[:cut]), " ".join(tokens[cut:])
|
||||
|
||||
for idx in range(1, n):
|
||||
if _RE_TOKEN_DIGIT.search(tokens[idx]) or _is_reference_keyword(tokens[idx]):
|
||||
return " ".join(tokens[:idx]), " ".join(tokens[idx:])
|
||||
|
||||
return line, ""
|
||||
|
||||
|
||||
def parse(path: Path) -> ParsedStatement:
|
||||
with pdfplumber.open(path) as pdf:
|
||||
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
|
||||
opening = RE_OPENING.search(text)
|
||||
closing = RE_CLOSING.search(text)
|
||||
if not opening or not closing:
|
||||
raise ParserError("DKB: Anfangs-/Endsaldo nicht gefunden")
|
||||
period_start = parse_german_date(opening.group(1))
|
||||
period_end = parse_german_date(closing.group(1))
|
||||
iban_m = RE_IBAN.search(text)
|
||||
txs: list[ParsedTransaction] = []
|
||||
pending: dict | None = None
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Schlusssaldo erreicht: laufende Buchung abschließen und Sammlung beenden
|
||||
# (danach folgen nur noch Summen, Rechnungsabschluss und Hinweise).
|
||||
if RE_CLOSING.search(line):
|
||||
break
|
||||
m = RE_TX_HEAD.match(line)
|
||||
if m:
|
||||
if pending:
|
||||
txs.append(_finish(pending))
|
||||
booking = parse_german_date(m.group(1))
|
||||
pending = {
|
||||
"booking": booking,
|
||||
"value": booking,
|
||||
"head": m.group(2).strip(),
|
||||
"amount": parse_german_amount(m.group(3)),
|
||||
"extra": [],
|
||||
}
|
||||
continue
|
||||
if pending and not RE_OPENING.search(line) and not _is_noise(line):
|
||||
pending["extra"].append(line)
|
||||
if pending:
|
||||
txs.append(_finish(pending))
|
||||
return ParsedStatement(bank=BANK,
|
||||
iban=iban_m.group(1).replace(" ", "") if iban_m else None,
|
||||
period_start=period_start, period_end=period_end,
|
||||
opening_balance=parse_german_amount(opening.group(2)),
|
||||
closing_balance=parse_german_amount(closing.group(2)),
|
||||
transactions=txs)
|
||||
|
||||
|
||||
def _finish(p: dict) -> ParsedTransaction:
|
||||
if p["extra"]:
|
||||
counterparty, remainder = _split_counterparty(p["extra"][0])
|
||||
purpose_parts = [p["head"]]
|
||||
if remainder:
|
||||
purpose_parts.append(remainder)
|
||||
purpose_parts.extend(p["extra"][1:])
|
||||
else:
|
||||
counterparty = ""
|
||||
purpose_parts = [p["head"]]
|
||||
purpose = " ".join(purpose_parts).strip()
|
||||
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
|
||||
amount=p["amount"], purpose=purpose,
|
||||
counterparty=counterparty)
|
||||
143
finance/app/parsers/hvb.py
Normal file
143
finance/app/parsers/hvb.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Parser für HypoVereinsbank (UniCredit)-Kontoauszüge.
|
||||
|
||||
Realer Fixture-Abgleich (Task 7, Schritt 4): Das Layout weicht strukturell vom
|
||||
generischen Platzhalter ab, nicht nur in der Wortwahl:
|
||||
- Eine Buchung besteht aus zwei Zeilen: Zeile 1 = Buchungsdatum + Vorgangsart +
|
||||
Saldo/Umsatz-Betrag mit direkt angehängtem Vorzeichen (kein Leerzeichen vor
|
||||
"+"/"-"); Zeile 2 = Wertstellungsdatum + Buchungsinformation (Text kann
|
||||
leer sein, dann folgt nur ein Datum ohne weiteren Text). Fehlt Zeile 2 ganz
|
||||
(z. B. Seitenumbruch mitten in der Buchung), gilt das als Parserfehler statt
|
||||
eine unvollständige Buchung zu erzeugen.
|
||||
- Mehrseitige Auszüge wiederholen Kopfzeilen und Zwischensummen
|
||||
("Übertrag: EUR ...", "Saldo per DATUM: EUR ..."), die nicht mit dem
|
||||
tatsächlichen Anfangs-/Endsaldo verwechselt werden dürfen und aus dem
|
||||
Verwendungszweck herausgefiltert werden.
|
||||
- Der Auszugszeitraum steht nur in der wiederholten Kopfzeile als
|
||||
"<Start> - <Ende> <Auszug-Nr.> <Seite>".
|
||||
"""
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
|
||||
parse_german_amount, parse_german_date)
|
||||
|
||||
BANK = "hvb"
|
||||
RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b")
|
||||
# Kopfzeile pro Seite: "... <Zeitraum-Start> - <Zeitraum-Ende> <Auszug-Nr> <Seite>"
|
||||
RE_PERIOD = re.compile(r"(\d{2}\.\d{2}\.\d{4})\s*-\s*(\d{2}\.\d{2}\.\d{4})\s+\d+\s+\d+\s*$", re.M)
|
||||
# "Ihr alter Kontostand per 01.06.2020: EUR 1.234,56+"
|
||||
RE_OPENING = re.compile(r"Ihr\s+alter\s+Kontostand\s+per\s+\d{2}\.\d{2}\.\d{4}:\s*EUR\s+([\d.,]+[+-]?)", re.I)
|
||||
# "Ihr neuer Kontostand: EUR 2.345,67+" (Zwischensummen heißen "Saldo per ...:" und matchen hier nicht)
|
||||
RE_CLOSING = re.compile(r"Ihr\s+neuer\s+Kontostand:\s*EUR\s+([\d.,]+[+-]?)", re.I)
|
||||
# Buchungszeile Teil 1: "01.06. LASTSCHRIFT 123,45-" (Buchungsdatum, Vorgang, Betrag+Vorzeichen ohne Leerzeichen)
|
||||
RE_TX_HEAD = re.compile(r"^(\d{2}\.\d{2}\.)\s+(.+?)\s+([\d.,]+[+-])$")
|
||||
# Buchungszeile Teil 2: "01.06. Muster GmbH" (Wertdatum, Buchungsinformation; Text darf leer sein)
|
||||
RE_TX_DETAIL = re.compile(r"^(\d{2}\.\d{2}\.)\s*(.*)$")
|
||||
# Zwischensummen/Übertrag zwischen Seiten, dürfen nicht in den Verwendungszweck rutschen
|
||||
RE_CARRYOVER = re.compile(r"^(?:Übertrag|Saldo\s+per)\b", re.I)
|
||||
# Wiederkehrende Seiten-Kopf-/Fußzeilen (Titel, Spaltenköpfe, Fußnoten), ebenfalls kein Buchungstext
|
||||
RE_PAGE_NOISE = re.compile(
|
||||
r"^(?:K\s?O\s?N\s?T\s?O\s?A\s?U\s?S\s?Z\s?U\s?G"
|
||||
r"|Kontonummer\s+Bankleitzahl"
|
||||
r"|IBAN\s+BIC"
|
||||
r"|Kontoinhaber\s+Ansprechpartner"
|
||||
r"|Buchung/Wert\s+Buchungsinformation"
|
||||
r"|Bitte\s+R(?:ü|ue)ckseite\s+beachten"
|
||||
r"|Seite\s+\d+\s+von\s+\d+)",
|
||||
re.I,
|
||||
)
|
||||
# Weitere strukturelle Kopf-/Fußzeilen am Seitenwechsel (keine Kontodaten hart
|
||||
# codiert): Formularkennung, Ansprechpartner-Zeile, zusammengesetzte IBAN+BIC-Zeile.
|
||||
RE_HEADER_EXTRA = re.compile(
|
||||
r"(?:Smart Banking Team" # Ansprechpartner-/Kopfzeile
|
||||
r"|^\d{4}\.\d{2}/[A-Z]\d" # Formularkennung (z. B. 5202.70/C0..)
|
||||
r"|^DE\d{2}\s\d{8}\s\d{10}\s[A-Z]{8}" # IBAN + BIC in einer Zeile
|
||||
r")"
|
||||
)
|
||||
# Ganzseitiger Hinweis-/Rückseitenblock zwischen den Buchungsseiten: als
|
||||
# Sequenz vom Startsatz bis zur nächsten "Seite N von M"-Zeile überspringen.
|
||||
RE_HINT_START = re.compile(r"^Wir bitten Sie")
|
||||
RE_SEITE = re.compile(r"^Seite\s+\d+\s+von\s+\d+")
|
||||
|
||||
|
||||
def _is_noise(line: str) -> bool:
|
||||
return bool(RE_OPENING.search(line) or RE_CLOSING.search(line)
|
||||
or RE_CARRYOVER.match(line) or RE_PAGE_NOISE.match(line)
|
||||
or RE_HEADER_EXTRA.search(line) or RE_PERIOD.search(line))
|
||||
|
||||
|
||||
def parse(path: Path) -> ParsedStatement:
|
||||
with pdfplumber.open(path) as pdf:
|
||||
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
|
||||
period = RE_PERIOD.search(text)
|
||||
if not period:
|
||||
raise ParserError("HVB: Abrechnungszeitraum nicht gefunden")
|
||||
period_start = parse_german_date(period.group(1))
|
||||
period_end = parse_german_date(period.group(2))
|
||||
opening = RE_OPENING.search(text)
|
||||
closing = RE_CLOSING.search(text)
|
||||
if not opening or not closing:
|
||||
raise ParserError("HVB: Anfangs-/Endsaldo nicht gefunden")
|
||||
iban_m = RE_IBAN.search(text)
|
||||
txs: list[ParsedTransaction] = []
|
||||
pending: dict | None = None
|
||||
in_hint = False
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Ganzseitigen Hinweisblock als Sequenz überspringen.
|
||||
if in_hint:
|
||||
if RE_SEITE.match(line):
|
||||
in_hint = False
|
||||
continue
|
||||
if RE_HINT_START.match(line):
|
||||
in_hint = True
|
||||
continue
|
||||
# Schlusssaldo erreicht: laufende Buchung abschließen und Sammlung beenden.
|
||||
if RE_CLOSING.search(line):
|
||||
break
|
||||
m = RE_TX_HEAD.match(line)
|
||||
if m:
|
||||
if pending:
|
||||
if pending["value"] is None:
|
||||
raise ParserError("HVB: Buchungszeile ohne Detailzeile")
|
||||
txs.append(_finish(pending))
|
||||
pending = {
|
||||
"booking": parse_german_date(m.group(1), period_end.year),
|
||||
"value": None,
|
||||
"head": m.group(2).strip(),
|
||||
"amount": parse_german_amount(m.group(3)),
|
||||
"extra": [],
|
||||
}
|
||||
continue
|
||||
if pending and pending["value"] is None:
|
||||
d = RE_TX_DETAIL.match(line)
|
||||
if d:
|
||||
pending["value"] = parse_german_date(d.group(1), period_end.year)
|
||||
if d.group(2).strip():
|
||||
pending["extra"].append(d.group(2).strip())
|
||||
continue
|
||||
if pending and not _is_noise(line):
|
||||
pending["extra"].append(line)
|
||||
if pending:
|
||||
if pending["value"] is None:
|
||||
raise ParserError("HVB: Buchungszeile ohne Detailzeile")
|
||||
txs.append(_finish(pending))
|
||||
return ParsedStatement(bank=BANK,
|
||||
iban=iban_m.group(1).replace(" ", "") if iban_m else None,
|
||||
period_start=period_start, period_end=period_end,
|
||||
opening_balance=parse_german_amount(opening.group(1)),
|
||||
closing_balance=parse_german_amount(closing.group(1)),
|
||||
transactions=txs)
|
||||
|
||||
|
||||
def _finish(p: dict) -> ParsedTransaction:
|
||||
counterparty = p["extra"][0] if p["extra"] else ""
|
||||
purpose = " ".join([p["head"], *p["extra"][1:]]).strip()
|
||||
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
|
||||
amount=p["amount"], purpose=purpose,
|
||||
counterparty=counterparty)
|
||||
21
finance/app/parsers/registry.py
Normal file
21
finance/app/parsers/registry.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from app.parsers import dkb, hvb, vr
|
||||
from app.parsers.base import ParsedStatement, ParserError
|
||||
from app.parsers.detect import detect_bank
|
||||
|
||||
PARSERS: dict[str, Callable[[Path], ParsedStatement]] = {
|
||||
"vr": vr.parse, "hvb": hvb.parse, "dkb": dkb.parse,
|
||||
}
|
||||
|
||||
|
||||
def parse_pdf(path: Path) -> ParsedStatement:
|
||||
with pdfplumber.open(path) as pdf:
|
||||
first = pdf.pages[0].extract_text() or ""
|
||||
bank = detect_bank(first)
|
||||
if bank is None:
|
||||
raise ParserError("Bank nicht erkannt")
|
||||
return PARSERS[bank](path)
|
||||
15
finance/app/parsers/validate.py
Normal file
15
finance/app/parsers/validate.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import hashlib
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.parsers.base import ParsedStatement
|
||||
|
||||
|
||||
def balance_difference(p: ParsedStatement) -> Decimal:
|
||||
total = sum((t.amount for t in p.transactions), Decimal("0"))
|
||||
return (p.opening_balance + total - p.closing_balance).copy_abs()
|
||||
|
||||
|
||||
def dedup_hash(account_id: int, booking_date: date, amount: Decimal, purpose: str) -> str:
|
||||
raw = f"{account_id}|{booking_date.isoformat()}|{amount:.2f}|{' '.join(purpose.split())}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
139
finance/app/parsers/vr.py
Normal file
139
finance/app/parsers/vr.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Parser für Volksbank/Raiffeisenbank-Kontoauszüge.
|
||||
|
||||
Realer Fixture-Abgleich (Task 7, Schritt 4): Der eigentliche Auszugszeitraum
|
||||
steht nicht als "vom X bis Y"-Satz im Text (dieser Wortlaut kommt nur in einem
|
||||
separaten quartalsweisen Zinsabschluss-Absatz mit abweichendem Zeitraum vor).
|
||||
Anfangs-/Endsaldo tragen stattdessen je ein eigenes Datum ("alter/neuer
|
||||
Kontostand vom DD.MM.YYYY ..."), aus dem der Auszugszeitraum abgeleitet wird;
|
||||
RE_PERIOD bleibt als Fallback erhalten, falls ein Auszug diese Daten nicht
|
||||
enthält.
|
||||
"""
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
|
||||
parse_german_amount, parse_german_date)
|
||||
|
||||
BANK = "vr"
|
||||
RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b")
|
||||
# Fallback, falls kein Datum an den Kontostand-Zeilen hängt: "vom 01.06.2020 bis 30.06.2020"
|
||||
RE_PERIOD = re.compile(r"vom\s+(\d{2}\.\d{2}\.\d{4})\s+bis\s+(\d{2}\.\d{2}\.\d{4})")
|
||||
# "alter Kontostand vom 01.06.2020 1.234,56 H" (Datum optional, je nach Formatvariante)
|
||||
RE_OPENING = re.compile(r"alter\s+Kontostand\s+(?:vom\s+(\d{2}\.\d{2}\.\d{4})\s+)?([\d.,]+\s*[SH-]?)", re.I)
|
||||
# "neuer Kontostand vom 30.06.2020 2.345,67 H"
|
||||
RE_CLOSING = re.compile(r"neuer\s+Kontostand\s+(?:vom\s+(\d{2}\.\d{2}\.\d{4})\s+)?([\d.,]+\s*[SH-]?)", re.I)
|
||||
# Buchungszeile: "01.06. 01.06. Muster GmbH 1.234,56 S"
|
||||
RE_TX = re.compile(r"^(\d{2}\.\d{2}\.)\s+(\d{2}\.\d{2}\.)\s+(.*?)\s+([\d.,]+\s*[SH-])$")
|
||||
# Folgeseiten-Kopfblock (Bankanschrift bis Spaltenüberschrift) als Sequenz
|
||||
# überspringen: fängt auch die Kontoinhaber-Kopfzeile ab, ohne gleichnamige
|
||||
# Gegenparteien in Buchungen zu zerstören.
|
||||
RE_HEADER_START = re.compile(r"^Beraterpark am Olgaplatz")
|
||||
RE_HEADER_END = re.compile(r"^Bu-Tag\s+Wert\s+Vorgang")
|
||||
# Blattwechsel-/Formular-Reste, die sonst in den Verwendungszweck rutschen.
|
||||
RE_NOISE = re.compile(
|
||||
r"(?:Übertrag" # "Übertrag auf/von Blatt N"
|
||||
r"|^\d{3,4}$" # Formularcodes (z. B. 0007, 000)
|
||||
r"|^K\d{6}" # Formularcode (K + Ziffernblock)
|
||||
r"|Bitte beachten Sie die Hinweise" # Fußzeilen-Hinweis
|
||||
r"|^[─—–\-]{3,}$" # Trennlinien
|
||||
r")"
|
||||
)
|
||||
|
||||
|
||||
def _is_noise(line: str) -> bool:
|
||||
return bool(RE_NOISE.search(line))
|
||||
|
||||
|
||||
def _join_purpose(parts: list[str]) -> str:
|
||||
"""Verwendungszweck zusammensetzen und harte Zeilenumbrüche der VR-PDF
|
||||
(~54 Zeichen, teils mitten im Wort) rekonstruieren.
|
||||
|
||||
Ohne Leerzeichen wird nur dann verbunden, wenn die Vorzeile mindestens 53
|
||||
Zeichen lang ist UND (Vorzeile endet auf Ziffer und Folgezeile beginnt mit
|
||||
Ziffer) ODER (beide an der Bruchstelle Kleinbuchstaben) – sonst mit
|
||||
Leerzeichen. So bleibt z. B. "... 75" + "Euro" mit Leerzeichen getrennt.
|
||||
"""
|
||||
if not parts:
|
||||
return ""
|
||||
out = parts[0]
|
||||
for prev, cur in zip(parts, parts[1:]):
|
||||
glue = ""
|
||||
if len(prev) >= 53 and prev and cur:
|
||||
if (prev[-1].isdigit() and cur[0].isdigit()) or \
|
||||
(prev[-1].islower() and cur[0].islower()):
|
||||
glue = ""
|
||||
else:
|
||||
glue = " "
|
||||
else:
|
||||
glue = " "
|
||||
out += glue + cur
|
||||
return out.strip()
|
||||
|
||||
|
||||
def parse(path: Path) -> ParsedStatement:
|
||||
with pdfplumber.open(path) as pdf:
|
||||
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
|
||||
opening = RE_OPENING.search(text)
|
||||
closing = RE_CLOSING.search(text)
|
||||
if not opening or not closing:
|
||||
raise ParserError("VR: Anfangs-/Endsaldo nicht gefunden")
|
||||
if opening.group(1) and closing.group(1):
|
||||
period_start = parse_german_date(opening.group(1))
|
||||
period_end = parse_german_date(closing.group(1))
|
||||
else:
|
||||
period = RE_PERIOD.search(text)
|
||||
if not period:
|
||||
raise ParserError("VR: Abrechnungszeitraum nicht gefunden")
|
||||
period_start = parse_german_date(period.group(1))
|
||||
period_end = parse_german_date(period.group(2))
|
||||
iban_m = RE_IBAN.search(text)
|
||||
txs: list[ParsedTransaction] = []
|
||||
pending: dict | None = None
|
||||
in_header = False
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Folgeseiten-Kopfblock komplett überspringen (inkl. Kontoinhaber-Zeile).
|
||||
if in_header:
|
||||
if RE_HEADER_END.match(line):
|
||||
in_header = False
|
||||
continue
|
||||
if RE_HEADER_START.match(line):
|
||||
in_header = True
|
||||
continue
|
||||
# Schlusssaldo erreicht: laufende Buchung abschließen und Sammlung beenden.
|
||||
if RE_CLOSING.search(line):
|
||||
break
|
||||
m = RE_TX.match(line)
|
||||
if m:
|
||||
if pending:
|
||||
txs.append(_finish(pending))
|
||||
pending = {
|
||||
"booking": parse_german_date(m.group(1), period_end.year),
|
||||
"value": parse_german_date(m.group(2), period_end.year),
|
||||
"head": m.group(3).strip(),
|
||||
"amount": parse_german_amount(m.group(4)),
|
||||
"extra": [],
|
||||
}
|
||||
elif pending and not _is_noise(line):
|
||||
pending["extra"].append(line)
|
||||
if pending:
|
||||
txs.append(_finish(pending))
|
||||
return ParsedStatement(bank=BANK,
|
||||
iban=iban_m.group(1).replace(" ", "") if iban_m else None,
|
||||
period_start=period_start, period_end=period_end,
|
||||
opening_balance=parse_german_amount(opening.group(2)),
|
||||
closing_balance=parse_german_amount(closing.group(2)),
|
||||
transactions=txs)
|
||||
|
||||
|
||||
def _finish(p: dict) -> ParsedTransaction:
|
||||
counterparty = p["extra"][0] if p["extra"] else ""
|
||||
purpose = _join_purpose([p["head"], *p["extra"][1:]])
|
||||
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
|
||||
amount=p["amount"], purpose=purpose,
|
||||
counterparty=counterparty)
|
||||
0
finance/app/routers/__init__.py
Normal file
0
finance/app/routers/__init__.py
Normal file
111
finance/app/routers/accounts.py
Normal file
111
finance/app/routers/accounts.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.db import get_session
|
||||
from app.models.tables import Account
|
||||
from app.services.balances import account_balance
|
||||
|
||||
router = APIRouter(prefix="/api/accounts", tags=["accounts"],
|
||||
dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
class AccountIn(BaseModel):
|
||||
bank: str
|
||||
iban: str
|
||||
name: str
|
||||
type: str = "giro"
|
||||
|
||||
|
||||
class AccountOut(AccountIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
balance: Decimal = Decimal("0")
|
||||
anchor_date: date | None = None
|
||||
anchor_balance: Decimal | None = None
|
||||
|
||||
|
||||
class AccountPatch(BaseModel):
|
||||
# Alle Felder optional; model_fields_set entscheidet, was tatsaechlich
|
||||
# geaendert wird (Muster wie TransactionPatch.category_id). anchor_date
|
||||
# und anchor_balance muessen gemeinsam gesetzt oder gemeinsam null sein.
|
||||
name: str | None = None
|
||||
anchor_date: date | None = None
|
||||
anchor_balance: Decimal | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_not_blank(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
v = v.strip()
|
||||
if not v or len(v) > 100:
|
||||
raise ValueError("Name muss 1–100 Zeichen lang sein")
|
||||
return v
|
||||
|
||||
|
||||
@router.get("", response_model=list[AccountOut])
|
||||
def list_accounts(session: Session = Depends(get_session)):
|
||||
out = []
|
||||
for acc in session.execute(select(Account)).scalars():
|
||||
item = AccountOut.model_validate(acc)
|
||||
item.balance = account_balance(session, acc)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{account_id}", response_model=AccountOut)
|
||||
def get_account(account_id: int, session: Session = Depends(get_session)):
|
||||
acc = session.get(Account, account_id)
|
||||
if acc is None:
|
||||
raise HTTPException(404, "Konto nicht gefunden")
|
||||
item = AccountOut.model_validate(acc)
|
||||
item.balance = account_balance(session, acc)
|
||||
return item
|
||||
|
||||
|
||||
@router.post("", response_model=AccountOut, status_code=201)
|
||||
def create_account(data: AccountIn, session: Session = Depends(get_session)):
|
||||
if session.execute(select(Account).where(Account.iban == data.iban)).scalar():
|
||||
raise HTTPException(409, "IBAN existiert bereits")
|
||||
acc = Account(**data.model_dump())
|
||||
session.add(acc)
|
||||
session.commit()
|
||||
session.refresh(acc)
|
||||
return AccountOut.model_validate(acc)
|
||||
|
||||
|
||||
@router.patch("/{account_id}", response_model=AccountOut)
|
||||
def patch_account(account_id: int, data: AccountPatch, session: Session = Depends(get_session)):
|
||||
acc = session.get(Account, account_id)
|
||||
if acc is None:
|
||||
raise HTTPException(404, "Konto nicht gefunden")
|
||||
|
||||
# Validierung zuerst, VOR jeder Mutation - sonst haengt bei einem 422 auf
|
||||
# dem Anker eine bereits geschriebene name-Aenderung im Session-State.
|
||||
fields = data.model_fields_set
|
||||
if "name" in fields and data.name is None:
|
||||
raise HTTPException(422, "Name darf nicht leer sein")
|
||||
|
||||
anchor_fields = {"anchor_date", "anchor_balance"} & fields
|
||||
if anchor_fields and (
|
||||
anchor_fields != {"anchor_date", "anchor_balance"}
|
||||
or (data.anchor_date is None) != (data.anchor_balance is None)):
|
||||
raise HTTPException(422, "Anker braucht Datum und Betrag (oder beide leeren)")
|
||||
|
||||
if "name" in fields:
|
||||
acc.name = data.name
|
||||
if anchor_fields:
|
||||
acc.anchor_date = data.anchor_date
|
||||
acc.anchor_balance = data.anchor_balance
|
||||
|
||||
session.commit()
|
||||
session.refresh(acc)
|
||||
item = AccountOut.model_validate(acc)
|
||||
item.balance = account_balance(session, acc)
|
||||
return item
|
||||
75
finance/app/routers/categories.py
Normal file
75
finance/app/routers/categories.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.db import get_session
|
||||
from app.models.tables import Category, CategoryRule
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["categories"],
|
||||
dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
class CategoryIn(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class CategoryOut(CategoryIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
class CategoryRuleIn(BaseModel):
|
||||
pattern: str
|
||||
category_id: int
|
||||
priority: int = 100
|
||||
|
||||
|
||||
class CategoryRuleOut(CategoryRuleIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[CategoryOut])
|
||||
def list_categories(session: Session = Depends(get_session)):
|
||||
return [CategoryOut.model_validate(c)
|
||||
for c in session.execute(select(Category)).scalars()]
|
||||
|
||||
|
||||
@router.post("/categories", response_model=CategoryOut, status_code=201)
|
||||
def create_category(data: CategoryIn, session: Session = Depends(get_session)):
|
||||
if session.execute(select(Category).where(Category.name == data.name)).scalar():
|
||||
raise HTTPException(409, "Kategorie existiert bereits")
|
||||
cat = Category(**data.model_dump())
|
||||
session.add(cat)
|
||||
session.commit()
|
||||
session.refresh(cat)
|
||||
return CategoryOut.model_validate(cat)
|
||||
|
||||
|
||||
@router.get("/category-rules", response_model=list[CategoryRuleOut])
|
||||
def list_category_rules(session: Session = Depends(get_session)):
|
||||
rules = session.execute(
|
||||
select(CategoryRule).order_by(CategoryRule.priority)).scalars()
|
||||
return [CategoryRuleOut.model_validate(r) for r in rules]
|
||||
|
||||
|
||||
@router.post("/category-rules", response_model=CategoryRuleOut, status_code=201)
|
||||
def create_category_rule(data: CategoryRuleIn, session: Session = Depends(get_session)):
|
||||
if session.get(Category, data.category_id) is None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
rule = CategoryRule(**data.model_dump())
|
||||
session.add(rule)
|
||||
session.commit()
|
||||
session.refresh(rule)
|
||||
return CategoryRuleOut.model_validate(rule)
|
||||
|
||||
|
||||
@router.delete("/category-rules/{rule_id}", status_code=204)
|
||||
def delete_category_rule(rule_id: int, session: Session = Depends(get_session)):
|
||||
rule = session.get(CategoryRule, rule_id)
|
||||
if rule is None:
|
||||
raise HTTPException(404, "Regel nicht gefunden")
|
||||
session.delete(rule)
|
||||
session.commit()
|
||||
297
finance/app/routers/gui.py
Normal file
297
finance/app/routers/gui.py
Normal file
@@ -0,0 +1,297 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import COOKIE, session_valid
|
||||
from app.db import get_session
|
||||
from app.engine.loans import add_months
|
||||
from app.engine.recurrence import occurrences
|
||||
from app.models.tables import (Account, Category, PlannedItem,
|
||||
ProjectionResult, RecurringItem,
|
||||
ScenarioLoan, ScenarioModifier, Transaction)
|
||||
from app.routers.imports import list_imports as _list_imports
|
||||
from app.routers.imports import preview as _preview_import
|
||||
from app.routers.planning import list_loans as _list_loans
|
||||
from app.routers.planning import list_planned as _list_planned
|
||||
from app.routers.planning import list_recurring as _list_recurring
|
||||
from app.routers.planning import recurring_suggestions as _recurring_suggestions
|
||||
from app.routers.scenarios import list_scenarios as _list_scenarios
|
||||
from app.routers.transactions import count_transactions, list_transactions
|
||||
from app.services.balances import account_balance, total_balance
|
||||
from app.version import get_version
|
||||
|
||||
PAGE_SIZE = 50
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
templates.env.globals["app_version"] = get_version()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def gui_session(request: Request):
|
||||
if not session_valid(request.cookies.get(COOKIE)):
|
||||
raise _redirect()
|
||||
|
||||
|
||||
def _redirect():
|
||||
exc = HTTPException(302)
|
||||
exc.headers = {"Location": "/login"}
|
||||
return exc
|
||||
|
||||
|
||||
def _upcoming_items(session: Session, today: date, days: int = 30, limit: int = 10) -> list[dict]:
|
||||
"""Nächste Posten aus RecurringItems (Termine) und PlannedItems der nächsten `days` Tage."""
|
||||
end = today + timedelta(days=days)
|
||||
items: list[dict] = []
|
||||
for r in session.execute(select(RecurringItem)).scalars():
|
||||
for d in occurrences(r.rhythm, r.due_day, today, end, r.start_date, r.end_date):
|
||||
items.append({"date": d, "name": r.name, "amount": Decimal(r.amount)})
|
||||
for p in session.execute(
|
||||
select(PlannedItem).where(PlannedItem.due >= today, PlannedItem.due <= end)
|
||||
).scalars():
|
||||
items.append({"date": p.due, "name": p.name, "amount": Decimal(p.amount)})
|
||||
items.sort(key=lambda i: i["date"])
|
||||
return items[:limit]
|
||||
|
||||
|
||||
def _latest_projection(session: Session) -> ProjectionResult | None:
|
||||
return session.execute(
|
||||
select(ProjectionResult).order_by(ProjectionResult.computed_at.desc())
|
||||
).scalars().first()
|
||||
|
||||
|
||||
def _opt_int(value: str | None) -> int | None:
|
||||
"""Wandelt einen GUI-Query-Parameter tolerant in int|None um. HTML-Formulare
|
||||
senden bei nicht ausgefüllten Feldern leere Strings statt gar keinen
|
||||
Parameter - das darf nie zu einem 422 fuehren, sondern degradiert zu None."""
|
||||
if value is None or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _opt_date(value: str | None) -> date | None:
|
||||
"""Wie _opt_int, nur fuer date-Felder (ISO-Format aus <input type=date>)."""
|
||||
if value is None or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _month_starts_desc(session: Session, today: date) -> list[date]:
|
||||
"""Liefert die Monatsersten von "heute" rueckwaerts bis zum Monat der
|
||||
fruehesten bestaetigten Buchung, absteigend sortiert (heute zuerst).
|
||||
Ohne bestaetigte Buchungen: leere Liste."""
|
||||
earliest = session.execute(
|
||||
select(func.min(Transaction.booking_date)).where(Transaction.status == "confirmed")
|
||||
).scalar_one_or_none()
|
||||
if earliest is None:
|
||||
return []
|
||||
start = date(earliest.year, earliest.month, 1)
|
||||
current = date(today.year, today.month, 1)
|
||||
months = []
|
||||
m = current
|
||||
while m >= start:
|
||||
months.append(m)
|
||||
m = add_months(m, -1)
|
||||
return months
|
||||
|
||||
|
||||
@router.get("/", dependencies=[Depends(gui_session)])
|
||||
def index(request: Request, session: Session = Depends(get_session)):
|
||||
accounts = session.execute(select(Account)).scalars().all()
|
||||
balances = [(a, account_balance(session, a)) for a in accounts]
|
||||
return templates.TemplateResponse(request, "index.html", {
|
||||
"balances": balances,
|
||||
"total": total_balance(session),
|
||||
"upcoming": _upcoming_items(session, date.today()),
|
||||
"projection": _latest_projection(session),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/buchungen", dependencies=[Depends(gui_session)])
|
||||
def buchungen(
|
||||
request: Request,
|
||||
account_id: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
category_id: str | None = None,
|
||||
q: str | None = None,
|
||||
status: str = "confirmed",
|
||||
page: int = 1,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
# HTML-Filterformular sendet leere Strings fuer nicht ausgefuellte Felder -
|
||||
# daher hier bewusst str|None statt int|None/date|None und tolerante
|
||||
# Konvertierung, statt FastAPI vor dem Handler mit 422 abbrechen zu lassen.
|
||||
# Die API-Routen (/api/transactions) bleiben strikt typisiert.
|
||||
account_id = _opt_int(account_id)
|
||||
date_from = _opt_date(date_from)
|
||||
date_to = _opt_date(date_to)
|
||||
category_id = _opt_int(category_id)
|
||||
page = max(1, page)
|
||||
total = count_transactions(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
category_id=category_id,
|
||||
q=q,
|
||||
status=status,
|
||||
session=session,
|
||||
)
|
||||
total_pages = max(1, -(-total // PAGE_SIZE)) # ceil division
|
||||
page = min(page, total_pages)
|
||||
txs = list_transactions(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
category_id=category_id,
|
||||
q=q,
|
||||
status=status,
|
||||
limit=PAGE_SIZE,
|
||||
offset=(page - 1) * PAGE_SIZE,
|
||||
session=session,
|
||||
)
|
||||
accounts = session.execute(select(Account)).scalars().all()
|
||||
return templates.TemplateResponse(request, "transactions.html", {
|
||||
"transactions": txs,
|
||||
"accounts": accounts,
|
||||
"account_names": {a.id: a.name for a in accounts},
|
||||
"categories": session.execute(select(Category)).scalars().all(),
|
||||
"filters": {
|
||||
"account_id": account_id,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"category_id": category_id,
|
||||
"q": q or "",
|
||||
"status": status,
|
||||
},
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/salden", dependencies=[Depends(gui_session)])
|
||||
def salden_page(
|
||||
request: Request,
|
||||
stichtag: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
# Lenient wie die Filter auf /buchungen: ein leerer oder kaputter
|
||||
# stichtag-Parameter fuehrt nie zu 422, sondern faellt auf "heute" zurueck.
|
||||
today = date.today()
|
||||
parsed_stichtag = _opt_date(stichtag) or today
|
||||
|
||||
accounts = session.execute(select(Account)).scalars().all()
|
||||
balances = [(a, account_balance(session, a, at=parsed_stichtag)) for a in accounts]
|
||||
total = sum((b for _, b in balances), Decimal("0"))
|
||||
|
||||
month_rows = []
|
||||
for month in _month_starts_desc(session, today):
|
||||
# Saldo am Monatsanfang = Stand am ENDE DES VORTAGS (letzter Tag des
|
||||
# Vormonats), NICHT account_balance(at=month) - sonst wuerden
|
||||
# Buchungen AM 1. bereits in den "Anfangs"-Saldo einfliessen und den
|
||||
# Ausweis verzerren. Siehe Fussnote im Template ("Stand: Ende des
|
||||
# Vortags").
|
||||
vortag = month - timedelta(days=1)
|
||||
values = [(a, account_balance(session, a, at=vortag)) for a in accounts]
|
||||
month_rows.append({
|
||||
"month": month,
|
||||
"values": values,
|
||||
"total": sum((v for _, v in values), Decimal("0")),
|
||||
})
|
||||
|
||||
accounts_without_anchor = [
|
||||
a for a in accounts if a.anchor_date is None or a.anchor_balance is None
|
||||
]
|
||||
|
||||
return templates.TemplateResponse(request, "salden.html", {
|
||||
"stichtag": parsed_stichtag,
|
||||
"balances": balances,
|
||||
"total": total,
|
||||
"accounts": accounts,
|
||||
"months": month_rows,
|
||||
"accounts_without_anchor": accounts_without_anchor,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/import", dependencies=[Depends(gui_session)])
|
||||
def import_page(request: Request, session: Session = Depends(get_session)):
|
||||
return templates.TemplateResponse(request, "import.html", {
|
||||
"statements": _list_imports(session=session),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/import/list", dependencies=[Depends(gui_session)])
|
||||
def import_list_fragment(request: Request, session: Session = Depends(get_session)):
|
||||
return templates.TemplateResponse(request, "_import_list.html", {
|
||||
"statements": _list_imports(session=session),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/import/{statement_id}/preview-fragment", dependencies=[Depends(gui_session)])
|
||||
def import_preview_fragment(statement_id: int, request: Request,
|
||||
session: Session = Depends(get_session)):
|
||||
result = _preview_import(statement_id, session=session)
|
||||
return templates.TemplateResponse(request, "_preview_table.html", {
|
||||
"statement": result.statement,
|
||||
"transactions": result.transactions,
|
||||
"balance_ok": result.balance_ok,
|
||||
"duplicates": result.duplicates,
|
||||
})
|
||||
|
||||
|
||||
def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]:
|
||||
"""Baut je Szenario den Kontext fürs Template: zugeordnete Kredite,
|
||||
Modifikatoren und (falls vorhanden) das letzte Projektionsergebnis."""
|
||||
rows = []
|
||||
for sc in scenarios:
|
||||
assigned_loan_ids = {
|
||||
sl.loan_id for sl in session.execute(
|
||||
select(ScenarioLoan).where(ScenarioLoan.scenario_id == sc.id)
|
||||
).scalars()
|
||||
}
|
||||
modifiers = session.execute(
|
||||
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
|
||||
).scalars().all()
|
||||
rows.append({
|
||||
"scenario": sc,
|
||||
"assigned_loan_ids": assigned_loan_ids,
|
||||
"modifiers": modifiers,
|
||||
"result": session.get(ProjectionResult, sc.id),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/hilfe", dependencies=[Depends(gui_session)])
|
||||
def hilfe_page(request: Request):
|
||||
return templates.TemplateResponse(request, "hilfe.html", {})
|
||||
|
||||
|
||||
@router.get("/planung", dependencies=[Depends(gui_session)])
|
||||
def planung_page(request: Request, session: Session = Depends(get_session)):
|
||||
categories = session.execute(select(Category)).scalars().all()
|
||||
recurring = _list_recurring(session=session)
|
||||
planned = _list_planned(session=session)
|
||||
loans = _list_loans(session=session)
|
||||
scenarios = _list_scenarios(session=session)
|
||||
return templates.TemplateResponse(request, "planning.html", {
|
||||
"categories": categories,
|
||||
"category_names": {c.id: c.name for c in categories},
|
||||
"recurring": recurring,
|
||||
"recurring_names": {r.id: r.name for r in recurring},
|
||||
"suggestions": _recurring_suggestions(session=session),
|
||||
"planned": planned,
|
||||
"loans": loans,
|
||||
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
||||
"rhythms": ["monthly", "quarterly", "yearly"],
|
||||
"repayment_types": ["annuity", "bullet"],
|
||||
"modifier_kinds": ["percent", "absolute", "remove"],
|
||||
})
|
||||
195
finance/app/routers/imports.py
Normal file
195
finance/app/routers/imports.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.models.tables import Statement, Transaction
|
||||
from app.services.importer import process_file
|
||||
|
||||
router = APIRouter(prefix="/api/imports", tags=["imports"],
|
||||
dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
class StatementOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
filename: str
|
||||
bank: str
|
||||
account_id: int | None = None
|
||||
period_start: date | None = None
|
||||
period_end: date | None = None
|
||||
opening_balance: Decimal | None = None
|
||||
closing_balance: Decimal | None = None
|
||||
status: str
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class TransactionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
account_id: int
|
||||
statement_id: int | None = None
|
||||
booking_date: date
|
||||
value_date: date | None = None
|
||||
amount: Decimal
|
||||
purpose: str
|
||||
counterparty: str
|
||||
category_id: int | None = None
|
||||
status: str
|
||||
dedup_hash: str
|
||||
is_duplicate: bool
|
||||
|
||||
|
||||
class PreviewOut(BaseModel):
|
||||
statement: StatementOut
|
||||
transactions: list[TransactionOut]
|
||||
balance_ok: bool | None
|
||||
duplicates: int
|
||||
|
||||
|
||||
@router.post("/upload", response_model=StatementOut, status_code=201)
|
||||
def upload(file: UploadFile, session: Session = Depends(get_session)):
|
||||
safe_name = Path(file.filename or "upload.pdf").name
|
||||
if not safe_name or not safe_name.lower().endswith((".pdf", ".csv")):
|
||||
raise HTTPException(400, "Nur PDF- oder CSV-Dateien")
|
||||
settings = get_settings()
|
||||
inbox_dir = settings.inbox_dir
|
||||
inbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = inbox_dir / safe_name
|
||||
with dest.open("wb") as f:
|
||||
f.write(file.file.read())
|
||||
stmt = process_file(session, dest)
|
||||
return StatementOut.model_validate(stmt)
|
||||
|
||||
|
||||
@router.post("/scan-inbox", response_model=list[StatementOut])
|
||||
def scan_inbox(session: Session = Depends(get_session)):
|
||||
settings = get_settings()
|
||||
inbox_dir = settings.inbox_dir
|
||||
inbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths = sorted(inbox_dir.glob("*.pdf")) + sorted(inbox_dir.glob("*.csv"))
|
||||
results = []
|
||||
for path in paths:
|
||||
stmt = process_file(session, path)
|
||||
results.append(StatementOut.model_validate(stmt))
|
||||
return results
|
||||
|
||||
|
||||
@router.get("", response_model=list[StatementOut])
|
||||
def list_imports(session: Session = Depends(get_session)):
|
||||
stmts = session.execute(select(Statement).order_by(Statement.id)).scalars()
|
||||
return [StatementOut.model_validate(s) for s in stmts]
|
||||
|
||||
|
||||
@router.get("/{statement_id}/preview", response_model=PreviewOut)
|
||||
def preview(statement_id: int, session: Session = Depends(get_session)):
|
||||
stmt = session.get(Statement, statement_id)
|
||||
if stmt is None:
|
||||
raise HTTPException(404, "Import nicht gefunden")
|
||||
txs = session.execute(
|
||||
select(Transaction).where(Transaction.statement_id == statement_id)
|
||||
.order_by(Transaction.booking_date, Transaction.id)
|
||||
).scalars().all()
|
||||
# Dreiwertig (Ausbaustufe 3 Task 3): manche CSV-Formate (z.B. HVB, DKB)
|
||||
# liefern keine Saldodaten - None statt False, damit die Vorschau nicht
|
||||
# faelschlich einen Saldo-Fehler anzeigt, wo schlicht keine Pruefung
|
||||
# moeglich ist.
|
||||
if stmt.opening_balance is None or stmt.closing_balance is None:
|
||||
balance_ok = None
|
||||
else:
|
||||
balance_ok = (
|
||||
stmt.opening_balance + sum((t.amount for t in txs), Decimal("0"))
|
||||
- stmt.closing_balance
|
||||
) == 0
|
||||
duplicates = sum(1 for t in txs if t.is_duplicate)
|
||||
return PreviewOut(
|
||||
statement=StatementOut.model_validate(stmt),
|
||||
transactions=[TransactionOut.model_validate(t) for t in txs],
|
||||
balance_ok=balance_ok,
|
||||
duplicates=duplicates,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{statement_id}/confirm", response_model=StatementOut)
|
||||
def confirm(statement_id: int, session: Session = Depends(get_session)):
|
||||
stmt = session.get(Statement, statement_id)
|
||||
if stmt is None:
|
||||
raise HTTPException(404, "Import nicht gefunden")
|
||||
if stmt.status != "draft":
|
||||
raise HTTPException(409, "Import ist nicht im Entwurfsstatus")
|
||||
txs = session.execute(
|
||||
select(Transaction).where(
|
||||
Transaction.statement_id == statement_id,
|
||||
Transaction.status == "draft",
|
||||
Transaction.is_duplicate == False, # noqa: E712
|
||||
)
|
||||
).scalars().all()
|
||||
for tx in txs:
|
||||
# Re-check against currently confirmed transactions: another import
|
||||
# confirmed between preview and now may have introduced the same
|
||||
# booking. Mark late duplicates and skip promoting them.
|
||||
clash = session.execute(
|
||||
select(Transaction).where(
|
||||
Transaction.dedup_hash == tx.dedup_hash,
|
||||
Transaction.status == "confirmed",
|
||||
Transaction.id != tx.id,
|
||||
# Only guard against duplicates from OTHER statements. Two
|
||||
# genuinely identical bookings within the same statement share a
|
||||
# dedup_hash; the balance the preview validated depends on both
|
||||
# being confirmed, so they must not knock each other out here.
|
||||
Transaction.statement_id != tx.statement_id,
|
||||
)
|
||||
).scalar()
|
||||
if clash is not None:
|
||||
tx.is_duplicate = True
|
||||
continue
|
||||
tx.status = "confirmed"
|
||||
stmt.status = "confirmed"
|
||||
session.commit()
|
||||
session.refresh(stmt)
|
||||
return StatementOut.model_validate(stmt)
|
||||
|
||||
|
||||
@router.post("/{statement_id}/rollback")
|
||||
def rollback(statement_id: int, session: Session = Depends(get_session)):
|
||||
stmt = session.get(Statement, statement_id)
|
||||
if stmt is None:
|
||||
raise HTTPException(404, "Import nicht gefunden")
|
||||
if stmt.status != "confirmed":
|
||||
raise HTTPException(409, "Nur bestätigte Importe können zurückgerollt werden")
|
||||
txs = session.execute(
|
||||
select(Transaction).where(Transaction.statement_id == statement_id)
|
||||
).scalars().all()
|
||||
deleted = len(txs)
|
||||
for tx in txs:
|
||||
session.delete(tx)
|
||||
session.flush()
|
||||
session.delete(stmt)
|
||||
session.commit()
|
||||
return {"deleted_transactions": deleted, "statement_id": statement_id}
|
||||
|
||||
|
||||
@router.delete("/{statement_id}", status_code=204)
|
||||
def delete_import(statement_id: int, session: Session = Depends(get_session)):
|
||||
stmt = session.get(Statement, statement_id)
|
||||
if stmt is None:
|
||||
raise HTTPException(404, "Import nicht gefunden")
|
||||
if stmt.status == "confirmed":
|
||||
raise HTTPException(409, "Bestätigter Import kann nicht gelöscht werden")
|
||||
drafts = session.execute(
|
||||
select(Transaction).where(
|
||||
Transaction.statement_id == statement_id,
|
||||
Transaction.status == "draft",
|
||||
)
|
||||
).scalars().all()
|
||||
for tx in drafts:
|
||||
session.delete(tx)
|
||||
session.delete(stmt)
|
||||
session.commit()
|
||||
249
finance/app/routers/planning.py
Normal file
249
finance/app/routers/planning.py
Normal file
@@ -0,0 +1,249 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.db import get_session
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
|
||||
ScenarioLoan)
|
||||
from app.services.suggestions import suggest_recurring
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["planning"],
|
||||
dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Recurring
|
||||
class RecurringIn(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: Literal["monthly", "quarterly", "yearly"]
|
||||
due_day: int = Field(ge=1, le=31)
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
class RecurringOut(RecurringIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
class RecurringPatch(BaseModel):
|
||||
name: str | None = None
|
||||
amount: Decimal | None = None
|
||||
rhythm: Literal["monthly", "quarterly", "yearly"] | None = None
|
||||
due_day: int | None = Field(default=None, ge=1, le=31)
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
class SuggestionOut(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
due_day: int
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
def _check_category(session: Session, category_id: int | None) -> None:
|
||||
if category_id is not None and session.get(Category, category_id) is None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
|
||||
|
||||
@router.get("/recurring", response_model=list[RecurringOut])
|
||||
def list_recurring(session: Session = Depends(get_session)):
|
||||
return [RecurringOut.model_validate(r)
|
||||
for r in session.execute(select(RecurringItem)).scalars()]
|
||||
|
||||
|
||||
@router.post("/recurring", response_model=RecurringOut, status_code=201)
|
||||
def create_recurring(data: RecurringIn, session: Session = Depends(get_session)):
|
||||
_check_category(session, data.category_id)
|
||||
item = RecurringItem(**data.model_dump())
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return RecurringOut.model_validate(item)
|
||||
|
||||
|
||||
# Muss vor /recurring/{item_id} stehen, sonst faengt die {id}-Route den Pfad ab.
|
||||
@router.get("/recurring/suggestions", response_model=list[SuggestionOut])
|
||||
def recurring_suggestions(session: Session = Depends(get_session)):
|
||||
return suggest_recurring(session)
|
||||
|
||||
|
||||
@router.patch("/recurring/{item_id}", response_model=RecurringOut)
|
||||
def patch_recurring(item_id: int, data: RecurringPatch,
|
||||
session: Session = Depends(get_session)):
|
||||
item = session.get(RecurringItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(404, "Fixposten nicht gefunden")
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
if "category_id" in fields:
|
||||
_check_category(session, fields["category_id"])
|
||||
for key, value in fields.items():
|
||||
setattr(item, key, value)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return RecurringOut.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/recurring/{item_id}", status_code=204)
|
||||
def delete_recurring(item_id: int, session: Session = Depends(get_session)):
|
||||
item = session.get(RecurringItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(404, "Fixposten nicht gefunden")
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Planned
|
||||
class PlannedIn(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
due: date
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
class PlannedOut(PlannedIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
class PlannedPatch(BaseModel):
|
||||
name: str | None = None
|
||||
amount: Decimal | None = None
|
||||
due: date | None = None
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
@router.get("/planned", response_model=list[PlannedOut])
|
||||
def list_planned(session: Session = Depends(get_session)):
|
||||
return [PlannedOut.model_validate(p)
|
||||
for p in session.execute(select(PlannedItem)).scalars()]
|
||||
|
||||
|
||||
@router.post("/planned", response_model=PlannedOut, status_code=201)
|
||||
def create_planned(data: PlannedIn, session: Session = Depends(get_session)):
|
||||
_check_category(session, data.category_id)
|
||||
item = PlannedItem(**data.model_dump())
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return PlannedOut.model_validate(item)
|
||||
|
||||
|
||||
@router.patch("/planned/{item_id}", response_model=PlannedOut)
|
||||
def patch_planned(item_id: int, data: PlannedPatch,
|
||||
session: Session = Depends(get_session)):
|
||||
item = session.get(PlannedItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(404, "Einmalposten nicht gefunden")
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
if "category_id" in fields:
|
||||
_check_category(session, fields["category_id"])
|
||||
for key, value in fields.items():
|
||||
setattr(item, key, value)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return PlannedOut.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/planned/{item_id}", status_code=204)
|
||||
def delete_planned(item_id: int, session: Session = Depends(get_session)):
|
||||
item = session.get(PlannedItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(404, "Einmalposten nicht gefunden")
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- Loans
|
||||
class LoanIn(BaseModel):
|
||||
name: str
|
||||
principal: Decimal
|
||||
annual_rate_pct: Decimal
|
||||
term_months: int
|
||||
payout_date: date
|
||||
repayment_type: Literal["annuity", "bullet"] = "annuity"
|
||||
|
||||
|
||||
class LoanOut(LoanIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
class LoanPatch(BaseModel):
|
||||
name: str | None = None
|
||||
principal: Decimal | None = None
|
||||
annual_rate_pct: Decimal | None = None
|
||||
term_months: int | None = None
|
||||
payout_date: date | None = None
|
||||
repayment_type: Literal["annuity", "bullet"] | None = None
|
||||
|
||||
|
||||
class InstallmentOut(BaseModel):
|
||||
due: date
|
||||
payment: Decimal
|
||||
interest: Decimal
|
||||
principal: Decimal
|
||||
remaining: Decimal
|
||||
|
||||
|
||||
@router.get("/loans", response_model=list[LoanOut])
|
||||
def list_loans(session: Session = Depends(get_session)):
|
||||
return [LoanOut.model_validate(l)
|
||||
for l in session.execute(select(Loan)).scalars()]
|
||||
|
||||
|
||||
@router.post("/loans", response_model=LoanOut, status_code=201)
|
||||
def create_loan(data: LoanIn, session: Session = Depends(get_session)):
|
||||
loan = Loan(**data.model_dump())
|
||||
session.add(loan)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
return LoanOut.model_validate(loan)
|
||||
|
||||
|
||||
@router.patch("/loans/{loan_id}", response_model=LoanOut)
|
||||
def patch_loan(loan_id: int, data: LoanPatch, session: Session = Depends(get_session)):
|
||||
loan = session.get(Loan, loan_id)
|
||||
if loan is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(loan, key, value)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
return LoanOut.model_validate(loan)
|
||||
|
||||
|
||||
@router.delete("/loans/{loan_id}", status_code=204)
|
||||
def delete_loan(loan_id: int, session: Session = Depends(get_session)):
|
||||
loan = session.get(Loan, loan_id)
|
||||
if loan is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
# Remove scenario assignments first: no DB-level cascade exists, so a
|
||||
# dangling ScenarioLoan row would raise a foreign-key violation (500 on
|
||||
# Postgres).
|
||||
session.execute(delete(ScenarioLoan).where(ScenarioLoan.loan_id == loan_id))
|
||||
session.delete(loan)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.get("/loans/{loan_id}/schedule", response_model=list[InstallmentOut])
|
||||
def get_loan_schedule(loan_id: int, session: Session = Depends(get_session)):
|
||||
loan = session.get(Loan, loan_id)
|
||||
if loan is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
plan = loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
|
||||
loan.term_months, loan.payout_date, loan.repayment_type)
|
||||
return [InstallmentOut(due=i.due, payment=i.payment, interest=i.interest,
|
||||
principal=i.principal, remaining=i.remaining)
|
||||
for i in plan]
|
||||
186
finance/app/routers/scenarios.py
Normal file
186
finance/app/routers/scenarios.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
||||
Scenario, ScenarioLoan, ScenarioModifier)
|
||||
from app.services.projection_service import run_projection
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
|
||||
dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
class ScenarioIn(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
include_recurring: bool = True
|
||||
include_planned: bool = True
|
||||
|
||||
|
||||
class ScenarioOut(ScenarioIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
class ScenarioPatch(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
include_recurring: bool | None = None
|
||||
include_planned: bool | None = None
|
||||
|
||||
|
||||
class ModifierIn(BaseModel):
|
||||
target_type: Literal["category", "recurring"]
|
||||
target_id: int
|
||||
kind: Literal["percent", "absolute", "remove"]
|
||||
value: Decimal = Decimal("0")
|
||||
|
||||
|
||||
class ModifierOut(ModifierIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
scenario_id: int
|
||||
|
||||
|
||||
class SeriesPointOut(BaseModel):
|
||||
day: date
|
||||
balance: Decimal
|
||||
|
||||
|
||||
class ProjectionOut(BaseModel):
|
||||
low_point_date: date
|
||||
low_point_balance: Decimal
|
||||
below_zero_date: date | None
|
||||
below_threshold_date: date | None
|
||||
series: list[SeriesPointOut]
|
||||
|
||||
|
||||
def _get_scenario(session: Session, scenario_id: int) -> Scenario:
|
||||
scenario = session.get(Scenario, scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(404, "Szenario nicht gefunden")
|
||||
return scenario
|
||||
|
||||
|
||||
@router.get("", response_model=list[ScenarioOut])
|
||||
def list_scenarios(session: Session = Depends(get_session)):
|
||||
return [ScenarioOut.model_validate(s)
|
||||
for s in session.execute(select(Scenario)).scalars()]
|
||||
|
||||
|
||||
@router.post("", response_model=ScenarioOut, status_code=201)
|
||||
def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)):
|
||||
if session.execute(select(Scenario).where(Scenario.name == data.name)).scalar():
|
||||
raise HTTPException(409, "Szenario existiert bereits")
|
||||
scenario = Scenario(**data.model_dump())
|
||||
session.add(scenario)
|
||||
session.commit()
|
||||
session.refresh(scenario)
|
||||
return ScenarioOut.model_validate(scenario)
|
||||
|
||||
|
||||
@router.patch("/{scenario_id}", response_model=ScenarioOut)
|
||||
def patch_scenario(scenario_id: int, data: ScenarioPatch,
|
||||
session: Session = Depends(get_session)):
|
||||
scenario = _get_scenario(session, scenario_id)
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
if "name" in fields and fields["name"] != scenario.name:
|
||||
clash = session.execute(
|
||||
select(Scenario).where(Scenario.name == fields["name"])).scalar()
|
||||
if clash is not None:
|
||||
raise HTTPException(409, "Szenario existiert bereits")
|
||||
for key, value in fields.items():
|
||||
setattr(scenario, key, value)
|
||||
session.commit()
|
||||
session.refresh(scenario)
|
||||
return ScenarioOut.model_validate(scenario)
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}", status_code=204)
|
||||
def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
|
||||
scenario = _get_scenario(session, scenario_id)
|
||||
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
|
||||
# foreign-key violations (would surface as a 500 on Postgres).
|
||||
session.execute(delete(ProjectionPoint).where(
|
||||
ProjectionPoint.scenario_id == scenario_id))
|
||||
session.execute(delete(ProjectionResult).where(
|
||||
ProjectionResult.scenario_id == scenario_id))
|
||||
session.execute(delete(ScenarioLoan).where(
|
||||
ScenarioLoan.scenario_id == scenario_id))
|
||||
session.execute(delete(ScenarioModifier).where(
|
||||
ScenarioModifier.scenario_id == scenario_id))
|
||||
session.delete(scenario)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.post("/{scenario_id}/loans/{loan_id}", status_code=204)
|
||||
def add_scenario_loan(scenario_id: int, loan_id: int,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
if session.get(Loan, loan_id) is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
if session.get(ScenarioLoan, (scenario_id, loan_id)) is None:
|
||||
session.add(ScenarioLoan(scenario_id=scenario_id, loan_id=loan_id))
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}/loans/{loan_id}", status_code=204)
|
||||
def remove_scenario_loan(scenario_id: int, loan_id: int,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
link = session.get(ScenarioLoan, (scenario_id, loan_id))
|
||||
if link is not None:
|
||||
session.delete(link)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.post("/{scenario_id}/modifiers", response_model=ModifierOut, status_code=201)
|
||||
def add_modifier(scenario_id: int, data: ModifierIn,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
modifier = ScenarioModifier(scenario_id=scenario_id, **data.model_dump())
|
||||
session.add(modifier)
|
||||
session.commit()
|
||||
session.refresh(modifier)
|
||||
return ModifierOut.model_validate(modifier)
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}/modifiers/{mod_id}", status_code=204)
|
||||
def delete_modifier(scenario_id: int, mod_id: int,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
modifier = session.get(ScenarioModifier, mod_id)
|
||||
if modifier is None or modifier.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Modifikator nicht gefunden")
|
||||
session.delete(modifier)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.post("/{scenario_id}/project", response_model=ProjectionOut)
|
||||
def project_scenario(scenario_id: int, horizon_days: int | None = None,
|
||||
start_date: date | None = None,
|
||||
session: Session = Depends(get_session)):
|
||||
scenario = _get_scenario(session, scenario_id)
|
||||
settings = get_settings()
|
||||
resolved_horizon = horizon_days if horizon_days is not None else settings.horizon_days
|
||||
resolved_start = start_date if start_date is not None else date.today()
|
||||
result = run_projection(session, scenario, resolved_horizon, resolved_start)
|
||||
points = session.execute(
|
||||
select(ProjectionPoint).where(ProjectionPoint.scenario_id == scenario_id)
|
||||
.order_by(ProjectionPoint.day)
|
||||
).scalars().all()
|
||||
return ProjectionOut(
|
||||
low_point_date=result.low_point_date,
|
||||
low_point_balance=result.low_point_balance,
|
||||
below_zero_date=result.below_zero_date,
|
||||
below_threshold_date=result.below_threshold_date,
|
||||
series=[SeriesPointOut(day=p.day, balance=p.balance) for p in points],
|
||||
)
|
||||
157
finance/app/routers/transactions.py
Normal file
157
finance/app/routers/transactions.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.db import get_session
|
||||
from app.models.tables import Account, Category, Transaction
|
||||
from app.parsers.validate import dedup_hash
|
||||
from app.services.categorize import apply_rules
|
||||
|
||||
router = APIRouter(prefix="/api/transactions", tags=["transactions"],
|
||||
dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
class TransactionIn(BaseModel):
|
||||
account_id: int
|
||||
booking_date: date
|
||||
value_date: date | None = None
|
||||
amount: Decimal
|
||||
purpose: str = ""
|
||||
counterparty: str = ""
|
||||
force: bool = False
|
||||
|
||||
|
||||
class TransactionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
account_id: int
|
||||
statement_id: int | None = None
|
||||
booking_date: date
|
||||
value_date: date | None = None
|
||||
amount: Decimal
|
||||
purpose: str
|
||||
counterparty: str
|
||||
category_id: int | None = None
|
||||
status: str
|
||||
dedup_hash: str
|
||||
is_duplicate: bool
|
||||
|
||||
|
||||
class TransactionPatch(BaseModel):
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
def _apply_transaction_filters(
|
||||
stmt,
|
||||
account_id: int | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
category_id: int | None = None,
|
||||
q: str | None = None,
|
||||
status: str = "confirmed",
|
||||
):
|
||||
"""Wendet die Buchungs-Filter auf ein select()-Statement an. Wird sowohl fuer
|
||||
die Liste als auch fuer die dazugehoerige Gesamtanzahl (Pagination) genutzt,
|
||||
damit beide garantiert dieselben Ergebnisse zaehlen/zeigen."""
|
||||
if status:
|
||||
stmt = stmt.where(Transaction.status == status)
|
||||
if account_id is not None:
|
||||
stmt = stmt.where(Transaction.account_id == account_id)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(Transaction.booking_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(Transaction.booking_date <= date_to)
|
||||
if category_id is not None:
|
||||
stmt = stmt.where(Transaction.category_id == category_id)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
stmt = stmt.where(or_(Transaction.purpose.ilike(like),
|
||||
Transaction.counterparty.ilike(like)))
|
||||
return stmt
|
||||
|
||||
|
||||
@router.get("", response_model=list[TransactionOut])
|
||||
def list_transactions(
|
||||
account_id: int | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
category_id: int | None = None,
|
||||
q: str | None = None,
|
||||
status: str = "confirmed",
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
stmt = _apply_transaction_filters(
|
||||
select(Transaction), account_id=account_id, date_from=date_from,
|
||||
date_to=date_to, category_id=category_id, q=q, status=status,
|
||||
)
|
||||
stmt = stmt.order_by(Transaction.booking_date, Transaction.id).offset(offset).limit(limit)
|
||||
return [TransactionOut.model_validate(t) for t in session.execute(stmt).scalars()]
|
||||
|
||||
|
||||
def count_transactions(
|
||||
account_id: int | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
category_id: int | None = None,
|
||||
q: str | None = None,
|
||||
status: str = "confirmed",
|
||||
session: Session = Depends(get_session),
|
||||
) -> int:
|
||||
"""Gesamtanzahl der Buchungen fuer die gleichen Filter wie list_transactions
|
||||
(ohne limit/offset) - Grundlage fuer die Seitenzahl in der GUI-Pagination."""
|
||||
stmt = _apply_transaction_filters(
|
||||
select(func.count(Transaction.id)), account_id=account_id, date_from=date_from,
|
||||
date_to=date_to, category_id=category_id, q=q, status=status,
|
||||
)
|
||||
return session.execute(stmt).scalar_one()
|
||||
|
||||
|
||||
@router.post("", response_model=TransactionOut, status_code=201)
|
||||
def create_transaction(data: TransactionIn, session: Session = Depends(get_session)):
|
||||
if session.get(Account, data.account_id) is None:
|
||||
raise HTTPException(404, "Konto nicht gefunden")
|
||||
h = dedup_hash(data.account_id, data.booking_date, data.amount, data.purpose)
|
||||
existing = session.execute(
|
||||
select(Transaction).where(Transaction.dedup_hash == h)).scalar()
|
||||
if existing is not None and not data.force:
|
||||
raise HTTPException(409, "Buchung existiert bereits (Duplikat)")
|
||||
tx = Transaction(
|
||||
account_id=data.account_id,
|
||||
booking_date=data.booking_date,
|
||||
value_date=data.value_date,
|
||||
amount=data.amount,
|
||||
purpose=data.purpose,
|
||||
counterparty=data.counterparty,
|
||||
status="confirmed",
|
||||
dedup_hash=h,
|
||||
is_duplicate=existing is not None,
|
||||
)
|
||||
session.add(tx)
|
||||
session.commit()
|
||||
session.refresh(tx)
|
||||
apply_rules(session, [tx])
|
||||
session.commit()
|
||||
session.refresh(tx)
|
||||
return TransactionOut.model_validate(tx)
|
||||
|
||||
|
||||
@router.patch("/{tx_id}", response_model=TransactionOut)
|
||||
def patch_transaction(tx_id: int, data: TransactionPatch,
|
||||
session: Session = Depends(get_session)):
|
||||
tx = session.get(Transaction, tx_id)
|
||||
if tx is None:
|
||||
raise HTTPException(404, "Buchung nicht gefunden")
|
||||
if "category_id" in data.model_fields_set:
|
||||
if data.category_id is not None and session.get(Category, data.category_id) is None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
tx.category_id = data.category_id
|
||||
session.commit()
|
||||
session.refresh(tx)
|
||||
return TransactionOut.model_validate(tx)
|
||||
0
finance/app/services/__init__.py
Normal file
0
finance/app/services/__init__.py
Normal file
53
finance/app/services/balances.py
Normal file
53
finance/app/services/balances.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.tables import Account, Transaction
|
||||
|
||||
|
||||
def _confirmed_sum(session: Session, account_id: int,
|
||||
after: date | None, upto: date | None) -> Decimal:
|
||||
"""Summe bestaetigter Buchungen fuer `account_id` mit `after < booking_date
|
||||
<= upto` (Raender werden nur gesetzt, wenn nicht None)."""
|
||||
q = select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
Transaction.account_id == account_id, Transaction.status == "confirmed")
|
||||
if after is not None:
|
||||
q = q.where(Transaction.booking_date > after)
|
||||
if upto is not None:
|
||||
q = q.where(Transaction.booking_date <= upto)
|
||||
return Decimal(session.execute(q).scalar_one())
|
||||
|
||||
|
||||
def account_balance(session: Session, account: Account, at: date | None = None) -> Decimal:
|
||||
"""Kontostand am Ende des Tages `at` (Default: heute).
|
||||
|
||||
Konto-Saldo-Anker (Ausbaustufe 3 Task 1) ersetzt die bisherige
|
||||
Statement-closing-Logik ersatzlos. Konvention: der Anker gilt PER
|
||||
TAGESENDE des Ankerdatums - er enthaelt bereits alle Buchungen bis
|
||||
einschliesslich diesem Tag.
|
||||
|
||||
Mit Anker: anchor_balance + Sum(tx: anchor_date < booking_date <= at)
|
||||
- Sum(tx: at < booking_date <= anchor_date)
|
||||
(jeweils nur eine der beiden Summen ist nicht-leer, je nachdem ob `at`
|
||||
vor oder nach dem Anker liegt; bei at == anchor_date sind beide leer.)
|
||||
|
||||
Ohne Anker: Sum(tx: booking_date <= at) - bisheriges Verhalten mit Basis 0.
|
||||
"""
|
||||
if at is None:
|
||||
at = date.today()
|
||||
|
||||
if account.anchor_date is not None and account.anchor_balance is not None:
|
||||
anchor_date = account.anchor_date
|
||||
anchor_balance = Decimal(account.anchor_balance)
|
||||
if at >= anchor_date:
|
||||
return anchor_balance + _confirmed_sum(session, account.id, anchor_date, at)
|
||||
return anchor_balance - _confirmed_sum(session, account.id, at, anchor_date)
|
||||
|
||||
return _confirmed_sum(session, account.id, None, at)
|
||||
|
||||
|
||||
def total_balance(session: Session, at: date | None = None) -> Decimal:
|
||||
accounts = session.execute(select(Account)).scalars().all()
|
||||
return sum((account_balance(session, a, at) for a in accounts), Decimal("0"))
|
||||
20
finance/app/services/categorize.py
Normal file
20
finance/app/services/categorize.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.tables import CategoryRule, Transaction
|
||||
|
||||
|
||||
def apply_rules(session: Session, transactions: list[Transaction]) -> int:
|
||||
rules = session.execute(
|
||||
select(CategoryRule).order_by(CategoryRule.priority)).scalars().all()
|
||||
hits = 0
|
||||
for tx in transactions:
|
||||
if tx.category_id is not None:
|
||||
continue
|
||||
haystack = f"{tx.purpose} {tx.counterparty}".lower()
|
||||
for rule in rules:
|
||||
if rule.pattern.lower() in haystack:
|
||||
tx.category_id = rule.category_id
|
||||
hits += 1
|
||||
break
|
||||
return hits
|
||||
186
finance/app/services/importer.py
Normal file
186
finance/app/services/importer.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.tables import Account, Statement, Transaction
|
||||
from app.parsers.base import ParsedStatement, ParserError
|
||||
from app.parsers.csv_formats import parse_csv
|
||||
from app.parsers.registry import parse_pdf
|
||||
from app.parsers.validate import balance_difference, dedup_hash
|
||||
from app.services.categorize import apply_rules
|
||||
|
||||
|
||||
def _find_or_create_account(session: Session, bank: str, iban: str | None, filename: str) -> Account:
|
||||
if not iban:
|
||||
iban = f"UNBEKANNT-{filename}"
|
||||
if bank == "hvb_csv":
|
||||
# HVB-CSV traegt nur die Kontonummer (keine IBAN). Konto-Zuordnung
|
||||
# ueber "endet auf Kontonummer" gegen alle bestehenden IBANs -
|
||||
# dokumentierte Einschraenkung (siehe Formatreferenz Task 2/3).
|
||||
for existing in session.execute(select(Account)).scalars():
|
||||
if existing.iban.endswith(iban):
|
||||
return existing
|
||||
acc = Account(bank=bank, iban=iban, name=iban, type="giro")
|
||||
session.add(acc)
|
||||
session.flush()
|
||||
return acc
|
||||
acc = session.execute(select(Account).where(Account.iban == iban)).scalar()
|
||||
if acc is not None:
|
||||
return acc
|
||||
acc = Account(bank=bank, iban=iban, name=iban, type="giro")
|
||||
session.add(acc)
|
||||
session.flush()
|
||||
return acc
|
||||
|
||||
|
||||
def _apply_anchor_autofill(account: Account, anchor: tuple[date, Decimal] | None) -> None:
|
||||
"""CSV-Anker-Regel (Ausbaustufe 3 Task 3): der Datei-Kontostand wird nur
|
||||
uebernommen, wenn er neuer ist als ein evtl. vorhandener Anker (oder noch
|
||||
keiner existiert). Ein neuerer, manuell per PATCH gesetzter Anker bleibt
|
||||
unangetastet - so entkommt kein aelterer CSV-Export einem bewusst
|
||||
gesetzten aktuellen Stand."""
|
||||
if anchor is None:
|
||||
return
|
||||
anchor_date, anchor_balance = anchor
|
||||
if account.anchor_date is None or anchor_date >= account.anchor_date:
|
||||
account.anchor_date = anchor_date
|
||||
account.anchor_balance = anchor_balance
|
||||
|
||||
|
||||
def _error_statement(session: Session, filename: str, message: str) -> Statement:
|
||||
stmt = Statement(filename=filename, bank="unbekannt", account_id=None,
|
||||
status="error", error_message=message)
|
||||
session.add(stmt)
|
||||
session.commit()
|
||||
session.refresh(stmt)
|
||||
return stmt
|
||||
|
||||
|
||||
def _finish_statement(session: Session, path: Path, parsed: ParsedStatement,
|
||||
check_balance: bool,
|
||||
anchor: tuple[date, Decimal] | None) -> Statement:
|
||||
"""Gemeinsamer Abschluss fuer PDF- und CSV-Importe, sobald ein
|
||||
ParsedStatement vorliegt: Konto zuordnen, Statement+Transaktionen als
|
||||
Entwurf anlegen, optional den Saldo pruefen, Datei aus dem Posteingang
|
||||
verschieben. `check_balance=False` (CSV-Formate ohne Saldodaten, z.B.
|
||||
HVB/DKB) laesst den Entwurf OHNE Fehler stehen statt einer
|
||||
Saldo-Differenz-Pruefung, die auf None-Feldern crashen wuerde."""
|
||||
settings = get_settings()
|
||||
filename = path.name
|
||||
|
||||
account = _find_or_create_account(session, parsed.bank, parsed.iban, filename)
|
||||
|
||||
stmt = Statement(
|
||||
filename=filename,
|
||||
bank=parsed.bank,
|
||||
account_id=account.id,
|
||||
period_start=parsed.period_start,
|
||||
period_end=parsed.period_end,
|
||||
opening_balance=parsed.opening_balance,
|
||||
closing_balance=parsed.closing_balance,
|
||||
status="draft",
|
||||
)
|
||||
session.add(stmt)
|
||||
session.flush()
|
||||
|
||||
if check_balance:
|
||||
diff = balance_difference(parsed)
|
||||
if diff != 0:
|
||||
stmt.status = "error"
|
||||
stmt.error_message = f"Saldo-Differenz {diff} EUR"
|
||||
session.commit()
|
||||
session.refresh(stmt)
|
||||
return stmt
|
||||
|
||||
drafts = []
|
||||
for t in parsed.transactions:
|
||||
h = dedup_hash(account.id, t.booking_date, t.amount, t.purpose)
|
||||
is_duplicate = session.execute(
|
||||
select(Transaction).where(
|
||||
Transaction.dedup_hash == h,
|
||||
Transaction.status == "confirmed",
|
||||
)
|
||||
).scalar() is not None
|
||||
tx = Transaction(
|
||||
account_id=account.id,
|
||||
statement_id=stmt.id,
|
||||
booking_date=t.booking_date,
|
||||
value_date=t.value_date,
|
||||
amount=t.amount,
|
||||
purpose=t.purpose,
|
||||
counterparty=t.counterparty,
|
||||
status="draft",
|
||||
dedup_hash=h,
|
||||
is_duplicate=is_duplicate,
|
||||
)
|
||||
session.add(tx)
|
||||
drafts.append(tx)
|
||||
|
||||
session.flush()
|
||||
apply_rules(session, drafts)
|
||||
|
||||
stmt.status = "draft"
|
||||
_apply_anchor_autofill(account, anchor)
|
||||
|
||||
# Invariant: a committed draft implies the source file left the inbox.
|
||||
# Move the file before committing so a failed move can never leave a
|
||||
# committed draft with the file still sitting in the inbox.
|
||||
uploads_dir = settings.uploads_dir
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
path.replace(uploads_dir / filename)
|
||||
except OSError:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
session.commit()
|
||||
session.refresh(stmt)
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
def process_pdf(session: Session, path: Path) -> Statement:
|
||||
filename = path.name
|
||||
try:
|
||||
parsed = parse_pdf(path)
|
||||
except ParserError as exc:
|
||||
return _error_statement(session, filename, str(exc))
|
||||
except Exception as exc:
|
||||
# Fremdbibliotheken (pdfplumber/pdfminer) werfen bei strukturell
|
||||
# kaputten PDFs eigene Exception-Typen statt ParserError (z.B.
|
||||
# PdfminerException "No /Root object!"). Ohne diesen Fang wuerde ein
|
||||
# kaputtes Upload zu einem unbehandelten 500 fuehren statt zu einem
|
||||
# sauberen Fehler-Import wie bei "Bank nicht erkannt". Die Datei
|
||||
# bleibt dabei unangetastet im Posteingang.
|
||||
return _error_statement(session, filename, f"PDF nicht lesbar: {exc}")
|
||||
|
||||
return _finish_statement(session, path, parsed, check_balance=True, anchor=None)
|
||||
|
||||
|
||||
def process_csv(session: Session, path: Path) -> Statement:
|
||||
filename = path.name
|
||||
try:
|
||||
parsed_csv = parse_csv(path)
|
||||
except ParserError as exc:
|
||||
return _error_statement(session, filename, str(exc))
|
||||
except Exception as exc:
|
||||
# Analog zu process_pdf: eine strukturell kaputte/unlesbare CSV darf
|
||||
# nicht als unbehandelter 500 durchschlagen.
|
||||
return _error_statement(session, filename, f"CSV nicht lesbar: {exc}")
|
||||
|
||||
return _finish_statement(session, path, parsed_csv.statement,
|
||||
check_balance=parsed_csv.balance_checkable,
|
||||
anchor=parsed_csv.anchor)
|
||||
|
||||
|
||||
def process_file(session: Session, path: Path) -> Statement:
|
||||
"""Dateiendungs-Weiche fuer die Import-Pipeline (Ausbaustufe 3 Task 3):
|
||||
.csv laeuft ueber den CSV-Formatparser, alles andere weiterhin ueber den
|
||||
PDF-Parser."""
|
||||
if path.suffix.lower() == ".csv":
|
||||
return process_csv(session, path)
|
||||
return process_pdf(session, path)
|
||||
58
finance/app/services/projection_service.py
Normal file
58
finance/app/services/projection_service.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import date, timedelta
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.engine.projection import project
|
||||
from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
|
||||
build_cashflows)
|
||||
from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
|
||||
ProjectionResult, RecurringItem, Scenario,
|
||||
ScenarioLoan, ScenarioModifier)
|
||||
from app.services.balances import total_balance
|
||||
|
||||
|
||||
def run_projection(session: Session, scenario: Scenario, horizon_days: int,
|
||||
start_date: date) -> ProjectionResult:
|
||||
end = start_date + timedelta(days=horizon_days)
|
||||
recurring = []
|
||||
if scenario.include_recurring:
|
||||
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
|
||||
r.due_day, r.start_date, r.end_date, r.category_id)
|
||||
for r in session.execute(select(RecurringItem)).scalars()]
|
||||
planned = []
|
||||
if scenario.include_planned:
|
||||
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
||||
for p in session.execute(select(PlannedItem)).scalars()]
|
||||
loans = session.execute(
|
||||
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
|
||||
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all()
|
||||
schedules = [loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
|
||||
l.term_months, l.payout_date, l.repayment_type)
|
||||
for l in loans]
|
||||
payouts = [(l.payout_date, Decimal(l.principal)) for l in loans]
|
||||
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind, Decimal(m.value))
|
||||
for m in session.execute(select(ScenarioModifier).where(
|
||||
ScenarioModifier.scenario_id == scenario.id)).scalars()]
|
||||
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers,
|
||||
start_date, end)
|
||||
proj = project(total_balance(session), start_date, flows, horizon_days,
|
||||
threshold=get_settings().warn_threshold)
|
||||
session.execute(delete(ProjectionPoint).where(
|
||||
ProjectionPoint.scenario_id == scenario.id))
|
||||
for d, bal in proj.series:
|
||||
session.add(ProjectionPoint(scenario_id=scenario.id, day=d, balance=bal))
|
||||
result = session.get(ProjectionResult, scenario.id) or ProjectionResult(
|
||||
scenario_id=scenario.id, computed_at=datetime.now(timezone.utc),
|
||||
low_point_date=proj.low_point[0], low_point_balance=proj.low_point[1])
|
||||
result.computed_at = datetime.now(timezone.utc)
|
||||
result.low_point_date, result.low_point_balance = proj.low_point
|
||||
result.below_zero_date = proj.first_below_zero
|
||||
result.below_threshold_date = proj.first_below_threshold
|
||||
session.add(result)
|
||||
session.commit()
|
||||
return result
|
||||
52
finance/app/services/suggestions.py
Normal file
52
finance/app/services/suggestions.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import statistics
|
||||
from collections import Counter, defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.tables import RecurringItem, Transaction
|
||||
|
||||
|
||||
def _max_consecutive_months(months: list[tuple[int, int]]) -> int:
|
||||
if not months:
|
||||
return 0
|
||||
best = current = 1
|
||||
for prev, cur in zip(months, months[1:]):
|
||||
prev_idx = prev[0] * 12 + prev[1]
|
||||
cur_idx = cur[0] * 12 + cur[1]
|
||||
current = current + 1 if cur_idx == prev_idx + 1 else 1
|
||||
best = max(best, current)
|
||||
return best
|
||||
|
||||
|
||||
def suggest_recurring(session: Session) -> list[dict]:
|
||||
txs = session.execute(
|
||||
select(Transaction).where(Transaction.status == "confirmed")
|
||||
).scalars().all()
|
||||
groups: dict[tuple, list[Transaction]] = defaultdict(list)
|
||||
for t in txs:
|
||||
groups[(t.account_id, t.counterparty, t.amount)].append(t)
|
||||
|
||||
existing = {(r.name, Decimal(r.amount))
|
||||
for r in session.execute(select(RecurringItem)).scalars()}
|
||||
|
||||
suggestions: list[dict] = []
|
||||
for (_account_id, counterparty, amount), items in groups.items():
|
||||
months = sorted({(t.booking_date.year, t.booking_date.month) for t in items})
|
||||
if _max_consecutive_months(months) < 3:
|
||||
continue
|
||||
name = counterparty
|
||||
if (name, Decimal(amount)) in existing:
|
||||
continue
|
||||
due_day = int(statistics.median(sorted(t.booking_date.day for t in items)))
|
||||
cat_counts = Counter(t.category_id for t in items if t.category_id is not None)
|
||||
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
|
||||
suggestions.append({
|
||||
"name": name,
|
||||
"amount": Decimal(amount),
|
||||
"rhythm": "monthly",
|
||||
"due_day": due_day,
|
||||
"category_id": category_id,
|
||||
})
|
||||
return suggestions
|
||||
1
finance/app/static/htmx.min.js
vendored
Normal file
1
finance/app/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
173
finance/app/static/style.css
Normal file
173
finance/app/static/style.css
Normal file
@@ -0,0 +1,173 @@
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
color: #222;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
nav a {
|
||||
text-decoration: none;
|
||||
color: #06c;
|
||||
}
|
||||
|
||||
nav form {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f2f2f2;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.neg {
|
||||
color: #b00;
|
||||
}
|
||||
|
||||
.amount {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
td.account {
|
||||
word-break: break-all;
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #e0c26b;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.total-balance {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
form.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
form.filter-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
iframe.grafana {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #7a5c00;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: #fdecea;
|
||||
color: #b00020;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
background: #e6f4ea;
|
||||
color: #1e7e34;
|
||||
}
|
||||
|
||||
#dropzone {
|
||||
border: 2px dashed #999;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
#dropzone.dragover {
|
||||
background: #f0f6ff;
|
||||
border-color: #06c;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
section.planning-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
section.planning-section fieldset {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.version {
|
||||
margin-top: 2rem;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
67
finance/app/templates/_import_list.html
Normal file
67
finance/app/templates/_import_list.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Datei</th><th>Bank</th><th>Zeitraum</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in statements %}
|
||||
<tr>
|
||||
<td>{{ s.filename }}</td>
|
||||
<td>{{ s.bank }}</td>
|
||||
<td>
|
||||
{% if s.period_start and s.period_end %}
|
||||
{{ s.period_start.strftime('%d.%m.%Y') }} – {{ s.period_end.strftime('%d.%m.%Y') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if s.status == "draft" %}
|
||||
<span class="badge badge-warning">wartet auf Bestätigung</span>
|
||||
{% elif s.status == "error" %}
|
||||
<span class="badge badge-error">Fehler: {{ s.error_message }}</span>
|
||||
{% elif s.status == "confirmed" %}
|
||||
<span class="badge badge-ok">übernommen</span>
|
||||
{% else %}
|
||||
<span class="badge">{{ s.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if s.status == "draft" %}
|
||||
<details hx-get="/import/{{ s.id }}/preview-fragment"
|
||||
hx-trigger="toggle once" hx-target="find .preview-slot" hx-swap="innerHTML">
|
||||
<summary>Vorschau</summary>
|
||||
<div class="preview-slot">Lädt…</div>
|
||||
</details>
|
||||
<form class="inline-form" hx-post="/api/imports/{{ s.id }}/confirm" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
||||
<button type="submit">Übernehmen</button>
|
||||
</form>
|
||||
<form class="inline-form" hx-delete="/api/imports/{{ s.id }}" hx-swap="none"
|
||||
hx-confirm="Import „{{ s.filename }}“ wirklich verwerfen?"
|
||||
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
||||
<button type="submit">Verwerfen</button>
|
||||
</form>
|
||||
<button type="button" class="inline-form" disabled title="Nur für bestätigte Importe">Import zurückrollen</button>
|
||||
{% elif s.status == "confirmed" %}
|
||||
<span class="muted">Bestätigt — Buchungen übernommen</span>
|
||||
<button type="button" class="inline-form" disabled title="Nur für Entwürfe">Übernehmen</button>
|
||||
<button type="button" class="inline-form" disabled title="Nur für Entwürfe">Verwerfen</button>
|
||||
<form class="inline-form" hx-post="/api/imports/{{ s.id }}/rollback" hx-swap="none"
|
||||
hx-confirm="Diesen Import und ALLE zugehörigen Buchungen unwiderruflich löschen? Salden und Projektionen ändern sich."
|
||||
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
||||
<button type="submit">Import zurückrollen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button type="button" class="inline-form" disabled title="Nur für Entwürfe">Übernehmen</button>
|
||||
<form class="inline-form" hx-delete="/api/imports/{{ s.id }}" hx-swap="none"
|
||||
hx-confirm="Import „{{ s.filename }}“ wirklich verwerfen?"
|
||||
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
||||
<button type="submit">Verwerfen</button>
|
||||
</form>
|
||||
<button type="button" class="inline-form" disabled title="Nur für bestätigte Importe">Import zurückrollen</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Noch keine Imports vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
31
finance/app/templates/_preview_table.html
Normal file
31
finance/app/templates/_preview_table.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Datum</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th><th>Betrag</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in transactions %}
|
||||
<tr>
|
||||
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ tx.purpose }}</td>
|
||||
<td>{{ tx.counterparty }}</td>
|
||||
<td class="{{ 'neg' if tx.amount < 0 else '' }}">{{ '%.2f'|format(tx.amount) }} €</td>
|
||||
<td>{% if tx.is_duplicate %}<span class="badge badge-warning">Duplikat</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Keine Buchungen in diesem Import.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Saldo-Status:
|
||||
{% if balance_ok is none %}
|
||||
<span class="badge">Saldo-Prüfung: nicht verfügbar (Format ohne Saldodaten)</span>
|
||||
{% elif balance_ok %}
|
||||
<span class="badge badge-ok">Saldo plausibel</span>
|
||||
{% else %}
|
||||
<span class="badge badge-error">Saldo-Differenz – bitte prüfen</span>
|
||||
{% endif %}
|
||||
· Eröffnungssaldo: {{ '%.2f'|format(statement.opening_balance) if statement.opening_balance is not none else '–' }} €
|
||||
· Endsaldo: {{ '%.2f'|format(statement.closing_balance) if statement.closing_balance is not none else '–' }} €
|
||||
· Duplikate: {{ duplicates }}
|
||||
</p>
|
||||
71
finance/app/templates/base.html
Normal file
71
finance/app/templates/base.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Finanzberatung{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/">Übersicht</a>
|
||||
<a href="/import">Import</a>
|
||||
<a href="/buchungen">Buchungen</a>
|
||||
<a href="/salden">Salden</a>
|
||||
<a href="/planung">Planung</a>
|
||||
<a href="/hilfe">Hilfe</a>
|
||||
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
</nav>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script>
|
||||
document.body.addEventListener('htmx:responseError', function (e) {
|
||||
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
|
||||
});
|
||||
|
||||
// Minimale JSON-Kodierung für htmx-Formulare/Selects, die gegen die JSON-APIs
|
||||
// (POST/PATCH /api/...) senden. Felder mit data-type="int" werden als Zahl
|
||||
// kodiert, leere Werte als null, alles andere bleibt String.
|
||||
htmx.defineExtension('json-form', {
|
||||
onEvent: function (name, evt) {
|
||||
if (name === 'htmx:configRequest') {
|
||||
evt.detail.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
},
|
||||
encodeParameters: function (xhr, parameters, elt) {
|
||||
var out = {};
|
||||
for (var k in parameters) {
|
||||
var v = parameters[k];
|
||||
var field = elt.matches && elt.matches('[name="' + k + '"]')
|
||||
? elt
|
||||
: elt.querySelector('[name="' + k + '"]');
|
||||
var kind = field && field.dataset ? field.dataset.type : null;
|
||||
if (v === '') {
|
||||
out[k] = null;
|
||||
} else if (kind === 'int') {
|
||||
out[k] = parseInt(v, 10);
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
// Checkboxen mit data-type="bool" werden unabhaengig vom parameters-Objekt
|
||||
// ausgelesen, weil unmarkierte Checkboxen beim Serialisieren komplett
|
||||
// fehlen wuerden (kein Feld => kein "false" moeglich).
|
||||
if (elt.querySelectorAll) {
|
||||
elt.querySelectorAll('[data-type="bool"]').forEach(function (field) {
|
||||
out[field.name] = field.checked;
|
||||
});
|
||||
}
|
||||
xhr.overrideMimeType('text/json');
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
|
||||
</body>
|
||||
</html>
|
||||
103
finance/app/templates/hilfe.html
Normal file
103
finance/app/templates/hilfe.html
Normal file
@@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Hilfe – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Gebrauchsanleitung</h1>
|
||||
|
||||
<h2>Was dieses Tool ist</h2>
|
||||
<p>
|
||||
Liquiditätsplanung auf Basis echter Kontoauszüge: Ist-Stand aus importierten
|
||||
Auszügen, Zukunft aus gepflegten Planposten und durchgerechneten Szenarien.
|
||||
</p>
|
||||
|
||||
<h2>Konzepte</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Konten:</strong> entstehen automatisch beim ersten Import (per IBAN).
|
||||
Über die Übersicht umbenennbar (z. B. "DKB Giro").
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kontoauszug-Import:</strong> primärer Weg ist der
|
||||
Kontoumsatz-CSV-Export der Bank (DKB, VR-Bank, HypoVereinsbank) – in die
|
||||
Drop-Zone auf der Import-Seite ziehen (oder in die Inbox
|
||||
<code>~/.local/share/finance_pod/data/inbox/</code> legen und "Inbox
|
||||
scannen"). PDF-Kontoauszüge funktionieren weiterhin als Fallback, falls
|
||||
kein CSV-Export vorliegt. Jeder Import landet als Entwurf: erst nach
|
||||
Kontrolle der Vorschau "Übernehmen" klicken. Der Saldo-Status in der
|
||||
Vorschau ist dreiwertig – "Saldo plausibel" (Anfangssaldo + Buchungen =
|
||||
Endsaldo bzw. lückenlose Saldo-Kette), eine rote Saldo-Differenz, oder
|
||||
"Saldo-Prüfung nicht verfügbar", wenn das Format (z. B. HVB-CSV) keine
|
||||
Saldodaten liefert – dort lohnt ein Blick in die Zeilenzahl und
|
||||
Stichproben vor dem Übernehmen. CSV-Importe mit Datei-Kontostand (DKB)
|
||||
oder lückenloser Saldo-Kette (VR) aktualisieren beim Import automatisch
|
||||
den Konto-Saldo-Anker, sofern er dadurch nicht älter würde als ein bereits
|
||||
vorhandener. Duplikate werden erkannt und nicht doppelt übernommen.
|
||||
Bestätigte Importe lassen sich als Ganzes zurückrollen (Auszug + alle
|
||||
seine Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu
|
||||
zu importieren; einzelne Buchungen bleiben unlöschbar.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Salden-Seite:</strong> zeigt den Saldo je Konto zu einem
|
||||
wählbaren Stichtag (Default: heute) sowie eine Monatsanfangs-Übersicht
|
||||
vom frühesten Buchungsmonat bis heute, absteigend. Die
|
||||
Monatsanfangs-Werte gelten "Stand: Ende des Vortags" (letzter Tag des
|
||||
Vormonats), damit Buchungen am 1. eines Monats den Ausweis des
|
||||
Monatsanfangs nicht verfälschen. Konten ohne Anker sind mit "ohne Anker —
|
||||
relative Werte" gekennzeichnet.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kategorien & Regeln:</strong> Buchungen lassen sich kategorisieren;
|
||||
eine Regel ("Muster im Text → Kategorie") kategorisiert künftige Importe
|
||||
automatisch. Regel direkt aus einer Buchung erzeugen: Button in der
|
||||
Buchungsliste.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Wiederkehrende Posten:</strong> Miete, Gehalt, Abos – mit Rhythmus
|
||||
(monatlich/quartalsweise/jährlich) und Fälligkeitstag. Der
|
||||
Vorschlags-Knopf erkennt Kandidaten aus mind. 3 Monaten gleichartiger
|
||||
Buchungen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Einmalposten:</strong> einzelne künftige Zahlungen (z. B.
|
||||
Steuernachzahlung).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kredite:</strong> Annuität oder endfällig; Tilgungsplan
|
||||
aufklappbar. Ein Kredit wirkt erst, wenn er einem Szenario zugeordnet ist.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Szenarien:</strong> eine "Was-wäre-wenn"-Rechnung. Basis = alle
|
||||
wiederkehrenden Posten + Einmalposten. Varianten entstehen durch Zuordnen
|
||||
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
|
||||
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
|
||||
mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach
|
||||
Kategorie und der Szenario-Vergleich als Kurven (einmal mit demselben
|
||||
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
|
||||
Übersicht).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Empfohlener Arbeitsablauf</h2>
|
||||
<ol>
|
||||
<li>Monatlich: neue Auszüge importieren, Vorschau prüfen, übernehmen.</li>
|
||||
<li>
|
||||
Unkategorisierte Buchungen durchsehen; für Wiederkehrendes Regeln
|
||||
anlegen.
|
||||
</li>
|
||||
<li>Planung pflegen: Vorschläge prüfen, Einmalposten eintragen.</li>
|
||||
<li>
|
||||
Fragestellung ("Können wir uns X leisten?") als Szenario-Varianten
|
||||
abbilden und durchrechnen; Entscheidung anhand Tiefpunkt und
|
||||
Unterschreitungsdaten treffen, Kurven in Grafana vergleichen.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h2>Grundregeln</h2>
|
||||
<ul>
|
||||
<li>Bestätigte Buchungen sind die Wahrheit – nie ändern, nur kategorisieren.</li>
|
||||
<li>Zukunft ausschließlich über Szenarien planen.</li>
|
||||
<li>Empfehlungen immer mit durchgerechneten Zahlen begründen.</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
74
finance/app/templates/import.html
Normal file
74
finance/app/templates/import.html
Normal file
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Import – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Import</h1>
|
||||
|
||||
<div id="dropzone">
|
||||
Kontoumsatz-CSV oder Kontoauszug-PDF hier ablegen oder klicken zum Hochladen
|
||||
<input type="file" id="file-input" accept=".csv,.pdf,application/pdf,text/csv" style="display:none">
|
||||
</div>
|
||||
<button type="button" hx-post="/api/imports/scan-inbox" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
||||
Inbox scannen
|
||||
</button>
|
||||
<p class="muted">Durchsucht <code>~/.local/share/finance_pod/data/inbox/</code> nach neuen CSV- oder PDF-Kontoauszügen.</p>
|
||||
<p id="upload-message"></p>
|
||||
|
||||
<h2>Importe</h2>
|
||||
<div id="imports" hx-get="/import/list" hx-trigger="every 5s, refresh" hx-swap="innerHTML">
|
||||
{% include "_import_list.html" %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var dropzone = document.getElementById('dropzone');
|
||||
var fileInput = document.getElementById('file-input');
|
||||
var message = document.getElementById('upload-message');
|
||||
|
||||
function refreshImports() {
|
||||
htmx.trigger(document.getElementById('imports'), 'refresh');
|
||||
}
|
||||
|
||||
function uploadFile(file) {
|
||||
if (!file) return;
|
||||
var fd = new FormData();
|
||||
fd.append('file', file);
|
||||
message.textContent = 'Lade hoch: ' + file.name + ' …';
|
||||
fetch('/api/imports/upload', { method: 'POST', body: fd })
|
||||
.then(function (resp) {
|
||||
return resp.json().then(function (data) { return { ok: resp.ok, data: data }; });
|
||||
})
|
||||
.then(function (result) {
|
||||
if (result.ok) {
|
||||
message.textContent = 'Hochgeladen: ' + file.name + ' – Status: ' + result.data.status;
|
||||
} else {
|
||||
message.textContent = 'Fehler beim Hochladen: ' + (result.data.detail || 'unbekannter Fehler');
|
||||
}
|
||||
refreshImports();
|
||||
})
|
||||
.catch(function (err) {
|
||||
message.textContent = 'Fehler beim Hochladen: ' + err;
|
||||
});
|
||||
}
|
||||
|
||||
dropzone.addEventListener('click', function () { fileInput.click(); });
|
||||
fileInput.addEventListener('change', function () {
|
||||
uploadFile(fileInput.files[0]);
|
||||
fileInput.value = '';
|
||||
});
|
||||
dropzone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('dragover');
|
||||
});
|
||||
dropzone.addEventListener('dragleave', function () {
|
||||
dropzone.classList.remove('dragover');
|
||||
});
|
||||
dropzone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('dragover');
|
||||
var file = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
uploadFile(file);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
84
finance/app/templates/index.html
Normal file
84
finance/app/templates/index.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Übersicht – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Übersicht</h1>
|
||||
|
||||
{% if projection and projection.below_threshold_date %}
|
||||
<div class="warning">
|
||||
Achtung: Prognostizierter Saldo unterschreitet den Schwellenwert am
|
||||
<strong>{{ projection.below_threshold_date.strftime('%d.%m.%Y') }}</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="total-balance {{ 'neg' if total < 0 else '' }}">
|
||||
Gesamtsaldo: {{ '%.2f'|format(total) }} €
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Konto</th><th>Bank</th><th>Saldo</th><th>Anker</th>
|
||||
<th>Umbenennen</th><th>Anker pflegen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account, balance in balances %}
|
||||
<tr>
|
||||
<td class="account">{{ account.name }}</td>
|
||||
<td>{{ account.bank }}</td>
|
||||
<td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
|
||||
<td>
|
||||
{% if account.anchor_date is not none and account.anchor_balance is not none %}
|
||||
Anker: {{ '%.2f'|format(account.anchor_balance) }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
|
||||
{% else %}
|
||||
kein Anker
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
|
||||
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<input type="text" name="name" value="{{ account.name }}" required maxlength="100">
|
||||
<button type="submit">Umbenennen</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
|
||||
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<input type="date" name="anchor_date"
|
||||
value="{{ account.anchor_date.isoformat() if account.anchor_date is not none else '' }}" required>
|
||||
<input type="text" name="anchor_balance" placeholder="500.00"
|
||||
value="{{ account.anchor_balance if account.anchor_balance is not none else '' }}" required>
|
||||
<button type="submit">Anker setzen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6">Keine Konten angelegt.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Nächste anstehende Posten (30 Tage)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Datum</th><th>Bezeichnung</th><th>Betrag</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in upcoming %}
|
||||
<tr>
|
||||
<td>{{ item.date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td class="{{ 'neg' if item.amount < 0 else '' }}">{{ '%.2f'|format(item.amount) }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3">Keine anstehenden Posten.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Grafana-Dashboard</h2>
|
||||
<iframe class="grafana" src="http://{{ request.url.hostname or '127.0.0.1' }}:8097/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
|
||||
<p class="hint">Kein Diagramm sichtbar? Einmal in
|
||||
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank">Grafana anmelden</a>
|
||||
(gleiches Passwort wie hier).</p>
|
||||
{% endblock %}
|
||||
21
finance/app/templates/login.html
Normal file
21
finance/app/templates/login.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Anmelden – Finanzberatung</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Finanzberatung</h1>
|
||||
<form method="post" action="/login">
|
||||
<label>Benutzer
|
||||
<input name="username" placeholder="Benutzer">
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input name="password" type="password" placeholder="Passwort">
|
||||
</label>
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
366
finance/app/templates/planning.html
Normal file
366
finance/app/templates/planning.html
Normal file
@@ -0,0 +1,366 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Planung – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Planung</h1>
|
||||
|
||||
<section class="planning-section">
|
||||
<h2>Wiederkehrende Posten</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Kategorie</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recurring %}
|
||||
<tr>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ '%.2f'|format(r.amount) }} €</td>
|
||||
<td>{{ r.rhythm }}</td>
|
||||
<td>{{ r.due_day }}</td>
|
||||
<td>{{ category_names.get(r.category_id, '–') }}</td>
|
||||
<td>
|
||||
<form class="inline-form" hx-delete="/api/recurring/{{ r.id }}" hx-swap="none"
|
||||
hx-confirm="„{{ r.name }}“ wirklich löschen?"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6">Noch keine wiederkehrenden Posten.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<fieldset>
|
||||
<legend>Neuen wiederkehrenden Posten anlegen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>Betrag <input type="text" name="amount" placeholder="-49.99" required></label>
|
||||
<label>Rhythmus
|
||||
<select name="rhythm" data-type="str">
|
||||
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required></label>
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Vorschläge aus Buchungen</legend>
|
||||
{% if suggestions %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in suggestions %}
|
||||
<tr>
|
||||
<td>{{ s.name }}</td>
|
||||
<td class="{{ 'neg' if s.amount < 0 else '' }}">{{ '%.2f'|format(s.amount) }} €</td>
|
||||
<td>{{ s.rhythm }}</td>
|
||||
<td>{{ s.due_day }}</td>
|
||||
<td>
|
||||
<form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<input type="hidden" name="name" value="{{ s.name }}">
|
||||
<input type="hidden" name="amount" value="{{ s.amount }}">
|
||||
<input type="hidden" name="rhythm" value="{{ s.rhythm }}">
|
||||
<input type="hidden" name="due_day" data-type="int" value="{{ s.due_day }}">
|
||||
<input type="hidden" name="category_id" data-type="int" value="{{ s.category_id if s.category_id is not none else '' }}">
|
||||
<button type="submit">Vorschlag übernehmen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Keine Vorschläge — erkannt werden Serien aus mindestens 3 Monaten gleichartiger Buchungen.</p>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="planning-section">
|
||||
<h2>Einmalposten</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Betrag</th><th>Fällig am</th><th>Kategorie</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in planned %}
|
||||
<tr>
|
||||
<td>{{ p.name }}</td>
|
||||
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ '%.2f'|format(p.amount) }} €</td>
|
||||
<td>{{ p.due.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ category_names.get(p.category_id, '–') }}</td>
|
||||
<td>
|
||||
<form class="inline-form" hx-delete="/api/planned/{{ p.id }}" hx-swap="none"
|
||||
hx-confirm="„{{ p.name }}“ wirklich löschen?"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Noch keine Einmalposten.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<fieldset>
|
||||
<legend>Neuen Einmalposten anlegen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/planned" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>Betrag <input type="text" name="amount" placeholder="-2000.00" required></label>
|
||||
<label>Fällig am <input type="date" name="due" required></label>
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="planning-section">
|
||||
<h2>Kredite</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Summe</th><th>Zins p.a.</th><th>Laufzeit</th><th>Auszahlung</th><th>Tilgungsart</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in loans %}
|
||||
<tr>
|
||||
<td>{{ l.name }}</td>
|
||||
<td>{{ '%.2f'|format(l.principal) }} €</td>
|
||||
<td>{{ '%.2f'|format(l.annual_rate_pct) }} %</td>
|
||||
<td>{{ l.term_months }} Monate</td>
|
||||
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ l.repayment_type }}</td>
|
||||
<td>
|
||||
<form class="inline-form" hx-delete="/api/loans/{{ l.id }}" hx-swap="none"
|
||||
hx-confirm="„{{ l.name }}“ wirklich löschen?"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}">
|
||||
<summary>Tilgungsplan</summary>
|
||||
<div id="schedule-{{ l.id }}">Wird beim Öffnen geladen…</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7">Noch keine Kredite.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<fieldset>
|
||||
<legend>Neuen Kredit anlegen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/loans" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>Darlehenssumme <input type="text" name="principal" placeholder="10000.00" required></label>
|
||||
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" placeholder="4.5" required></label>
|
||||
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" required></label>
|
||||
<label>Auszahlungsdatum <input type="date" name="payout_date" required></label>
|
||||
<label>Tilgungsart
|
||||
<select name="repayment_type" data-type="str">
|
||||
{% for rt in repayment_types %}<option value="{{ rt }}">{{ rt }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="planning-section">
|
||||
<h2>Szenarien</h2>
|
||||
{% for row in scenario_rows %}
|
||||
{% set sc = row.scenario %}
|
||||
<fieldset>
|
||||
<legend>{{ sc.name }}</legend>
|
||||
<p>
|
||||
{{ sc.description }}
|
||||
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
|
||||
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary>Kredite zuordnen</summary>
|
||||
{% for l in loans %}
|
||||
<label class="inline-form">
|
||||
<input type="checkbox" {% if l.id in row.assigned_loan_ids %}checked{% endif %}
|
||||
onchange="toggleScenarioLoan({{ sc.id }}, {{ l.id }}, this.checked, this)">
|
||||
{{ l.name }}
|
||||
</label>
|
||||
{% else %}
|
||||
<p>Keine Kredite vorhanden.</p>
|
||||
{% endfor %}
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Modifikatoren</summary>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Ziel</th><th>Art</th><th>Wert</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in row.modifiers %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
|
||||
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
|
||||
</td>
|
||||
<td>{{ m.kind }}</td>
|
||||
<td>{{ '%.2f'|format(m.value) }}</td>
|
||||
<td>
|
||||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4">Keine Modifikatoren.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Ziel-Typ
|
||||
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||
<option value="category">Kategorie</option>
|
||||
<option value="recurring">Wiederkehrender Posten</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="mod-target-category">
|
||||
<label>Kategorie
|
||||
<select name="target_id" data-type="int">
|
||||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<span class="mod-target-recurring" style="display:none">
|
||||
<label>Posten
|
||||
<select name="target_id" data-type="int" disabled>
|
||||
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<label>Art
|
||||
<select name="kind" data-type="str">
|
||||
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Wert <input type="text" name="value" value="0"></label>
|
||||
<button type="submit">Modifikator hinzufügen</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Durchrechnen</button>
|
||||
</form>
|
||||
|
||||
{% if row.result %}
|
||||
<p>
|
||||
Tiefpunkt: {{ '%.2f'|format(row.result.low_point_balance) }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
|
||||
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
|
||||
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
|
||||
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
|
||||
</p>
|
||||
{% else %}
|
||||
<p>Noch nicht durchgerechnet.</p>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% else %}
|
||||
<p>Noch keine Szenarien.</p>
|
||||
{% endfor %}
|
||||
|
||||
<fieldset>
|
||||
<legend>Neues Szenario anlegen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/scenarios" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>Beschreibung <input type="text" name="description"></label>
|
||||
<label><input type="checkbox" name="include_recurring" data-type="bool" checked> Wiederkehrende Posten einschließen</label>
|
||||
<label><input type="checkbox" name="include_planned" data-type="bool" checked> Einmalposten einschließen</label>
|
||||
<button type="submit">Szenario anlegen</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// json-form-Extension ist zentral in base.html registriert (eine
|
||||
// Definition fuer alle Templates, inkl. data-type="bool"-Handling).
|
||||
|
||||
function onModTargetTypeChange(select) {
|
||||
var form = select.closest('form');
|
||||
var catSpan = form.querySelector('.mod-target-category');
|
||||
var recSpan = form.querySelector('.mod-target-recurring');
|
||||
var catSelect = catSpan.querySelector('select');
|
||||
var recSelect = recSpan.querySelector('select');
|
||||
var isCategory = select.value === 'category';
|
||||
catSpan.style.display = isCategory ? '' : 'none';
|
||||
recSpan.style.display = isCategory ? 'none' : '';
|
||||
catSelect.disabled = !isCategory;
|
||||
recSelect.disabled = isCategory;
|
||||
}
|
||||
|
||||
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
|
||||
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
throw new Error('HTTP ' + resp.status);
|
||||
}
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
checkbox.checked = !checked;
|
||||
alert('Zuordnung fehlgeschlagen: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
var loadedSchedules = {};
|
||||
function formatIsoDate(iso) {
|
||||
var parts = iso.split('-');
|
||||
return parts[2] + '.' + parts[1] + '.' + parts[0];
|
||||
}
|
||||
function loadLoanSchedule(loanId) {
|
||||
if (loadedSchedules[loanId]) return;
|
||||
loadedSchedules[loanId] = true;
|
||||
fetch('/api/loans/' + loanId + '/schedule')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (rows) {
|
||||
var html = '<table><thead><tr><th>Fällig</th><th>Rate</th><th>Zins</th>'
|
||||
+ '<th>Tilgung</th><th>Restschuld</th></tr></thead><tbody>';
|
||||
rows.forEach(function (r) {
|
||||
html += '<tr><td>' + formatIsoDate(r.due) + '</td>'
|
||||
+ '<td>' + Number(r.payment).toFixed(2) + ' €</td>'
|
||||
+ '<td>' + Number(r.interest).toFixed(2) + ' €</td>'
|
||||
+ '<td>' + Number(r.principal).toFixed(2) + ' €</td>'
|
||||
+ '<td>' + Number(r.remaining).toFixed(2) + ' €</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('schedule-' + loanId).innerHTML = html;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
67
finance/app/templates/salden.html
Normal file
67
finance/app/templates/salden.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Salden – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Salden</h1>
|
||||
|
||||
<h2>Salden am {{ stichtag.strftime('%d.%m.%Y') }}</h2>
|
||||
<form method="get" action="/salden" class="inline-form">
|
||||
<input type="date" name="stichtag" value="{{ stichtag.isoformat() }}">
|
||||
<button type="submit">Anzeigen</button>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Konto</th><th>Saldo</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account, balance in balances %}
|
||||
<tr>
|
||||
<td class="account">{{ account.name }}</td>
|
||||
<td class="amount {{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2">Keine Konten angelegt.</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td><strong>Gesamt</strong></td>
|
||||
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ '%.2f'|format(total) }} €</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Monatsanfangs-Übersicht</h2>
|
||||
<p class="hint">
|
||||
Stand: Ende des Vortags (letzter Tag des Vormonats) — Buchungen am 1. eines
|
||||
Monats zählen bewusst noch nicht zum "Anfangs"-Saldo, sonst würde der
|
||||
Monatsanfang durch Buchungen des laufenden Monats verzerrt.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Monat</th>
|
||||
{% for account in accounts %}<th>{{ account.name }}</th>{% endfor %}
|
||||
<th>Gesamt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in months %}
|
||||
<tr data-month="{{ row['month'].isoformat() }}">
|
||||
<td>{{ row['month'].strftime('%m.%Y') }}</td>
|
||||
{% for account, value in row['values'] %}
|
||||
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ '%.2f'|format(value) }} €</td>
|
||||
{% endfor %}
|
||||
<td class="amount {{ 'neg' if row['total'] < 0 else '' }}">{{ '%.2f'|format(row['total']) }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="{{ accounts|length + 2 }}">Keine bestätigten Buchungen vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if accounts_without_anchor %}
|
||||
<p class="hint">
|
||||
Fußnote „ohne Anker — relative Werte" (Basis 0 statt Datei-Kontostand) gilt für:
|
||||
{{ accounts_without_anchor|map(attribute='name')|join(', ') }}.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
134
finance/app/templates/transactions.html
Normal file
134
finance/app/templates/transactions.html
Normal file
@@ -0,0 +1,134 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Buchungen – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Buchungen</h1>
|
||||
|
||||
<form class="filter-form" method="get" action="/buchungen">
|
||||
<label>Konto
|
||||
<select name="account_id">
|
||||
<option value="">alle</option>
|
||||
{% for a in accounts %}
|
||||
<option value="{{ a.id }}" {% if filters.account_id == a.id %}selected{% endif %}>{{ a.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Von
|
||||
<input type="date" name="date_from" value="{{ filters.date_from.isoformat() if filters.date_from else '' }}">
|
||||
</label>
|
||||
<label>Bis
|
||||
<input type="date" name="date_to" value="{{ filters.date_to.isoformat() if filters.date_to else '' }}">
|
||||
</label>
|
||||
<label>Kategorie
|
||||
<select name="category_id">
|
||||
<option value="">alle</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c.id }}" {% if filters.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Suche
|
||||
<input type="text" name="q" value="{{ filters.q }}" placeholder="Verwendungszweck / Empfänger">
|
||||
</label>
|
||||
<label>Status
|
||||
<select name="status">
|
||||
{% for s in ("confirmed", "draft") %}
|
||||
<option value="{{ s }}" {% if filters.status == s %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Filtern</button>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th><th>Konto</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th>
|
||||
<th>Betrag (€)</th><th>Kategorie</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in transactions %}
|
||||
<tr>
|
||||
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
|
||||
<td class="account">{{ account_names.get(tx.account_id, tx.account_id) }}</td>
|
||||
<td>{{ tx.purpose }}</td>
|
||||
<td>{{ tx.counterparty }}</td>
|
||||
<td class="amount {{ 'neg' if tx.amount < 0 else '' }}">{{ '%.2f'|format(tx.amount) }}</td>
|
||||
<td>
|
||||
<select name="category_id" data-type="int" hx-ext="json-form"
|
||||
hx-patch="/api/transactions/{{ tx.id }}" hx-trigger="change" hx-swap="none">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c.id }}" {% if tx.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<details>
|
||||
<summary>Regel erzeugen</summary>
|
||||
<form hx-ext="json-form" hx-post="/api/category-rules" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){this.closest('details').open=false}">
|
||||
<input type="hidden" name="pattern" value="{{ tx.counterparty }}">
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int" required>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c.id }}" {% if tx.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Regel speichern</button>
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7">Keine Buchungen gefunden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="pager">
|
||||
{% set pager_params = {
|
||||
'account_id': filters.account_id,
|
||||
'date_from': filters.date_from.isoformat() if filters.date_from else None,
|
||||
'date_to': filters.date_to.isoformat() if filters.date_to else None,
|
||||
'category_id': filters.category_id,
|
||||
'q': filters.q,
|
||||
'status': filters.status,
|
||||
} %}
|
||||
{% if page > 1 %}
|
||||
<a href="/buchungen?{% for k, v in pager_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}page={{ page - 1 }}">Zurück</a>
|
||||
{% endif %}
|
||||
Seite {{ page }} von {{ total_pages }}
|
||||
{% if page < total_pages %}
|
||||
<a href="/buchungen?{% for k, v in pager_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}page={{ page + 1 }}">Weiter</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<fieldset>
|
||||
<legend>Buchung manuell erfassen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/transactions" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){this.reset(); window.location.reload()}">
|
||||
<label>Konto
|
||||
<select name="account_id" data-type="int" required>
|
||||
{% for a in accounts %}
|
||||
<option value="{{ a.id }}">{{ a.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Buchungsdatum
|
||||
<input type="date" name="booking_date" required>
|
||||
</label>
|
||||
<label>Betrag
|
||||
<input type="text" name="amount" placeholder="-12.50" required>
|
||||
</label>
|
||||
<label>Verwendungszweck
|
||||
<input type="text" name="purpose">
|
||||
</label>
|
||||
<label>Empfänger/Zahler
|
||||
<input type="text" name="counterparty">
|
||||
</label>
|
||||
<button type="submit">Buchung speichern</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
{% endblock %}
|
||||
9
finance/app/version.py
Normal file
9
finance/app/version.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
f = Path(__file__).resolve().parent.parent / "VERSION"
|
||||
try:
|
||||
return f.read_text().strip()
|
||||
except OSError:
|
||||
return "0.0.0-dev"
|
||||
4
finance/entrypoint.sh
Executable file
4
finance/entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
alembic upgrade head
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
169
finance/grafana/dashboards/finanzen.json
Normal file
169
finance/grafana/dashboards/finanzen.json
Normal file
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"uid": "finanzen",
|
||||
"title": "Finanzen",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"editable": true,
|
||||
"timezone": "browser",
|
||||
"tags": ["finanzen"],
|
||||
"time": {
|
||||
"from": "now-1y",
|
||||
"to": "now"
|
||||
},
|
||||
"refresh": "",
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "timeseries",
|
||||
"title": "Kontostand-Verlauf",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 0
|
||||
},
|
||||
"unit": "currencyEUR"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"format": "time_series",
|
||||
"rawSql": "SELECT day AS time, account AS metric, balance AS value FROM v_balance_history ORDER BY day",
|
||||
"rawQuery": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "timeseries",
|
||||
"title": "Gesamt-Saldo",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 10
|
||||
},
|
||||
"unit": "currencyEUR"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "single" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"format": "time_series",
|
||||
"rawSql": "SELECT day AS time, balance AS value FROM v_balance_total ORDER BY day",
|
||||
"rawQuery": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "barchart",
|
||||
"title": "Monat nach Kategorie",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "currencyEUR"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||
"stacking": "normal",
|
||||
"xTickLabelRotation": 0
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"format": "time_series",
|
||||
"rawSql": "SELECT month AS time, category AS metric, total AS value FROM v_monthly_by_category ORDER BY month",
|
||||
"rawQuery": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "timeseries",
|
||||
"title": "Szenario-Vergleich",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 0,
|
||||
"thresholdsStyle": { "mode": "line" }
|
||||
},
|
||||
"unit": "currencyEUR",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "orange", "value": 0 },
|
||||
{ "color": "green", "value": 500 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"format": "time_series",
|
||||
"rawSql": "SELECT day AS time, scenario AS metric, balance AS value FROM v_projection ORDER BY day",
|
||||
"rawQuery": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
6
finance/grafana/provisioning/dashboards/provider.yaml
Normal file
6
finance/grafana/provisioning/dashboards/provider.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: 1
|
||||
providers:
|
||||
- name: finanzen
|
||||
type: file
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
13
finance/grafana/provisioning/datasources/postgres.yaml
Normal file
13
finance/grafana/provisioning/datasources/postgres.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: FinanzDB
|
||||
type: postgres
|
||||
uid: FinanzDB
|
||||
access: proxy
|
||||
url: localhost:5432
|
||||
user: finance_read
|
||||
jsonData:
|
||||
database: finance
|
||||
sslmode: disable
|
||||
secureJsonData:
|
||||
password: ${FINANCE_READ_PASSWORD}
|
||||
2
finance/pytest.ini
Normal file
2
finance/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
3
finance/requirements-dev.txt
Normal file
3
finance/requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==8.4.1
|
||||
httpx==0.28.1
|
||||
9
finance/requirements.txt
Normal file
9
finance/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn==0.35.0
|
||||
sqlalchemy==2.0.41
|
||||
alembic==1.16.2
|
||||
psycopg[binary]==3.2.9
|
||||
jinja2==3.1.6
|
||||
python-multipart==0.0.20
|
||||
itsdangerous==2.2.0
|
||||
pdfplumber==0.11.7
|
||||
9
finance/scripts/dump_pdf_text.py
Normal file
9
finance/scripts/dump_pdf_text.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""PDF-Text anzeigen, um Parser-Regexe abzugleichen: python scripts/dump_pdf_text.py <pdf>"""
|
||||
import sys
|
||||
|
||||
import pdfplumber
|
||||
|
||||
with pdfplumber.open(sys.argv[1]) as pdf:
|
||||
for i, page in enumerate(pdf.pages, 1):
|
||||
print(f"--- Seite {i} ---")
|
||||
print(page.extract_text() or "(kein Text)")
|
||||
29
finance/scripts/parser_audit.py
Normal file
29
finance/scripts/parser_audit.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Parser-Audit: geparste Transaktionen + Rohtext einer Fixture anzeigen.
|
||||
|
||||
NUR lokal verwenden - Ausgabe enthaelt echte Kontodaten und darf nicht
|
||||
in Commits, Reports oder Tickets uebernommen werden.
|
||||
Aufruf: python scripts/parser_audit.py tests/fixtures/dkb_beispiel.pdf
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from app.parsers.registry import parse_pdf
|
||||
from app.parsers.validate import balance_difference
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
stmt = parse_pdf(path)
|
||||
print(f"bank={stmt.bank} iban={stmt.iban} "
|
||||
f"period={stmt.period_start}..{stmt.period_end}")
|
||||
print(f"opening={stmt.opening_balance} closing={stmt.closing_balance} "
|
||||
f"diff={balance_difference(stmt)} n_tx={len(stmt.transactions)}")
|
||||
print("-" * 100)
|
||||
for i, t in enumerate(stmt.transactions):
|
||||
print(f"#{i:3d} | {t.booking_date} | {t.value_date} | {t.amount:>12} "
|
||||
f"| {t.counterparty[:40]:40} | {t.purpose[:60]}")
|
||||
print("=" * 100)
|
||||
with pdfplumber.open(path) as pdf:
|
||||
for n, page in enumerate(pdf.pages, 1):
|
||||
print(f"--- Seite {n} ---")
|
||||
print(page.extract_text() or "(kein Text)")
|
||||
166
finance/scripts/parser_vs_csv.py
Normal file
166
finance/scripts/parser_vs_csv.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Vergleichswerkzeug: PDF-Parser-Ergebnis gegen CSV-Ground-Truth (Task 5, Ausbaustufe 3).
|
||||
|
||||
NUR LOKAL VERWENDEN - die Ausgabe enthaelt echte Kontodaten (Betraege,
|
||||
Gegenparteien, Verwendungszwecke) und darf nicht in Commits, Reports oder
|
||||
Tickets uebernommen werden.
|
||||
|
||||
Matcht die aus einem Kontoauszugs-PDF geparsten Transaktionen gegen die
|
||||
Zeilen einer oder mehrerer Kontoumsatz-CSV-Dateien (auf den vom PDF
|
||||
abgedeckten Zeitraum eingeschraenkt) anhand von (booking_date, amount) und
|
||||
vergleicht je Match Gegenpartei (counterparty) und Verwendungszweck
|
||||
(purpose) mittels difflib.SequenceMatcher.
|
||||
|
||||
Aufruf:
|
||||
PYTHONPATH=. .venv/bin/python scripts/parser_vs_csv.py <pdf> <csv> [<csv>...]
|
||||
"""
|
||||
import statistics
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
|
||||
from app.parsers.base import ParsedTransaction
|
||||
from app.parsers.csv_formats import parse_csv
|
||||
from app.parsers.registry import parse_pdf
|
||||
|
||||
TRUNC = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class CsvRow:
|
||||
booking_date: date
|
||||
amount: Decimal
|
||||
counterparty: str
|
||||
purpose: str
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return " ".join((s or "").casefold().split())
|
||||
|
||||
|
||||
def _sim(a: str, b: str) -> float:
|
||||
return SequenceMatcher(None, _norm(a), _norm(b)).ratio()
|
||||
|
||||
|
||||
def _trunc(s: str) -> str:
|
||||
s = s or ""
|
||||
return s if len(s) <= TRUNC else s[: TRUNC - 1] + "…"
|
||||
|
||||
|
||||
def load_csv_rows(paths: list[Path], period_start: date | None,
|
||||
period_end: date | None) -> list[CsvRow]:
|
||||
rows: list[CsvRow] = []
|
||||
for p in paths:
|
||||
parsed = parse_csv(p)
|
||||
for t in parsed.statement.transactions:
|
||||
if period_start is not None and t.booking_date < period_start:
|
||||
continue
|
||||
if period_end is not None and t.booking_date > period_end:
|
||||
continue
|
||||
rows.append(CsvRow(booking_date=t.booking_date, amount=t.amount,
|
||||
counterparty=t.counterparty, purpose=t.purpose))
|
||||
return rows
|
||||
|
||||
|
||||
def match(pdf_txs: list[ParsedTransaction], csv_rows: list[CsvRow]):
|
||||
"""Greedy 1:1 match auf (booking_date, amount); Reihenfolge = PDF-Reihenfolge.
|
||||
|
||||
Returns (matches, unmatched_pdf, unmatched_csv, ambiguous_keys).
|
||||
matches: list[tuple[ParsedTransaction, CsvRow]]
|
||||
"""
|
||||
buckets: dict[tuple[date, Decimal], list[CsvRow]] = defaultdict(list)
|
||||
for r in csv_rows:
|
||||
buckets[(r.booking_date, r.amount)].append(r)
|
||||
|
||||
ambiguous_keys = {k for k, v in buckets.items() if len(v) > 1}
|
||||
|
||||
matches: list[tuple[ParsedTransaction, CsvRow]] = []
|
||||
unmatched_pdf: list[ParsedTransaction] = []
|
||||
for t in pdf_txs:
|
||||
key = (t.booking_date, t.amount)
|
||||
bucket = buckets.get(key)
|
||||
if bucket:
|
||||
row = bucket.pop(0)
|
||||
matches.append((t, row))
|
||||
else:
|
||||
unmatched_pdf.append(t)
|
||||
|
||||
unmatched_csv = [r for bucket in buckets.values() for r in bucket]
|
||||
return matches, unmatched_pdf, unmatched_csv, ambiguous_keys
|
||||
|
||||
|
||||
def purpose_sim(pdf_purpose: str, csv_counterparty: str, csv_purpose: str) -> float:
|
||||
direct = _sim(pdf_purpose, csv_purpose)
|
||||
combined = _sim(pdf_purpose, f"{csv_counterparty} {csv_purpose}")
|
||||
return max(direct, combined)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) < 3:
|
||||
print(f"Usage: {argv[0]} <pdf> <csv> [<csv>...]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
pdf_path = Path(argv[1])
|
||||
csv_paths = [Path(p) for p in argv[2:]]
|
||||
|
||||
stmt = parse_pdf(pdf_path)
|
||||
csv_rows = load_csv_rows(csv_paths, stmt.period_start, stmt.period_end)
|
||||
|
||||
print(f"PDF: {pdf_path.name} bank={stmt.bank} "
|
||||
f"period={stmt.period_start}..{stmt.period_end} n_pdf={len(stmt.transactions)}")
|
||||
print(f"CSV: {', '.join(p.name for p in csv_paths)} "
|
||||
f"n_csv(in Periode)={len(csv_rows)}")
|
||||
print("-" * 100)
|
||||
|
||||
matches, unmatched_pdf, unmatched_csv, ambiguous_keys = match(
|
||||
stmt.transactions, csv_rows)
|
||||
|
||||
cp_sims: list[float] = []
|
||||
p_sims: list[float] = []
|
||||
|
||||
for i, (t, r) in enumerate(matches, start=1):
|
||||
cp_s = _sim(t.counterparty, r.counterparty)
|
||||
p_s = purpose_sim(t.purpose, r.counterparty, r.purpose)
|
||||
cp_sims.append(cp_s)
|
||||
p_sims.append(p_s)
|
||||
print(f"#{i} | {t.booking_date} | {t.amount:>10} "
|
||||
f"| PDF-cp={_trunc(t.counterparty)} | CSV-cp={_trunc(r.counterparty)} "
|
||||
f"| cp-sim={cp_s:.2f} "
|
||||
f"| PDF-p={_trunc(t.purpose)} | CSV-p={_trunc(r.purpose)} "
|
||||
f"| p-sim={p_s:.2f}")
|
||||
|
||||
print("-" * 100)
|
||||
if unmatched_pdf:
|
||||
print(f"Unmatched PDF-Transaktionen ({len(unmatched_pdf)}):")
|
||||
for t in unmatched_pdf:
|
||||
print(f" {t.booking_date} | {t.amount:>10}")
|
||||
if unmatched_csv:
|
||||
print(f"Unmatched CSV-Zeilen ({len(unmatched_csv)}):")
|
||||
for r in unmatched_csv:
|
||||
print(f" {r.booking_date} | {r.amount:>10}")
|
||||
|
||||
print("=" * 100)
|
||||
print("Summary")
|
||||
print(f" n_pdf = {len(stmt.transactions)}")
|
||||
print(f" n_csv(in Periode) = {len(csv_rows)}")
|
||||
print(f" n_matched = {len(matches)}")
|
||||
print(f" ambiguities = {len(ambiguous_keys)} "
|
||||
f"(Buchungs-Keys mit >1 CSV-Zeile gleichen Datums/Betrags)")
|
||||
if cp_sims:
|
||||
print(f" cp-sim mean/median = {statistics.mean(cp_sims):.3f} / "
|
||||
f"{statistics.median(cp_sims):.3f}")
|
||||
print(f" p-sim mean/median = {statistics.mean(p_sims):.3f} / "
|
||||
f"{statistics.median(p_sims):.3f}")
|
||||
low_cp = sum(1 for s in cp_sims if s < 0.5)
|
||||
print(f" cp-sim < 0.5 = {low_cp} von {len(matches)} Matches")
|
||||
else:
|
||||
print(" (keine Matches)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
0
finance/tests/__init__.py
Normal file
0
finance/tests/__init__.py
Normal file
49
finance/tests/conftest.py
Normal file
49
finance/tests/conftest.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db import Base, get_session
|
||||
import app.models # noqa: F401 ensures all ORM tables are registered on Base.metadata
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
engine = create_engine(
|
||||
"sqlite://", poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
# SQLite does not enforce foreign keys unless told to per-connection. Turn
|
||||
# enforcement on so the tests catch FK violations (missing cascades) the
|
||||
# same way Postgres would in production.
|
||||
@event.listens_for(engine, "connect")
|
||||
def _fk_pragma(dbapi_conn, _):
|
||||
cur = dbapi_conn.cursor()
|
||||
cur.execute("PRAGMA foreign_keys=ON")
|
||||
cur.close()
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db, monkeypatch):
|
||||
monkeypatch.setenv("FB_API_KEY", "test-key")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.auth import hash_password
|
||||
monkeypatch.setenv("FB_GUI_PASSWORD_HASH", hash_password("geheim"))
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
|
||||
def _override_get_session():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_session] = _override_get_session
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
get_settings.cache_clear()
|
||||
9
finance/tests/fixtures/README.md
vendored
Normal file
9
finance/tests/fixtures/README.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Beispiel-Auszüge (nicht committen!)
|
||||
|
||||
Hier pro Bank mindestens einen echten PDF-Kontoauszug ablegen:
|
||||
- `vr_beispiel.pdf` (Volksbank/Raiffeisenbank)
|
||||
- `hvb_beispiel.pdf` (HypoVereinsbank)
|
||||
- `dkb_beispiel.pdf` (DKB)
|
||||
|
||||
Beträge dürfen verfremdet sein, das Layout muss echt sein.
|
||||
PDFs sind via .gitignore vom Repo ausgeschlossen.
|
||||
30
finance/tests/test_auth.py
Normal file
30
finance/tests/test_auth.py
Normal file
@@ -0,0 +1,30 @@
|
||||
def test_api_requires_key(client):
|
||||
assert client.get("/api/accounts").status_code == 401
|
||||
r = client.get("/api/accounts", headers={"Authorization": "Bearer test-key"})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_login_flow(client):
|
||||
r = client.post("/login", data={"username": "admin", "password": "falsch"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 401
|
||||
r = client.post("/login", data={"username": "admin", "password": "geheim"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert client.get("/api/accounts").status_code == 200 # Cookie reicht
|
||||
|
||||
|
||||
def test_logout_flow(client):
|
||||
r = client.post("/login", data={"username": "admin", "password": "geheim"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert client.get("/api/accounts").status_code == 200
|
||||
|
||||
r = client.post("/logout", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert client.get("/api/accounts").status_code == 401
|
||||
|
||||
|
||||
def test_tampered_session_cookie_rejected(client):
|
||||
client.cookies.set("fb_session", "gui.invalid-signature")
|
||||
assert client.get("/api/accounts").status_code == 401
|
||||
120
finance/tests/test_bank_parsers.py
Normal file
120
finance/tests/test_bank_parsers.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.parsers.dkb import _split_counterparty
|
||||
from app.parsers.registry import parse_pdf
|
||||
from app.parsers.validate import balance_difference
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
CASES = [("vr_beispiel.pdf", "vr"), ("hvb_beispiel.pdf", "hvb"),
|
||||
("dkb_beispiel.pdf", "dkb")]
|
||||
|
||||
NOISE = re.compile(
|
||||
r"(Kontostand|Übertrag|alter Saldo|neuer Saldo|Seite \d|Blatt \d)", re.I)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,bank", CASES)
|
||||
def test_parse_fixture(filename, bank):
|
||||
path = FIXTURES / filename
|
||||
if not path.exists():
|
||||
pytest.skip(f"Fixture {filename} fehlt")
|
||||
stmt = parse_pdf(path)
|
||||
assert stmt.bank == bank
|
||||
assert stmt.opening_balance is not None
|
||||
assert stmt.closing_balance is not None
|
||||
assert len(stmt.transactions) > 0
|
||||
assert balance_difference(stmt) == Decimal("0")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,bank", CASES)
|
||||
def test_no_noise_in_parsed_fields(filename, bank):
|
||||
path = FIXTURES / filename
|
||||
if not path.exists():
|
||||
pytest.skip(f"Fixture {filename} fehlt")
|
||||
stmt = parse_pdf(path)
|
||||
for t in stmt.transactions:
|
||||
assert not NOISE.search(t.purpose), f"Noise im Verwendungszweck: #{stmt.transactions.index(t)}"
|
||||
assert not NOISE.search(t.counterparty), f"Noise im Empfänger: #{stmt.transactions.index(t)}"
|
||||
assert stmt.period_start <= t.booking_date <= stmt.period_end
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,bank", CASES)
|
||||
def test_expected_values(filename, bank):
|
||||
path = FIXTURES / filename
|
||||
exp_path = FIXTURES / f"expected_{bank}.json"
|
||||
if not path.exists() or not exp_path.exists():
|
||||
pytest.skip("Fixture oder expected-Datei fehlt")
|
||||
stmt = parse_pdf(path)
|
||||
exp = json.loads(exp_path.read_text())
|
||||
assert len(stmt.transactions) == exp["transaction_count"]
|
||||
for c in exp["spot_checks"]:
|
||||
t = stmt.transactions[c["index"]]
|
||||
assert str(t.booking_date) == c["booking_date"]
|
||||
assert str(t.amount) == c["amount"]
|
||||
assert c["counterparty_contains"].lower() in t.counterparty.lower()
|
||||
assert c["purpose_contains"].lower() in t.purpose.lower()
|
||||
|
||||
|
||||
# --- DKB Empfänger/Zweck-Split-Heuristik (D-T5-1/D-T5-2) -------------------
|
||||
# Ausschließlich synthetische Zeilen (fiktive Namen/Nummern), keine echten
|
||||
# Kontodaten.
|
||||
|
||||
def test_split_counterparty_legal_form_single():
|
||||
cp, rest = _split_counterparty(
|
||||
"Musterfirma Beispiel GmbH Rechnung Nr. F000123 vom 01.01.20")
|
||||
assert cp == "Musterfirma Beispiel GmbH"
|
||||
assert rest == "Rechnung Nr. F000123 vom 01.01.20"
|
||||
|
||||
|
||||
def test_split_counterparty_legal_form_et_cie_chain():
|
||||
cp, rest = _split_counterparty(
|
||||
"Fiktiv Global S.a.r.l. et Cie S.C.A Onlinedienst Referenz 999")
|
||||
assert cp == "Fiktiv Global S.a.r.l. et Cie S.C.A"
|
||||
assert rest == "Onlinedienst Referenz 999"
|
||||
|
||||
|
||||
def test_split_counterparty_reference_boundary_digit_token():
|
||||
cp, rest = _split_counterparty(
|
||||
"Beispiel Handelsgesellschaft Auftrag 84210 Sonderzahlung")
|
||||
assert cp == "Beispiel Handelsgesellschaft Auftrag"
|
||||
assert rest == "84210 Sonderzahlung"
|
||||
|
||||
|
||||
def test_split_counterparty_reference_boundary_keyword_token():
|
||||
cp, rest = _split_counterparty(
|
||||
"Servicepartner Nord Vertrag ABC123 Laufzeit")
|
||||
assert cp == "Servicepartner Nord"
|
||||
assert rest == "Vertrag ABC123 Laufzeit"
|
||||
|
||||
|
||||
def test_split_counterparty_no_split_pure_words():
|
||||
cp, rest = _split_counterparty("MUSTER PERSON Nebenkosten")
|
||||
assert cp == "MUSTER PERSON Nebenkosten"
|
||||
assert rest == ""
|
||||
|
||||
|
||||
def test_split_counterparty_siehe_anlage():
|
||||
cp, rest = _split_counterparty("siehe Anlage Nr. 7")
|
||||
assert cp == ""
|
||||
assert rest == "siehe Anlage Nr. 7"
|
||||
|
||||
|
||||
def test_split_counterparty_never_empty_guard():
|
||||
# Erstes Token enthält eine Ziffer, darf aber nie selbst die
|
||||
# Referenz-Grenze bilden (Regel greift frühestens ab Token 1) - da kein
|
||||
# weiteres Token eine Ziffer/Schlüsselwort liefert, bleibt die ganze
|
||||
# Zeile als counterparty erhalten (nie leer).
|
||||
cp, rest = _split_counterparty("42 Testfirma Ausgleich")
|
||||
assert cp == "42 Testfirma Ausgleich"
|
||||
assert rest == ""
|
||||
assert cp != ""
|
||||
|
||||
# Einzelnes Token (auch mit Ziffer): kein Schnitt möglich, bleibt
|
||||
# counterparty (nie leer, nie None).
|
||||
cp2, rest2 = _split_counterparty("12345")
|
||||
assert cp2 == "12345"
|
||||
assert rest2 == ""
|
||||
7
finance/tests/test_config.py
Normal file
7
finance/tests/test_config.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def test_defaults():
|
||||
s = get_settings()
|
||||
assert s.horizon_days == 548
|
||||
assert s.gui_user == "admin"
|
||||
188
finance/tests/test_crud_api.py
Normal file
188
finance/tests/test_crud_api.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.tables import Account, Transaction
|
||||
from app.services.balances import account_balance
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def test_account_crud_and_balance(client, db):
|
||||
# Portiert von Statement-closing-Logik auf Konto-Saldo-Anker (Ausbaustufe 3
|
||||
# Task 1): frueher lieferte die closing_balance des juengsten bestaetigten
|
||||
# Statements die Basis, jetzt uebernimmt der Anker (Datum+Betrag am Konto)
|
||||
# dieselbe Rolle. `at` wird explizit gesetzt statt auf date.today() zu
|
||||
# vertrauen, damit der Test unabhaengig vom Ausfuehrungsdatum ist.
|
||||
r = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE02120300000000202051",
|
||||
"name": "Giro", "type": "giro"})
|
||||
assert r.status_code == 201
|
||||
acc_id = r.json()["id"]
|
||||
acc = db.get(Account, acc_id)
|
||||
acc.anchor_date = date(2026, 6, 30)
|
||||
acc.anchor_balance = Decimal("100.00")
|
||||
db.add(Transaction(account_id=acc_id, booking_date=date(2026, 7, 2),
|
||||
amount=Decimal("-30.00"), purpose="Bar", status="confirmed",
|
||||
dedup_hash="x1"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc_id)
|
||||
assert account_balance(db, acc, at=date(2026, 7, 15)) == Decimal("70.00")
|
||||
|
||||
|
||||
def test_account_balance_anchor_forward_and_backward(client, db):
|
||||
# Spec-Interface (Plan Ausbaustufe 3 Task 1): anchor_balance +
|
||||
# Sum(tx: anchor_date < booking_date <= at) - Sum(tx: at < booking_date
|
||||
# <= anchor_date). Buchungen VOR und NACH dem Anker duerfen sich nicht
|
||||
# gegenseitig beeinflussen.
|
||||
acc = Account(bank="dkb", iban="DE-ANCHOR-1", name="Anker-Konto", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
anchor_date = date(2026, 6, 30)
|
||||
acc.anchor_date = anchor_date
|
||||
acc.anchor_balance = Decimal("500.00")
|
||||
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 6, 29),
|
||||
amount=Decimal("-20.00"), purpose="vor Anker",
|
||||
status="confirmed", dedup_hash="x2"))
|
||||
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 7, 1),
|
||||
amount=Decimal("50.00"), purpose="nach Anker",
|
||||
status="confirmed", dedup_hash="x3"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc.id)
|
||||
|
||||
assert account_balance(db, acc, at=anchor_date) == Decimal("500.00")
|
||||
assert account_balance(db, acc, at=anchor_date + timedelta(days=2)) == Decimal("550.00")
|
||||
assert account_balance(db, acc, at=anchor_date - timedelta(days=2)) == Decimal("520.00")
|
||||
|
||||
|
||||
def test_account_balance_anchor_day_transaction_convention(client, db):
|
||||
# Konvention (Plan Step 5): der Anker gilt PER TAGESENDE des Ankerdatums.
|
||||
# Eine Buchung AM Ankertag ist im Anker bereits enthalten -> zaehlt NICHT
|
||||
# zur Vorwaertssumme, wohl aber zur Rueckwaertssumme.
|
||||
acc = Account(bank="dkb", iban="DE-ANCHOR-2", name="Anker-Konto 2", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
anchor_date = date(2026, 6, 30)
|
||||
acc.anchor_date = anchor_date
|
||||
acc.anchor_balance = Decimal("200.00")
|
||||
db.add(Transaction(account_id=acc.id, booking_date=anchor_date,
|
||||
amount=Decimal("15.00"), purpose="am Ankertag",
|
||||
status="confirmed", dedup_hash="x4"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc.id)
|
||||
|
||||
assert account_balance(db, acc, at=anchor_date + timedelta(days=1)) == Decimal("200.00")
|
||||
assert account_balance(db, acc, at=anchor_date - timedelta(days=1)) == Decimal("185.00")
|
||||
|
||||
|
||||
def test_account_without_anchor_uses_zero_base(client, db):
|
||||
# Ohne Anker: Summe aller bestaetigten Buchungen bis `at` (Basis 0) -
|
||||
# bisheriges Verhalten bleibt fuer Konten ohne Anker erhalten.
|
||||
acc = Account(bank="dkb", iban="DE-NOANCHOR", name="Kein Anker", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 7, 1),
|
||||
amount=Decimal("42.00"), purpose="ohne Anker",
|
||||
status="confirmed", dedup_hash="x5"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc.id)
|
||||
assert account_balance(db, acc, at=date(2026, 7, 15)) == Decimal("42.00")
|
||||
|
||||
|
||||
def test_manual_transaction_dedup(client):
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE99", "name": "G", "type": "giro"}).json()
|
||||
payload = {"account_id": acc["id"], "booking_date": "2026-07-01",
|
||||
"amount": "-10.00", "purpose": "Kaffee", "counterparty": ""}
|
||||
assert client.post("/api/transactions", headers=H, json=payload).status_code == 201
|
||||
assert client.post("/api/transactions", headers=H, json=payload).status_code == 409
|
||||
payload["force"] = True
|
||||
assert client.post("/api/transactions", headers=H, json=payload).status_code == 201
|
||||
|
||||
|
||||
def test_rules_categorize(client):
|
||||
cat = client.post("/api/categories", headers=H, json={"name": "Energie"}).json()
|
||||
client.post("/api/category-rules", headers=H,
|
||||
json={"pattern": "stadtwerke", "category_id": cat["id"]})
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE98", "name": "G2", "type": "giro"}).json()
|
||||
client.post("/api/transactions", headers=H, json={
|
||||
"account_id": acc["id"], "booking_date": "2026-07-03",
|
||||
"amount": "-80.00", "purpose": "STADTWERKE Abschlag", "counterparty": ""})
|
||||
txs = client.get("/api/transactions", headers=H,
|
||||
params={"account_id": acc["id"]}).json()
|
||||
assert txs[0]["category_id"] == cat["id"]
|
||||
|
||||
|
||||
def test_patch_account_name(client):
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()
|
||||
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"name": "DKB Giro"})
|
||||
assert r.status_code == 200 and r.json()["name"] == "DKB Giro"
|
||||
assert client.patch("/api/accounts/9999", headers=H,
|
||||
json={"name": "x"}).status_code == 404
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"name": " "}).status_code == 422
|
||||
|
||||
|
||||
def test_patch_account_anchor_set_and_clear(client):
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE72", "name": "Anker-Test", "type": "giro"}).json()
|
||||
assert acc["anchor_date"] is None
|
||||
assert acc["anchor_balance"] is None
|
||||
|
||||
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": "2026-06-30", "anchor_balance": "500.00"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["anchor_date"] == "2026-06-30"
|
||||
assert Decimal(body["anchor_balance"]) == Decimal("500.00")
|
||||
|
||||
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": None, "anchor_balance": None})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["anchor_date"] is None
|
||||
assert body["anchor_balance"] is None
|
||||
|
||||
|
||||
def test_patch_account_anchor_requires_both_fields(client):
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE73", "name": "Anker-422", "type": "giro"}).json()
|
||||
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": "2026-06-30"}).status_code == 422
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_balance": "500.00"}).status_code == 422
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": "2026-06-30",
|
||||
"anchor_balance": None}).status_code == 422
|
||||
|
||||
|
||||
def test_patch_transaction_category(client):
|
||||
cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json()
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE77", "name": "G3", "type": "giro"}).json()
|
||||
tx = client.post("/api/transactions", headers=H, json={
|
||||
"account_id": acc["id"], "booking_date": "2026-07-04",
|
||||
"amount": "-15.00", "purpose": "Kino", "counterparty": ""}).json()
|
||||
tx_id = tx["id"]
|
||||
assert tx["category_id"] is None
|
||||
|
||||
r = client.patch(f"/api/transactions/{tx_id}", headers=H,
|
||||
json={"category_id": cat["id"]})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["category_id"] == cat["id"]
|
||||
|
||||
r = client.patch(f"/api/transactions/{tx_id}", headers=H, json={})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["category_id"] == cat["id"]
|
||||
|
||||
r = client.patch(f"/api/transactions/{tx_id}", headers=H,
|
||||
json={"category_id": None})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["category_id"] is None
|
||||
|
||||
r = client.patch(f"/api/transactions/{tx_id}", headers=H,
|
||||
json={"category_id": 9999})
|
||||
assert r.status_code == 404
|
||||
255
finance/tests/test_csv_formats.py
Normal file
255
finance/tests/test_csv_formats.py
Normal file
@@ -0,0 +1,255 @@
|
||||
import json
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.parsers.base import ParserError
|
||||
from app.parsers.csv_formats import detect_csv_format, parse_csv
|
||||
from app.parsers.validate import balance_difference
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
FIXTURE_CASES = [
|
||||
("DKB_2025.csv", "dkb_csv"),
|
||||
("DKB_2026.csv", "dkb_csv"),
|
||||
("VR_2025.csv", "vr_csv"),
|
||||
("VR_2026.csv", "vr_csv"),
|
||||
("HVB_2025.csv", "hvb_csv"),
|
||||
("HVB_2026.csv", "hvb_csv"),
|
||||
]
|
||||
|
||||
|
||||
def _read_bytes(filename: str) -> bytes:
|
||||
return (FIXTURES / filename).read_bytes()
|
||||
|
||||
|
||||
def _gebucht_count(raw: bytes) -> int:
|
||||
"""Independently count 'Gebucht' rows without keeping/printing any row content."""
|
||||
# DKB is UTF-8 (with BOM); Status column is ASCII, safe to count on raw text.
|
||||
text = raw.decode("utf-8-sig")
|
||||
return text.count(';"Gebucht";')
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,fmt", FIXTURE_CASES)
|
||||
def test_fixture_rowcount_and_ascending(filename, fmt):
|
||||
path = FIXTURES / filename
|
||||
if not path.exists():
|
||||
pytest.skip(f"Fixture {filename} fehlt")
|
||||
parsed = parse_csv(path)
|
||||
stmt = parsed.statement
|
||||
assert stmt.bank == fmt
|
||||
assert len(stmt.transactions) > 0
|
||||
dates = [t.booking_date for t in stmt.transactions]
|
||||
assert dates == sorted(dates), "Transaktionen muessen chronologisch aufsteigend sein"
|
||||
for t in stmt.transactions:
|
||||
assert isinstance(t.amount, Decimal)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,fmt", [c for c in FIXTURE_CASES if c[1] == "vr_csv"])
|
||||
def test_vr_fixture_balance_chain_and_anchor(filename, fmt):
|
||||
path = FIXTURES / filename
|
||||
if not path.exists():
|
||||
pytest.skip(f"Fixture {filename} fehlt")
|
||||
parsed = parse_csv(path)
|
||||
assert parsed.balance_checkable is True
|
||||
assert balance_difference(parsed.statement) == Decimal("0")
|
||||
assert parsed.anchor is not None
|
||||
anchor_date, anchor_balance = parsed.anchor
|
||||
assert isinstance(anchor_date, date)
|
||||
assert isinstance(anchor_balance, Decimal)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,fmt", [c for c in FIXTURE_CASES if c[1] == "dkb_csv"])
|
||||
def test_dkb_fixture_anchor_and_gebucht_filter(filename, fmt):
|
||||
path = FIXTURES / filename
|
||||
if not path.exists():
|
||||
pytest.skip(f"Fixture {filename} fehlt")
|
||||
raw = _read_bytes(filename)
|
||||
parsed = parse_csv(path)
|
||||
assert parsed.anchor is not None
|
||||
assert parsed.balance_checkable is False
|
||||
assert parsed.statement.opening_balance is None
|
||||
assert parsed.statement.closing_balance is None
|
||||
# Filtering: parsed transaction count must equal independently-counted
|
||||
# "Gebucht" data rows in the raw file (counts only, no content asserted).
|
||||
assert len(parsed.statement.transactions) == _gebucht_count(raw)
|
||||
for t in parsed.statement.transactions:
|
||||
assert t.counterparty != ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,fmt", [c for c in FIXTURE_CASES if c[1] == "hvb_csv"])
|
||||
def test_hvb_fixture_no_balance_check(filename, fmt):
|
||||
path = FIXTURES / filename
|
||||
if not path.exists():
|
||||
pytest.skip(f"Fixture {filename} fehlt")
|
||||
parsed = parse_csv(path)
|
||||
assert parsed.balance_checkable is False
|
||||
assert parsed.anchor is None
|
||||
assert parsed.statement.opening_balance is None
|
||||
assert parsed.statement.closing_balance is None
|
||||
for t in parsed.statement.transactions:
|
||||
assert t.counterparty == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synthetic tests: minimal, fictitious CSVs constructed as bytes in-test.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _dkb_row(booking, value, status, payer, payee, purpose, umsatztyp, iban, amount):
|
||||
return (
|
||||
f'"{booking}";"{value}";"{status}";"{payer}";"{payee}";'
|
||||
f'"{purpose}";"{umsatztyp}";"{iban}";"{amount}";"";"";""\r\n'
|
||||
)
|
||||
|
||||
|
||||
def _build_dkb_csv() -> bytes:
|
||||
header = (
|
||||
'"Girokonto";"DE00123456780000000042"\r\n'
|
||||
'"Zeitraum:";"01.01.2025 - 31.01.2025"\r\n'
|
||||
'"Kontostand vom 31.01.2025:";"1.000,00\xa0€"\r\n'
|
||||
'""\r\n'
|
||||
'"Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";'
|
||||
'"Zahlungsempfänger*in";"Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)";'
|
||||
'"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz"\r\n'
|
||||
)
|
||||
rows = (
|
||||
_dkb_row("15.01.25", "15.01.25", "Gebucht", "Max Muster", "Erika Beispiel",
|
||||
"Testzweck A", "Ausgang", "DE00TESTIBAN1", "-10,00")
|
||||
+ _dkb_row("10.01.25", "10.01.25", "Vorgemerkt", "Max Muster", "Erika Beispiel",
|
||||
"Testzweck B (nicht gebucht)", "Ausgang", "DE00TESTIBAN1", "-99,00")
|
||||
+ _dkb_row("05.01.25", "05.01.25", "Gebucht", "Erika Beispiel", "Max Muster",
|
||||
"Testzweck C", "Eingang", "DE00TESTIBAN1", "50,00")
|
||||
)
|
||||
return (header + rows).encode("utf-8-sig")
|
||||
|
||||
|
||||
def _build_vr_csv(saldi_consistent: bool = True) -> bytes:
|
||||
header = (
|
||||
"Bezeichnung Auftragskonto;IBAN Auftragskonto;BIC Auftragskonto;"
|
||||
"Bankname Auftragskonto;Buchungstag;Valutadatum;Name Zahlungsbeteiligter;"
|
||||
"IBAN Zahlungsbeteiligter;BIC (SWIFT-Code) Zahlungsbeteiligter;Buchungstext;"
|
||||
"Verwendungszweck;Betrag;Waehrung;Saldo nach Buchung;Bemerkung;"
|
||||
"Gekennzeichneter Umsatz;Glaeubiger ID;Mandatsreferenz\r\n"
|
||||
)
|
||||
# File order is descending (newest first), matches real format.
|
||||
saldo_newest = "120,00" if saldi_consistent else "999,00"
|
||||
rows = (
|
||||
"Testkonto;DE00TESTVR000000000001;GENODETEST;Test-Bank;15.01.2025;15.01.2025;"
|
||||
"Beispiel Gegenpartei;DE00GEGEN0001;GENODETEST;Buchung;Testzweck A;20,00;EUR;"
|
||||
f"{saldo_newest};;;;\r\n"
|
||||
"Testkonto;DE00TESTVR000000000001;GENODETEST;Test-Bank;05.01.2025;05.01.2025;"
|
||||
"Beispiel Gegenpartei 2;DE00GEGEN0002;GENODETEST;Buchung;Testzweck B;100,00;EUR;"
|
||||
"100,00;;;;\r\n"
|
||||
)
|
||||
return (header + rows).encode("utf-8-sig")
|
||||
|
||||
|
||||
def _build_hvb_csv() -> bytes:
|
||||
header = "Kontonummer;Buchungsdatum;Valuta;Verwendungszweck;Betrag;Waehrung\r\n"
|
||||
rows = (
|
||||
"123456789;05.01.2025;05.01.2025;Testzweck mit Umlaut ä ö ü ß;-30,00;EUR\r\n"
|
||||
"123456789;15.01.2025;15.01.2025;Testzweck zwei;40,00;EUR\r\n"
|
||||
)
|
||||
return (header + rows).encode("utf-16")
|
||||
|
||||
|
||||
def test_detect_dkb_csv():
|
||||
assert detect_csv_format(_build_dkb_csv()) == "dkb_csv"
|
||||
|
||||
|
||||
def test_detect_vr_csv():
|
||||
assert detect_csv_format(_build_vr_csv()) == "vr_csv"
|
||||
|
||||
|
||||
def test_detect_hvb_csv():
|
||||
assert detect_csv_format(_build_hvb_csv()) == "hvb_csv"
|
||||
|
||||
|
||||
def test_detect_garbage_returns_none():
|
||||
assert detect_csv_format(b"this,is,not;a\nrecognised\tformat garbage 1234") is None
|
||||
|
||||
|
||||
def test_dkb_synthetic_skips_non_gebucht_rows(tmp_path):
|
||||
p = tmp_path / "dkb_synth.csv"
|
||||
p.write_bytes(_build_dkb_csv())
|
||||
parsed = parse_csv(p)
|
||||
assert len(parsed.statement.transactions) == 2
|
||||
for t in parsed.statement.transactions:
|
||||
assert t.purpose != "" and "nicht gebucht" not in t.purpose
|
||||
|
||||
|
||||
def test_dkb_synthetic_two_digit_year_parsed(tmp_path):
|
||||
p = tmp_path / "dkb_synth.csv"
|
||||
p.write_bytes(_build_dkb_csv())
|
||||
parsed = parse_csv(p)
|
||||
dates = [t.booking_date for t in parsed.statement.transactions]
|
||||
assert date(2025, 1, 5) in dates
|
||||
assert date(2025, 1, 15) in dates
|
||||
|
||||
|
||||
def test_dkb_synthetic_anchor(tmp_path):
|
||||
p = tmp_path / "dkb_synth.csv"
|
||||
p.write_bytes(_build_dkb_csv())
|
||||
parsed = parse_csv(p)
|
||||
assert parsed.anchor == (date(2025, 1, 31), Decimal("1000.00"))
|
||||
assert parsed.balance_checkable is False
|
||||
|
||||
|
||||
def test_vr_synthetic_balance_chain_ok(tmp_path):
|
||||
p = tmp_path / "vr_synth.csv"
|
||||
p.write_bytes(_build_vr_csv(saldi_consistent=True))
|
||||
parsed = parse_csv(p)
|
||||
assert parsed.balance_checkable is True
|
||||
assert balance_difference(parsed.statement) == Decimal("0")
|
||||
assert parsed.anchor == (date(2025, 1, 15), Decimal("120.00"))
|
||||
|
||||
|
||||
def test_vr_synthetic_balance_chain_break_raises(tmp_path):
|
||||
p = tmp_path / "vr_synth_broken.csv"
|
||||
p.write_bytes(_build_vr_csv(saldi_consistent=False))
|
||||
with pytest.raises(ParserError):
|
||||
parse_csv(p)
|
||||
|
||||
|
||||
def test_hvb_synthetic_utf16_decode(tmp_path):
|
||||
p = tmp_path / "hvb_synth.csv"
|
||||
p.write_bytes(_build_hvb_csv())
|
||||
parsed = parse_csv(p)
|
||||
stmt = parsed.statement
|
||||
assert len(stmt.transactions) == 2
|
||||
assert stmt.transactions[0].booking_date == date(2025, 1, 5)
|
||||
assert stmt.transactions[1].booking_date == date(2025, 1, 15)
|
||||
assert "ä" in stmt.transactions[0].purpose
|
||||
assert all(t.counterparty == "" for t in stmt.transactions)
|
||||
assert parsed.balance_checkable is False
|
||||
assert parsed.anchor is None
|
||||
|
||||
|
||||
def test_parse_csv_unknown_format_raises(tmp_path):
|
||||
p = tmp_path / "garbage.csv"
|
||||
p.write_bytes(b"not,a;known\nformat\n")
|
||||
with pytest.raises(ParserError):
|
||||
parse_csv(p)
|
||||
|
||||
|
||||
CSV_FILES = ["DKB_2025", "DKB_2026", "VR_2025", "VR_2026", "HVB_2025", "HVB_2026"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", CSV_FILES)
|
||||
def test_csv_expected_values(name):
|
||||
path = FIXTURES / f"{name}.csv"
|
||||
exp_path = FIXTURES / f"expected_csv_{name}.json"
|
||||
if not path.exists() or not exp_path.exists():
|
||||
pytest.skip("Fixture oder expected-Datei fehlt")
|
||||
result = parse_csv(path)
|
||||
exp = json.loads(exp_path.read_text())
|
||||
txs = result.statement.transactions
|
||||
assert len(txs) == exp["transaction_count"]
|
||||
for c in exp["spot_checks"]:
|
||||
t = txs[c["index"]]
|
||||
assert str(t.booking_date) == c["booking_date"]
|
||||
assert str(t.amount) == c["amount"]
|
||||
assert c["counterparty_contains"] in t.counterparty
|
||||
assert c["purpose_contains"] in t.purpose
|
||||
93
finance/tests/test_final_review.py
Normal file
93
finance/tests/test_final_review.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Tests fuer die Final-Review-Findings F2, F3, F6."""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.tables import (ProjectionPoint, ProjectionResult, Scenario,
|
||||
Statement, Transaction)
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def _account(client, iban="DE01"):
|
||||
return client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": iban, "name": "G", "type": "giro"}).json()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- F2 cascades
|
||||
def test_delete_projected_scenario_removes_dependent_rows(client, db):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "Basis"}).json()
|
||||
sid = sc["id"]
|
||||
r = client.post(f"/api/scenarios/{sid}/project", headers=H,
|
||||
params={"horizon_days": 10, "start_date": "2026-07-15"})
|
||||
assert r.status_code == 200
|
||||
assert db.query(ProjectionPoint).filter_by(scenario_id=sid).count() > 0
|
||||
assert db.get(ProjectionResult, sid) is not None
|
||||
|
||||
assert client.delete(f"/api/scenarios/{sid}", headers=H).status_code == 204
|
||||
db.expire_all()
|
||||
assert db.get(Scenario, sid) is None
|
||||
assert db.query(ProjectionPoint).filter_by(scenario_id=sid).count() == 0
|
||||
assert db.get(ProjectionResult, sid) is None
|
||||
|
||||
|
||||
def test_delete_loan_assigned_to_scenario(client):
|
||||
loan = client.post("/api/loans", headers=H, json={
|
||||
"name": "K1", "principal": "5000.00", "annual_rate_pct": "6.0",
|
||||
"term_months": 48, "payout_date": "2026-07-20"}).json()
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "Kredit"}).json()
|
||||
assert client.post(f"/api/scenarios/{sc['id']}/loans/{loan['id']}",
|
||||
headers=H).status_code == 204
|
||||
assert client.delete(f"/api/loans/{loan['id']}", headers=H).status_code == 204
|
||||
|
||||
|
||||
def test_delete_confirmed_import_returns_409(client, db):
|
||||
acc = _account(client)
|
||||
db.add(Statement(filename="s.pdf", bank="dkb", account_id=acc["id"],
|
||||
status="confirmed"))
|
||||
db.commit()
|
||||
sid = db.query(Statement).one().id
|
||||
assert client.delete(f"/api/imports/{sid}", headers=H).status_code == 409
|
||||
|
||||
|
||||
# --------------------------------------------------------------- F3 validation
|
||||
def test_recurring_invalid_rhythm_422(client):
|
||||
r = client.post("/api/recurring", headers=H, json={
|
||||
"name": "X", "amount": "-1.00", "rhythm": "weekly", "due_day": 1})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_recurring_due_day_zero_422(client):
|
||||
r = client.post("/api/recurring", headers=H, json={
|
||||
"name": "X", "amount": "-1.00", "rhythm": "monthly", "due_day": 0})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_loan_invalid_repayment_type_422(client):
|
||||
r = client.post("/api/loans", headers=H, json={
|
||||
"name": "K", "principal": "1000.00", "annual_rate_pct": "3.0",
|
||||
"term_months": 12, "payout_date": "2026-08-01",
|
||||
"repayment_type": "balloon"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_modifier_invalid_kind_422(client):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "S"}).json()
|
||||
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||
"target_type": "category", "target_id": 1, "kind": "double",
|
||||
"value": "10"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ------------------------------------------------------- F6 409/404 statt 500
|
||||
def test_patch_scenario_rename_clash_409(client):
|
||||
client.post("/api/scenarios", headers=H, json={"name": "A"})
|
||||
b = client.post("/api/scenarios", headers=H, json={"name": "B"}).json()
|
||||
r = client.patch(f"/api/scenarios/{b['id']}", headers=H, json={"name": "A"})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_create_transaction_unknown_account_404(client):
|
||||
r = client.post("/api/transactions", headers=H, json={
|
||||
"account_id": 9999, "booking_date": "2026-07-01",
|
||||
"amount": "-10.00", "purpose": "X", "counterparty": ""})
|
||||
assert r.status_code == 404
|
||||
270
finance/tests/test_gui.py
Normal file
270
finance/tests/test_gui.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.tables import Account
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def test_pages_require_login(client):
|
||||
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"):
|
||||
r = client.get(path, follow_redirects=False)
|
||||
assert r.status_code in (302, 303), path
|
||||
assert r.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_pages_render_after_login(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert "Finanzberatung" in r.text
|
||||
assert "Gebrauchsanleitung" in client.get("/hilfe").text
|
||||
|
||||
|
||||
def test_index_shows_account_anchor(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
with_anchor = Account(bank="dkb", iban="DE-GUI-1", name="Mit Anker", type="giro")
|
||||
without_anchor = Account(bank="vr", iban="DE-GUI-2", name="Ohne Anker", type="giro")
|
||||
db.add_all([with_anchor, without_anchor])
|
||||
db.flush()
|
||||
with_anchor.anchor_date = date(2026, 6, 30)
|
||||
with_anchor.anchor_balance = Decimal("321.00")
|
||||
db.commit()
|
||||
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "Anker: 321.00" in r.text
|
||||
assert "30.06.2026" in r.text
|
||||
assert "kein Anker" in r.text
|
||||
# Inline-Pflegeformular fuer den Anker muss je Konto vorhanden sein.
|
||||
assert 'name="anchor_date"' in r.text
|
||||
assert 'name="anchor_balance"' in r.text
|
||||
|
||||
|
||||
def test_import_page_has_dropzone_and_list(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/import")
|
||||
assert r.status_code == 200
|
||||
assert 'id="dropzone"' in r.text
|
||||
assert 'id="imports"' in r.text
|
||||
|
||||
|
||||
def test_import_list_fragment_requires_login(client):
|
||||
r = client.get("/import/list", follow_redirects=False)
|
||||
assert r.status_code in (302, 303)
|
||||
|
||||
|
||||
def test_import_list_fragment_renders_after_login(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/import/list")
|
||||
assert r.status_code == 200
|
||||
assert "Noch keine Imports" in r.text
|
||||
|
||||
|
||||
def test_preview_fragment_shows_balance_ok_none_for_format_without_saldo(
|
||||
client, tmp_path, monkeypatch):
|
||||
# UX-Regel: Saldo-Status ist IMMER sichtbar, auch wenn das Format (hier
|
||||
# HVB-CSV) keine Saldodaten liefert - dreiwertig statt faelschlich "rot".
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from tests.test_import_api import _hvb_csv_bytes
|
||||
sid = client.post("/api/imports/upload", headers=H,
|
||||
files={"file": ("HVB_gui.csv", _hvb_csv_bytes(), "text/csv")}
|
||||
).json()["id"]
|
||||
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get(f"/import/{sid}/preview-fragment")
|
||||
assert r.status_code == 200
|
||||
assert "Saldo-Prüfung: nicht verfügbar (Format ohne Saldodaten)" in r.text
|
||||
|
||||
|
||||
def test_preview_fragment_shows_balance_ok_true_for_vr_csv(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from tests.test_import_api import _vr_csv_bytes
|
||||
sid = client.post("/api/imports/upload", headers=H,
|
||||
files={"file": ("VR_gui.csv", _vr_csv_bytes(), "text/csv")}
|
||||
).json()["id"]
|
||||
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get(f"/import/{sid}/preview-fragment")
|
||||
assert r.status_code == 200
|
||||
assert "Saldo plausibel" in r.text
|
||||
|
||||
|
||||
def test_version_visible(client):
|
||||
from pathlib import Path
|
||||
version = (Path(__file__).resolve().parent.parent / "VERSION").read_text().strip()
|
||||
r = client.get("/api/version", headers={"Authorization": "Bearer test-key"})
|
||||
assert r.status_code == 200 and r.json()["version"] == version
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
assert f"v{version}" in client.get("/").text
|
||||
|
||||
|
||||
def test_buchungen_pagination(client, db):
|
||||
from app.models.tables import Account, Transaction
|
||||
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
base = date(2026, 1, 1)
|
||||
for i in range(1, 61):
|
||||
purpose = "MARKER51" if i == 51 else f"TX{i}"
|
||||
db.add(Transaction(
|
||||
account_id=acc.id, booking_date=base + timedelta(days=i),
|
||||
amount=Decimal("10.00"), purpose=purpose, counterparty="X",
|
||||
status="confirmed", dedup_hash=f"hash-{i}",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
|
||||
r1 = client.get("/buchungen")
|
||||
assert r1.status_code == 200
|
||||
assert "Seite 1 von 2" in r1.text
|
||||
assert "Weiter" in r1.text
|
||||
assert "MARKER51" not in r1.text
|
||||
|
||||
r2 = client.get("/buchungen?page=2")
|
||||
assert r2.status_code == 200
|
||||
assert "Seite 2 von 2" in r2.text
|
||||
assert "Zurück" in r2.text
|
||||
assert "MARKER51" in r2.text
|
||||
|
||||
|
||||
def test_buchungen_empty_filter_fields_no_422(client):
|
||||
# HTML-Formulare senden bei nicht ausgefüllten Feldern leere Strings, nicht
|
||||
# "kein Parameter" — die GUI-Route muss das tolerant behandeln statt mit
|
||||
# einem rohen 422 zu scheitern.
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get(
|
||||
"/buchungen"
|
||||
"?account_id=2&date_from=&date_to=&category_id=&q=&status=confirmed"
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_buchungen_filled_filters_scope_to_account(client, db):
|
||||
from app.models.tables import Account, Transaction
|
||||
|
||||
acc_a = Account(bank="dkb", iban="DE01", name="Konto A", type="giro")
|
||||
acc_b = Account(bank="dkb", iban="DE02", name="Konto B", type="giro")
|
||||
db.add_all([acc_a, acc_b])
|
||||
db.flush()
|
||||
db.add(Transaction(
|
||||
account_id=acc_a.id, booking_date=date(2026, 6, 15),
|
||||
amount=Decimal("10.00"), purpose="MARKER-A", counterparty="X",
|
||||
status="confirmed", dedup_hash="hash-a",
|
||||
))
|
||||
db.add(Transaction(
|
||||
account_id=acc_b.id, booking_date=date(2026, 6, 15),
|
||||
amount=Decimal("20.00"), purpose="MARKER-B", counterparty="Y",
|
||||
status="confirmed", dedup_hash="hash-b",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get(
|
||||
f"/buchungen?account_id={acc_a.id}&date_from=2026-06-01&date_to=2026-06-30"
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "MARKER-A" in r.text
|
||||
assert "MARKER-B" not in r.text
|
||||
|
||||
|
||||
def test_planning_page_has_all_sections(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/planung")
|
||||
assert r.status_code == 200
|
||||
for heading in ("Wiederkehrende Posten", "Einmalposten", "Kredite", "Szenarien"):
|
||||
assert heading in r.text
|
||||
|
||||
|
||||
def test_planning_page_shows_empty_suggestions_hint(client):
|
||||
# UX-Regel: das "Vorschläge aus Buchungen"-Fieldset ist immer sichtbar,
|
||||
# auch ohne Daten - statt komplett zu verschwinden zeigt es einen Hinweis.
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/planung")
|
||||
assert r.status_code == 200
|
||||
assert "Vorschläge aus Buchungen" in r.text
|
||||
assert "Keine Vorschläge" in r.text
|
||||
|
||||
|
||||
def test_salden_page_stichtag_and_month_overview(client, db):
|
||||
from app.engine.loans import add_months
|
||||
from app.models.tables import Transaction
|
||||
|
||||
# Drei Monate relativ zu "heute" verwenden statt fester Kalenderdaten,
|
||||
# damit der Test unabhaengig vom Ausfuehrungsdatum immer genau 3
|
||||
# Monatsanfangs-Zeilen (frühester Buchungsmonat bis heute) erzeugt.
|
||||
today = date.today()
|
||||
m0 = date(today.year, today.month, 1)
|
||||
m1 = add_months(m0, -1)
|
||||
m2 = add_months(m0, -2)
|
||||
|
||||
acc = Account(bank="dkb", iban="DE-SALDEN-1", name="Salden-Konto", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
acc.anchor_date = m2
|
||||
acc.anchor_balance = Decimal("1000.00")
|
||||
db.flush()
|
||||
|
||||
db.add_all([
|
||||
Transaction(account_id=acc.id, booking_date=m2 + timedelta(days=5),
|
||||
amount=Decimal("111.11"), purpose="SALDEN-TEST-1",
|
||||
counterparty="X", status="confirmed", dedup_hash="salden-h1"),
|
||||
Transaction(account_id=acc.id, booking_date=m1 + timedelta(days=3),
|
||||
amount=Decimal("22.22"), purpose="SALDEN-TEST-2",
|
||||
counterparty="X", status="confirmed", dedup_hash="salden-h2"),
|
||||
Transaction(account_id=acc.id, booking_date=m0 + timedelta(days=2),
|
||||
amount=Decimal("-33.33"), purpose="SALDEN-TEST-3",
|
||||
counterparty="X", status="confirmed", dedup_hash="salden-h3"),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
|
||||
# Stichtag zwischen Buchung 2 und Buchung 3: Saldo = Anker + Buchung1 + Buchung2.
|
||||
stichtag = m1 + timedelta(days=10)
|
||||
expected_stichtag = Decimal("1000.00") + Decimal("111.11") + Decimal("22.22")
|
||||
r = client.get(f"/salden?stichtag={stichtag.isoformat()}")
|
||||
assert r.status_code == 200
|
||||
assert f"{expected_stichtag:.2f}" in r.text
|
||||
|
||||
# Monatsuebersicht: genau 3 Monatszeilen (m2, m1, m0).
|
||||
assert r.text.count('data-month="') == 3
|
||||
for m in (m0, m1, m2):
|
||||
assert f'data-month="{m.isoformat()}"' in r.text
|
||||
|
||||
# Monatsanfangs-Saldo fuer m1 = Stand Ende Vortag von m1 (letzter Tag von
|
||||
# m2), d.h. NACH Buchung 1 (in m2), aber VOR Buchung 2 (die faellt erst
|
||||
# in m1 selbst und darf den Monatsanfang nicht verfaelschen).
|
||||
expected_m1_start = Decimal("1000.00") + Decimal("111.11")
|
||||
assert f"{expected_m1_start:.2f}" in r.text
|
||||
|
||||
|
||||
def test_salden_stichtag_garbage_falls_back_to_today(client):
|
||||
# Lenient wie die Filter auf /buchungen: ein kaputter stichtag-Parameter
|
||||
# darf nie zu 422/500 fuehren, sondern degradiert auf "heute".
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/salden?stichtag=garbage")
|
||||
assert r.status_code == 200
|
||||
assert date.today().strftime('%d.%m.%Y') in r.text
|
||||
|
||||
|
||||
def test_salden_shows_ohne_anker_footnote(client, db):
|
||||
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
|
||||
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
|
||||
acc = Account(bank="vr", iban="DE-SALDEN-2", name="Ohne-Anker-Konto", type="giro")
|
||||
db.add(acc)
|
||||
db.commit()
|
||||
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/salden")
|
||||
assert r.status_code == 200
|
||||
assert "ohne Anker" in r.text and "relative Werte" in r.text
|
||||
480
finance/tests/test_import_api.py
Normal file
480
finance/tests/test_import_api.py
Normal file
@@ -0,0 +1,480 @@
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.parsers.base import ParsedStatement, ParsedTransaction
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
FAKE = ParsedStatement(
|
||||
bank="dkb", iban="DE02120300000000202051",
|
||||
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
|
||||
opening_balance=Decimal("100.00"), closing_balance=Decimal("40.00"),
|
||||
transactions=[ParsedTransaction(date(2026, 6, 3), date(2026, 6, 3),
|
||||
Decimal("-60.00"), "Miete Juni", "Vermieter")])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_parse(monkeypatch):
|
||||
monkeypatch.setattr("app.services.importer.parse_pdf", lambda p: FAKE)
|
||||
|
||||
|
||||
# Ein Auszug mit ZWEI identischen Buchungen (gleicher dedup_hash). Saldo bleibt
|
||||
# konsistent (100 - 60 - 60 = -20). Beide muessen bestaetigt werden koennen.
|
||||
FAKE_TWINS = ParsedStatement(
|
||||
bank="dkb", iban="DE02120300000000202051",
|
||||
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
|
||||
opening_balance=Decimal("100.00"), closing_balance=Decimal("-20.00"),
|
||||
transactions=[
|
||||
ParsedTransaction(date(2026, 6, 3), date(2026, 6, 3),
|
||||
Decimal("-60.00"), "Miete Juni", "Vermieter"),
|
||||
ParsedTransaction(date(2026, 6, 3), date(2026, 6, 3),
|
||||
Decimal("-60.00"), "Miete Juni", "Vermieter"),
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_parse_twins(monkeypatch):
|
||||
monkeypatch.setattr("app.services.importer.parse_pdf", lambda p: FAKE_TWINS)
|
||||
|
||||
|
||||
def _upload(client, name="auszug.pdf"):
|
||||
return client.post("/api/imports/upload", headers=H,
|
||||
files={"file": (name, b"%PDF-fake", "application/pdf")})
|
||||
|
||||
|
||||
def test_upload_preview_confirm(client, fake_parse, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
r = _upload(client)
|
||||
assert r.status_code == 201
|
||||
sid = r.json()["id"]
|
||||
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
|
||||
assert prev["balance_ok"] is True
|
||||
assert len(prev["transactions"]) == 1
|
||||
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
|
||||
txs = client.get("/api/transactions", headers=H).json()
|
||||
assert len(txs) == 1 and txs[0]["status"] == "confirmed"
|
||||
# Zweiter Import desselben Auszugs: alles Duplikate
|
||||
sid2 = _upload(client, "auszug2.pdf").json()["id"]
|
||||
prev2 = client.get(f"/api/imports/{sid2}/preview", headers=H).json()
|
||||
assert prev2["duplicates"] == 1
|
||||
client.post(f"/api/imports/{sid2}/confirm", headers=H)
|
||||
assert len(client.get("/api/transactions", headers=H).json()) == 1
|
||||
|
||||
|
||||
def test_confirm_rechecks_duplicates_across_two_drafts(client, fake_parse, tmp_path, monkeypatch):
|
||||
# Beide Auszuege werden importiert, bevor einer bestaetigt ist -> zum
|
||||
# Importzeitpunkt ist keine Buchung ein Duplikat (dedup prueft nur gegen
|
||||
# bestaetigte). Erst beim zweiten Confirm faellt auf, dass die Buchung nun
|
||||
# bereits bestaetigt existiert. Der Re-Check muss sie ueberspringen, sonst
|
||||
# wird dieselbe Buchung doppelt bestaetigt.
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
sid1 = _upload(client, "a1.pdf").json()["id"]
|
||||
sid2 = _upload(client, "a2.pdf").json()["id"]
|
||||
assert client.post(f"/api/imports/{sid1}/confirm", headers=H).status_code == 200
|
||||
assert client.post(f"/api/imports/{sid2}/confirm", headers=H).status_code == 200
|
||||
# Zweites Confirm darf die Buchung nicht erneut bestaetigen.
|
||||
assert len(client.get("/api/transactions", headers=H).json()) == 1
|
||||
# Zweites Confirm eines bereits bestaetigten Imports -> 409.
|
||||
assert client.post(f"/api/imports/{sid1}/confirm", headers=H).status_code == 409
|
||||
|
||||
|
||||
def test_confirm_keeps_identical_twins_within_same_statement(client, fake_parse_twins, tmp_path, monkeypatch):
|
||||
# Zwei identische Buchungen im SELBEN Auszug teilen sich den dedup_hash. Der
|
||||
# Confirm-Re-Check darf nur statement-uebergreifende Duplikate abfangen,
|
||||
# sonst wuerde die erste bestaetigte Buchung ihre Zwillingsbuchung als
|
||||
# Duplikat markieren und den vom Preview geprueften Saldo zerstoeren.
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
sid = _upload(client).json()["id"]
|
||||
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
|
||||
assert prev["balance_ok"] is True
|
||||
assert prev["duplicates"] == 0
|
||||
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
|
||||
txs = client.get("/api/transactions", headers=H).json()
|
||||
assert len(txs) == 2
|
||||
assert all(t["status"] == "confirmed" for t in txs)
|
||||
|
||||
|
||||
def test_upload_sanitizes_path_traversal_filename(client, fake_parse, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
r = client.post("/api/imports/upload", headers=H,
|
||||
files={"file": ("../evil.pdf", b"%PDF-fake", "application/pdf")})
|
||||
assert r.status_code == 201
|
||||
# Der Dateiname darf keine Pfad-Anteile aus dem Client uebernehmen: nur
|
||||
# der Basisname landet im Uploads-Verzeichnis, nichts entkommt nach oben.
|
||||
assert not (tmp_path / "evil.pdf").exists()
|
||||
assert (tmp_path / "uploads" / "evil.pdf").exists()
|
||||
assert not (tmp_path / "inbox" / "evil.pdf").exists()
|
||||
|
||||
|
||||
def test_upload_rejects_non_pdf(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
r = client.post("/api/imports/upload", headers=H,
|
||||
files={"file": ("x.txt", b"not a pdf", "text/plain")})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_delete_error_import_returns_204(client, db):
|
||||
# Ein fehlgeschlagener Import (status="error") muss verworfen werden
|
||||
# koennen - nur bestaetigte Importe sind vor dem Loeschen geschuetzt.
|
||||
from app.models.tables import Statement
|
||||
db.add(Statement(filename="kaputt.pdf", bank="dkb", status="error",
|
||||
error_message="PDF nicht lesbar"))
|
||||
db.commit()
|
||||
sid = db.query(Statement).one().id
|
||||
assert client.delete(f"/api/imports/{sid}", headers=H).status_code == 204
|
||||
|
||||
|
||||
def test_rollback_confirmed_import_deletes_transactions_and_statement(client, fake_parse, tmp_path, monkeypatch):
|
||||
# Ein bestaetigter Import laesst sich als Ganzes zurueckrollen: alle seine
|
||||
# Buchungen UND der Auszug selbst verschwinden, das Konto bleibt bestehen
|
||||
# (Name/IBAN fuer einen Re-Import mit verbessertem Parser).
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
sid = _upload(client).json()["id"]
|
||||
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
|
||||
account_id = client.get("/api/accounts", headers=H).json()[0]["id"]
|
||||
|
||||
r = client.post(f"/api/imports/{sid}/rollback", headers=H)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body == {"deleted_transactions": 1, "statement_id": sid}
|
||||
|
||||
txs = client.get("/api/transactions", headers=H).json()
|
||||
assert len(txs) == 0
|
||||
imports = client.get("/api/imports", headers=H).json()
|
||||
assert all(s["id"] != sid for s in imports)
|
||||
accounts = client.get("/api/accounts", headers=H).json()
|
||||
assert any(a["id"] == account_id for a in accounts)
|
||||
|
||||
|
||||
def test_rollback_then_reimport_restores_transactions(client, fake_parse, tmp_path, monkeypatch):
|
||||
# Der eigentliche Zweck des Rollbacks: denselben Auszug erneut importieren
|
||||
# koennen (z.B. nach einem verbesserten Parser), ohne dass alte Buchungen
|
||||
# als Duplikate im Weg stehen.
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
sid = _upload(client).json()["id"]
|
||||
client.post(f"/api/imports/{sid}/confirm", headers=H)
|
||||
client.post(f"/api/imports/{sid}/rollback", headers=H)
|
||||
|
||||
sid2 = _upload(client, "auszug_reimport.pdf").json()["id"]
|
||||
prev = client.get(f"/api/imports/{sid2}/preview", headers=H).json()
|
||||
assert prev["balance_ok"] is True
|
||||
assert prev["duplicates"] == 0
|
||||
assert client.post(f"/api/imports/{sid2}/confirm", headers=H).status_code == 200
|
||||
txs = client.get("/api/transactions", headers=H).json()
|
||||
assert len(txs) == 1 and txs[0]["status"] == "confirmed"
|
||||
|
||||
|
||||
def test_rollback_draft_import_returns_409(client, fake_parse, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
sid = _upload(client).json()["id"]
|
||||
r = client.post(f"/api/imports/{sid}/rollback", headers=H)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_rollback_unknown_import_returns_404(client):
|
||||
r = client.post("/api/imports/999999/rollback", headers=H)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def _row_for(html: str, filename: str) -> str:
|
||||
"""Isoliert den <tr>-Block eines bestimmten Import-Statements anhand seines
|
||||
Dateinamens, damit Button-Zustaende eindeutig dem richtigen Statement
|
||||
zugeordnet werden (statt nur global auf Disabled-Buttons zu zaehlen)."""
|
||||
for row in html.split("<tr>"):
|
||||
if filename in row:
|
||||
return row
|
||||
raise AssertionError(f"keine Zeile fuer {filename!r} gefunden")
|
||||
|
||||
|
||||
def _has_active_button(row: str, label: str) -> bool:
|
||||
for tag in re.finditer(r"<button[^>]*>" + re.escape(label) + r"</button>", row):
|
||||
if "disabled" not in tag.group(0):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_disabled_button(row: str, label: str) -> bool:
|
||||
return bool(re.search(r"<button[^>]*disabled[^>]*>" + re.escape(label) + r"</button>", row))
|
||||
|
||||
|
||||
def test_import_list_button_matrix(client, tmp_path, monkeypatch):
|
||||
# UX-Regel: Bedienelemente sind immer sichtbar, ggf. ausgegraut (disabled),
|
||||
# statt je nach Status komplett zu fehlen. Drei Statements mit den drei
|
||||
# moeglichen Status erzeugen, dann pro Zeile die Button-Matrix pruefen.
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
|
||||
# parse_pdf direkt (nicht ueber die fake_parse-Fixture) patchen/zuruecksetzen,
|
||||
# damit der dritte Upload weiter unten wieder den echten Parser durchlaeuft
|
||||
# und an kaputten Bytes wie in test_upload_corrupt_pdf_produces_error_statement_not_500
|
||||
# scheitert - monkeypatch.undo() wuerde hier auch die setenv-Aenderungen
|
||||
# oben zuruecknehmen.
|
||||
import app.services.importer as importer_mod
|
||||
original_parse_pdf = importer_mod.parse_pdf
|
||||
importer_mod.parse_pdf = lambda p: FAKE
|
||||
try:
|
||||
sid_draft = _upload(client, "entwurf.pdf").json()["id"]
|
||||
sid_confirmed = _upload(client, "bestaetigt.pdf").json()["id"]
|
||||
assert client.post(f"/api/imports/{sid_confirmed}/confirm", headers=H).status_code == 200
|
||||
finally:
|
||||
importer_mod.parse_pdf = original_parse_pdf
|
||||
|
||||
r_err = client.post("/api/imports/upload", headers=H,
|
||||
files={"file": ("fehler.pdf", b"not a pdf", "application/pdf")})
|
||||
assert r_err.status_code == 201
|
||||
assert r_err.json()["status"] == "error"
|
||||
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
r = client.get("/import/list")
|
||||
assert r.status_code == 200
|
||||
|
||||
draft_row = _row_for(r.text, "entwurf.pdf")
|
||||
assert _has_active_button(draft_row, "Übernehmen")
|
||||
assert _has_active_button(draft_row, "Verwerfen")
|
||||
assert _has_disabled_button(draft_row, "Import zurückrollen")
|
||||
assert not _has_active_button(draft_row, "Import zurückrollen")
|
||||
|
||||
confirmed_row = _row_for(r.text, "bestaetigt.pdf")
|
||||
assert _has_disabled_button(confirmed_row, "Übernehmen")
|
||||
assert _has_disabled_button(confirmed_row, "Verwerfen")
|
||||
assert _has_active_button(confirmed_row, "Import zurückrollen")
|
||||
assert not _has_disabled_button(confirmed_row, "Import zurückrollen")
|
||||
|
||||
error_row = _row_for(r.text, "fehler.pdf")
|
||||
assert _has_active_button(error_row, "Verwerfen")
|
||||
assert _has_disabled_button(error_row, "Übernehmen")
|
||||
assert _has_disabled_button(error_row, "Import zurückrollen")
|
||||
|
||||
|
||||
def test_upload_corrupt_pdf_produces_error_statement_not_500(client, tmp_path, monkeypatch):
|
||||
# Datei besteht die Endungspruefung (.pdf), ist aber strukturell kein
|
||||
# gueltiges PDF -> pdfplumber wirft eine eigene Exception (keine
|
||||
# ParserError). Das darf nicht als unbehandelter 500 durchschlagen,
|
||||
# sondern muss wie "Bank nicht erkannt" als sauberer Fehler-Import
|
||||
# sichtbar werden.
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
r = client.post("/api/imports/upload", headers=H,
|
||||
files={"file": ("kaputt.pdf", b"not a pdf", "application/pdf")})
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["status"] == "error"
|
||||
assert "PDF nicht lesbar" in body["error_message"]
|
||||
# Datei bleibt im Posteingang liegen, wird nicht ins Uploads-Verzeichnis verschoben.
|
||||
assert (tmp_path / "inbox" / "kaputt.pdf").exists()
|
||||
assert not (tmp_path / "uploads" / "kaputt.pdf").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSV-Import (Ausbaustufe 3, Task 3): rein SYNTHETISCHE Mini-CSVs, nach der
|
||||
# in docs/superpowers/plans/2026-07-19-ausbaustufe-3.md dokumentierten
|
||||
# Formatreferenz konstruiert. KEINE echten Kontodaten.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _dkb_csv_bytes(kontostand_date="30.06.2026", kontostand_amount="1.000,00 €") -> bytes:
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=";", quoting=csv.QUOTE_ALL)
|
||||
w.writerow(["Girokonto", "DE12500105170001234567"])
|
||||
w.writerow(["Zeitraum:", "01.06.2026 - 30.06.2026"])
|
||||
w.writerow([f"Kontostand vom {kontostand_date}:", kontostand_amount])
|
||||
w.writerow([])
|
||||
w.writerow(["Buchungsdatum", "Wertstellung", "Status", "Zahlungspflichtige*r",
|
||||
"Zahlungsempfänger*in", "Verwendungszweck", "Umsatztyp", "IBAN",
|
||||
"Betrag (€)", "Gläubiger-ID", "Mandatsreferenz", "Kundenreferenz"])
|
||||
# Datei ist absteigend sortiert (neueste Buchung zuerst).
|
||||
w.writerow(["15.06.26", "15.06.26", "Gebucht", "Max Mustermann", "Beispiel GmbH",
|
||||
"Testzweck Miete", "Lastschrift", "DE12500105170001234567",
|
||||
"-50,00", "", "", ""])
|
||||
w.writerow(["10.06.26", "10.06.26", "Storniert", "Max Mustermann", "Nicht Gebucht GmbH",
|
||||
"sollte ignoriert werden", "Lastschrift", "DE12500105170001234567",
|
||||
"-999,00", "", "", ""])
|
||||
w.writerow(["01.06.26", "01.06.26", "Gebucht", "Arbeitgeber XY", "Max Mustermann",
|
||||
"Testzweck Gehalt", "Gutschrift", "DE12500105170001234567",
|
||||
"1500,00", "", "", ""])
|
||||
return ("" + buf.getvalue()).encode("utf-8")
|
||||
|
||||
|
||||
def _vr_csv_bytes() -> bytes:
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=";")
|
||||
w.writerow(["Bezeichnung Auftragskonto", "IBAN Auftragskonto", "BIC Auftragskonto",
|
||||
"Bankname Auftragskonto", "Buchungstag", "Valutadatum",
|
||||
"Name Zahlungsbeteiligter", "IBAN Zahlungsbeteiligter",
|
||||
"BIC (SWIFT-Code) Zahlungsbeteiligter", "Buchungstext", "Verwendungszweck",
|
||||
"Betrag", "Waehrung", "Saldo nach Buchung", "Bemerkung",
|
||||
"Gekennzeichneter Umsatz", "Glaeubiger ID", "Mandatsreferenz"])
|
||||
# Datei ist absteigend sortiert (neueste Buchung zuerst).
|
||||
w.writerow(["Girokonto", "DE02600501010000123456", "SOLADEST600", "VR Bank Test",
|
||||
"15.06.2026", "15.06.2026", "Test Empfaenger", "DE00000000000000000000",
|
||||
"", "Ueberweisung", "Testzweck 2", "100,00", "EUR", "1.100,00", "", "", "", ""])
|
||||
w.writerow(["Girokonto", "DE02600501010000123456", "SOLADEST600", "VR Bank Test",
|
||||
"01.06.2026", "01.06.2026", "Test Arbeitgeber", "DE00000000000000000000",
|
||||
"", "Gutschrift", "Testzweck 1", "1000,00", "EUR", "1.000,00", "", "", "", ""])
|
||||
text = ("" + buf.getvalue()).replace("\n", "\r\n")
|
||||
return text.encode("utf-8")
|
||||
|
||||
|
||||
def _hvb_csv_bytes(kontonummer="1234567") -> bytes:
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=";")
|
||||
w.writerow(["Kontonummer", "Buchungsdatum", "Valuta", "Verwendungszweck", "Betrag",
|
||||
"Waehrung"])
|
||||
# Datei ist aufsteigend sortiert (aelteste Buchung zuerst).
|
||||
w.writerow([kontonummer, "01.06.2026", "01.06.2026", "Testzweck A", "500,00", "EUR"])
|
||||
w.writerow([kontonummer, "15.06.2026", "15.06.2026", "Testzweck B", "-20,00", "EUR"])
|
||||
return buf.getvalue().encode("utf-16")
|
||||
|
||||
|
||||
def _upload_csv(client, name: str, content: bytes):
|
||||
return client.post("/api/imports/upload", headers=H,
|
||||
files={"file": (name, content, "text/csv")})
|
||||
|
||||
|
||||
def test_upload_csv_vr_balance_ok_true_and_confirm(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
r = _upload_csv(client, "VR_test.csv", _vr_csv_bytes())
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["status"] == "draft"
|
||||
assert body["bank"] == "vr_csv"
|
||||
sid = body["id"]
|
||||
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
|
||||
assert prev["balance_ok"] is True
|
||||
assert len(prev["transactions"]) == 2
|
||||
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
|
||||
accounts = client.get("/api/accounts", headers=H).json()
|
||||
acc = next(a for a in accounts if a["iban"] == "DE02600501010000123456")
|
||||
assert acc["anchor_balance"] == "1100.00"
|
||||
assert acc["anchor_date"] == "2026-06-15"
|
||||
|
||||
|
||||
def test_upload_csv_hvb_balance_ok_none(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
r = _upload_csv(client, "HVB_test.csv", _hvb_csv_bytes())
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["status"] == "draft"
|
||||
assert body["bank"] == "hvb_csv"
|
||||
sid = body["id"]
|
||||
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
|
||||
assert prev["balance_ok"] is None
|
||||
assert len(prev["transactions"]) == 2
|
||||
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
|
||||
# HVB traegt die Kontonummer als iban im Konto (dokumentierte Einschraenkung).
|
||||
accounts = client.get("/api/accounts", headers=H).json()
|
||||
acc = next(a for a in accounts if a["iban"] == "1234567")
|
||||
assert acc["anchor_date"] is None # HVB liefert keinen Anker
|
||||
|
||||
|
||||
def test_upload_csv_dkb_balance_ok_none_and_sets_account_anchor(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
r = _upload_csv(client, "DKB_test.csv", _dkb_csv_bytes())
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["status"] == "draft"
|
||||
assert body["bank"] == "dkb_csv"
|
||||
sid = body["id"]
|
||||
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
|
||||
assert prev["balance_ok"] is None
|
||||
# Storno-Zeile ("Storniert") darf nicht importiert werden.
|
||||
assert len(prev["transactions"]) == 2
|
||||
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
|
||||
accounts = client.get("/api/accounts", headers=H).json()
|
||||
acc = next(a for a in accounts if a["iban"] == "DE12500105170001234567")
|
||||
assert acc["anchor_date"] == "2026-06-30"
|
||||
assert acc["anchor_balance"] == "1000.00"
|
||||
|
||||
|
||||
def test_upload_csv_dkb_does_not_downgrade_newer_manual_anchor(client, db, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.models.tables import Account
|
||||
# Konto existiert bereits mit einem manuellen Anker NACH dem CSV-Kontostand-Datum
|
||||
# (30.06.2026) - dieser neuere, manuell gesetzte Anker darf durch den CSV-Import
|
||||
# NICHT ueberschrieben/zurueckdatiert werden.
|
||||
acc = Account(bank="dkb_csv", iban="DE12500105170001234567", name="DKB Giro",
|
||||
type="giro", anchor_date=date(2026, 7, 10), anchor_balance=Decimal("42.00"))
|
||||
db.add(acc)
|
||||
db.commit()
|
||||
|
||||
r = _upload_csv(client, "DKB_test.csv", _dkb_csv_bytes())
|
||||
assert r.status_code == 201
|
||||
sid = r.json()["id"]
|
||||
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
|
||||
|
||||
accounts = client.get("/api/accounts", headers=H).json()
|
||||
acc_after = next(a for a in accounts if a["iban"] == "DE12500105170001234567")
|
||||
assert acc_after["anchor_date"] == "2026-07-10"
|
||||
assert acc_after["anchor_balance"] == "42.00"
|
||||
|
||||
|
||||
def test_upload_rejects_non_pdf_non_csv(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
r = client.post("/api/imports/upload", headers=H,
|
||||
files={"file": ("x.txt", b"not a csv or pdf", "text/plain")})
|
||||
assert r.status_code == 400
|
||||
assert "PDF" in r.text and "CSV" in r.text
|
||||
|
||||
|
||||
def test_scan_inbox_picks_up_csv(client, tmp_path, monkeypatch):
|
||||
inbox = tmp_path / "inbox"
|
||||
monkeypatch.setenv("FB_INBOX_DIR", str(inbox))
|
||||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
inbox.mkdir(parents=True)
|
||||
(inbox / "VR_scan.csv").write_bytes(_vr_csv_bytes())
|
||||
r = client.post("/api/imports/scan-inbox", headers=H)
|
||||
assert r.status_code == 200
|
||||
results = r.json()
|
||||
assert len(results) == 1
|
||||
assert results[0]["bank"] == "vr_csv"
|
||||
assert results[0]["status"] == "draft"
|
||||
29
finance/tests/test_loans.py
Normal file
29
finance/tests/test_loans.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.engine.loans import add_months, annuity_payment, loan_schedule
|
||||
|
||||
|
||||
def test_add_months_clamps_month_end():
|
||||
assert add_months(date(2026, 1, 31), 1) == date(2026, 2, 28)
|
||||
|
||||
|
||||
def test_annuity_payment():
|
||||
# 10.000 EUR, 6 % p.a., 48 Monate -> 234,85 EUR Monatsrate
|
||||
assert annuity_payment(Decimal("10000"), Decimal("6"), 48) == Decimal("234.85")
|
||||
|
||||
|
||||
def test_annuity_schedule_amortizes_fully():
|
||||
plan = loan_schedule(Decimal("10000"), Decimal("6"), 48, date(2026, 8, 1), "annuity")
|
||||
assert len(plan) == 48
|
||||
assert plan[-1].remaining == Decimal("0.00")
|
||||
assert plan[0].due == date(2026, 9, 1)
|
||||
assert plan[0].interest == Decimal("50.00") # 10000 * 0,5 %
|
||||
assert plan[0].payment < 0
|
||||
|
||||
|
||||
def test_bullet_loan():
|
||||
plan = loan_schedule(Decimal("10000"), Decimal("6"), 12, date(2026, 8, 1), "bullet")
|
||||
assert all(i.principal == 0 for i in plan[:-1])
|
||||
assert plan[-1].principal == Decimal("10000")
|
||||
assert plan[-1].remaining == Decimal("0.00")
|
||||
24
finance/tests/test_models.py
Normal file
24
finance/tests/test_models.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.tables import Account, Statement, Transaction
|
||||
|
||||
|
||||
def test_account_transaction_roundtrip(db):
|
||||
acc = Account(bank="DKB", iban="DE02120300000000202051", name="Giro", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
st = Statement(filename="a.pdf", bank="DKB", account_id=acc.id,
|
||||
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
|
||||
opening_balance=Decimal("100.00"), closing_balance=Decimal("50.00"),
|
||||
status="draft")
|
||||
db.add(st)
|
||||
db.flush()
|
||||
tx = Transaction(account_id=acc.id, statement_id=st.id,
|
||||
booking_date=date(2026, 6, 3), value_date=date(2026, 6, 3),
|
||||
amount=Decimal("-50.00"), purpose="Miete Juni",
|
||||
counterparty="Vermieter GmbH", status="draft",
|
||||
dedup_hash="abc", is_duplicate=False)
|
||||
db.add(tx)
|
||||
db.commit()
|
||||
assert db.query(Transaction).one().amount == Decimal("-50.00")
|
||||
56
finance/tests/test_parser_base.py
Normal file
56
finance/tests/test_parser_base.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
|
||||
parse_german_amount, parse_german_date)
|
||||
from app.parsers.detect import detect_bank
|
||||
from app.parsers.validate import balance_difference, dedup_hash
|
||||
|
||||
|
||||
def test_parse_german_amount():
|
||||
assert parse_german_amount("1.234,56") == Decimal("1234.56")
|
||||
assert parse_german_amount("1.234,56-") == Decimal("-1234.56")
|
||||
assert parse_german_amount("12,00 S") == Decimal("-12.00")
|
||||
assert parse_german_amount("12,00 H") == Decimal("12.00")
|
||||
assert parse_german_amount("1\xa0234,56") == Decimal("1234.56")
|
||||
|
||||
|
||||
def test_parse_german_date():
|
||||
assert parse_german_date("03.06.2026") == date(2026, 6, 3)
|
||||
assert parse_german_date("03.06.", default_year=2026) == date(2026, 6, 3)
|
||||
|
||||
|
||||
def test_parse_german_date_invalid():
|
||||
with pytest.raises(ParserError):
|
||||
parse_german_date("31.02.2026")
|
||||
|
||||
|
||||
def test_detect_bank():
|
||||
assert detect_bank("... Volksbank Musterstadt eG ...") == "vr"
|
||||
assert detect_bank("... Raiffeisenbank ...") == "vr"
|
||||
assert detect_bank("... HypoVereinsbank ...") == "hvb"
|
||||
assert detect_bank("... UniCredit Bank ...") == "hvb"
|
||||
assert detect_bank("... Deutsche Kreditbank AG ...") == "dkb"
|
||||
assert detect_bank("... DKB ...") == "dkb"
|
||||
assert detect_bank("irgendwas") is None
|
||||
|
||||
|
||||
def _stmt(txs, opening, closing):
|
||||
return ParsedStatement(bank="vr", iban=None, period_start=None,
|
||||
period_end=None, opening_balance=opening,
|
||||
closing_balance=closing, transactions=txs)
|
||||
|
||||
|
||||
def test_balance_difference():
|
||||
txs = [ParsedTransaction(date(2026, 6, 1), None, Decimal("-50"), "Miete", "V"),
|
||||
ParsedTransaction(date(2026, 6, 2), None, Decimal("20"), "Gutschrift", "K")]
|
||||
assert balance_difference(_stmt(txs, Decimal("100"), Decimal("70"))) == Decimal("0")
|
||||
assert balance_difference(_stmt(txs, Decimal("100"), Decimal("75"))) == Decimal("5")
|
||||
|
||||
|
||||
def test_dedup_hash_normalizes_whitespace():
|
||||
a = dedup_hash(1, date(2026, 6, 1), Decimal("-50.00"), "Miete Juni")
|
||||
b = dedup_hash(1, date(2026, 6, 1), Decimal("-50.00"), "Miete Juni")
|
||||
assert a == b and len(a) == 64
|
||||
102
finance/tests/test_planning_api.py
Normal file
102
finance/tests/test_planning_api.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.tables import Account, Category, RecurringItem, Transaction
|
||||
from app.services.suggestions import suggest_recurring
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def _seed_balance(db, amount="1000.00"):
|
||||
# Portiert von Statement-closing-Logik auf Konto-Saldo-Anker (Ausbaustufe 3
|
||||
# Task 1): der Anker am Konto uebernimmt die Rolle der frueheren
|
||||
# Statement.closing_balance als Saldo-Basis.
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
acc.anchor_date = date(2026, 6, 30)
|
||||
acc.anchor_balance = Decimal(amount)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_scenario_projection(client, db):
|
||||
_seed_balance(db)
|
||||
client.post("/api/recurring", headers=H, json={
|
||||
"name": "Miete", "amount": "-600.00", "rhythm": "monthly", "due_day": 1})
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "Basis"}).json()
|
||||
r = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
|
||||
params={"horizon_days": 92, "start_date": "2026-07-15"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# 3 Mietzahlungen (01.08., 01.09., 01.10.): 400 -> -200 -> -800
|
||||
assert Decimal(body["low_point_balance"]) == Decimal("-800.00")
|
||||
assert body["below_zero_date"] == "2026-09-01"
|
||||
assert len(body["series"]) == 92
|
||||
|
||||
|
||||
def test_loan_in_scenario_keeps_balance_positive(client, db):
|
||||
_seed_balance(db)
|
||||
client.post("/api/recurring", headers=H, json={
|
||||
"name": "Miete", "amount": "-600.00", "rhythm": "monthly", "due_day": 1})
|
||||
loan = client.post("/api/loans", headers=H, json={
|
||||
"name": "K1", "principal": "5000.00", "annual_rate_pct": "6.0",
|
||||
"term_months": 48, "payout_date": "2026-07-20",
|
||||
"repayment_type": "annuity"}).json()
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "Kredit"}).json()
|
||||
client.post(f"/api/scenarios/{sc['id']}/loans/{loan['id']}", headers=H)
|
||||
body = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
|
||||
params={"horizon_days": 92, "start_date": "2026-07-15"}).json()
|
||||
assert Decimal(body["low_point_balance"]) > Decimal("0")
|
||||
|
||||
|
||||
def _tx(acc, d, amount, counterparty, dedup, category_id=None):
|
||||
return Transaction(account_id=acc.id, booking_date=d, amount=Decimal(amount),
|
||||
purpose="", counterparty=counterparty, status="confirmed",
|
||||
dedup_hash=dedup, category_id=category_id)
|
||||
|
||||
|
||||
def test_suggest_recurring_three_consecutive_months_with_year_wrap(db):
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
cat = Category(name="Miete")
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
# Dez 2025 -> Jan 2026 -> Feb 2026: 3 aufeinanderfolgende Monate ueber den
|
||||
# Jahreswechsel hinweg (prueft die Monats-Linearisierung y*12+m).
|
||||
db.add(_tx(acc, date(2025, 12, 1), "-600.00", "Vermieter", "h1", cat.id))
|
||||
db.add(_tx(acc, date(2026, 1, 15), "-600.00", "Vermieter", "h2", cat.id))
|
||||
db.add(_tx(acc, date(2026, 2, 28), "-600.00", "Vermieter", "h3", cat.id))
|
||||
db.commit()
|
||||
|
||||
out = suggest_recurring(db)
|
||||
|
||||
# Median der Tage [1, 15, 28] = 15; Betrag unveraendert uebernommen.
|
||||
assert out == [{"name": "Vermieter", "amount": Decimal("-600.00"),
|
||||
"rhythm": "monthly", "due_day": 15, "category_id": cat.id}]
|
||||
|
||||
|
||||
def test_suggest_recurring_two_months_no_suggestion(db):
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
db.add(_tx(acc, date(2026, 3, 10), "-50.00", "Zweimonatig", "h1"))
|
||||
db.add(_tx(acc, date(2026, 4, 10), "-50.00", "Zweimonatig", "h2"))
|
||||
db.commit()
|
||||
|
||||
assert suggest_recurring(db) == []
|
||||
|
||||
|
||||
def test_suggest_recurring_excludes_existing_recurring_item(db):
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
db.add(_tx(acc, date(2026, 1, 5), "-30.00", "Streaming", "h1"))
|
||||
db.add(_tx(acc, date(2026, 2, 5), "-30.00", "Streaming", "h2"))
|
||||
db.add(_tx(acc, date(2026, 3, 5), "-30.00", "Streaming", "h3"))
|
||||
db.add(RecurringItem(name="Streaming", amount=Decimal("-30.00"),
|
||||
rhythm="monthly", due_day=5))
|
||||
db.commit()
|
||||
|
||||
# Gleicher Name + Betrag wie ein bereits vorhandenes RecurringItem -> ausgelassen.
|
||||
assert suggest_recurring(db) == []
|
||||
30
finance/tests/test_projection.py
Normal file
30
finance/tests/test_projection.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.engine.projection import project
|
||||
|
||||
|
||||
def test_low_point_and_threshold():
|
||||
flows = [(date(2026, 8, 1), Decimal("-150")), (date(2026, 8, 10), Decimal("200"))]
|
||||
p = project(Decimal("100"), date(2026, 7, 31), flows, horizon_days=15,
|
||||
threshold=Decimal("60"))
|
||||
assert p.low_point == (date(2026, 8, 1), Decimal("-50"))
|
||||
assert p.first_below_zero == date(2026, 8, 1)
|
||||
assert p.first_below_threshold == date(2026, 8, 1)
|
||||
assert p.series[-1][1] == Decimal("150")
|
||||
|
||||
|
||||
def test_no_crossing():
|
||||
p = project(Decimal("100"), date(2026, 7, 31), [], horizon_days=5)
|
||||
assert p.first_below_zero is None
|
||||
assert p.low_point[1] == Decimal("100")
|
||||
|
||||
|
||||
def test_cashflow_on_start_date_excluded():
|
||||
"""Cashflows dated exactly on start_date are excluded (part of starting balance)"""
|
||||
p = project(Decimal("100"), date(2026, 8, 1), [(date(2026, 8, 1), Decimal("-999"))],
|
||||
horizon_days=5)
|
||||
# All balances remain 100 because the -999 flow on start_date is ignored
|
||||
assert all(bal == Decimal("100") for _, bal in p.series)
|
||||
assert p.first_below_zero is None
|
||||
assert p.low_point[1] == Decimal("100")
|
||||
29
finance/tests/test_recurrence.py
Normal file
29
finance/tests/test_recurrence.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from datetime import date
|
||||
|
||||
from app.engine.recurrence import occurrences
|
||||
|
||||
|
||||
def test_monthly_clamps_short_months():
|
||||
got = occurrences("monthly", 31, date(2026, 1, 1), date(2026, 3, 31))
|
||||
assert got == [date(2026, 1, 31), date(2026, 2, 28), date(2026, 3, 31)]
|
||||
|
||||
|
||||
def test_quarterly_respects_item_bounds():
|
||||
got = occurrences("quarterly", 15, date(2026, 1, 1), date(2026, 12, 31),
|
||||
item_start=date(2026, 4, 1), item_end=date(2026, 10, 31))
|
||||
assert got == [date(2026, 4, 15), date(2026, 7, 15), date(2026, 10, 15)]
|
||||
|
||||
|
||||
def test_yearly():
|
||||
got = occurrences("yearly", 1, date(2026, 1, 1), date(2028, 12, 31))
|
||||
assert got == [date(2026, 1, 1), date(2027, 1, 1), date(2028, 1, 1)]
|
||||
|
||||
|
||||
def test_monthly_leap_year_clamping():
|
||||
got = occurrences("monthly", 31, date(2028, 1, 1), date(2028, 3, 31))
|
||||
assert got == [date(2028, 1, 31), date(2028, 2, 29), date(2028, 3, 31)]
|
||||
|
||||
|
||||
def test_quarterly_clamping():
|
||||
got = occurrences("quarterly", 31, date(2026, 1, 1), date(2026, 12, 31))
|
||||
assert got == [date(2026, 1, 31), date(2026, 4, 30), date(2026, 7, 31), date(2026, 10, 31)]
|
||||
59
finance/tests/test_scenario_engine.py
Normal file
59
finance/tests/test_scenario_engine.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
|
||||
build_cashflows)
|
||||
|
||||
W = (date(2026, 8, 1), date(2026, 10, 31))
|
||||
|
||||
|
||||
def test_recurring_with_percent_cut():
|
||||
rec = [PlainRecurring(id=1, name="Marketing", amount=Decimal("-1000"),
|
||||
rhythm="monthly", due_day=5, start_date=None,
|
||||
end_date=None, category_id=7)]
|
||||
mods = [PlainModifier(target_type="category", target_id=7,
|
||||
kind="percent", value=Decimal("50"))]
|
||||
flows = build_cashflows(rec, [], [], [], mods, *W)
|
||||
assert flows == [(date(2026, 8, 5), Decimal("-500.00")),
|
||||
(date(2026, 9, 5), Decimal("-500.00")),
|
||||
(date(2026, 10, 5), Decimal("-500.00"))]
|
||||
|
||||
|
||||
def test_remove_modifier_and_planned_and_loan():
|
||||
rec = [PlainRecurring(id=2, name="Abo", amount=Decimal("-50"),
|
||||
rhythm="monthly", due_day=1, start_date=None,
|
||||
end_date=None, category_id=None)]
|
||||
mods = [PlainModifier(target_type="recurring", target_id=2,
|
||||
kind="remove", value=Decimal("0"))]
|
||||
planned = [PlainPlanned(name="Steuer", amount=Decimal("-2000"),
|
||||
due=date(2026, 9, 15), category_id=None)]
|
||||
sched = loan_schedule(Decimal("10000"), Decimal("6"), 48, date(2026, 8, 1), "annuity")
|
||||
flows = build_cashflows(rec, planned, [sched],
|
||||
[(date(2026, 8, 1), Decimal("10000"))], mods, *W)
|
||||
days = [d for d, _ in flows]
|
||||
assert date(2026, 8, 1) in days # Kredit-Auszahlung
|
||||
assert date(2026, 9, 15) in days # Einmalposten
|
||||
assert all(a != Decimal("-50") for _, a in flows) # Abo entfernt
|
||||
|
||||
|
||||
def test_absolute_modifier_reduces_outflow():
|
||||
"""Test absolute modifier for outflows: -200 - 50 = -150"""
|
||||
rec = [PlainRecurring(id=1, name="Marketing", amount=Decimal("-200"),
|
||||
rhythm="monthly", due_day=10, start_date=None,
|
||||
end_date=None, category_id=3)]
|
||||
mods = [PlainModifier(target_type="category", target_id=3,
|
||||
kind="absolute", value=Decimal("50"))]
|
||||
flows = build_cashflows(rec, [], [], [], mods, date(2026, 8, 1), date(2026, 8, 31))
|
||||
assert flows == [(date(2026, 8, 10), Decimal("-150.00"))]
|
||||
|
||||
|
||||
def test_absolute_modifier_clamps_to_zero():
|
||||
"""Test absolute modifier clamping: -200 + 300 = 0 (never flips sign)"""
|
||||
rec = [PlainRecurring(id=1, name="Marketing", amount=Decimal("-200"),
|
||||
rhythm="monthly", due_day=10, start_date=None,
|
||||
end_date=None, category_id=3)]
|
||||
mods = [PlainModifier(target_type="category", target_id=3,
|
||||
kind="absolute", value=Decimal("300"))]
|
||||
flows = build_cashflows(rec, [], [], [], mods, date(2026, 8, 1), date(2026, 8, 31))
|
||||
assert flows == [(date(2026, 8, 10), Decimal("0.00"))]
|
||||
Reference in New Issue
Block a user