8 Commits
Author SHA1 Message Date
Jonas MenzelandClaude Opus 5 6d182d0054 chore: Version 0.1.2
Images bauen / build (backend) (push) Successful in 4m13s
Images bauen / build (frontend) (push) Successful in 5m34s
CI / frontend (push) Successful in 6m48s
CI / backend (push) Successful in 2m41s
Der Versionshinweis im README stand noch auf 0.1.0 und wird
mitgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
2026-09-10 10:05:23 +02:00
Jonas MenzelandClaude Opus 5 0a6261fc55 feat: einstellbarer Monatsbeginn zum Gehaltstag
CI / backend (push) Successful in 2m33s
Images bauen / build (backend) (push) Successful in 3m26s
Images bauen / build (frontend) (push) Successful in 4m4s
CI / frontend (push) Successful in 6m32s
Wer nach dem Gehaltseingang plant, stellt unter Einstellungen den Tag
ein, ab dem ein neuer Monat zählt. Der Zeitraum läuft dann vom
Gehaltstag bis zum Vortag des nächsten und trägt den Namen des Monats,
in dem er beginnt: Mit dem 25. umfasst „September 2026“ den 25.09. bis
zum 24.10. Ein Starttag jenseits der Monatslänge rutscht auf den
Monatsletzten, sodass 31 verlässlich den letzten Tag des Monats meint.

Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die
Kategorienauswertung, der Monatsexport und die Benachrichtigung über
überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben je
Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der
Schnitt verschiebt sich. Bestandsinstallationen bleiben beim Ersten.

Die Einstellung liegt in einer einzeiligen Tabelle hinter
GET/PUT /api/settings; die Monatsauswertungen liefern zusätzlich
period_start und period_end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
2026-09-10 09:52:51 +02:00
Jonas MenzelandClaude Opus 5 998d5867df fix: stille Anmeldeschleife bei Secure-Cookie über HTTP erklären
CI / backend (push) Successful in 2m33s
Images bauen / build (backend) (push) Successful in 3m45s
Images bauen / build (frontend) (push) Successful in 4m23s
CI / frontend (push) Successful in 6m38s
Bei COOKIE_SECURE=true und Zugriff über HTTP verwirft der Browser das
Sitzungs-Cookie. Die Anmeldung selbst meldet Erfolg, die nächste Anfrage gilt
aber als nicht angemeldet – der Nutzer landet ohne jede Meldung wieder auf der
Anmeldemaske und hält es für ein falsches Passwort.

- Das Frontend prüft die Sitzung unmittelbar nach der Anmeldung und nennt bei
  einem Fehlschlag die Ursache samt beider Auswege
- Das Backend protokolliert dieselbe Kombination als Warnung und wertet dabei
  X-Forwarded-Proto aus, weil hinter einem Reverse Proxy immer http ankommt
- docs/deployment.md beschreibt das Symptom wörtlich

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-10 00:00:29 +02:00
Jonas MenzelandClaude Opus 5 c110340628 feat: Kommando zum Setzen des Passworts
CI / backend (push) Successful in 2m49s
Images bauen / build (frontend) (push) Successful in 3m12s
Images bauen / build (backend) (push) Successful in 3m22s
CI / frontend (push) Successful in 6m20s
Die Erstanlage beim Start greift nur, solange die Benutzertabelle leer ist. Wer
.env.example zuerst unverändert übernimmt, bekommt einen Benutzer mit dem
Platzhalter; ein späteres Ändern von MONEYFY_ADMIN_PASSWORD bleibt danach
wirkungslos. Das Kommando setzt das Passwort direkt, legt den Benutzer bei
Bedarf an und beendet alle offenen Sitzungen.

docs/deployment.md nennt jetzt die drei Ursachen einer fehlgeschlagenen
Anmeldung in der Reihenfolge ihrer Häufigkeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 23:40:42 +02:00
Jonas MenzelandClaude Opus 5 a022a99f97 chore: Version 0.1.1
Images bauen / build (frontend) (push) Successful in 3m8s
Images bauen / build (backend) (push) Successful in 3m11s
CI / frontend (push) Successful in 6m16s
CI / backend (push) Successful in 2m19s
Aus v0.1.0 sind keine Images entstanden – der Build scheiterte am noch nicht
angelegten Registry-Secret. Der Tag bleibt als historischer Marker stehen,
ausgeliefert wird 0.1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 22:02:08 +02:00
Jonas MenzelandClaude Opus 5 ba02e17776 test: Kürzel-Verdrahtung zwischen Layout und Seiten absichern
CI / backend (push) Successful in 2m2s
CI / frontend (push) Canceled after 2m41s
Images bauen / build (backend) (push) Successful in 3m54s
Images bauen / build (frontend) (push) Successful in 4m32s
Sechs Tests für usePendingAction und useSearchFocus. Der Store wird vor dem
Test zurückgesetzt statt danach – nach dem Test hängen die Komponenten noch am
Store und React meldet ein Update außerhalb von act().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 21:58:20 +02:00
Jonas MenzelandClaude Opus 5 d63cc0af65 feat: Politur – Demodaten, Kürzel, Fehlerseiten, Kontraste
Images bauen / build (backend) (push) Failing after 58s
Images bauen / build (frontend) (push) Failing after 57s
CI / backend (push) Successful in 2m21s
CI / frontend (push) Canceled after 4m40s
- Demo-Seed mit realistischem deutschem Haushalt (make seed-demo): drei Konten,
  zehn Firmen samt Logos, zwölf Posten, bestätigte Historie, Buchungen, Budgets
  und Sparziele; alle Termine relativ zum heutigen Monat
- Tastaturkürzel n / r / / / ? samt Übersicht; greifen nur außerhalb von
  Eingabefeldern
- Fehlergrenze für Renderfehler und eigene 404-Seite
- Farbtoken beider Modi gegen WCAG geprüft und nachgezogen: Hilfstext lag hell
  bei 2,56:1, Weiß auf dem Primärknopf bei 3,30:1 – jetzt überall >= 4,5:1
- Abo-Übersicht heißt „Laufende Kosten“, weil sie auch Miete und Sparplan enthält
- build.yml fällt auf GITEA_TOKEN zurück, wenn REGISTRY_TOKEN fehlt, und erklärt
  im Fehlerfall, wie das Secret anzulegen ist
- README mit Screenshot-Abschnitt, Kürzeln und Barrierefreiheit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 21:55:01 +02:00
Jonas MenzelandClaude Opus 5 bdf4097bd7 fix(tests): Testsuite unabhängig von einer lokalen .env machen
Images bauen / build (backend) (push) Failing after 29s
Images bauen / build (frontend) (push) Failing after 30s
CI / backend (push) Successful in 2m6s
CI / frontend (push) Successful in 6m5s
Ohne backend/.env galt COOKIE_SECURE=true. Der Testclient spricht über http,
httpx verwarf die Secure-Cookies, und 106 Tests scheiterten mit 401. Lokal fiel
das nicht auf, weil dort eine nicht eingecheckte .env den Wert überschrieb – die
CI hat es aufgedeckt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 17:25:34 +02:00
63 changed files with 2905 additions and 164 deletions
+14 -2
View File
@@ -7,11 +7,20 @@ Zwei Workflows:
| `ci.yml` | jeder Push, jeder Pull Request | Backend: `ruff check`, `ruff format --check`, `pytest` gegen einen PostgreSQL-Dienst. Frontend: `npm ci`, `tsc --noEmit`, `eslint`, `vitest`, Bundle-Bau. |
| `build.yml` | Push auf `main`, Tags `v*` | Baut beide Images für `linux/amd64` und lädt sie in die Gitea-Registry. **Kein Deploy** das Ausrollen erfolgt von Hand. |
## Benötigte Repo-Secrets
## Anmeldung an der Registry
`build.yml` versucht die Anmeldung in dieser Reihenfolge:
1. das Repo-Secret **`REGISTRY_TOKEN`**, falls vorhanden,
2. sonst `GITEA_TOKEN` den Token, den Gitea jedem Lauf automatisch mitgibt.
Damit läuft der Workflow im Normalfall ohne jede manuelle Einrichtung. Reicht
das Recht des automatischen Tokens auf der Instanz nicht aus, bricht der Schritt
mit `denied` oder `unauthorized` ab; dann das Secret von Hand anlegen:
| Name | Zweck | Woher |
|---|---|---|
| `REGISTRY_TOKEN` | Anmeldung an `git.menzel.center` zum Hochladen der Images | Gitea → Benutzereinstellungen → Applications → **Generate New Token** mit dem Recht `write:package` |
| `REGISTRY_TOKEN` | Anmeldung an `git.menzel.center` zum Hochladen der Images | Gitea → Benutzereinstellungen → Applications → **Generate New Token**, bei *package* auf `Read and Write` |
Anlegen unter *Repository → Settings → Actions → Secrets → Add Secret*.
@@ -19,6 +28,9 @@ Als Benutzername verwendet der Workflow `${{ github.actor }}`, also den Auslöse
des Laufs. Dieser Benutzer braucht Schreibrecht auf die Pakete des Namensraums
`menzeljonas`.
> Fehlt beides, meldet der Schritt `::error::Password required` und es entsteht
> **kein Image** `docker compose pull` läuft dann in ein `not found`.
## Erzeugte Tags
| Auslöser | Tags |
+14 -1
View File
@@ -38,7 +38,10 @@ jobs:
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
# Bevorzugt das eigens angelegte Secret. Fehlt es, greift der Token,
# den Gitea jedem Lauf ohnehin mitgibt damit läuft der Workflow ohne
# jede manuelle Einrichtung.
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITEA_TOKEN }}
- name: Tags und Beschriftungen ermitteln
id: meta
@@ -74,6 +77,16 @@ jobs:
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/moneyfy-${{ matrix.component }}:buildcache,mode=max
provenance: false
- name: Hinweis bei fehlgeschlagener Anmeldung
if: failure()
run: |
echo "Die Anmeldung an ${{ env.REGISTRY }} ist fehlgeschlagen." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Lege ein Repo-Secret **REGISTRY_TOKEN** an:" >> "$GITHUB_STEP_SUMMARY"
echo "1. Benutzereinstellungen → Applications → Generate New Token" >> "$GITHUB_STEP_SUMMARY"
echo "2. Bei *package* auf \`Read and Write\` stellen" >> "$GITHUB_STEP_SUMMARY"
echo "3. Repository → Settings → Actions → Secrets → Add Secret" >> "$GITHUB_STEP_SUMMARY"
- name: Ergebnis zusammenfassen
run: |
echo "### moneyfy-${{ matrix.component }}" >> "$GITHUB_STEP_SUMMARY"
+69 -2
View File
@@ -7,13 +7,78 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased]
Noch keine Änderungen seit 0.1.0.
Noch keine Änderungen seit 0.1.2.
## [0.1.2] 2026-09-10
Der Monat beginnt nicht mehr zwangsläufig am Ersten.
### Hinzugefügt
- Einstellbarer Monatsbeginn: Wer am Gehaltstag rechnet, setzt unter
*Einstellungen Monatsbeginn* den Tag, ab dem ein neuer Monat zählt. Der
Zeitraum läuft dann vom Gehaltstag bis zum Vortag des nächsten und trägt den
Namen des Monats, in dem er beginnt der 25. September eröffnet also
„September“ und schließt am 24. Oktober. Ein Starttag jenseits der
Monatslänge rutscht auf den Monatsletzten, sodass 31 verlässlich „letzter Tag
des Monats“ bedeutet. Dashboard, Cashflow-Kalender, Budgets, Vorschau,
Kategorien, Monatsexport und die Budget-Benachrichtigung rechnen mit diesem
Zeitraum. Neue Endpunkte `GET`/`PUT /api/settings`; die Monatsauswertungen
liefern zusätzlich `period_start` und `period_end`. Bestandsinstallationen
bleiben beim Monatsersten.
- `python -m app.scripts.reset_password` setzt das Passwort eines Benutzers oder
legt ihn an. Nötig, weil die Erstanlage beim Start nur bei leerer
Benutzertabelle greift und ein nachträglich geändertes
`MONEYFY_ADMIN_PASSWORD` danach wirkungslos bleibt.
### Behoben
- Eine Anmeldung über HTTP bei aktivem `COOKIE_SECURE` fiel stillschweigend auf
die Anmeldemaske zurück: Der Browser verwirft das `Secure`-Cookie, die
Anmeldung selbst meldet aber Erfolg. Die Oberfläche prüft die Sitzung jetzt
unmittelbar nach der Anmeldung und erklärt den Fall samt beider Auswege; das
Backend protokolliert dieselbe Kombination als Warnung.
## [0.1.1] 2026-09-09
Politur und ein Fehler, den erst die CI zutage gefördert hat.
### Hinzugefügt
- Demo-Seed mit einem realistischen deutschen Haushalt: drei Konten, zehn Firmen
samt Logos, zwölf wiederkehrende Posten (Miete, Strom, Netflix, Spotify,
jährliche Kfz-Versicherung, Handyvertrag mit Mindestlaufzeit, Kredit über 36
Raten, Gehalt), bestätigte Historie, Buchungen, Budgets und Sparziele. Alle
Termine liegen relativ zum heutigen Monat. Aufruf über `make seed-demo`.
- Tastaturkürzel: `n` neue Buchung, `r` neuer Posten, `/` Suche, `?` Übersicht.
Sie greifen nur, solange kein Eingabefeld den Fokus hat.
- Fehlergrenze für unerwartete Renderfehler und eine eigene 404-Seite.
### Geändert
- Farbtoken beider Modi gegen die WCAG-Kontraste geprüft und nachgezogen: der
Hilfstext lag im hellen Modus bei 2,56:1, Weiß auf dem Primärknopf bei 3,30:1.
Jetzt erreicht jeder Text mindestens 4,5:1 und jedes Bedienelement 3:1.
- Die Abo-Übersicht heißt „Laufende Kosten" sie enthält jede wiederkehrende
Ausgabe, auch Miete und Sparplan, und nicht nur Abonnements.
- `build.yml` weicht auf den automatischen `GITEA_TOKEN` aus, wenn das Secret
`REGISTRY_TOKEN` fehlt, und schreibt bei einer gescheiterten Anmeldung die
nötigen Schritte in die Job-Zusammenfassung.
### Behoben
- Die Testsuite hing an einer nicht eingecheckten lokalen `.env`: ohne sie galt
`COOKIE_SECURE=true`, httpx verwarf die Secure-Cookies über http, und 106
Tests scheiterten mit 401. Aufgefallen ist das erst in der CI.
## [0.1.0] 2026-09-09
Erste lauffähige Fassung: Planung, Auswertung und Benachrichtigung stehen,
die Anwendung lässt sich per Compose betreiben.
> Aus diesem Tag sind keine Images entstanden der Build scheiterte am noch
> nicht angelegten Registry-Secret. Verwende 0.1.1.
### Hinzugefügt
- Projektgerüst mit getrennten Bereichen für Backend, Frontend, Dokumentation und CI.
@@ -140,5 +205,7 @@ die Anwendung lässt sich per Compose betreiben.
- Der Bildtyp eines Uploads wird ausschließlich am Inhalt bestimmt; die vom
Aufrufer gemeldete Kopfzeile wird nicht mehr als Nachweis akzeptiert.
[Unreleased]: https://git.menzel.center/menzeljonas/moneyfy/compare/v0.1.0...HEAD
[Unreleased]: https://git.menzel.center/menzeljonas/moneyfy/compare/v0.1.2...HEAD
[0.1.2]: https://git.menzel.center/menzeljonas/moneyfy/compare/v0.1.1...v0.1.2
[0.1.1]: https://git.menzel.center/menzeljonas/moneyfy/compare/v0.1.0...v0.1.1
[0.1.0]: https://git.menzel.center/menzeljonas/moneyfy/releases/tag/v0.1.0
+16
View File
@@ -32,6 +32,22 @@ seed: ## Stammdaten (Kategoriebaum, Benachrichtigungsregeln) anlegen
vendor-icons: ## simple-icons-Index neu erzeugen (benötigt npm und Netzzugang)
cd $(BACKEND) && .venv/bin/python scripts/vendor_simple_icons.py
.PHONY: reset-password
reset-password: ## Passwort setzen oder Administrator anlegen: make reset-password u=admin
cd $(BACKEND) && .venv/bin/python -m app.scripts.reset_password $(if $(u),--username $(u),)
.PHONY: seed-demo
seed-demo: ## Realistische Demodaten anlegen (vorhandene Daten bleiben, sonst --reset)
cd $(BACKEND) && .venv/bin/python -m app.scripts.seed_demo
.PHONY: reset-password
reset-password: ## Passwort setzen oder Administrator anlegen: make reset-password u=admin
cd $(BACKEND) && .venv/bin/python -m app.scripts.reset_password $(if $(u),--username $(u),)
.PHONY: seed-demo-reset
seed-demo-reset: ## Demodaten neu aufsetzen; löscht Konten, Firmen, Posten und Buchungen
cd $(BACKEND) && .venv/bin/python -m app.scripts.seed_demo --reset
.PHONY: dev
dev: ## Backend mit Hot Reload starten
cd $(BACKEND) && .venv/bin/uvicorn app.main:app --reload --port 8000
+65 -6
View File
@@ -4,15 +4,18 @@ Selbst gehostete Web-Anwendung zur Planung monatlicher Kosten und Einkünfte.
Single-User, deutschsprachige Oberfläche, ausgelegt für den Betrieb im Homelab
hinter einem Reverse Proxy.
> **Status:** in Entwicklung. Siehe [CHANGELOG.md](CHANGELOG.md).
> **Version 0.1.2.** Siehe [CHANGELOG.md](CHANGELOG.md).
## Screenshots
_Platzhalter werden ergänzt, sobald die Oberfläche steht._
Die Bilder entstehen aus dem Demo-Datensatz (`make seed-demo`), damit sie ohne
echte Kontostände auskommen. Ablegen unter `docs/screenshots/`.
| Dashboard | Cashflow-Kalender | Firmen |
|---|---|---|
| _folgt_ | _folgt_ | _folgt_ |
| | |
|---|---|
| ![Dashboard](docs/screenshots/dashboard.png)<br>**Dashboard** verfügbar nach Fixkosten, Kategorienring, Zwölf-Monats-Vorschau | ![Cashflow-Kalender](docs/screenshots/calendar.png)<br>**Cashflow-Kalender** Fälligkeiten je Tag mit Firmenlogo, darunter der Kontostandsverlauf |
| ![Wiederkehrende Posten](docs/screenshots/recurrences.png)<br>**Wiederkehrend** Abos, Verträge und Raten mit Klartext-Rhythmus | ![RRULE-Editor](docs/screenshots/rrule-editor.png)<br>**RRULE-Editor** geführte Auswahl, Expertenmodus und Live-Vorschau der nächsten Termine |
| ![Firmen](docs/screenshots/merchants.png)<br>**Firmen** Kachelgrid mit Logo, Markenfarbe und Jahreskosten | ![Auswertungen](docs/screenshots/reports.png)<br>**Auswertungen** laufende Kosten, Jahresvergleich und Export |
## Funktionsumfang
@@ -23,6 +26,8 @@ _Platzhalter werden ergänzt, sobald die Oberfläche steht._
- Ratenzahlungen mit Restschuld- und Restratenberechnung.
- Rücklagenbildung für nicht-monatliche Posten.
- Firmenlogos und Markenfarben, lokal zwischengespeichert (siehe unten).
- Einstellbarer Monatsbeginn: Der Monat startet wahlweise am Ersten oder am
Gehaltstag; alle Monatsansichten folgen diesem Zeitraum (siehe unten).
- Auswertungen: Monatsübersicht, Cashflow-Kalender, 12-Monats-Forecast,
Kategorien, Abo-Übersicht, Jahresvergleich, Budgets, Sparziele.
- Benachrichtigungen per SMTP und Apprise.
@@ -83,9 +88,63 @@ make fe-install
make fe-dev # http://localhost:5173
```
Für einen gefüllten Stand:
```bash
make seed-demo # realistischer Beispielhaushalt, Termine relativ zu heute
make seed-demo-reset # setzt Konten, Firmen, Posten und Buchungen vorher zurück
```
Der Demo-Seed legt drei Konten an, zehn Firmen samt Logos, zwölf wiederkehrende
Posten (Miete, Strom, Netflix, Spotify, jährliche Kfz-Versicherung, Handyvertrag
mit Mindestlaufzeit, Kredit über 36 Raten, Gehalt), bestätigt die vergangenen
Fälligkeiten, ergänzt Buchungen, Budgets und Sparziele.
Nützliche Ziele: `make check` (alle Prüfungen), `make test`, `make lint`,
`make format`, `make fe-lint`, `make fe-test`, `make fe-build`.
## Bedienung
| Kürzel | Wirkung |
|---|---|
| `n` | Neue Buchung |
| `r` | Neuer wiederkehrender Posten |
| `/` | Suchfeld der aktuellen Seite |
| `?` | Übersicht der Kürzel |
| `Esc` | Dialog schließen |
Kürzel greifen nur, solange kein Eingabefeld den Fokus hat.
### Monatsbeginn
Standardmäßig ist ein Monat der Kalendermonat. Wer nach dem Gehaltseingang
plant, stellt unter *Einstellungen Monatsbeginn* den Tag ein, ab dem ein neuer
Monat zählt. Der Zeitraum läuft dann vom Gehaltstag bis zum Vortag des nächsten
und heißt nach dem Monat, in dem er beginnt: Mit dem 25. als Beginn umfasst
„September 2026“ die Tage vom 25.09. bis zum 24.10.
Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten die 31
bedeutet also verlässlich „letzter Tag des Monats“, auch im Februar.
Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die
Kategorienauswertung, der Monatsexport und die Benachrichtigung über
überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben dabei je
Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der Schnitt
verschiebt sich. Die Einstellung gilt für die gesamte Installation und wirkt
sofort, ohne die Daten anzufassen.
## Barrierefreiheit
Die Farbtoken sind gegen die WCAG-Kontraste geprüft in beiden Modi erreicht
jeder Text mindestens 4,5:1, jedes Bedienelement 3:1. Zwei Werte weichen
deshalb bewusst von den naheliegenden Tailwind-Stufen ab: `slate-400` als
Hilfstext käme auf hellem Grund nur auf 2,56:1, und Weiß auf `green-600` nur
auf 3,30:1.
Die Serienfarben der Diagramme sind zusätzlich auf Farbfehlsichtigkeit geprüft
(siehe [Diagramme](#diagramme)). Jeder Zustand Budget-Ampel, Versandstatus,
Vertragswarnung trägt neben der Farbe ein Symbol und einen Wortlaut.
## Konfiguration
Alle Einstellungen kommen aus Umgebungsvariablen; `.env.example` enthält die
@@ -95,7 +154,7 @@ vollständige, kommentierte Liste. Die wichtigsten:
|---|---|---|
| `DATABASE_URL` | | PostgreSQL-DSN, `postgresql://` wird auf asyncpg umgestellt |
| `SECRET_KEY` | | Signaturschlüssel für JWTs, mind. 32 Zeichen (`openssl rand -hex 32`) |
| `MONEYFY_ADMIN_USER` / `MONEYFY_ADMIN_PASSWORD` | `admin` / | Beim Erststart angelegter Benutzer |
| `MONEYFY_ADMIN_USER` / `MONEYFY_ADMIN_PASSWORD` | `admin` / | Beim Erststart angelegter Benutzer. Es gibt **kein** eingebautes Standardpasswort; ohne diesen Wert wird niemand angelegt. Die Anlage greift nur, solange die Benutzertabelle leer ist danach hilft `app.scripts.reset_password`. |
| `TIMEZONE` | `Europe/Berlin` | Zeitzone der gesamten Anwendung |
| `HOLIDAY_REGION` | `DE-NW` | Feiertagsregion für Werktagsverschiebungen |
| `LOGO_STORAGE_DIR` | `/data/logos` | Verzeichnis des Logo-Caches |
@@ -0,0 +1,38 @@
"""app setting
Revision ID: 5f2a91c0d7e4
Revises: 07d8d62011b3
Create Date: 2026-09-10 09:00:00.000000+02:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = '5f2a91c0d7e4'
down_revision: str | None = '07d8d62011b3'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table('app_setting',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('month_start_day', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint('id = 1', name=op.f('ck_app_setting_single_row')),
sa.CheckConstraint('month_start_day BETWEEN 1 AND 31', name=op.f('ck_app_setting_month_start_day_range')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_app_setting'))
)
# Bestandsinstallationen rechnen bisher ab dem Monatsersten dabei bleibt es,
# bis der Gehaltstag in den Einstellungen geändert wird.
op.execute(
"INSERT INTO app_setting (id, month_start_day, created_at, updated_at) "
"VALUES (1, 1, now(), now())"
)
def downgrade() -> None:
op.drop_table('app_setting')
+9
View File
@@ -11,6 +11,7 @@ from app.core.security import decode_token
from app.db.session import get_session
from app.models import AppUser
from app.services.auth import get_user
from app.services.settings import month_start_day
DbSession = Annotated[AsyncSession, Depends(get_session)]
@@ -53,3 +54,11 @@ async def get_active_user(user: CurrentUser) -> AppUser:
ActiveUser = Annotated[AppUser, Depends(get_active_user)]
async def get_month_start_day(session: DbSession) -> int:
"""Der eingestellte Monatsbeginn jede monatsbezogene Auswertung richtet sich danach."""
return await month_start_day(session)
MonthStartDay = Annotated[int, Depends(get_month_start_day)]
+2
View File
@@ -21,6 +21,7 @@ from app.api.routes import (
recurrences,
reports,
savings_goals,
settings,
system,
transactions,
)
@@ -47,5 +48,6 @@ protected.include_router(savings_goals.router)
protected.include_router(reports.router)
protected.include_router(export.router)
protected.include_router(notifications.router)
protected.include_router(settings.router)
api_router.include_router(protected)
+12 -2
View File
@@ -3,7 +3,12 @@
from fastapi import APIRouter, Request, Response, status
from app.api.deps import CurrentUser, DbSession
from app.core.cookies import REFRESH_COOKIE, clear_auth_cookies, set_auth_cookies
from app.core.cookies import (
REFRESH_COOKIE,
clear_auth_cookies,
set_auth_cookies,
warn_if_cookie_unusable,
)
from app.core.errors import AuthError
from app.schemas.auth import ChangePasswordRequest, LoginRequest, UserOut
from app.schemas.common import ErrorResponse, MessageResponse
@@ -29,7 +34,12 @@ UNAUTHORIZED = {status.HTTP_401_UNAUTHORIZED: {"model": ErrorResponse}}
description="Prüft die Zugangsdaten und legt Access- und Refresh-Token als "
"httpOnly-Cookies ab.",
)
async def login(payload: LoginRequest, response: Response, session: DbSession) -> UserOut:
async def login(
payload: LoginRequest, request: Request, response: Response, session: DbSession
) -> UserOut:
# Meldet die Kombination, bei der der Browser das Cookie stillschweigend verwirft.
warn_if_cookie_unusable(request)
provider = get_auth_provider("local")
user = await provider.authenticate(
session, Credentials(username=payload.username, password=payload.password)
+10 -7
View File
@@ -6,8 +6,8 @@ from typing import Literal
from fastapi import APIRouter, Query, Response, status
from sqlalchemy import select
from app.api.deps import DbSession
from app.core.clock import month_end, month_start, today
from app.api.deps import DbSession, MonthStartDay
from app.core.clock import today
from app.core.errors import ValidationError
from app.models import Account, Merchant, Transaction
from app.models.enums import EntryKind
@@ -15,9 +15,11 @@ from app.schemas.common import ErrorResponse
from app.services.export import CONTENT_TYPES, filename, to_csv, to_xlsx
from app.services.reports import (
category_names,
current_period,
flow_rows,
flows,
month_report,
period_of,
recurrence_rows,
reserve_total,
)
@@ -156,15 +158,16 @@ async def export_recurrences(
)
async def export_month(
session: DbSession,
start_day: MonthStartDay,
fmt: ExportFormat = Query(default="xlsx", alias="format"),
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
) -> Response:
monat = month_start(month or today())
zeitraum = period_of(month, start_day) if month else current_period(start_day)
namen = await category_names(session)
bewegungen = await flows(session, monat, month_end(monat))
bewegungen = await flows(session, zeitraum.start, zeitraum.end)
zeilen = flow_rows(bewegungen, namen)
bericht = await month_report(session, monat)
bericht = await month_report(session, zeitraum.key, start_day)
kennzahlen: list[dict[str, object]] = [
{"Kennzahl": "Einnahmen (Plan)", "Betrag": bericht.planned.income},
@@ -175,11 +178,11 @@ async def export_month(
{"Kennzahl": "Saldo (Ist)", "Betrag": bericht.actual.balance},
{"Kennzahl": "Fixkosten", "Betrag": bericht.fixed_costs},
{"Kennzahl": "Variable Kosten", "Betrag": bericht.variable_costs},
{"Kennzahl": "Rücklagen", "Betrag": await reserve_total(session, monat)},
{"Kennzahl": "Rücklagen", "Betrag": await reserve_total(session, zeitraum)},
{"Kennzahl": "Verfügbar nach Fixkosten", "Betrag": bericht.available_after_fixed},
]
kennung = monat.strftime("%Y-%m")
kennung = zeitraum.key.strftime("%Y-%m")
if fmt == "csv":
# CSV kennt keine Blätter die Kennzahlen folgen nach einer Leerzeile.
inhalt = to_csv(zeilen, FLOW_COLUMNS)
+45 -20
View File
@@ -6,8 +6,8 @@ from decimal import Decimal
from fastapi import APIRouter, Query
from sqlalchemy import select
from app.api.deps import DbSession
from app.core.clock import month_end, month_start, today
from app.api.deps import DbSession, MonthStartDay
from app.core.clock import today
from app.core.errors import ValidationError
from app.models import SavingsGoal
from app.models.enums import EntryKind
@@ -43,10 +43,12 @@ from app.services.reports import (
budget_status,
calendar_month,
category_breakdown,
current_period,
flows,
forecast,
month_report,
months_between,
period_of,
required_monthly_rate,
subscriptions,
year_comparison,
@@ -67,6 +69,8 @@ def _totals(value: Totals) -> TotalsOut:
def _month(report: MonthReport) -> MonthReportOut:
return MonthReportOut(
month=report.month,
period_start=report.period.start,
period_end=report.period.end,
planned=_totals(report.planned),
actual=_totals(report.actual),
previous_planned=_totals(report.previous_planned),
@@ -89,6 +93,8 @@ def _month(report: MonthReport) -> MonthReportOut:
def _forecast_month(entry: ForecastMonth) -> ForecastMonthOut:
return ForecastMonthOut(
month=entry.month,
period_start=entry.period_start,
period_end=entry.period_end,
income=entry.income,
expenses=entry.expenses,
balance=entry.balance,
@@ -216,11 +222,15 @@ async def _goals(session: DbSession, as_of: date) -> list[SavingsGoalProgressOut
)
async def read_month_report(
session: DbSession,
start_day: MonthStartDay,
month: date | None = Query(
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
default=None,
description="Beliebiger Tag im gewünschten Monat; ausschlaggebend ist dessen "
"Monatserster. Vorgabe ist der laufende Abrechnungsmonat.",
),
) -> MonthReportOut:
return _month(await month_report(session, month or today()))
monat = month or current_period(start_day).key
return _month(await month_report(session, monat, start_day))
@router.get(
@@ -232,10 +242,13 @@ async def read_month_report(
)
async def read_forecast(
session: DbSession,
start_day: MonthStartDay,
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
start: date | None = Query(default=None, description="Erster Monat; Vorgabe ist heute."),
start: date | None = Query(
default=None, description="Erster Monat; Vorgabe ist der laufende Abrechnungsmonat."
),
) -> ForecastOut:
monate = await forecast(session, months, start)
monate = await forecast(session, months, start, start_day)
return ForecastOut(
months=[_forecast_month(monat) for monat in monate],
total_income=sum((monat.income for monat in monate), Decimal("0.00")),
@@ -251,13 +264,14 @@ async def read_forecast(
)
async def read_categories(
session: DbSession,
start_day: MonthStartDay,
date_from: date | None = Query(default=None, alias="from"),
date_to: date | None = Query(default=None, alias="to"),
kind: EntryKind = Query(default=EntryKind.EXPENSE),
) -> CategoryReportOut:
heute = today()
start = date_from or month_start(heute)
ende = date_to or month_end(heute)
laufend = current_period(start_day)
start = date_from or laufend.start
ende = date_to or laufend.end
if ende < start:
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
@@ -274,9 +288,10 @@ async def read_categories(
@router.get(
"/subscriptions",
response_model=SubscriptionReportOut,
summary="Abo-Übersicht",
description="Alle laufenden Ausgabenposten mit Jahreskosten. Ratenzahlungen sind "
"gekennzeichnet und zählen nicht in die Gesamtsumme.",
summary="Laufende Kosten",
description="Jede aktive wiederkehrende Ausgabe mit ihren Jahreskosten von der "
"Miete bis zum Streamingdienst. Ratenzahlungen sind gekennzeichnet und zählen nicht "
"in `total_annual`, weil sie ein Ende haben.",
)
async def read_subscriptions(session: DbSession) -> SubscriptionReportOut:
bericht = await subscriptions(session)
@@ -326,11 +341,15 @@ async def read_year_comparison(
)
async def read_calendar(
session: DbSession,
start_day: MonthStartDay,
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
) -> CalendarMonthOut:
raster = await calendar_month(session, month or today())
monat = month or current_period(start_day).key
raster = await calendar_month(session, monat, start_day=start_day)
return CalendarMonthOut(
month=raster.month,
period_start=raster.period_start,
period_end=raster.period_end,
days=[
CalendarDayOut(
date=tag.on,
@@ -357,9 +376,11 @@ async def read_calendar(
)
async def read_budget_status(
session: DbSession,
start_day: MonthStartDay,
month: date | None = Query(default=None),
) -> list[BudgetStatusOut]:
return [_budget(eintrag) for eintrag in await budget_status(session, month or today())]
monat = month or current_period(start_day).key
return [_budget(eintrag) for eintrag in await budget_status(session, monat, start_day)]
@router.get(
@@ -381,25 +402,29 @@ async def read_goal_progress(session: DbSession) -> list[SavingsGoalProgressOut]
)
async def read_dashboard(
session: DbSession,
start_day: MonthStartDay,
month: date | None = Query(default=None),
) -> DashboardOut:
heute = today()
monat = month_start(month or heute)
zeitraum = period_of(month, start_day) if month else current_period(start_day, heute)
bericht = await month_report(session, monat)
vorschau = await forecast(session, 12, monat)
gruppen = await category_breakdown(session, monat, month_end(monat))
bericht = await month_report(session, zeitraum.key, start_day)
vorschau = await forecast(session, 12, zeitraum.key, start_day)
gruppen = await category_breakdown(session, zeitraum.start, zeitraum.end)
abos = await subscriptions(session)
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Monat.
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Zeitraum.
naechste = await flows(session, heute, heute + timedelta(days=14))
return DashboardOut(
month_start_day=start_day,
month=_month(bericht),
total_balance=await total_balance(session, heute),
forecast=[_forecast_month(eintrag) for eintrag in vorschau],
categories=[_slice(gruppe) for gruppe in gruppen],
budgets=[_budget(eintrag) for eintrag in await budget_status(session, monat)],
budgets=[
_budget(eintrag) for eintrag in await budget_status(session, zeitraum.key, start_day)
],
goals=await _goals(session, heute),
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
+45
View File
@@ -0,0 +1,45 @@
"""Anwendungseinstellungen."""
from fastapi import APIRouter
from app.api.deps import DbSession
from app.core.clock import period_bounds, period_key, today
from app.schemas.settings import AppSettingsIn, AppSettingsOut
from app.services.settings import load_settings, save_settings
router = APIRouter(prefix="/settings", tags=["settings"])
def _out(month_start_day: int) -> AppSettingsOut:
schluessel = period_key(today(), month_start_day)
beginn, ende = period_bounds(schluessel, month_start_day)
return AppSettingsOut(
month_start_day=month_start_day,
current_month=schluessel,
current_period_start=beginn,
current_period_end=ende,
)
@router.get(
"",
response_model=AppSettingsOut,
summary="Einstellungen lesen",
description="Liefert den eingestellten Monatsbeginn und den daraus folgenden "
"laufenden Abrechnungsmonat.",
)
async def read_settings(session: DbSession) -> AppSettingsOut:
return _out((await load_settings(session)).month_start_day)
@router.put(
"",
response_model=AppSettingsOut,
summary="Einstellungen ändern",
description="Ein geänderter Monatsbeginn wirkt sofort auf Dashboard, Kalender, "
"Budgets, Vorschau und Export.",
)
async def update_settings(session: DbSession, payload: AppSettingsIn) -> AppSettingsOut:
eintrag = await save_settings(session, month_start_day=payload.month_start_day)
await session.commit()
return _out(eintrag.month_start_day)
+42
View File
@@ -44,3 +44,45 @@ def add_months(day: date, months: int) -> date:
month = total % 12 + 1
last_day = month_end(date(year, month, 1)).day
return date(year, month, min(day.day, last_day))
# --- Abrechnungsmonat ----------------------------------------------------------
#
# Wer am Gehaltstag rechnet, für den beginnt der Monat nicht am Ersten. Ein
# Abrechnungsmonat läuft vom Gehaltstag bis zum Vortag des nächsten und trägt
# den Namen des Monats, in dem er beginnt: Startet er am 25. September, heißt
# er „September“ und endet am 24. Oktober.
MIN_MONTH_START_DAY = 1
MAX_MONTH_START_DAY = 31
DEFAULT_MONTH_START_DAY = 1
def month_anchor(day: date, start_day: int) -> date:
"""Der Gehaltstag im Monat von `day`.
Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten so
trifft der 31. in jedem Monat den letzten Tag.
"""
return day.replace(day=min(start_day, month_end(day).day))
def period_start(day: date, start_day: int) -> date:
"""Beginn des Abrechnungsmonats, in dem `day` liegt."""
anchor = month_anchor(day, start_day)
if day >= anchor:
return anchor
return month_anchor(add_months(month_start(day), -1), start_day)
def period_key(day: date, start_day: int) -> date:
"""Bezeichner des Abrechnungsmonats, in dem `day` liegt immer ein Monatserster."""
return month_start(period_start(day, start_day))
def period_bounds(month: date, start_day: int) -> tuple[date, date]:
"""Erster und letzter Tag des Abrechnungsmonats mit dem Bezeichner `month`."""
schluessel = month_start(month)
beginn = month_anchor(schluessel, start_day)
ende = month_anchor(add_months(schluessel, 1), start_day) - timedelta(days=1)
return beginn, ende
+1 -1
View File
@@ -23,7 +23,7 @@ class Settings(BaseSettings):
# --- Allgemein -----------------------------------------------------------
app_name: str = "moneyfy"
app_version: str = "0.1.0"
app_version: str = "0.1.2"
environment: Literal["development", "production", "test"] = "development"
debug: bool = False
timezone: str = "Europe/Berlin"
+36 -1
View File
@@ -1,11 +1,14 @@
"""Setzen und Löschen der Authentifizierungs-Cookies."""
import logging
from datetime import UTC, datetime
from fastapi import Response
from fastapi import Request, Response
from app.core.config import settings
logger = logging.getLogger(__name__)
ACCESS_COOKIE = "moneyfy_access"
REFRESH_COOKIE = "moneyfy_refresh"
@@ -13,6 +16,38 @@ REFRESH_COOKIE = "moneyfy_refresh"
REFRESH_COOKIE_PATH = "/api/auth"
def request_is_secure(request: Request) -> bool:
"""Kam die Anfrage über HTTPS?
Hinter einem Reverse Proxy steht das echte Schema in `X-Forwarded-Proto`;
das Schema der Verbindung zum Container ist dort immer `http`.
"""
weitergeleitet = request.headers.get("x-forwarded-proto", "")
if weitergeleitet:
# Bei mehreren Proxies steht das ursprüngliche Schema vorne.
return weitergeleitet.split(",")[0].strip().lower() == "https"
return request.url.scheme == "https"
def warn_if_cookie_unusable(request: Request) -> bool:
"""Meldet die Kombination, bei der der Browser das Cookie verwirft.
`Secure`-Cookies über eine reine HTTP-Verbindung werden stillschweigend
weggeworfen: Die Anmeldung meldet Erfolg, die nächste Anfrage gilt aber
wieder als nicht angemeldet. Ohne diesen Hinweis ist das kaum zu erkennen.
"""
if not settings.cookie_secure or request_is_secure(request):
return False
logger.warning(
"Anmeldung über %s ohne HTTPS bei COOKIE_SECURE=true der Browser "
"verwirft das Sitzungs-Cookie und die Anmeldemaske erscheint erneut. "
"Entweder über HTTPS zugreifen oder COOKIE_SECURE=false setzen.",
request.url.hostname or "unbekannt",
)
return True
def set_auth_cookies(
response: Response,
access_token: str,
+10 -1
View File
@@ -1,7 +1,15 @@
"""SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig."""
from app.db.base import Base
from app.models.core import Account, AppUser, Category, LogoAsset, Merchant, RefreshToken
from app.models.core import (
Account,
AppSetting,
AppUser,
Category,
LogoAsset,
Merchant,
RefreshToken,
)
from app.models.enums import (
AccountType,
BusinessDayShift,
@@ -29,6 +37,7 @@ __all__ = [
"Account",
"AccountType",
"AmountVersion",
"AppSetting",
"AppUser",
"Base",
"Budget",
+18
View File
@@ -129,6 +129,24 @@ class Merchant(Base, CreatedAtMixin):
__table_args__ = (Index("ix_merchant_normalized_name", "normalized_name"),)
class AppSetting(Base, TimestampMixin):
"""Anwendungsweite Einstellungen. Es gibt genau eine Zeile mit `id = 1`.
`month_start_day` legt den Gehaltstag fest: Ab diesem Tag rechnet moneyfy
einen neuen Monat. Ein Wert jenseits der Monatslänge trifft den Monatsletzten.
"""
__tablename__ = "app_setting"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
month_start_day: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__table_args__ = (
CheckConstraint("id = 1", name="single_row"),
CheckConstraint("month_start_day BETWEEN 1 AND 31", name="month_start_day_range"),
)
class AppUser(Base, CreatedAtMixin):
"""Single-User-Betrieb; `external_subject` ist für eine spätere OIDC-Anbindung vorgesehen."""
+18 -6
View File
@@ -28,7 +28,11 @@ class MonthComparisonOut(ApiModel):
class MonthReportOut(ApiModel):
"""Monatsübersicht mit Plan-Ist-Vergleich."""
month: date = Field(description="Immer der Monatserste.")
month: date = Field(
description="Bezeichner des Abrechnungsmonats immer der Monatserste des Startmonats."
)
period_start: date = Field(description="Erster Tag des Zeitraums, also der Monatsbeginn.")
period_end: date = Field(description="Letzter Tag des Zeitraums.")
planned: TotalsOut = Field(description="Soll aus Fälligkeiten und Buchungen.")
actual: TotalsOut = Field(
description="Ist aus bestätigten Fälligkeiten und allen einmaligen Buchungen."
@@ -48,14 +52,16 @@ class MonthReportOut(ApiModel):
class ForecastMonthOut(ApiModel):
"""Ein Monat der Vorschau."""
"""Ein Abrechnungsmonat der Vorschau."""
month: date
period_start: date
period_end: date
income: Money
expenses: Money
balance: Money
cumulative_balance: Money = Field(
description="Prognostizierter Kontostand am Monatsende über alle Konten."
description="Prognostizierter Kontostand am Ende des Zeitraums über alle Konten."
)
@@ -98,7 +104,7 @@ class SubscriptionOut(ApiModel):
monthly_cost: Money
rrule: str
is_installment: bool = Field(
description="Ratenzahlungen zählen nicht in die Summe „Abos gesamt p. a.“."
description="Ratenzahlungen zählen nicht in `total_annual`, weil sie enden."
)
is_cancelled: bool
contract_term: ContractTermOut | None = None
@@ -159,8 +165,13 @@ class CalendarDayOut(ApiModel):
class CalendarMonthOut(ApiModel):
month: date
days: list[CalendarDayOut]
month: date = Field(description="Bezeichner des Abrechnungsmonats immer der Monatserste.")
period_start: date
period_end: date
days: list[CalendarDayOut] = Field(
description="Alle Tage des Zeitraums; bei abweichendem Monatsbeginn über zwei "
"Kalendermonate hinweg."
)
opening_balance: Money
closing_balance: Money
lowest_balance: Money
@@ -210,6 +221,7 @@ class SavingsGoalProgressOut(ApiModel):
class DashboardOut(ApiModel):
"""Alles, was das Dashboard in einem Aufruf braucht."""
month_start_day: int = Field(description="Der eingestellte Monatsbeginn.")
month: MonthReportOut
total_balance: Money
forecast: list[ForecastMonthOut]
+36
View File
@@ -0,0 +1,36 @@
"""Schemata der Anwendungseinstellungen."""
from datetime import date
from typing import Annotated
from pydantic import Field
from app.core.clock import MAX_MONTH_START_DAY, MIN_MONTH_START_DAY
from app.schemas.common import ApiModel, InputModel
MonthStartDay = Annotated[
int,
Field(
ge=MIN_MONTH_START_DAY,
le=MAX_MONTH_START_DAY,
description="Tag, an dem der Abrechnungsmonat beginnt üblicherweise der Gehaltstag. "
"Ein Wert jenseits der Monatslänge trifft den Monatsletzten.",
),
]
class AppSettingsOut(ApiModel):
"""Die gültigen Einstellungen samt aktuellem Abrechnungsmonat."""
month_start_day: MonthStartDay
current_month: date = Field(
description="Bezeichner des laufenden Abrechnungsmonats immer ein Monatserster."
)
current_period_start: date
current_period_end: date
class AppSettingsIn(InputModel):
"""Änderbare Einstellungen."""
month_start_day: MonthStartDay
+108
View File
@@ -0,0 +1,108 @@
"""CLI: Passwort setzen oder Administrator anlegen.
python -m app.scripts.reset_password
python -m app.scripts.reset_password --username admin --password geheim123456
Nötig, weil die Erstanlage beim Start nur greift, solange noch kein Benutzer
existiert ein nachträglich geändertes `MONEYFY_ADMIN_PASSWORD` bleibt danach
wirkungslos.
"""
import argparse
import asyncio
import getpass
import os
import sys
from sqlalchemy import select
from app.core.config import settings
from app.core.security import hash_password
from app.db.session import SessionLocal, engine
from app.models import AppUser
from app.services.auth import (
MIN_PASSWORD_LENGTH,
get_user_by_username,
revoke_all_refresh_tokens,
)
def _passwort_ermitteln(vorgabe: str | None) -> str:
"""Reihenfolge: Argument, Umgebungsvariable, interaktive Eingabe."""
if vorgabe:
return vorgabe
aus_umgebung = os.environ.get("MONEYFY_ADMIN_PASSWORD")
if aus_umgebung:
print("Verwende MONEYFY_ADMIN_PASSWORD aus der Umgebung.")
return aus_umgebung
if not sys.stdin.isatty():
raise SystemExit(
"Kein Passwort angegeben. Entweder --password nutzen oder "
"MONEYFY_ADMIN_PASSWORD setzen."
)
erste = getpass.getpass("Neues Passwort: ")
zweite = getpass.getpass("Wiederholen: ")
if erste != zweite:
raise SystemExit("Die Eingaben stimmen nicht überein.")
return erste
async def main(username: str, passwort: str, force_change: bool) -> int:
if len(passwort) < MIN_PASSWORD_LENGTH:
print(
f"Das Passwort muss mindestens {MIN_PASSWORD_LENGTH} Zeichen lang sein.",
file=sys.stderr,
)
return 1
async with SessionLocal() as session:
benutzer = await get_user_by_username(session, username)
if benutzer is None:
vorhandene = (await session.execute(select(AppUser.username))).scalars().all()
benutzer = AppUser(username=username, must_change_password=force_change)
session.add(benutzer)
aktion = "angelegt"
if vorhandene:
print(f"Vorhandene Benutzer: {', '.join(vorhandene)}")
else:
aktion = "geändert"
benutzer.must_change_password = force_change
# Ein Passwortwechsel beendet alle offenen Sitzungen.
await revoke_all_refresh_tokens(session, benutzer.id)
benutzer.password_hash = hash_password(passwort)
await session.commit()
await engine.dispose()
print(f"Passwort für '{username}' {aktion}.")
if force_change:
print("Beim ersten Anmelden wird ein neues Passwort verlangt.")
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--username",
default=settings.moneyfy_admin_user,
help=f"Benutzername (Vorgabe: {settings.moneyfy_admin_user}).",
)
parser.add_argument(
"--password",
help="Neues Passwort. Ohne Angabe wird MONEYFY_ADMIN_PASSWORD genutzt "
"oder interaktiv gefragt.",
)
parser.add_argument(
"--force-change",
action="store_true",
help="Verlangt beim nächsten Anmelden erneut einen Passwortwechsel.",
)
args = parser.parse_args()
raise SystemExit(
asyncio.run(main(args.username, _passwort_ermitteln(args.password), args.force_change))
)
+57
View File
@@ -0,0 +1,57 @@
"""CLI: Demodaten anlegen `python -m app.scripts.seed_demo [--reset]`.
Legt einen vollständigen deutschen Beispielhaushalt an: Konten, Firmen samt
Logos, wiederkehrende Posten, bestätigte Historie, Buchungen, Budgets und
Sparziele. Alle Termine liegen relativ zum heutigen Monat.
"""
import argparse
import asyncio
import sys
from app.db.session import SessionLocal, engine
from app.services.demo import has_data, seed_demo
async def main(reset: bool, resolve_logos: bool) -> int:
async with SessionLocal() as session:
if not reset and await has_data(session):
print(
"Es sind bereits Daten vorhanden. Mit --reset werden Konten, Firmen, "
"Posten, Buchungen, Budgets und Sparziele zuvor gelöscht.\n"
"Kategorien und Benutzer bleiben in jedem Fall erhalten.",
file=sys.stderr,
)
await engine.dispose()
return 1
ergebnis = await seed_demo(session, reset=reset, resolve_logos=resolve_logos)
await engine.dispose()
print(
"Demodaten angelegt:\n"
f" {ergebnis.accounts} Konten\n"
f" {ergebnis.merchants} Firmen ({ergebnis.logos} mit Logo)\n"
f" {ergebnis.recurrences} wiederkehrende Posten\n"
f" {ergebnis.occurrences} bestätigte Fälligkeiten\n"
f" {ergebnis.transactions} Buchungen\n"
f" {ergebnis.budgets} Budgets und Vorlagen\n"
f" {ergebnis.goals} Sparziele"
)
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--reset",
action="store_true",
help="Vorhandene Konten, Firmen, Posten, Buchungen, Budgets und Sparziele löschen.",
)
parser.add_argument(
"--no-logos",
action="store_true",
help="Logosuche überspringen schneller und ohne jeden Netzzugriff.",
)
args = parser.parse_args()
raise SystemExit(asyncio.run(main(args.reset, not args.no_logos)))
+545
View File
@@ -0,0 +1,545 @@
"""Demodaten: ein realistischer deutscher Haushalt.
Alle Termine liegen relativ zum heutigen Monat, damit Dashboard, Kalender und
Auswertungen unmittelbar etwas zeigen unabhängig davon, wann der Seed läuft.
"""
import logging
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.clock import add_months, month_start, today
from app.models import (
Account,
AmountVersion,
Budget,
BudgetTemplate,
Category,
Merchant,
Occurrence,
Recurrence,
ReserveLedger,
SavingsGoal,
Transaction,
)
from app.models.enums import (
AccountType,
BusinessDayShift,
EntryKind,
LogoStatus,
)
from app.services.logos import resolve_merchant_logo
from app.services.merchants import normalize_name
from app.services.occurrences import confirm
from app.services.recurrence import expand
from app.services.seed import seed_all
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class DemoResult:
"""Was der Seed angelegt hat."""
accounts: int = 0
merchants: int = 0
recurrences: int = 0
transactions: int = 0
occurrences: int = 0
budgets: int = 0
goals: int = 0
logos: int = 0
# Firmen mit ihrer Domain die Domain verbessert die Logosuche.
DEMO_MERCHANTS: tuple[tuple[str, str | None], ...] = (
("Netflix", "netflix.com"),
("Spotify", "spotify.com"),
("Vodafone", "vodafone.de"),
("Deutsche Telekom", "telekom.de"),
("REWE", "rewe.de"),
("dm", "dm.de"),
("Deutsche Bahn", "bahn.de"),
("Sparkasse", "sparkasse.de"),
# Bewusst ohne Eintrag im Markenkatalog: zeigt den erzeugten Avatar.
("Stadtwerke Musterstadt", None),
("HUK-COBURG", "huk.de"),
)
async def _category_map(session: AsyncSession) -> dict[str, int]:
"""Kategoriename -> ID. Bei doppelten Namen gewinnt die Unterkategorie."""
rows = (await session.execute(select(Category))).scalars().all()
namen: dict[str, int] = {}
for kategorie in rows:
if kategorie.name not in namen or kategorie.parent_id is not None:
namen[kategorie.name] = kategorie.id
return namen
async def has_data(session: AsyncSession) -> bool:
"""True, wenn schon Bewegungsdaten vorhanden sind."""
for modell in (Recurrence, Transaction, Account):
anzahl = (await session.execute(select(func.count()).select_from(modell))).scalar_one()
if anzahl:
return True
return False
async def reset_data(session: AsyncSession) -> None:
"""Entfernt alle Bewegungs- und Stammdaten außer Kategorien und Benutzern.
Die Reihenfolge folgt den Fremdschlüsseln.
"""
for modell in (
Occurrence,
AmountVersion,
ReserveLedger,
Recurrence,
Transaction,
Budget,
BudgetTemplate,
SavingsGoal,
Merchant,
Account,
):
await session.execute(delete(modell))
await session.flush()
async def seed_demo(
session: AsyncSession, *, reset: bool = False, resolve_logos: bool = True
) -> DemoResult:
"""Legt einen vollständigen Beispielhaushalt an."""
await seed_all(session)
if reset:
await reset_data(session)
ergebnis = DemoResult()
heute = today()
monat = month_start(heute)
# --- Konten ---------------------------------------------------------------
konten = {
"giro": Account(
name="Girokonto",
type=AccountType.CHECKING,
iban_last4="4711",
opening_balance=Decimal("2450.00"),
# Sechs Monate Historie, damit Vergleiche etwas zeigen.
opening_balance_date=add_months(monat, -6),
color="#3b82f6",
icon="wallet",
sort_order=0,
),
"karte": Account(
name="Kreditkarte",
type=AccountType.CREDIT_CARD,
iban_last4="8820",
opening_balance=Decimal("0.00"),
opening_balance_date=add_months(monat, -6),
color="#a855f7",
icon="credit-card",
sort_order=1,
),
"tagesgeld": Account(
name="Tagesgeld",
type=AccountType.SAVINGS,
opening_balance=Decimal("8200.00"),
opening_balance_date=add_months(monat, -6),
color="#10b981",
icon="piggy-bank",
sort_order=2,
),
}
session.add_all(konten.values())
await session.flush()
ergebnis.accounts = len(konten)
# --- Firmen ---------------------------------------------------------------
firmen: dict[str, Merchant] = {}
for name, domain in DEMO_MERCHANTS:
firma = Merchant(
name=name,
normalized_name=normalize_name(name),
domain=domain,
logo_status=LogoStatus.PENDING,
)
session.add(firma)
firmen[name] = firma
await session.flush()
ergebnis.merchants = len(firmen)
if resolve_logos:
for firma in firmen.values():
try:
if await resolve_merchant_logo(session, firma) is not None:
ergebnis.logos += 1
except Exception: # eine fehlende Grafik darf den Seed nicht stoppen
logger.exception("Logo für %s konnte nicht ermittelt werden.", firma.name)
# --- Wiederkehrende Posten ------------------------------------------------
kategorie = await _category_map(session)
giro = konten["giro"].id
karte = konten["karte"].id
posten: list[Recurrence] = []
def anlegen(**felder) -> Recurrence:
eintrag = Recurrence(**felder)
session.add(eintrag)
posten.append(eintrag)
return eintrag
anlegen(
kind=EntryKind.INCOME,
title="Gehalt",
category_id=kategorie["Gehalt"],
account_id=giro,
amount=Decimal("3450.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
dtstart=add_months(monat, -6).replace(day=28),
# Das Gehalt kommt vor dem Wochenende, nicht danach.
business_day_shift=BusinessDayShift.PREVIOUS,
notes="Monatliches Nettogehalt.",
)
anlegen(
kind=EntryKind.EXPENSE,
title="Miete",
category_id=kategorie["Miete"],
account_id=giro,
amount=Decimal("1180.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart=add_months(monat, -6),
merchant_id=None,
notes="Kaltmiete zuzüglich Nebenkosten.",
)
anlegen(
kind=EntryKind.EXPENSE,
title="Strom",
category_id=kategorie["Strom"],
account_id=giro,
merchant_id=firmen["Stadtwerke Musterstadt"].id,
amount=Decimal("89.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=15",
dtstart=add_months(monat, -6).replace(day=15),
# Abschlag; die Jahresabrechnung weicht regelmäßig ab.
is_variable=True,
)
anlegen(
kind=EntryKind.EXPENSE,
title="Internet & Telefon",
category_id=kategorie["Internet"],
account_id=giro,
merchant_id=firmen["Vodafone"].id,
amount=Decimal("39.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=5",
dtstart=add_months(monat, -6).replace(day=5),
)
netflix = anlegen(
kind=EntryKind.EXPENSE,
title="Netflix Standard",
category_id=kategorie["Streaming"],
account_id=karte,
merchant_id=firmen["Netflix"].id,
amount=Decimal("13.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=17",
dtstart=add_months(monat, -6).replace(day=17),
)
anlegen(
kind=EntryKind.EXPENSE,
title="Spotify Premium",
category_id=kategorie["Streaming"],
account_id=karte,
merchant_id=firmen["Spotify"].id,
amount=Decimal("11.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=3",
dtstart=add_months(monat, -6).replace(day=3),
)
anlegen(
kind=EntryKind.EXPENSE,
title="Rundfunkbeitrag",
category_id=kategorie["Rundfunkbeitrag"],
account_id=giro,
amount=Decimal("55.08"),
# Vierteljährlich, jeweils Mitte des Quartalsmonats.
rrule="FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15",
dtstart=add_months(monat, -6).replace(day=15),
)
anlegen(
kind=EntryKind.EXPENSE,
title="Kfz-Versicherung",
category_id=kategorie["Kfz"],
account_id=giro,
merchant_id=firmen["HUK-COBURG"].id,
amount=Decimal("612.00"),
rrule="FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15",
# Vorjahr, damit eine Zahlung in der Historie liegt und die nächste im Januar.
dtstart=date(heute.year - 1, 1, 15),
# Jahresbeitrag auf zwölf Monate verteilt zurücklegen.
reserve_enabled=True,
notes="Jahresbeitrag, Zahlung im Januar.",
)
handyvertrag_start = add_months(monat, -8)
anlegen(
kind=EntryKind.EXPENSE,
title="Handyvertrag",
category_id=kategorie["Internet"],
account_id=giro,
merchant_id=firmen["Deutsche Telekom"].id,
amount=Decimal("29.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=8",
dtstart=handyvertrag_start.replace(day=8),
contract_start=handyvertrag_start,
contract_min_term_months=24,
contract_notice_period_days=90,
contract_auto_renew_months=12,
notes="Mindestlaufzeit 24 Monate, danach jährliche Verlängerung.",
)
kredit_start = add_months(monat, -9)
anlegen(
kind=EntryKind.EXPENSE,
title="Autokredit",
category_id=kategorie["Kredite"],
account_id=giro,
merchant_id=firmen["Sparkasse"].id,
amount=Decimal("289.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart=kredit_start,
installments_total=36,
principal_amount=Decimal("10404.00"),
notes="36 Raten, Restschuld siehe Detailansicht.",
)
anlegen(
kind=EntryKind.EXPENSE,
title="Sparplan Tagesgeld",
category_id=kategorie["Sparen"],
account_id=giro,
amount=Decimal("250.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
dtstart=add_months(monat, -6).replace(day=28),
)
anlegen(
kind=EntryKind.INCOME,
title="Nebeneinkünfte",
category_id=kategorie["Nebeneinkünfte"],
account_id=giro,
amount=Decimal("320.00"),
# Alle zwei Monate.
rrule="FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=20",
dtstart=add_months(monat, -6).replace(day=20),
is_variable=True,
)
await session.flush()
ergebnis.recurrences = len(posten)
# Preishistorie: für jeden Posten der Anfangsbetrag ab Serienstart.
for eintrag in posten:
session.add(
AmountVersion(
recurrence_id=eintrag.id,
amount=eintrag.amount,
valid_from=eintrag.dtstart,
note="Anfangsbetrag",
)
)
# Die Versionen müssen in der Datenbank stehen, bevor sie abgefragt werden.
await session.flush()
# Netflix ist vor drei Monaten teurer geworden vergangene Monate bleiben
# dadurch betragstreu.
preiswechsel = add_months(monat, -3)
session.add(
AmountVersion(
recurrence_id=netflix.id,
amount=Decimal("13.99"),
valid_from=preiswechsel,
note="Preiserhöhung laut Schreiben",
)
)
# Der Anfangsbetrag lag darunter.
anfang = next(
version
for version in await _versions_of(session, netflix.id)
if version.valid_from == netflix.dtstart
)
anfang.amount = Decimal("11.99")
await session.flush()
# --- Vergangene Fälligkeiten bestätigen -----------------------------------
# Erst dadurch zeigen die Berichte einen Plan-Ist-Vergleich.
ergebnis.occurrences = await _confirm_past(session, posten, heute)
# --- Einmalige Buchungen --------------------------------------------------
ergebnis.transactions = await _seed_transactions(session, kategorie, konten, firmen, monat)
# --- Budgets --------------------------------------------------------------
budgets = [
Budget(
category_id=kategorie["Lebensmittel"],
period_month=monat,
limit_amount=Decimal("450.00"),
rollover=True,
),
Budget(
category_id=kategorie["Restaurant"],
period_month=monat,
limit_amount=Decimal("120.00"),
),
Budget(
category_id=kategorie["Drogerie"],
period_month=monat,
limit_amount=Decimal("60.00"),
),
]
session.add_all(budgets)
session.add(
BudgetTemplate(
category_id=kategorie["Sprit"],
valid_from=add_months(monat, -6),
limit_amount=Decimal("180.00"),
)
)
ergebnis.budgets = len(budgets) + 1
# --- Sparziele ------------------------------------------------------------
ziele = [
SavingsGoal(
name="Neues Fahrrad",
target_amount=Decimal("1800.00"),
current_amount=Decimal("620.00"),
target_date=add_months(monat, 10),
monthly_contribution=Decimal("100.00"),
account_id=konten["tagesgeld"].id,
color="#22c55e",
icon="bike",
),
SavingsGoal(
name="Notgroschen",
target_amount=Decimal("6000.00"),
current_amount=Decimal("4200.00"),
monthly_contribution=Decimal("250.00"),
account_id=konten["tagesgeld"].id,
color="#3b82f6",
icon="shield",
),
]
session.add_all(ziele)
ergebnis.goals = len(ziele)
await session.commit()
logger.info("Demodaten angelegt: %s", ergebnis)
return ergebnis
async def _versions_of(session: AsyncSession, recurrence_id: int) -> list[AmountVersion]:
stmt = select(AmountVersion).where(AmountVersion.recurrence_id == recurrence_id)
return list((await session.execute(stmt)).scalars().all())
async def _confirm_past(session: AsyncSession, posten: list[Recurrence], heute: date) -> int:
"""Bestätigt alle Fälligkeiten, die vor heute lagen.
Beim Strom weicht der Ist-Betrag ab so wie eine echte Abrechnung.
"""
anzahl = 0
for eintrag in posten:
versionen = await _versions_of(session, eintrag.id)
faellig = expand(
eintrag,
eintrag.dtstart,
heute - timedelta(days=1),
amount_versions=versionen,
)
for index, geplant in enumerate(faellig):
ist = geplant.amount
if eintrag.is_variable:
# Abwechselnd etwas darüber und darunter.
abweichung = Decimal("7.40") if index % 2 == 0 else Decimal("-4.20")
ist = max(geplant.amount + abweichung, Decimal("0.01"))
await confirm(
session,
eintrag.id,
geplant.nominal_date,
actual_amount=ist,
)
anzahl += 1
await session.flush()
return anzahl
async def _seed_transactions(
session: AsyncSession,
kategorie: dict[str, int],
konten: dict[str, Account],
firmen: dict[str, Merchant],
monat: date,
) -> int:
"""Einmalige Buchungen der letzten drei Monate."""
giro = konten["giro"].id
karte = konten["karte"].id
muster: tuple[tuple[int, int, str, str, str, str | None, int], ...] = (
# (Monatsversatz, Tag, Titel, Betrag, Kategorie, Firma, Konto)
(0, 3, "Wocheneinkauf", "84.30", "Lebensmittel", "REWE", giro),
(0, 7, "Drogerie", "23.40", "Drogerie", "dm", giro),
(0, 9, "Tanken", "72.10", "Sprit", None, karte),
(0, 11, "Wocheneinkauf", "96.85", "Lebensmittel", "REWE", giro),
(0, 12, "Abendessen", "48.60", "Restaurant", None, karte),
(0, 18, "Wocheneinkauf", "78.20", "Lebensmittel", "REWE", giro),
(0, 21, "Bahnticket", "49.00", "ÖPNV", "Deutsche Bahn", giro),
(-1, 4, "Wocheneinkauf", "91.15", "Lebensmittel", "REWE", giro),
(-1, 8, "Werkstatt Inspektion", "312.00", "Werkstatt", None, giro),
(-1, 14, "Drogerie", "31.75", "Drogerie", "dm", giro),
(-1, 16, "Tanken", "68.40", "Sprit", None, karte),
(-1, 19, "Wocheneinkauf", "88.90", "Lebensmittel", "REWE", giro),
(-1, 24, "Restaurant", "62.30", "Restaurant", None, karte),
(-2, 6, "Wocheneinkauf", "79.55", "Lebensmittel", "REWE", giro),
(-2, 13, "Tanken", "70.20", "Sprit", None, karte),
(-2, 22, "Steuererstattung", "418.00", "Erstattungen", None, giro),
)
anzahl = 0
for versatz, tag, titel, betrag, kategoriename, firmenname, konto in muster:
buchungsmonat = add_months(monat, versatz)
ist_einkunft = kategoriename == "Erstattungen"
session.add(
Transaction(
kind=EntryKind.INCOME if ist_einkunft else EntryKind.EXPENSE,
title=titel,
category_id=kategorie[kategoriename],
account_id=konto,
merchant_id=firmen[firmenname].id if firmenname else None,
amount=Decimal(betrag),
booking_date=buchungsmonat.replace(day=tag),
)
)
anzahl += 1
await session.flush()
return anzahl
__all__ = ["DemoResult", "has_data", "reset_data", "seed_demo"]
+9 -7
View File
@@ -17,7 +17,7 @@ from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.clock import add_months, month_end, month_start, today, utcnow
from app.core.clock import add_months, month_end, today, utcnow
from app.core.config import settings
from app.models import LogoAsset, Merchant, NotificationLog, NotificationRule
from app.models.enums import (
@@ -29,7 +29,8 @@ from app.models.enums import (
from app.services.channels import Attachment, ChannelError, Notification, get_channel
from app.services.occurrences import load_recurrences
from app.services.recurrence import contract_term
from app.services.reports import budget_status, flows
from app.services.reports import budget_status, current_period, flows
from app.services.settings import month_start_day
logger = logging.getLogger(__name__)
@@ -169,14 +170,15 @@ async def collect_notice_deadlines(
async def collect_budget_exceeded(
session: AsyncSession, rule: NotificationRule, as_of: date
) -> list[Event]:
"""Überschrittene Budgets höchstens einmal je Monat und Kategorie."""
monat = month_start(as_of)
"""Überschrittene Budgets höchstens einmal je Abrechnungsmonat und Kategorie."""
monatsbeginn = await month_start_day(session)
zeitraum = current_period(monatsbeginn, as_of)
return [
Event(
ref_type=REF_BUDGET,
ref_id=str(eintrag.category_id),
dedupe_day=monat,
dedupe_day=zeitraum.key,
headline=eintrag.category_name,
detail=(
f"{_money(eintrag.spent)} von {_money(eintrag.available)} verbraucht "
@@ -184,9 +186,9 @@ async def collect_budget_exceeded(
f"{_money(abs(eintrag.remaining))} zu viel"
),
amount=eintrag.spent,
on=month_end(monat),
on=zeitraum.end,
)
for eintrag in await budget_status(session, monat)
for eintrag in await budget_status(session, zeitraum.key, monatsbeginn)
if eintrag.state == "exceeded"
]
+100 -35
View File
@@ -14,7 +14,14 @@ from typing import Literal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.clock import add_months, month_end, month_start, today
from app.core.clock import (
DEFAULT_MONTH_START_DAY,
add_months,
month_start,
period_bounds,
period_key,
today,
)
from app.models import Budget, BudgetTemplate, Category, Merchant, Recurrence, Transaction
from app.models.enums import EntryKind, OccurrenceStatus
from app.services.balances import total_balance
@@ -83,6 +90,33 @@ class Totals:
return self.income - self.expenses
@dataclass(frozen=True, slots=True)
class Period:
"""Ein Abrechnungsmonat.
Er läuft vom eingestellten Monatsbeginn dem Gehaltstag bis zum Vortag des
nächsten und trägt den Namen des Monats, in dem er beginnt. Bei Monatsbeginn 1
ist er deckungsgleich mit dem Kalendermonat.
"""
key: date
"""Bezeichner: der Erste des Monats, in dem der Zeitraum beginnt."""
start: date
end: date
def period_of(month: date, start_day: int = DEFAULT_MONTH_START_DAY) -> Period:
"""Der Abrechnungsmonat mit dem Bezeichner `month`."""
schluessel = month_start(month)
beginn, ende = period_bounds(schluessel, start_day)
return Period(key=schluessel, start=beginn, end=ende)
def current_period(start_day: int = DEFAULT_MONTH_START_DAY, as_of: date | None = None) -> Period:
"""Der Abrechnungsmonat, in dem `as_of` liegt Vorgabe ist heute."""
return period_of(period_key(as_of or today(), start_day), start_day)
def totals_of(
entries: Iterable[FlowEntry],
*,
@@ -180,9 +214,9 @@ async def flows(
@dataclass(slots=True)
class MonthReport:
"""Kennzahlen eines Monats."""
"""Kennzahlen eines Abrechnungsmonats."""
month: date
period: Period
planned: Totals
actual: Totals
previous_planned: Totals
@@ -194,6 +228,11 @@ class MonthReport:
open_count: int = 0
skipped_count: int = 0
@property
def month(self) -> date:
"""Bezeichner des Zeitraums immer ein Monatserster."""
return self.period.key
@property
def available_after_fixed(self) -> Decimal:
"""Einkünfte abzüglich Fixkosten und Rücklagen die große Kennzahl im Dashboard."""
@@ -213,27 +252,29 @@ class MonthReport:
async def _month_flows(
session: AsyncSession, month: date, fixed_costs: dict[int, bool]
session: AsyncSession, period: Period, fixed_costs: dict[int, bool]
) -> list[FlowEntry]:
return await flows(session, month_start(month), month_end(month), fixed_costs=fixed_costs)
return await flows(session, period.start, period.end, fixed_costs=fixed_costs)
async def reserve_total(session: AsyncSession, month: date) -> Decimal:
async def reserve_total(session: AsyncSession, period: Period) -> Decimal:
"""Summe der monatlichen Rücklagen aller Posten mit aktivierter Rücklagenbildung."""
total = ZERO
for recurrence in await load_recurrences(session):
if recurrence.reserve_enabled:
total += monthly_reserve(
recurrence, month_start(month), amount_versions=recurrence.amount_versions
recurrence, period.start, amount_versions=recurrence.amount_versions
)
return total
async def month_report(session: AsyncSession, month: date) -> MonthReport:
async def month_report(
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
) -> MonthReport:
"""Monatsübersicht inklusive Vergleich zum Vormonat."""
fix = await fixed_cost_map(session)
aktuell = month_start(month)
vormonat = add_months(aktuell, -1)
aktuell = period_of(month, start_day)
vormonat = period_of(add_months(aktuell.key, -1), start_day)
bewegungen = await _month_flows(session, aktuell, fix)
vorherige = await _month_flows(session, vormonat, fix)
@@ -245,13 +286,13 @@ async def month_report(session: AsyncSession, month: date) -> MonthReport:
# Ausgelassene Fälligkeiten fehlen in `flows` und werden separat gezählt.
ausgelassen = sum(
1
for item in await due_items(session, month_start(month), month_end(month))
for item in await due_items(session, aktuell.start, aktuell.end)
if item.planned.status is OccurrenceStatus.SKIPPED
)
serien = [entry for entry in bewegungen if entry.source == "recurrence"]
return MonthReport(
month=aktuell,
period=aktuell,
planned=totals_of(bewegungen),
actual=totals_of(bewegungen, basis="effective", only_confirmed=True),
previous_planned=totals_of(vorherige),
@@ -270,13 +311,15 @@ async def month_report(session: AsyncSession, month: date) -> MonthReport:
@dataclass(frozen=True, slots=True)
class ForecastMonth:
"""Ein Monat der Vorschau."""
"""Ein Abrechnungsmonat der Vorschau."""
month: date
period_start: date
period_end: date
income: Decimal
expenses: Decimal
cumulative_balance: Decimal
"""Prognostizierter Kontostand am Monatsende über alle Konten."""
"""Prognostizierter Kontostand am Ende des Zeitraums über alle Konten."""
@property
def balance(self) -> Decimal:
@@ -284,28 +327,33 @@ class ForecastMonth:
async def forecast(
session: AsyncSession, months: int = 12, start: date | None = None
session: AsyncSession,
months: int = 12,
start: date | None = None,
start_day: int = DEFAULT_MONTH_START_DAY,
) -> list[ForecastMonth]:
"""Vorschau über mehrere Monate.
Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil die Reihe
aus der tatsächlichen Expansion entsteht und nicht aus einem Durchschnitt.
"""
beginn = month_start(start or today())
erster = period_of(start, start_day) if start is not None else current_period(start_day)
fix = await fixed_cost_map(session)
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Monat.
laufend = await total_balance(session, beginn - timedelta(days=1))
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Zeitraum.
laufend = await total_balance(session, erster.start - timedelta(days=1))
ergebnis: list[ForecastMonth] = []
for versatz in range(max(1, months)):
monat = add_months(beginn, versatz)
bewegungen = await flows(session, month_start(monat), month_end(monat), fixed_costs=fix)
zeitraum = period_of(add_months(erster.key, versatz), start_day)
bewegungen = await flows(session, zeitraum.start, zeitraum.end, fixed_costs=fix)
summen = totals_of(bewegungen, basis="effective")
laufend += summen.balance
ergebnis.append(
ForecastMonth(
month=monat,
month=zeitraum.key,
period_start=zeitraum.start,
period_end=zeitraum.end,
income=summen.income,
expenses=summen.expenses,
cumulative_balance=laufend,
@@ -567,6 +615,9 @@ class CalendarDay:
@dataclass(slots=True)
class CalendarMonth:
month: date
"""Bezeichner des Abrechnungsmonats immer ein Monatserster."""
period_start: date
period_end: date
days: list[CalendarDay]
opening_balance: Decimal
closing_balance: Decimal
@@ -575,11 +626,16 @@ class CalendarMonth:
async def calendar_month(
session: AsyncSession, month: date, *, holiday_region: str = "DE-NW"
session: AsyncSession,
month: date,
*,
start_day: int = DEFAULT_MONTH_START_DAY,
holiday_region: str = "DE-NW",
) -> CalendarMonth:
"""Monatsraster mit den Fälligkeiten je Tag und dem laufenden Kontostand."""
beginn = month_start(month)
ende = month_end(month)
"""Tagesraster des Abrechnungsmonats mit den Fälligkeiten und dem laufenden Kontostand."""
zeitraum = period_of(month, start_day)
beginn = zeitraum.start
ende = zeitraum.end
eroeffnung = await total_balance(session, beginn - timedelta(days=1))
bewegungen = await flows(session, beginn, ende)
@@ -614,7 +670,9 @@ async def calendar_month(
tag += timedelta(days=1)
return CalendarMonth(
month=beginn,
month=zeitraum.key,
period_start=beginn,
period_end=ende,
days=tage,
opening_balance=eroeffnung,
closing_balance=laufend,
@@ -697,14 +755,17 @@ async def _effective_limits(
return ergebnis
async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, Decimal]:
"""Ausgaben eines Monats je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
async def _spent_by_category(
session: AsyncSession, month: date, start_day: int
) -> dict[int, Decimal]:
"""Ausgaben eines Zeitraums je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
kategorien = {
kategorie.id: kategorie.parent_id
for kategorie in (await session.execute(select(Category))).scalars()
}
zeitraum = period_of(month, start_day)
ergebnis: dict[int, Decimal] = {}
for entry in await flows(session, month_start(month), month_end(month)):
for entry in await flows(session, zeitraum.start, zeitraum.end):
if entry.kind is not EntryKind.EXPENSE:
continue
# Ein Budget auf der Oberkategorie umfasst auch deren Unterkategorien.
@@ -715,8 +776,10 @@ async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, De
return ergebnis
async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus]:
"""Budgets eines Monats samt Verbrauch und Übertrag."""
async def budget_status(
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
) -> list[BudgetStatus]:
"""Budgets eines Abrechnungsmonats samt Verbrauch und Übertrag."""
monat = month_start(month)
limits = await _effective_limits(session, monat)
if not limits:
@@ -725,7 +788,7 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
namen = {
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
}
ausgaben = await _spent_by_category(session, monat)
ausgaben = await _spent_by_category(session, monat, start_day)
ergebnis: list[BudgetStatus] = []
for kategorie_id, (limit, rollover, aus_vorlage) in limits.items():
@@ -733,7 +796,7 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
if kategorie is None:
continue
uebertrag = await _carry_over(session, kategorie_id, monat) if rollover else ZERO
uebertrag = await _carry_over(session, kategorie_id, monat, start_day) if rollover else ZERO
ergebnis.append(
BudgetStatus(
category_id=kategorie_id,
@@ -752,7 +815,9 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
return ergebnis
async def _carry_over(session: AsyncSession, category_id: int, month: date) -> Decimal:
async def _carry_over(
session: AsyncSession, category_id: int, month: date, start_day: int
) -> Decimal:
"""Nicht verbrauchtes Budget aus den Vormonaten.
Es wird höchstens ein Jahr zurückgeschaut; ein Überschreiten setzt den
@@ -772,7 +837,7 @@ async def _carry_over(session: AsyncSession, category_id: int, month: date) -> D
uebertrag = ZERO
continue
ausgaben = (await _spent_by_category(session, vormonat)).get(category_id, ZERO)
ausgaben = (await _spent_by_category(session, vormonat, start_day)).get(category_id, ZERO)
uebertrag = max(limit + uebertrag - ausgaben, ZERO)
return uebertrag
+37
View File
@@ -0,0 +1,37 @@
"""Anwendungsweite Einstellungen.
Die Tabelle enthält genau eine Zeile. Fehlt sie etwa direkt nach der
Migration , liefert `load_settings` die Vorgaben, ohne sie zu schreiben.
Erst ein Speichern legt die Zeile an.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.clock import DEFAULT_MONTH_START_DAY
from app.models import AppSetting
SETTING_ID = 1
async def load_settings(session: AsyncSession) -> AppSetting:
"""Die Einstellungen; ohne gespeicherte Zeile ein Objekt mit den Vorgaben."""
vorhanden = await session.get(AppSetting, SETTING_ID)
if vorhanden is not None:
return vorhanden
return AppSetting(id=SETTING_ID, month_start_day=DEFAULT_MONTH_START_DAY)
async def month_start_day(session: AsyncSession) -> int:
"""Der eingestellte Gehaltstag ab ihm beginnt der Abrechnungsmonat."""
return (await load_settings(session)).month_start_day
async def save_settings(session: AsyncSession, *, month_start_day: int) -> AppSetting:
"""Schreibt die Einstellungen und legt die Zeile bei Bedarf an."""
eintrag = await session.get(AppSetting, SETTING_ID)
if eintrag is None:
eintrag = AppSetting(id=SETTING_ID)
session.add(eintrag)
eintrag.month_start_day = month_start_day
await session.flush()
return eintrag
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "moneyfy-backend"
version = "0.1.0"
version = "0.1.2"
description = "moneyfy Planung monatlicher Kosten und Einkünfte"
requires-python = ">=3.12,<3.13"
dependencies = [
+4
View File
@@ -13,6 +13,10 @@ os.environ.setdefault(
)
os.environ.setdefault("SECRET_KEY", "test-secret-key-mindestens-32-zeichen-lang")
os.environ.setdefault("ENVIRONMENT", "test")
# Der Testclient spricht über http; `Secure`-Cookies würde httpx verwerfen und
# jede geschützte Route liefe in einen 401. Ohne diese Zeile hinge die Suite an
# einer lokalen .env die CI hat genau das aufgedeckt.
os.environ.setdefault("COOKIE_SECURE", "false")
# Die Hintergrundsuche liefe außerhalb der Testtransaktion sie wird gezielt aufgerufen.
os.environ.setdefault("LOGO_AUTO_RESOLVE", "false")
os.environ.setdefault("LOGO_STORAGE_DIR", tempfile.mkdtemp(prefix="moneyfy-logos-"))
+195
View File
@@ -0,0 +1,195 @@
"""Der einstellbare Monatsbeginn der Gehaltstag statt des Monatsersten."""
from datetime import date
import pytest
from httpx import AsyncClient
from app.core.clock import month_anchor, period_bounds, period_key, period_start
from app.services.reports import current_period, period_of
# --- Zeitrechnung ---------------------------------------------------------------
def test_monatserster_bleibt_der_kalendermonat() -> None:
assert period_bounds(date(2026, 9, 1), 1) == (date(2026, 9, 1), date(2026, 9, 30))
assert period_key(date(2026, 9, 30), 1) == date(2026, 9, 1)
def test_gehaltstag_verschiebt_den_zeitraum() -> None:
"""Der Zeitraum trägt den Namen des Monats, in dem er beginnt."""
assert period_bounds(date(2026, 9, 1), 25) == (date(2026, 9, 25), date(2026, 10, 24))
assert period_key(date(2026, 9, 24), 25) == date(2026, 8, 1)
assert period_key(date(2026, 9, 25), 25) == date(2026, 9, 1)
def test_starttag_jenseits_der_monatslaenge_trifft_den_letzten() -> None:
"""Der 31. bedeutet „letzter Tag des Monats“ auch im Februar."""
assert month_anchor(date(2026, 2, 10), 31) == date(2026, 2, 28)
assert period_bounds(date(2026, 1, 1), 31) == (date(2026, 1, 31), date(2026, 2, 27))
assert period_bounds(date(2026, 2, 1), 31) == (date(2026, 2, 28), date(2026, 3, 30))
@pytest.mark.parametrize("start_day", [1, 5, 15, 25, 28, 31])
def test_zeitraeume_schliessen_lueckenlos_aneinander_an(start_day: int) -> None:
"""Kein Tag fällt zwischen zwei Zeiträume, und keiner zählt doppelt."""
monat = date(2025, 12, 1)
_, vorher_ende = period_bounds(monat, start_day)
for versatz in range(1, 18):
schluessel = date(
monat.year + (monat.month - 1 + versatz) // 12,
(monat.month - 1 + versatz) % 12 + 1,
1,
)
beginn, ende = period_bounds(schluessel, start_day)
assert (beginn - vorher_ende).days == 1
assert period_start(beginn, start_day) == beginn
assert period_start(ende, start_day) == beginn
vorher_ende = ende
def test_current_period_richtet_sich_nach_dem_stichtag() -> None:
zeitraum = current_period(25, date(2026, 9, 10))
assert (zeitraum.key, zeitraum.start, zeitraum.end) == (
date(2026, 8, 1),
date(2026, 8, 25),
date(2026, 9, 24),
)
# --- Einstellung über die API ---------------------------------------------------
async def test_einstellung_lesen_und_speichern(auth_client: AsyncClient) -> None:
antwort = await auth_client.get("/api/settings")
assert antwort.status_code == 200
assert antwort.json()["month_start_day"] == 1
antwort = await auth_client.put("/api/settings", json={"month_start_day": 25})
assert antwort.status_code == 200
daten = antwort.json()
assert daten["month_start_day"] == 25
assert daten["current_period_start"].endswith("-25")
assert (await auth_client.get("/api/settings")).json()["month_start_day"] == 25
async def test_einstellung_weist_ungueltige_tage_ab(auth_client: AsyncClient) -> None:
for tag in (0, 32):
antwort = await auth_client.put("/api/settings", json={"month_start_day": tag})
assert antwort.status_code == 422, antwort.text
# --- Wirkung auf die Auswertungen -----------------------------------------------
async def _buchung(client: AsyncClient, seeded: dict, tag: str, betrag: str) -> None:
antwort = await client.post(
"/api/transactions",
json={
"kind": "expense",
"title": f"Einkauf {tag}",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": betrag,
"booking_date": tag,
},
)
assert antwort.status_code == 201, antwort.text
async def test_monatsuebersicht_folgt_dem_gehaltstag(
auth_client: AsyncClient, seeded: dict
) -> None:
"""Der 20. September gehört zum August, der 26. zum September."""
await _buchung(auth_client, seeded, "2026-09-20", "40.00")
await _buchung(auth_client, seeded, "2026-09-26", "60.00")
await auth_client.put("/api/settings", json={"month_start_day": 25})
antwort = await auth_client.get("/api/reports/month", params={"month": "2026-09-01"})
assert antwort.status_code == 200
daten = antwort.json()
assert daten["period_start"] == "2026-09-25"
assert daten["period_end"] == "2026-10-24"
assert daten["planned"]["expenses"] == "60.00"
# Der 20.09. liegt im Vormonat, also im Zeitraum August.
assert daten["previous_planned"]["expenses"] == "40.00"
async def test_kalender_laeuft_vom_gehaltstag_bis_zum_vortag(
auth_client: AsyncClient, seeded: dict
) -> None:
await auth_client.put("/api/settings", json={"month_start_day": 25})
antwort = await auth_client.get("/api/reports/calendar", params={"month": "2026-09-01"})
assert antwort.status_code == 200
daten = antwort.json()
assert daten["month"] == "2026-09-01"
assert daten["period_start"] == "2026-09-25"
assert daten["period_end"] == "2026-10-24"
assert daten["days"][0]["date"] == "2026-09-25"
assert daten["days"][-1]["date"] == "2026-10-24"
assert len(daten["days"]) == 30
async def test_vorschau_beginnt_mit_dem_gehaltsmonat(
auth_client: AsyncClient, seeded: dict
) -> None:
await auth_client.put("/api/settings", json={"month_start_day": 25})
antwort = await auth_client.get(
"/api/reports/forecast", params={"months": 3, "start": "2026-09-01"}
)
assert antwort.status_code == 200
monate = antwort.json()["months"]
assert [monat["month"] for monat in monate] == ["2026-09-01", "2026-10-01", "2026-11-01"]
assert monate[0]["period_start"] == "2026-09-25"
assert monate[2]["period_end"] == "2026-12-24"
async def test_budget_zaehlt_nur_ausgaben_des_gehaltsmonats(
auth_client: AsyncClient, seeded: dict
) -> None:
antwort = await auth_client.post(
"/api/budgets",
json={
"category_id": seeded["lebensmittel"],
"period_month": "2026-09-01",
"limit_amount": "500.00",
},
)
assert antwort.status_code == 201, antwort.text
await _buchung(auth_client, seeded, "2026-09-20", "40.00")
await _buchung(auth_client, seeded, "2026-10-05", "60.00")
await auth_client.put("/api/settings", json={"month_start_day": 25})
antwort = await auth_client.get("/api/reports/budgets", params={"month": "2026-09-01"})
assert antwort.status_code == 200
stand = next(
eintrag for eintrag in antwort.json() if eintrag["category_id"] == seeded["lebensmittel"]
)
# 20.09. liegt vor dem Gehaltstag, 05.10. danach.
assert stand["spent"] == "60.00"
async def test_dashboard_liefert_den_eingestellten_monatsbeginn(
auth_client: AsyncClient, seeded: dict
) -> None:
await auth_client.put("/api/settings", json={"month_start_day": 25})
antwort = await auth_client.get("/api/reports/dashboard", params={"month": "2026-09-01"})
assert antwort.status_code == 200
daten = antwort.json()
assert daten["month_start_day"] == 25
assert daten["month"]["period_start"] == "2026-09-25"
def test_period_of_kuerzt_beliebige_tage_auf_den_monatsersten() -> None:
zeitraum = period_of(date(2026, 9, 17), 25)
assert zeitraum.key == date(2026, 9, 1)
assert zeitraum.start == date(2026, 9, 25)
+49 -4
View File
@@ -188,10 +188,55 @@ Die Anwendung verweigert in Produktion den Start mit dem Platzhalterwert.
`openssl rand -hex 32` erzeugt einen passenden Schlüssel. Wird er später
geändert, sind alle Sitzungen ungültig das ist beabsichtigt.
**Anmeldung schlägt fehl, obwohl das Passwort stimmt**
Meist fehlt HTTPS: Die Cookies sind `Secure` gesetzt und erreichen den Server
über eine reine HTTP-Verbindung nicht. Entweder über NPMplus zugreifen oder
`COOKIE_SECURE=false` setzen.
**Anmeldung schlägt fehl**
Drei Ursachen kommen in Frage, in dieser Reihenfolge prüfen:
1. **Der Benutzer wurde mit einem anderen Passwort angelegt.** Die Erstanlage
greift nur, solange die Benutzertabelle leer ist. Wer `.env.example` zuerst
unverändert übernimmt und startet, bekommt einen Benutzer mit dem Platzhalter
ein späteres Ändern von `MONEYFY_ADMIN_PASSWORD` bleibt dann wirkungslos.
Abhilfe:
```bash
docker compose exec moneyfy-backend python -m app.scripts.reset_password \
--username admin --password "neues-passwort"
```
2. **Der Container kennt den neuen Wert noch nicht.** `docker compose restart`
behält die alte Umgebung. Nach einer Änderung an der `.env` muss der
Container neu erstellt werden:
```bash
docker compose up -d --force-recreate moneyfy-backend
docker compose exec moneyfy-backend printenv MONEYFY_ADMIN_PASSWORD
```
3. **Kein HTTPS.** Die Cookies sind `Secure` gesetzt; über eine reine
HTTP-Verbindung verwirft der Browser sie. Typisches Bild: **der Bildschirm
flackert kurz und die Anmeldemaske erscheint erneut, ohne Fehlermeldung.**
Seit 0.1.2 erklärt die Oberfläche das ausdrücklich, und im Log steht:
> `Anmeldung über … ohne HTTPS bei COOKIE_SECURE=true der Browser verwirft
> das Sitzungs-Cookie …`
Abhilfe: über NPMplus mit HTTPS zugreifen, oder für einen Test auf HTTP:
```bash
# in der .env
COOKIE_SECURE=false
```
```bash
# restart genügt nicht nur up -d übernimmt die geänderte Umgebung
docker compose up -d --force-recreate moneyfy-backend
```
Welcher Fall vorliegt, verrät das Log:
```bash
docker compose logs moneyfy-backend | grep -iE "administrator|MONEYFY_ADMIN"
```
**Firmen bekommen kein Logo**
Ohne Netzzugang findet nur der lokale Markenkatalog etwas; unbekannte Firmen
+26
View File
@@ -0,0 +1,26 @@
# Screenshots
Die Bilder in der [README](../../README.md) werden aus diesem Verzeichnis
geladen. Erwartet werden:
| Datei | Ansicht |
|---|---|
| `dashboard.png` | `/` Dashboard |
| `calendar.png` | `/calendar` Cashflow-Kalender |
| `recurrences.png` | `/recurrences` wiederkehrende Posten |
| `rrule-editor.png` | Dialog „Neuer wiederkehrender Posten" |
| `merchants.png` | `/merchants` Firmen |
| `reports.png` | `/reports` Auswertungen |
## Aufnehmen
```bash
make seed-demo-reset # reproduzierbarer Stand ohne echte Kontostände
make dev # Backend
make fe-dev # Frontend auf http://localhost:5173
```
Im dunklen Modus aufnehmen das ist die Vorgabe der Anwendung. Browserfenster
auf 1440 × 900 stellen, damit die Seitenleiste dauerhaft sichtbar ist. Die Bilder
zeigen ausschließlich Demodaten; vor dem Einchecken kurz prüfen, dass keine
echten Beträge, Kontonummern oder Mailadressen darauf zu sehen sind.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "moneyfy-frontend",
"private": true,
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite",
+3 -2
View File
@@ -1,6 +1,6 @@
/** Routen der Anwendung und die Weichen für die Anmeldung. */
import { Navigate, Route, Routes } from "react-router-dom";
import { Route, Routes } from "react-router-dom";
import { AppLayout } from "@/components/layout/AppLayout";
import { Skeleton } from "@/components/ui/Feedback";
@@ -11,6 +11,7 @@ import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { GoalsPage } from "@/pages/GoalsPage";
import { LoginPage } from "@/pages/LoginPage";
import { NotFoundPage } from "@/pages/NotFoundPage";
import { ReportsPage } from "@/pages/ReportsPage";
import { MerchantsPage } from "@/pages/MerchantsPage";
import { RecurrencesPage } from "@/pages/RecurrencesPage";
@@ -45,7 +46,7 @@ export function App() {
<Route path="/goals" element={<GoalsPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
@@ -0,0 +1,63 @@
/** Tests des Auffangnetzes für Renderfehler. */
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorBoundary } from "@/components/ErrorBoundary";
function Kaputt({ werfen }: { werfen: boolean }) {
if (werfen) throw new Error("Etwas ist entzwei");
return <p>Alles in Ordnung</p>;
}
describe("ErrorBoundary", () => {
beforeEach(() => {
// React protokolliert den abgefangenen Fehler zusätzlich selbst.
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("reicht den Inhalt im Normalfall durch", () => {
render(
<ErrorBoundary>
<Kaputt werfen={false} />
</ErrorBoundary>,
);
expect(screen.getByText("Alles in Ordnung")).toBeInTheDocument();
});
it("fängt einen Fehler ab und erklärt ihn", () => {
render(
<ErrorBoundary>
<Kaputt werfen />
</ErrorBoundary>,
);
expect(screen.getByText("Da ist etwas schiefgegangen")).toBeInTheDocument();
expect(screen.getByText(/Daten sind davon nicht betroffen/)).toBeInTheDocument();
// Die Ursache bleibt einsehbar, ohne sie aufzudrängen.
expect(screen.getByText("Etwas ist entzwei")).toBeInTheDocument();
});
it("bietet einen erneuten Versuch an", async () => {
const nutzer = userEvent.setup();
render(
<ErrorBoundary>
<Kaputt werfen={false} />
</ErrorBoundary>,
);
expect(screen.getByText("Alles in Ordnung")).toBeInTheDocument();
// Nach einem Fehler steht der Knopf bereit.
render(
<ErrorBoundary>
<Kaputt werfen />
</ErrorBoundary>,
);
const knopf = screen.getByRole("button", { name: /Erneut versuchen/ });
expect(knopf).toBeInTheDocument();
await nutzer.click(knopf);
});
});
+69
View File
@@ -0,0 +1,69 @@
/**
* Auffangnetz für unerwartete Fehler im Rendern.
*
* Ohne diese Grenze bliebe bei einem Fehler eine weiße Seite zurück; hier gibt
* es stattdessen eine Erklärung und einen Weg zurück.
*/
import { Component, type ErrorInfo, type ReactNode } from "react";
import { AlertOctagon, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/Button";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// In der Konsole bleibt die vollständige Ursache samt Komponentenpfad.
console.error("Unerwarteter Fehler in der Oberfläche:", error, info.componentStack);
}
render(): ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div className="flex h-full items-center justify-center p-6">
<div className="card max-w-md p-6 text-center">
<AlertOctagon aria-hidden className="mx-auto h-9 w-9 text-negative" />
<h1 className="mt-3 text-base font-semibold text-ink">Da ist etwas schiefgegangen</h1>
<p className="mt-1 text-sm text-muted">
Die Ansicht konnte nicht dargestellt werden. Deine Daten sind davon nicht betroffen.
</p>
<details className="mt-3 text-left">
<summary className="cursor-pointer text-xs text-faint transition hover:text-muted">
Technische Einzelheiten
</summary>
<pre className="mt-2 max-h-40 overflow-auto rounded-lg bg-raised p-2 text-[11px] text-muted">
{error.message}
</pre>
</details>
<div className="mt-4 flex justify-center gap-2">
<Button onClick={() => this.setState({ error: null })}>
<RotateCcw aria-hidden className="h-4 w-4" />
Erneut versuchen
</Button>
<Button variant="primary" onClick={() => window.location.assign("/")}>
Zum Dashboard
</Button>
</div>
</div>
</div>
);
}
}
+37
View File
@@ -0,0 +1,37 @@
/** Übersicht der Tastaturkürzel, erreichbar über `?`. */
import { Modal } from "@/components/ui/Modal";
import { GLOBAL_SHORTCUTS } from "@/hooks/useShortcuts";
export function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
<Modal
open={open}
onClose={onClose}
size="sm"
title="Tastaturkürzel"
description="Sie greifen, solange kein Eingabefeld den Fokus hat."
>
<dl className="space-y-2">
{GLOBAL_SHORTCUTS.map((kuerzel) => (
<div key={kuerzel.key} className="flex items-center justify-between gap-4">
<dt className="text-sm text-muted">{kuerzel.description}</dt>
<dd>
<kbd className="rounded border border-line bg-raised px-2 py-0.5 font-mono text-xs text-ink">
{kuerzel.key}
</kbd>
</dd>
</div>
))}
<div className="flex items-center justify-between gap-4 border-t border-line pt-2">
<dt className="text-sm text-muted">Dialog schließen</dt>
<dd>
<kbd className="rounded border border-line bg-raised px-2 py-0.5 font-mono text-xs text-ink">
Esc
</kbd>
</dd>
</div>
</dl>
</Modal>
);
}
+49 -3
View File
@@ -1,21 +1,55 @@
/** Rahmen der Anwendung: Seitenleiste, Kopfzeile und Inhaltsbereich. */
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { useMemo, useState } from "react";
import { Outlet, useNavigate } from "react-router-dom";
import { LogOut, Menu, Moon, Sun, X } from "lucide-react";
import { Keyboard, LogOut, Menu, Moon, Sun, X } from "lucide-react";
import { ShortcutHelp } from "@/components/ShortcutHelp";
import { Sidebar } from "@/components/layout/Sidebar";
import { Button } from "@/components/ui/Button";
import { useLogout, useMe } from "@/hooks/useAuth";
import { type Shortcut, useShortcuts } from "@/hooks/useShortcuts";
import { useThemeStore } from "@/store/theme";
import { useUiStore } from "@/store/ui";
export function AppLayout() {
const [menuOffen, setMenuOffen] = useState(false);
const [hilfeOffen, setHilfeOffen] = useState(false);
const { data: benutzer } = useMe();
const abmelden = useLogout();
const theme = useThemeStore((zustand) => zustand.theme);
const toggleTheme = useThemeStore((zustand) => zustand.toggleTheme);
const navigate = useNavigate();
const requestAction = useUiStore((zustand) => zustand.requestAction);
const requestSearchFocus = useUiStore((zustand) => zustand.requestSearchFocus);
const shortcuts: Shortcut[] = useMemo(
() => [
{
key: "n",
description: "Neue Buchung",
run: () => {
// Erst auf die Seite wechseln, dort holt sie die Absicht ab.
navigate("/transactions");
requestAction("new-transaction");
},
},
{
key: "r",
description: "Neuer wiederkehrender Posten",
run: () => {
navigate("/recurrences");
requestAction("new-recurrence");
},
},
{ key: "/", description: "Suche", run: requestSearchFocus },
{ key: "?", description: "Übersicht der Kürzel", run: () => setHilfeOffen(true) },
],
[navigate, requestAction, requestSearchFocus],
);
useShortcuts(shortcuts, !hilfeOffen);
return (
<div className="flex h-full">
@@ -54,6 +88,16 @@ export function AppLayout() {
</Button>
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="hidden sm:inline-flex"
onClick={() => setHilfeOffen(true)}
aria-label="Tastaturkürzel anzeigen"
>
<Keyboard aria-hidden className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -87,6 +131,8 @@ export function AppLayout() {
<Outlet />
</main>
</div>
<ShortcutHelp open={hilfeOffen} onClose={() => setHilfeOffen(false)} />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
/** Anwendungseinstellungen derzeit der Monatsbeginn (Gehaltstag). */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { DEFAULT_MONTH_START_DAY } from "@/lib/period";
import { toast } from "@/store/toast";
import type { AppSettings, AppSettingsInput } from "@/types/api";
export function useAppSettings() {
return useQuery({
queryKey: ["settings"],
queryFn: () => api.get<AppSettings>("/settings"),
// Der Monatsbeginn ändert sich selten und bestimmt jede Monatsansicht.
staleTime: 5 * 60 * 1000,
});
}
/** Der eingestellte Monatsbeginn; bis die Abfrage lädt, der Monatserste. */
export function useMonthStartDay(): number {
return useAppSettings().data?.month_start_day ?? DEFAULT_MONTH_START_DAY;
}
export function useSaveAppSettings() {
const client = useQueryClient();
return useMutation({
mutationFn: (daten: AppSettingsInput) => api.put<AppSettings>("/settings", daten),
onSuccess: (einstellungen) => {
client.setQueryData(["settings"], einstellungen);
// Jede Monatsansicht rechnet ab jetzt anders.
void client.invalidateQueries({ queryKey: ["reports"] });
toast.success("Monatsbeginn gespeichert.");
},
});
}
+33 -2
View File
@@ -24,11 +24,41 @@ export function useMe() {
});
}
/**
* Wird ausgelöst, wenn die Anmeldedaten stimmen, die Sitzung aber nicht hält.
*
* Ursache ist praktisch immer ein `Secure`-Cookie über eine HTTP-Verbindung:
* Der Browser verwirft es stillschweigend, die Anmeldung meldet Erfolg und die
* nächste Anfrage gilt wieder als nicht angemeldet.
*/
export class SessionNotEstablishedError extends Error {
constructor() {
super(
"Die Anmeldung hat geklappt, aber der Browser hat das Sitzungs-Cookie verworfen. " +
"Das passiert bei Zugriff über HTTP, solange COOKIE_SECURE aktiv ist " +
"greife über HTTPS zu oder setze COOKIE_SECURE=false.",
);
this.name = "SessionNotEstablishedError";
}
}
export function useLogin() {
const client = useQueryClient();
return useMutation({
mutationFn: (daten: { username: string; password: string }) =>
api.post<User>("/auth/login", daten),
mutationFn: async (daten: { username: string; password: string }) => {
const benutzer = await api.post<User>("/auth/login", daten);
// Gegenprobe: hält die Sitzung wirklich? Ohne sie liefe der Nutzer in eine
// stille Schleife zurück zur Anmeldemaske, ohne je einen Grund zu sehen.
try {
await api.get<User>("/me");
} catch (fehler) {
if (isUnauthorized(fehler)) throw new SessionNotEstablishedError();
throw fehler;
}
return benutzer;
},
onSuccess: (benutzer) => {
client.setQueryData(keys.me, benutzer);
void client.invalidateQueries();
@@ -64,6 +94,7 @@ export function useChangePassword() {
/** Übersetzt einen Anmeldefehler in eine Meldung für das Formular. */
export function loginErrorMessage(error: unknown): string {
if (error instanceof SessionNotEstablishedError) return error.message;
if (error instanceof ApiError) return error.message;
return "Der Server ist nicht erreichbar.";
}
@@ -0,0 +1,85 @@
/** Die Monatsansichten müssen den Abrechnungsmonat abfragen, nicht den Kalendermonat. */
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
import { mockFetch, renderWithProviders } from "@/test/utils";
function Seite() {
const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation();
return (
<div>
<p data-testid="monat">{monat}</p>
<p data-testid="beschriftung">{beschriftung}</p>
<button onClick={zurueck}>zurück</button>
<button onClick={vor}>vor</button>
<button onClick={heute}>heute</button>
</div>
);
}
function einstellung(tag: number) {
mockFetch({
"/api/settings": {
month_start_day: tag,
current_month: "2026-09-01",
current_period_start: "2026-09-01",
current_period_end: "2026-09-30",
},
});
}
describe("Monatsnavigation", () => {
beforeEach(() => {
// Fester Stichtag: der 10. liegt vor einem Gehaltstag am 25.
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date(2026, 8, 10, 12));
});
afterEach(() => {
vi.useRealTimers();
});
it("zeigt ohne abweichende Einstellung den Kalendermonat", async () => {
einstellung(1);
renderWithProviders(<Seite />);
await waitFor(() => {
expect(screen.getByTestId("beschriftung")).toHaveTextContent("September 2026");
});
expect(screen.getByTestId("monat")).toHaveTextContent("2026-09-01");
});
it("springt bei Gehaltstag 25 auf den laufenden Zeitraum", async () => {
einstellung(25);
renderWithProviders(<Seite />);
// Der 10.09. gehört noch zum Zeitraum, der am 25.08. begonnen hat.
await waitFor(() => {
expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01");
});
expect(screen.getByTestId("beschriftung")).toHaveTextContent(
"August 2026 · 25.08.2026 24.09.2026",
);
});
it("blättert monatsweise und kehrt zum laufenden Zeitraum zurück", async () => {
const nutzer = userEvent.setup();
einstellung(25);
renderWithProviders(<Seite />);
await waitFor(() => expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01"));
await nutzer.click(screen.getByText("vor"));
expect(screen.getByTestId("monat")).toHaveTextContent("2026-09-01");
await nutzer.click(screen.getByText("zurück"));
await nutzer.click(screen.getByText("zurück"));
expect(screen.getByTestId("monat")).toHaveTextContent("2026-07-01");
await nutzer.click(screen.getByText("heute"));
expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01");
});
});
+37
View File
@@ -0,0 +1,37 @@
/** Monatsauswahl der Monatsansichten, ausgerichtet am eingestellten Monatsbeginn. */
import { useState } from "react";
import { useMonthStartDay } from "@/hooks/useAppSettings";
import { addMonthsIso } from "@/lib/format";
import { periodKey, periodLabel } from "@/lib/period";
export interface MonthNavigation {
/** Bezeichner des angezeigten Abrechnungsmonats immer ein Monatserster. */
monat: string;
/** Der eingestellte Monatsbeginn. */
startDay: number;
/** „September 2026“, bei abweichendem Monatsbeginn samt Zeitraumgrenzen. */
beschriftung: string;
zurueck: () => void;
vor: () => void;
heute: () => void;
}
export function useMonthNavigation(): MonthNavigation {
const startDay = useMonthStartDay();
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
// Ohne eigene Auswahl folgt die Ansicht dem laufenden Zeitraum. Trifft die
// Einstellung später ein, rückt sie ohne Zutun auf den richtigen Monat.
const monat = gewaehlt ?? periodKey(startDay);
return {
monat,
startDay,
beschriftung: periodLabel(startDay, monat),
zurueck: () => setGewaehlt(addMonthsIso(monat, -1)),
vor: () => setGewaehlt(addMonthsIso(monat, 1)),
heute: () => setGewaehlt(null),
};
}
@@ -0,0 +1,108 @@
/** Tests des Bindeglieds zwischen globalen Kürzeln und den Seiten. */
import { useRef } from "react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
import { useUiStore } from "@/store/ui";
beforeEach(() => {
// Der Store lebt global sonst tropft eine Absicht in den nächsten Test.
// Bewusst vor dem Test und nicht danach: nach dem Test hängen die Komponenten
// noch am Store und React meldet ein Update außerhalb von act().
useUiStore.setState({ pendingAction: null, searchFocusRequest: 0 });
});
function Seite({ run }: { run: () => void }) {
const suchfeld = useRef<HTMLInputElement>(null);
usePendingAction("new-transaction", run);
useSearchFocus(suchfeld);
return <input ref={suchfeld} aria-label="Suche" defaultValue="Vorbelegung" />;
}
describe("usePendingAction", () => {
it("führt die Absicht aus, sobald sie hinterlegt wird", async () => {
const oeffnen = vi.fn();
render(<Seite run={oeffnen} />);
expect(oeffnen).not.toHaveBeenCalled();
await act(async () => {
useUiStore.getState().requestAction("new-transaction");
});
await waitFor(() => expect(oeffnen).toHaveBeenCalledOnce());
// Die Absicht ist danach verbraucht.
expect(useUiStore.getState().pendingAction).toBeNull();
});
it("ignoriert eine Absicht, die einer anderen Seite gilt", async () => {
const oeffnen = vi.fn();
render(<Seite run={oeffnen} />);
await act(async () => {
useUiStore.getState().requestAction("new-recurrence");
});
await waitFor(() => {
expect(useUiStore.getState().pendingAction).toBe("new-recurrence");
});
expect(oeffnen).not.toHaveBeenCalled();
});
it("führt dieselbe Absicht nicht zweimal aus", async () => {
const oeffnen = vi.fn();
const { rerender } = render(<Seite run={oeffnen} />);
await act(async () => {
useUiStore.getState().requestAction("new-transaction");
});
await waitFor(() => expect(oeffnen).toHaveBeenCalledOnce());
rerender(<Seite run={oeffnen} />);
expect(oeffnen).toHaveBeenCalledOnce();
});
});
describe("useSearchFocus", () => {
it("fokussiert und markiert das Suchfeld auf Anfrage", async () => {
render(<Seite run={vi.fn()} />);
const feld = screen.getByLabelText("Suche") as HTMLInputElement;
expect(feld).not.toHaveFocus();
await act(async () => {
useUiStore.getState().requestSearchFocus();
});
await waitFor(() => expect(feld).toHaveFocus());
// Der vorhandene Text ist markiert, damit Tippen ihn ersetzt.
expect(feld.selectionStart).toBe(0);
expect(feld.selectionEnd).toBe("Vorbelegung".length);
});
it("greift beim ersten Rendern noch nicht", () => {
render(<Seite run={vi.fn()} />);
expect(screen.getByLabelText("Suche")).not.toHaveFocus();
});
it("wirkt auch bei mehrfacher Anfrage", async () => {
render(<Seite run={vi.fn()} />);
const feld = screen.getByLabelText("Suche");
await act(async () => {
useUiStore.getState().requestSearchFocus();
});
await waitFor(() => expect(feld).toHaveFocus());
await act(async () => feld.blur());
await act(async () => {
useUiStore.getState().requestSearchFocus();
});
await waitFor(() => expect(feld).toHaveFocus());
});
});
+31
View File
@@ -0,0 +1,31 @@
/** Bindeglied zwischen den globalen Kürzeln und den einzelnen Seiten. */
import { type RefObject, useEffect } from "react";
import { type PendingAction, useUiStore } from "@/store/ui";
/**
* Ruft `run` auf, sobald das Layout die passende Absicht hinterlegt hat
* etwa nach `n` auf einer anderen Seite.
*/
export function usePendingAction(action: PendingAction, run: () => void): void {
const pending = useUiStore((zustand) => zustand.pendingAction);
const consume = useUiStore((zustand) => zustand.consumeAction);
useEffect(() => {
if (pending === action && consume(action)) run();
// `run` ändert sich bei jedem Rendern; die Absicht ist der Auslöser.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending, action]);
}
/** Fokussiert das Suchfeld der Seite, wenn `/` gedrückt wurde. */
export function useSearchFocus(ziel: RefObject<HTMLInputElement | null>): void {
const anfrage = useUiStore((zustand) => zustand.searchFocusRequest);
useEffect(() => {
if (anfrage === 0) return;
ziel.current?.focus();
ziel.current?.select();
}, [anfrage, ziel]);
}
+99
View File
@@ -0,0 +1,99 @@
/** Tests der Tastaturkürzel. */
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { useShortcuts, type Shortcut } from "@/hooks/useShortcuts";
function Huelle({ shortcuts, enabled = true }: { shortcuts: Shortcut[]; enabled?: boolean }) {
useShortcuts(shortcuts, enabled);
return (
<div>
<input aria-label="Eingabefeld" />
<textarea aria-label="Mehrzeilig" />
<button type="button">Knopf</button>
</div>
);
}
describe("useShortcuts", () => {
it("löst bei der passenden Taste aus", async () => {
const nutzer = userEvent.setup();
const neueBuchung = vi.fn();
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: neueBuchung }]} />);
await nutzer.keyboard("n");
expect(neueBuchung).toHaveBeenCalledOnce();
});
it("greift nicht, während ein Eingabefeld den Fokus hat", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />);
await nutzer.click(screen.getByLabelText("Eingabefeld"));
await nutzer.keyboard("nnn");
expect(ausloeser).not.toHaveBeenCalled();
expect(screen.getByLabelText("Eingabefeld")).toHaveValue("nnn");
});
it("greift auch in einem mehrzeiligen Feld nicht", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(<Huelle shortcuts={[{ key: "r", description: "Neu", run: ausloeser }]} />);
await nutzer.click(screen.getByLabelText("Mehrzeilig"));
await nutzer.keyboard("r");
expect(ausloeser).not.toHaveBeenCalled();
});
it("ignoriert Tastenkombinationen mit Zusatztaste", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />);
// Strg+N ist das Browserkürzel für ein neues Fenster.
await nutzer.keyboard("{Control>}n{/Control}");
expect(ausloeser).not.toHaveBeenCalled();
});
it("versteht auch Sonderzeichen wie den Schrägstrich", async () => {
const nutzer = userEvent.setup();
const suche = vi.fn();
render(<Huelle shortcuts={[{ key: "/", description: "Suche", run: suche }]} />);
await nutzer.keyboard("/");
expect(suche).toHaveBeenCalledOnce();
});
it("lässt sich abschalten", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(
<Huelle enabled={false} shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />,
);
await nutzer.keyboard("n");
expect(ausloeser).not.toHaveBeenCalled();
});
it("räumt den Zuhörer beim Abbau wieder ab", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
const { unmount } = render(
<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />,
);
unmount();
await nutzer.keyboard("n");
expect(ausloeser).not.toHaveBeenCalled();
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Tastaturkürzel der Anwendung.
*
* Kürzel greifen nur, wenn gerade kein Eingabefeld den Fokus hat und keine
* Zusatztaste gedrückt ist sonst käme man beim Tippen nicht weit.
*/
import { useEffect } from "react";
export interface Shortcut {
/** Taste in Kleinschreibung, etwa "n" oder "/". */
key: string;
description: string;
run: () => void;
}
function schreibtGerade(ziel: EventTarget | null): boolean {
if (!(ziel instanceof HTMLElement)) return false;
if (ziel.isContentEditable) return true;
return ["INPUT", "TEXTAREA", "SELECT"].includes(ziel.tagName);
}
export function useShortcuts(shortcuts: Shortcut[], enabled = true): void {
useEffect(() => {
if (!enabled || shortcuts.length === 0) return;
function behandeln(ereignis: KeyboardEvent) {
if (ereignis.metaKey || ereignis.ctrlKey || ereignis.altKey) return;
if (schreibtGerade(ereignis.target)) return;
const treffer = shortcuts.find(
(kuerzel) => kuerzel.key === ereignis.key.toLowerCase(),
);
if (!treffer) return;
ereignis.preventDefault();
treffer.run();
}
window.addEventListener("keydown", behandeln);
return () => window.removeEventListener("keydown", behandeln);
}, [shortcuts, enabled]);
}
/** Beschreibungen für die Übersicht der Kürzel. */
export const GLOBAL_SHORTCUTS: { key: string; description: string }[] = [
{ key: "n", description: "Neue Buchung" },
{ key: "r", description: "Neuer wiederkehrender Posten" },
{ key: "/", description: "Suche" },
{ key: "?", description: "Diese Übersicht" },
];
+11 -6
View File
@@ -3,16 +3,20 @@
@tailwind utilities;
@layer base {
/* Heller Modus */
/* Heller Modus.
Die Werte sind gegen die WCAG-Kontraste geprüft: Text erreicht überall
mindestens 4,5:1, Bedienelemente 3:1. Insbesondere `faint` und `accent`
sind bewusst dunkler als die naheliegenden Tailwind-Stufen slate-400
käme auf 2,56:1 und Weiß auf green-600 nur auf 3,30:1. */
:root {
--color-ground: 248 250 252;
--color-surface: 255 255 255;
--color-raised: 241 245 249;
--color-line: 226 232 240;
--color-line: 203 213 225;
--color-ink: 15 23 42;
--color-muted: 71 85 105;
--color-faint: 148 163 184;
--color-accent: 22 163 74;
--color-faint: 91 107 128;
--color-accent: 21 128 61;
--color-accent-ink: 255 255 255;
--color-positive: 21 128 61;
--color-negative: 190 18 60;
@@ -42,10 +46,11 @@
--color-ground: 15 17 21;
--color-surface: 24 27 33;
--color-raised: 33 37 45;
--color-line: 51 57 68;
--color-line: 59 66 80;
--color-ink: 226 232 240;
--color-muted: 148 163 184;
--color-faint: 100 116 139;
/* Heller als slate-500: dort läge der Hilfstext bei 3,62:1. */
--color-faint: 129 142 163;
--color-accent: 34 197 94;
--color-accent-ink: 4 20 11;
--color-positive: 74 222 128;
+1
View File
@@ -146,6 +146,7 @@ export const api = {
get: <T>(path: string, params?: Record<string, QueryValue>) => request<T>(path, { params }),
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: "POST", body, params }),
put: <T>(path: string, body: unknown) => request<T>(path, { method: "PUT", body }),
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body }),
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
upload: <T>(path: string, formData: FormData) =>
+28
View File
@@ -0,0 +1,28 @@
/** Abrechnungsmonate dieselben Fälle prüft das Backend in Python. */
import { describe, expect, it } from "vitest";
import { periodBounds, periodKey, periodLabel } from "@/lib/period";
describe("Abrechnungsmonate", () => {
it("lässt den Monatsersten den Kalendermonat sein", () => {
expect(periodBounds(1, "2026-09-01")).toEqual({ start: "2026-09-01", end: "2026-09-30" });
expect(periodKey(1, new Date(2026, 8, 30))).toBe("2026-09-01");
expect(periodLabel(1, "2026-09-01")).toBe("September 2026");
});
it("schneidet den Zeitraum am Gehaltstag", () => {
expect(periodBounds(25, "2026-09-01")).toEqual({ start: "2026-09-25", end: "2026-10-24" });
expect(periodKey(25, new Date(2026, 8, 24))).toBe("2026-08-01");
expect(periodKey(25, new Date(2026, 8, 25))).toBe("2026-09-01");
});
it("kürzt einen Starttag jenseits der Monatslänge auf den Letzten", () => {
expect(periodBounds(31, "2026-01-01")).toEqual({ start: "2026-01-31", end: "2026-02-27" });
expect(periodBounds(31, "2026-02-01")).toEqual({ start: "2026-02-28", end: "2026-03-30" });
});
it("nennt bei abweichendem Beginn die Grenzen des Zeitraums", () => {
expect(periodLabel(25, "2026-09-01")).toBe("September 2026 · 25.09.2026 24.10.2026");
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* Abrechnungsmonate im Frontend.
*
* Ein Abrechnungsmonat beginnt am eingestellten Gehaltstag und endet am Vortag
* des nächsten. Benannt wird er nach dem Monat, in dem er beginnt: Der 25.09.
* eröffnet den Zeitraum September, der bis zum 24.10. läuft. Liegt der
* Starttag jenseits der Monatslänge, rutscht er auf den Monatsletzten so
* bedeutet 31 verlässlich letzter Tag des Monats.
*
* Dieselbe Rechnung steht im Backend; hier steht sie, damit Seitenköpfe den
* Zeitraum schon vor der ersten Antwort benennen können.
*/
import { formatDate, formatMonth, toIsoDate } from "@/lib/format";
export const DEFAULT_MONTH_START_DAY = 1;
export const MIN_MONTH_START_DAY = 1;
export const MAX_MONTH_START_DAY = 31;
/** Der Gehaltstag im Monat von `jahr`/`monat`, gekürzt auf den Monatsletzten. */
function ankerTag(jahr: number, monat: number, startTag: number): Date {
const letzter = new Date(jahr, monat + 1, 0).getDate();
return new Date(jahr, monat, Math.min(startTag, letzter));
}
function ausIso(monatsErster: string): Date {
const teile = monatsErster.split("-").map(Number);
return new Date(teile[0] ?? 1970, (teile[1] ?? 1) - 1, 1);
}
/** Bezeichner des Abrechnungsmonats, in dem `datum` liegt immer ein Monatserster. */
export function periodKey(startDay: number, datum: Date = new Date()): string {
const tag = new Date(datum.getFullYear(), datum.getMonth(), datum.getDate());
const anker = ankerTag(tag.getFullYear(), tag.getMonth(), startDay);
const versatz = tag >= anker ? 0 : -1;
return toIsoDate(new Date(tag.getFullYear(), tag.getMonth() + versatz, 1));
}
/** Erster und letzter Tag des Abrechnungsmonats mit dem Bezeichner `monthKey`. */
export function periodBounds(startDay: number, monthKey: string): { start: string; end: string } {
const schluessel = ausIso(monthKey);
const beginn = ankerTag(schluessel.getFullYear(), schluessel.getMonth(), startDay);
const naechster = ankerTag(schluessel.getFullYear(), schluessel.getMonth() + 1, startDay);
naechster.setDate(naechster.getDate() - 1);
return { start: toIsoDate(beginn), end: toIsoDate(naechster) };
}
/**
* Beschriftung für Seitenköpfe: September 2026, bei abweichendem Monatsbeginn
* ergänzt um die Grenzen des Zeitraums.
*/
export function periodLabel(startDay: number, monthKey: string): string {
const name = formatMonth(monthKey);
if (startDay === DEFAULT_MONTH_START_DAY) return name;
const { start, end } = periodBounds(startDay, monthKey);
return `${name} · ${formatDate(start)} ${formatDate(end)}`;
}
+4 -1
View File
@@ -5,6 +5,7 @@ import { BrowserRouter } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { App } from "@/App";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { Toaster } from "@/components/ui/Toaster";
import "@/index.css";
import { queryClient } from "@/lib/queryClient";
@@ -16,7 +17,9 @@ createRoot(wurzel).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
<Toaster />
</BrowserRouter>
</QueryClientProvider>
+7 -6
View File
@@ -21,12 +21,13 @@ import {
useSaveBudget,
useSaveBudgetTemplate,
} from "@/hooks/useBudgets";
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
import { useBudgetStatus } from "@/hooks/useReports";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth } from "@/lib/format";
import { formatDate, formatMoney, formatMonth } from "@/lib/format";
import type { Budget, BudgetTemplate } from "@/types/api";
export function BudgetsPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation();
const [formular, setFormular] = useState<"budget" | "template" | null>(null);
const [loeschen, setLoeschen] = useState<Budget | null>(null);
const [vorlageLoeschen, setVorlageLoeschen] = useState<BudgetTemplate | null>(null);
@@ -42,23 +43,23 @@ export function BudgetsPage() {
<>
<PageHeader
title="Budgets"
description={formatMonth(monat)}
description={beschriftung}
actions={
<>
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
onClick={zurueck}
aria-label="Vorheriger Monat"
>
<ChevronLeft aria-hidden className="h-4 w-4" />
</Button>
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
<Button size="sm" onClick={heute}>
Heute
</Button>
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
onClick={vor}
aria-label="Nächster Monat"
>
<ChevronRight aria-hidden className="h-4 w-4" />
+4 -2
View File
@@ -90,6 +90,8 @@ function mockApi() {
if (url.includes("/api/reports/calendar")) {
return json({
month: "2026-03-01",
period_start: "2026-03-01",
period_end: "2026-03-31",
days: maerz(),
opening_balance: "1000.00",
closing_balance: "3250.00",
@@ -115,11 +117,11 @@ describe("Cashflow-Kalender", () => {
mockApi();
});
it("zeigt die Kennzahlen des Monats", async () => {
it("zeigt die Kennzahlen des Zeitraums", async () => {
renderWithProviders(<CalendarPage />);
await waitFor(() => {
expect(screen.getByText("Stand zu Monatsbeginn")).toBeInTheDocument();
expect(screen.getByText("Stand zu Beginn")).toBeInTheDocument();
});
expect(screen.getByText(/1.000,00/)).toBeInTheDocument();
expect(screen.getByText("Tiefster Stand")).toBeInTheDocument();
+25 -16
View File
@@ -1,8 +1,9 @@
/**
* Cashflow-Kalender.
*
* Monatsraster mit den Fälligkeiten je Tag; darunter der Verlauf des
* Kontostands über den Monat.
* Tagesraster des Abrechnungsmonats mit den Fälligkeiten je Tag; darunter der
* Verlauf des Kontostands über den Zeitraum. Beginnt der Monat am Gehaltstag,
* reicht das Raster über zwei Kalendermonate.
*/
import { useState } from "react";
@@ -26,21 +27,23 @@ import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { EmptyState, Skeleton } from "@/components/ui/Feedback";
import { useConfirmOccurrence, useMerchants, useSkipOccurrence } from "@/hooks/useEntities";
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
import { useCalendar } from "@/hooks/useReports";
import { cn } from "@/lib/cn";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, todayIso, toNumber } from "@/lib/format";
import { formatDate, formatMoney, todayIso, toNumber } from "@/lib/format";
import type { CalendarDay, CalendarEntry, Merchant } from "@/types/api";
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
/** Führende Leerfelder, damit der Monat am richtigen Wochentag beginnt. */
function fuehrendeLeerfelder(erster: string): number {
/** Führende Leerfelder, damit der erste Tag im richtigen Wochentag steht. */
function fuehrendeLeerfelder(erster: string | undefined): number {
if (!erster) return 0;
const datum = new Date(erster);
return (datum.getDay() + 6) % 7;
}
export function CalendarPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const { monat, beschriftung, zurueck, vor, heute: aufHeute } = useMonthNavigation();
const [gewaehlterTag, setGewaehlterTag] = useState<string | null>(null);
const { data, isLoading } = useCalendar(monat);
@@ -55,13 +58,13 @@ export function CalendarPage() {
<>
<PageHeader
title="Cashflow-Kalender"
description={formatMonth(monat)}
description={beschriftung}
actions={
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => {
setMonat((alt) => addMonthsIso(alt, -1));
zurueck();
setGewaehlterTag(null);
}}
aria-label="Vorheriger Monat"
@@ -71,7 +74,7 @@ export function CalendarPage() {
<Button
size="sm"
onClick={() => {
setMonat(firstOfMonth());
aufHeute();
setGewaehlterTag(null);
}}
>
@@ -80,7 +83,7 @@ export function CalendarPage() {
<Button
size="sm"
onClick={() => {
setMonat((alt) => addMonthsIso(alt, 1));
vor();
setGewaehlterTag(null);
}}
aria-label="Nächster Monat"
@@ -99,11 +102,16 @@ export function CalendarPage() {
) : (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatTile label="Stand zu Monatsbeginn" value={formatMoney(data.opening_balance)} />
<StatTile
label="Stand zum Monatsende"
label="Stand zu Beginn"
value={formatMoney(data.opening_balance)}
hint={formatDate(data.period_start)}
/>
<StatTile
label="Stand am Ende"
value={formatMoney(data.closing_balance)}
tone={toNumber(data.closing_balance) < 0 ? "negative" : "default"}
hint={formatDate(data.period_end)}
/>
<StatTile
label="Tiefster Stand"
@@ -123,7 +131,7 @@ export function CalendarPage() {
</div>
<div className="grid grid-cols-7 gap-1.5">
{Array.from({ length: fuehrendeLeerfelder(data.month) }, (_, index) => (
{Array.from({ length: fuehrendeLeerfelder(tage[0]?.date) }, (_, index) => (
<div key={`leer-${index}`} aria-hidden />
))}
@@ -150,13 +158,14 @@ export function CalendarPage() {
<ChartCard
title="Verlauf des Kontostands"
description="Fortgeschrieben aus dem Stand zu Monatsbeginn."
description="Fortgeschrieben aus dem Stand zu Beginn des Zeitraums."
>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={tage.map((tag) => ({
tag: new Date(tag.date).getDate(),
datum: tag.date,
stand: toNumber(tag.running_balance),
}))}
margin={{ top: 8, right: 8, bottom: 0, left: 4 }}
@@ -182,10 +191,10 @@ export function CalendarPage() {
<ReferenceLine y={0} stroke="var(--viz-axis)" strokeWidth={1} />
<Tooltip
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
content={({ active, payload, label }) =>
content={({ active, payload }) =>
active && payload?.length ? (
<ChartTooltip
title={`${label}. ${formatMonth(monat)}`}
title={formatDate(payload[0]?.payload.datum)}
rows={[
{
label: "Kontostand",
+8 -8
View File
@@ -1,6 +1,5 @@
/** Dashboard: die Kennzahlen des Monats auf einen Blick. */
/** Dashboard: die Kennzahlen des Abrechnungsmonats auf einen Blick. */
import { useState } from "react";
import { Link } from "react-router-dom";
import { AlertTriangle, ChevronLeft, ChevronRight, PiggyBank, Wallet } from "lucide-react";
@@ -15,11 +14,12 @@ import { Button } from "@/components/ui/Button";
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { useMerchants } from "@/hooks/useEntities";
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
import { useDashboard } from "@/hooks/useReports";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, relativeDays } from "@/lib/format";
import { formatDate, formatMoney, formatMonth, relativeDays } from "@/lib/format";
export function DashboardPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation();
const { data, isLoading } = useDashboard(monat);
const { data: firmenSeite } = useMerchants();
const kategorieName = useCategoryLookup();
@@ -30,22 +30,22 @@ export function DashboardPage() {
<>
<PageHeader
title="Dashboard"
description={formatMonth(monat)}
description={beschriftung}
actions={
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
onClick={zurueck}
aria-label="Vorheriger Monat"
>
<ChevronLeft aria-hidden className="h-4 w-4" />
</Button>
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
<Button size="sm" onClick={heute}>
Heute
</Button>
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
onClick={vor}
aria-label="Nächster Monat"
>
<ChevronRight aria-hidden className="h-4 w-4" />
+107
View File
@@ -0,0 +1,107 @@
/** Tests der Anmeldemaske, insbesondere der stillen Cookie-Falle. */
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LoginPage } from "@/pages/LoginPage";
import { renderWithProviders } from "@/test/utils";
const BENUTZER = {
id: 1,
username: "admin",
email: null,
must_change_password: false,
last_login_at: null,
};
/**
* `meAntwortet` steuert, ob die Sitzung nach der Anmeldung hält genau der
* Unterschied zwischen Passwort falsch" und „Cookie verworfen".
*/
function mockApi({
loginOk = true,
meAntwortet = true,
}: { loginOk?: boolean; meAntwortet?: boolean } = {}) {
const json = (daten: unknown, status = 200) =>
new Response(JSON.stringify(daten), {
status,
headers: { "content-type": "application/json" },
});
vi.stubGlobal(
"fetch",
vi.fn(async (eingabe: RequestInfo | URL) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
if (url.includes("/auth/login")) {
return loginOk
? json(BENUTZER)
: json({ detail: "Benutzername oder Passwort ist falsch.", code: "invalid_credentials" }, 401);
}
if (url.includes("/auth/refresh")) {
return json({ detail: "Nicht angemeldet.", code: "not_authenticated" }, 401);
}
if (url.includes("/me")) {
return meAntwortet
? json(BENUTZER)
: json({ detail: "Nicht angemeldet.", code: "not_authenticated" }, 401);
}
return json({});
}),
);
}
async function anmelden() {
const nutzer = userEvent.setup();
await nutzer.type(screen.getByLabelText(/Benutzername/), "admin");
await nutzer.type(screen.getByLabelText(/Passwort/), "geheim12345");
await nutzer.click(screen.getByRole("button", { name: "Anmelden" }));
}
describe("Anmeldemaske", () => {
beforeEach(() => {
mockApi();
});
it("verlangt Benutzername und Passwort", () => {
renderWithProviders(<LoginPage />);
expect(screen.getByRole("button", { name: "Anmelden" })).toBeDisabled();
});
it("meldet ein falsches Passwort im Klartext", async () => {
mockApi({ loginOk: false });
renderWithProviders(<LoginPage />);
await anmelden();
expect(
await screen.findByText("Benutzername oder Passwort ist falsch."),
).toBeInTheDocument();
});
it("erklärt eine verworfene Sitzung, statt still zurückzufallen", async () => {
// Anmeldung erfolgreich, aber das Cookie kommt nicht an.
mockApi({ loginOk: true, meAntwortet: false });
renderWithProviders(<LoginPage />);
await anmelden();
const meldung = await screen.findByRole("alert");
expect(meldung).toHaveTextContent(/Sitzungs-Cookie verworfen/);
// Der Hinweis nennt die Ursache und beide Auswege.
expect(meldung).toHaveTextContent(/HTTPS/);
expect(meldung).toHaveTextContent(/COOKIE_SECURE=false/);
});
it("meldet keinen Fehler, wenn die Sitzung hält", async () => {
renderWithProviders(<LoginPage />);
await anmelden();
await waitFor(() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
});
+6 -1
View File
@@ -1,6 +1,6 @@
/** Firmen als Kachelgrid mit Logo, Markenfarbe und Jahreskosten. */
import { type FormEvent, useMemo, useState } from "react";
import { type FormEvent, useMemo, useRef, useState } from "react";
import { Building2, ImageIcon, Pencil, Plus, Search, Trash2 } from "lucide-react";
@@ -17,6 +17,7 @@ import {
useRecurrences,
useSaveMerchant,
} from "@/hooks/useEntities";
import { useSearchFocus } from "@/hooks/usePageShortcuts";
import { formatMoney, toNumber } from "@/lib/format";
import { annualCostOf } from "@/lib/recurrenceMath";
import { useThemeStore } from "@/store/theme";
@@ -32,6 +33,9 @@ export function MerchantsPage() {
const { data: posten = [] } = useRecurrences({ active: true });
const entfernen = useDeleteMerchant();
const suchfeld = useRef<HTMLInputElement>(null);
useSearchFocus(suchfeld);
// Jahreskosten und Vertragszahl je Firma aus den aktiven Posten.
const kennzahlen = useMemo(() => {
const werte = new Map<number, { jahr: number; anzahl: number }>();
@@ -62,6 +66,7 @@ export function MerchantsPage() {
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
ref={suchfeld}
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Firma suchen"
+27
View File
@@ -0,0 +1,27 @@
/** Seite für unbekannte Adressen. */
import { Link } from "react-router-dom";
import { Compass } from "lucide-react";
import { Button } from "@/components/ui/Button";
export function NotFoundPage() {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="text-center">
<Compass aria-hidden className="mx-auto h-10 w-10 text-faint" />
<p className="mt-3 text-sm font-medium text-muted">Seite nicht gefunden</p>
<h1 className="mt-1 text-xl font-semibold text-ink">
Diese Adresse gibt es in moneyfy nicht.
</h1>
<p className="mx-auto mt-2 max-w-sm text-sm text-muted">
Vielleicht hat sich ein Tippfehler eingeschlichen oder der Link ist veraltet.
</p>
<Link to="/" className="mt-4 inline-block">
<Button variant="primary">Zum Dashboard</Button>
</Link>
</div>
</div>
);
}
+7 -1
View File
@@ -1,10 +1,11 @@
/** Wiederkehrende Posten: Liste, Formular und Detailansicht. */
import { useState } from "react";
import { useRef, useState } from "react";
import { Plus, Repeat, Search } from "lucide-react";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
import { MerchantLogo } from "@/components/MerchantLogo";
import { RecurrenceDetailDrawer } from "@/components/RecurrenceDetail";
import { RecurrenceFormDialog } from "@/components/RecurrenceForm";
@@ -28,6 +29,10 @@ export function RecurrencesPage() {
const { data: firmenSeite } = useMerchants();
const kategorieName = useCategoryLookup();
const suchfeld = useRef<HTMLInputElement>(null);
useSearchFocus(suchfeld);
usePendingAction("new-recurrence", () => setFormularFuer(null));
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
const begriff = suche.trim().toLowerCase();
const sichtbar = begriff
@@ -52,6 +57,7 @@ export function RecurrencesPage() {
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
ref={suchfeld}
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Posten suchen"
+25 -14
View File
@@ -11,6 +11,7 @@ import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Select } from "@/components/ui/Field";
import { useMonthStartDay } from "@/hooks/useAppSettings";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import {
exportUrl,
@@ -19,14 +20,15 @@ import {
useYearComparison,
} from "@/hooks/useReports";
import { cn } from "@/lib/cn";
import { firstOfMonth, formatDate, formatMoney, formatMonth, toNumber } from "@/lib/format";
import { formatDate, formatMoney, toNumber } from "@/lib/format";
import { periodBounds, periodKey, periodLabel } from "@/lib/period";
import { describeRRule } from "@/lib/rrule";
import type { Subscription } from "@/types/api";
type Reiter = "subscriptions" | "categories" | "year" | "export";
const REITER: { id: Reiter; label: string }[] = [
{ id: "subscriptions", label: "Abos" },
{ id: "subscriptions", label: "Laufende Kosten" },
{ id: "categories", label: "Kategorien" },
{ id: "year", label: "Jahresvergleich" },
{ id: "export", label: "Export" },
@@ -104,7 +106,12 @@ function SubscriptionsTab() {
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatTile label="Abos gesamt p. a." value={formatMoney(data.total_annual)} large />
<StatTile
label="Laufende Kosten p. a."
value={formatMoney(data.total_annual)}
large
hint="Alle wiederkehrenden Ausgaben ohne Ratenzahlungen."
/>
<StatTile label="Entspricht pro Monat" value={formatMoney(data.total_monthly)} />
<StatTile
label="Laufende Posten"
@@ -135,7 +142,9 @@ function SubscriptionsTab() {
<ChartCard
title="Alle laufenden Posten"
description="Ratenzahlungen sind gekennzeichnet und zählen nicht in die Gesamtsumme."
description="Jede wiederkehrende Ausgabe mit ihren Jahreskosten von der Miete bis
zum Streamingdienst. Ratenzahlungen sind gekennzeichnet und zählen nicht in die
Gesamtsumme, weil sie enden."
actions={
<Select
aria-label="Sortierung"
@@ -223,13 +232,13 @@ function SubscriptionRow({
function CategoriesTab() {
const [zeitraum, setZeitraum] = useState<"month" | "year">("month");
const startDay = useMonthStartDay();
const heute = new Date();
const von =
zeitraum === "month" ? firstOfMonth() : `${heute.getFullYear()}-01-01`;
const bis =
zeitraum === "month"
? new Date(heute.getFullYear(), heute.getMonth() + 1, 0).toISOString().slice(0, 10)
: `${heute.getFullYear()}-12-31`;
const laufend = periodKey(startDay);
const grenzen = periodBounds(startDay, laufend);
const von = zeitraum === "month" ? grenzen.start : `${heute.getFullYear()}-01-01`;
const bis = zeitraum === "month" ? grenzen.end : `${heute.getFullYear()}-12-31`;
const { data, isLoading } = useCategoryReport(von, bis);
@@ -240,7 +249,7 @@ function CategoriesTab() {
<div className="flex gap-1">
{(
[
["month", formatMonth(von)],
["month", periodLabel(startDay, laufend)],
["year", String(heute.getFullYear())],
] as const
).map(([wert, beschriftung]) => (
@@ -293,7 +302,9 @@ function YearTab() {
/* --- Export --------------------------------------------------------------- */
function ExportTab() {
const [monat, setMonat] = useState(() => firstOfMonth());
const startDay = useMonthStartDay();
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
const monat = gewaehlt ?? periodKey(startDay);
const jahr = new Date().getFullYear();
return (
@@ -318,13 +329,13 @@ function ExportTab() {
<ExportCard
title="Monatsauswertung"
description="Alle Bewegungen des Monats samt Kennzahlen."
description={`Alle Bewegungen samt Kennzahlen: ${periodLabel(startDay, monat)}.`}
extra={
<input
type="month"
aria-label="Monat der Auswertung"
value={monat.slice(0, 7)}
onChange={(ereignis) => setMonat(`${ereignis.target.value}-01`)}
onChange={(ereignis) => setGewaehlt(`${ereignis.target.value}-01`)}
className="mb-3 w-full rounded-lg border border-line bg-raised px-3 py-2 text-sm text-ink"
/>
}
+83
View File
@@ -0,0 +1,83 @@
/** Test des Abschnitts „Monatsbeginn“ in den Einstellungen. */
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsPage } from "@/pages/SettingsPage";
import { renderWithProviders } from "@/test/utils";
function mockApi() {
const anfragen: { url: string; method: string; body: unknown }[] = [];
let monatsbeginn = 1;
const json = (daten: unknown) =>
new Response(JSON.stringify(daten), {
status: 200,
headers: { "content-type": "application/json" },
});
vi.stubGlobal(
"fetch",
vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
const body = typeof init?.body === "string" ? JSON.parse(init.body) : null;
anfragen.push({ url, method: init?.method ?? "GET", body });
if (url.includes("/api/settings")) {
if (init?.method === "PUT") monatsbeginn = body.month_start_day;
return json({
month_start_day: monatsbeginn,
current_month: "2026-09-01",
current_period_start: "2026-09-01",
current_period_end: "2026-09-30",
});
}
if (url.includes("/api/accounts")) return json([]);
return json({});
}),
);
return anfragen;
}
describe("Einstellungen: Monatsbeginn", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date(2026, 8, 10, 12));
});
afterEach(() => {
vi.useRealTimers();
});
it("zeigt die Zeiträume zur Auswahl und speichert den Gehaltstag", async () => {
const nutzer = userEvent.setup();
const anfragen = mockApi();
renderWithProviders(<SettingsPage />);
await nutzer.click(screen.getByRole("tab", { name: "Monatsbeginn" }));
const auswahl = await screen.findByLabelText("Monatsbeginn");
expect(auswahl).toHaveValue("1");
// Vorgabe: der Zeitraum deckt sich mit dem Kalendermonat.
expect(screen.getByText("01.09.2026 30.09.2026")).toBeInTheDocument();
await nutzer.selectOptions(auswahl, "25");
// Die Vorschau rechnet sofort, noch vor dem Speichern.
expect(screen.getByText("25.08.2026 24.09.2026")).toBeInTheDocument();
expect(screen.getByText("25.09.2026 24.10.2026")).toBeInTheDocument();
await nutzer.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => {
expect(
anfragen.some(
(anfrage) => anfrage.method === "PUT" && anfrage.url.includes("/api/settings"),
),
).toBe(true);
});
const gespeichert = anfragen.find((anfrage) => anfrage.method === "PUT");
expect(gespeichert?.body).toEqual({ month_start_day: 25 });
});
});
+108 -4
View File
@@ -1,8 +1,8 @@
/** Einstellungen: Konten, Kategorien und das eigene Konto. */
/** Einstellungen: Konten, Kategorien, Monatsbeginn und das eigene Konto. */
import { type FormEvent, useState } from "react";
import { KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
import { CalendarRange, KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
import { NotificationSettings } from "@/components/NotificationSettings";
import { PageHeader } from "@/components/layout/AppLayout";
@@ -11,6 +11,7 @@ import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feed
import { Checkbox, Field, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { MoneyInput } from "@/components/ui/MoneyInput";
import { useAppSettings, useSaveAppSettings } from "@/hooks/useAppSettings";
import { useChangePassword, useMe } from "@/hooks/useAuth";
import {
useAccountBalance,
@@ -21,7 +22,14 @@ import {
useSaveAccount,
useSaveCategory,
} from "@/hooks/useEntities";
import { formatDate, formatMoney, todayIso } from "@/lib/format";
import { addMonthsIso, formatDate, formatMoney, formatMonth, todayIso } from "@/lib/format";
import {
DEFAULT_MONTH_START_DAY,
MAX_MONTH_START_DAY,
MIN_MONTH_START_DAY,
periodBounds,
periodKey,
} from "@/lib/period";
import { useThemeStore } from "@/store/theme";
import type { Account, AccountType, Category, CategoryTree, EntryKind } from "@/types/api";
@@ -32,11 +40,12 @@ const KONTOARTEN: Record<AccountType, string> = {
cash: "Bargeld",
};
type Reiter = "accounts" | "categories" | "notifications" | "account";
type Reiter = "accounts" | "categories" | "period" | "notifications" | "account";
const REITER: { id: Reiter; label: string }[] = [
{ id: "accounts", label: "Konten" },
{ id: "categories", label: "Kategorien" },
{ id: "period", label: "Monatsbeginn" },
{ id: "notifications", label: "Benachrichtigungen" },
{ id: "account", label: "Konto & Darstellung" },
];
@@ -69,12 +78,107 @@ export function SettingsPage() {
{reiter === "accounts" && <AccountsSection />}
{reiter === "categories" && <CategoriesSection />}
{reiter === "period" && <PeriodSection />}
{reiter === "notifications" && <NotificationSettings />}
{reiter === "account" && <UserSection />}
</>
);
}
/* --- Monatsbeginn --------------------------------------------------------- */
/** Auswahl 1 bis 31; der 31. meint verlässlich den letzten Tag des Monats. */
const STARTTAGE = Array.from(
{ length: MAX_MONTH_START_DAY - MIN_MONTH_START_DAY + 1 },
(_, index) => MIN_MONTH_START_DAY + index,
);
function PeriodSection() {
const { data, isLoading } = useAppSettings();
const speichern = useSaveAppSettings();
const [entwurf, setEntwurf] = useState<number | null>(null);
const gespeichert = data?.month_start_day ?? DEFAULT_MONTH_START_DAY;
const gewaehlt = entwurf ?? gespeichert;
const geaendert = data !== undefined && gewaehlt !== gespeichert;
const laufend = periodKey(gewaehlt);
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (!geaendert) return;
speichern.mutate({ month_start_day: gewaehlt }, { onSuccess: () => setEntwurf(null) });
}
if (isLoading) return <Skeleton className="h-64 w-full" />;
return (
<div className="grid gap-4 lg:grid-cols-2">
<section className="card p-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-ink">
<CalendarRange aria-hidden className="h-4 w-4" />
Erster Tag des Monats
</h2>
<p className="mt-0.5 text-xs text-muted">
Wer am Gehaltstag rechnet, für den beginnt der Monat nicht am Ersten. Dashboard,
Kalender, Budgets, Vorschau und Export richten sich nach diesem Tag.
</p>
<form onSubmit={absenden} className="mt-3 space-y-3">
<Field
label="Monatsbeginn"
hint="Ein Tag jenseits der Monatslänge rückt auf den Monatsletzten 31 meint also
den letzten Tag jedes Monats."
>
{(id) => (
<Select
id={id}
className="w-40"
value={gewaehlt}
onChange={(ereignis) => setEntwurf(Number(ereignis.target.value))}
>
{STARTTAGE.map((tag) => (
<option key={tag} value={tag}>
{tag}.
</option>
))}
</Select>
)}
</Field>
<Button type="submit" variant="primary" disabled={!geaendert || speichern.isPending}>
Speichern
</Button>
</form>
</section>
<section className="card p-4">
<h2 className="text-sm font-semibold text-ink">Vorschau</h2>
<p className="mt-0.5 text-xs text-muted">
So werden die Zeiträume mit dem gewählten Tag geschnitten.
</p>
<dl className="mt-3 space-y-2 text-sm">
{[laufend, addMonthsIso(laufend, 1)].map((schluessel, index) => {
const grenzen = periodBounds(gewaehlt, schluessel);
return (
<div key={schluessel} className="flex items-baseline justify-between gap-3">
<dt className="text-muted">
{formatMonth(schluessel)}
{index === 0 && <Badge className="ml-2">laufend</Badge>}
</dt>
<dd className="tabular text-ink">
{formatDate(grenzen.start)} {formatDate(grenzen.end)}
</dd>
</div>
);
})}
</dl>
</section>
</div>
);
}
/* --- Konten --------------------------------------------------------------- */
function AccountsSection() {
+7 -1
View File
@@ -1,11 +1,12 @@
/** Einmalige Buchungen: Liste, Filter und Formular. */
import { type FormEvent, useState } from "react";
import { type FormEvent, useRef, useState } from "react";
import { Pencil, Plus, Receipt, Search, Trash2 } from "lucide-react";
import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
import { MerchantLogo } from "@/components/MerchantLogo";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
@@ -35,6 +36,10 @@ export function TransactionsPage() {
const entfernen = useDeleteTransaction();
const kategorieName = useCategoryLookup();
const suchfeld = useRef<HTMLInputElement>(null);
useSearchFocus(suchfeld);
usePendingAction("new-transaction", () => setBearbeiten(null));
const buchungen = data?.items ?? [];
const gesamt = data?.total ?? 0;
const seite = Math.floor((filter.offset ?? 0) / SEITENGROESSE) + 1;
@@ -53,6 +58,7 @@ export function TransactionsPage() {
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
ref={suchfeld}
value={suche}
onChange={(ereignis) => {
setSuche(ereignis.target.value);
+38
View File
@@ -0,0 +1,38 @@
/**
* Kleiner Zustand für seitenübergreifende Absichten.
*
* Ein Tastaturkürzel wird im Layout ausgelöst, wirkt aber auf einer anderen
* Seite. Statt Rückrufe durch den Baum zu reichen, hinterlegt das Layout hier
* eine Absicht, die die Zielseite nach dem Wechsel abholt.
*/
import { create } from "zustand";
export type PendingAction = "new-transaction" | "new-recurrence";
interface UiStore {
pendingAction: PendingAction | null;
/** Zähler; jede Erhöhung fordert die aktive Seite auf, ihr Suchfeld zu fokussieren. */
searchFocusRequest: number;
requestAction: (action: PendingAction) => void;
/** Holt die Absicht ab und löscht sie. True, wenn sie zur Seite passte. */
consumeAction: (action: PendingAction) => boolean;
requestSearchFocus: () => void;
}
export const useUiStore = create<UiStore>((set, get) => ({
pendingAction: null,
searchFocusRequest: 0,
requestAction: (action) => set({ pendingAction: action }),
consumeAction: (action) => {
if (get().pendingAction !== action) return false;
set({ pendingAction: null });
return true;
},
requestSearchFocus: () =>
set((zustand) => ({ searchFocusRequest: zustand.searchFocusRequest + 1 })),
}));
+20
View File
@@ -368,7 +368,10 @@ export interface SavingsGoalInput {
}
export interface MonthReport {
/** Bezeichner des Abrechnungsmonats immer ein Monatserster. */
month: IsoDate;
period_start: IsoDate;
period_end: IsoDate;
planned: Totals;
actual: Totals;
previous_planned: Totals;
@@ -385,6 +388,8 @@ export interface MonthReport {
export interface ForecastMonth {
month: IsoDate;
period_start: IsoDate;
period_end: IsoDate;
income: Money;
expenses: Money;
balance: Money;
@@ -478,6 +483,8 @@ export interface CalendarDay {
export interface CalendarMonth {
month: IsoDate;
period_start: IsoDate;
period_end: IsoDate;
days: CalendarDay[];
opening_balance: Money;
closing_balance: Money;
@@ -520,6 +527,7 @@ export interface SavingsGoalProgress {
}
export interface Dashboard {
month_start_day: number;
month: MonthReport;
total_balance: Money;
forecast: ForecastMonth[];
@@ -601,3 +609,15 @@ export interface NotificationRunResult {
skipped: number;
failed: number;
}
export interface AppSettings {
/** Tag, an dem der Abrechnungsmonat beginnt der Gehaltstag. */
month_start_day: number;
current_month: IsoDate;
current_period_start: IsoDate;
current_period_end: IsoDate;
}
export interface AppSettingsInput {
month_start_day: number;
}