Compare commits
8
Commits
v0.1.0
...
6d182d0054
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d182d0054 | ||
|
|
0a6261fc55 | ||
|
|
998d5867df | ||
|
|
c110340628 | ||
|
|
a022a99f97 | ||
|
|
ba02e17776 | ||
|
|
d63cc0af65 | ||
|
|
bdf4097bd7 |
@@ -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. |
|
| `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. |
|
| `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 |
|
| 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*.
|
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
|
des Laufs. Dieser Benutzer braucht Schreibrecht auf die Pakete des Namensraums
|
||||||
`menzeljonas`.
|
`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
|
## Erzeugte Tags
|
||||||
|
|
||||||
| Auslöser | Tags |
|
| Auslöser | Tags |
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
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
|
- name: Tags und Beschriftungen ermitteln
|
||||||
id: meta
|
id: meta
|
||||||
@@ -74,6 +77,16 @@ jobs:
|
|||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/moneyfy-${{ matrix.component }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/moneyfy-${{ matrix.component }}:buildcache,mode=max
|
||||||
provenance: false
|
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
|
- name: Ergebnis zusammenfassen
|
||||||
run: |
|
run: |
|
||||||
echo "### moneyfy-${{ matrix.component }}" >> "$GITHUB_STEP_SUMMARY"
|
echo "### moneyfy-${{ matrix.component }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|||||||
+69
-2
@@ -7,13 +7,78 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.1.0] – 2026-09-09
|
||||||
|
|
||||||
Erste lauffähige Fassung: Planung, Auswertung und Benachrichtigung stehen,
|
Erste lauffähige Fassung: Planung, Auswertung und Benachrichtigung stehen,
|
||||||
die Anwendung lässt sich per Compose betreiben.
|
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
|
### Hinzugefügt
|
||||||
|
|
||||||
- Projektgerüst mit getrennten Bereichen für Backend, Frontend, Dokumentation und CI.
|
- 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
|
- Der Bildtyp eines Uploads wird ausschließlich am Inhalt bestimmt; die vom
|
||||||
Aufrufer gemeldete Kopfzeile wird nicht mehr als Nachweis akzeptiert.
|
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
|
[0.1.0]: https://git.menzel.center/menzeljonas/moneyfy/releases/tag/v0.1.0
|
||||||
|
|||||||
@@ -32,6 +32,22 @@ seed: ## Stammdaten (Kategoriebaum, Benachrichtigungsregeln) anlegen
|
|||||||
vendor-icons: ## simple-icons-Index neu erzeugen (benötigt npm und Netzzugang)
|
vendor-icons: ## simple-icons-Index neu erzeugen (benötigt npm und Netzzugang)
|
||||||
cd $(BACKEND) && .venv/bin/python scripts/vendor_simple_icons.py
|
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
|
.PHONY: dev
|
||||||
dev: ## Backend mit Hot Reload starten
|
dev: ## Backend mit Hot Reload starten
|
||||||
cd $(BACKEND) && .venv/bin/uvicorn app.main:app --reload --port 8000
|
cd $(BACKEND) && .venv/bin/uvicorn app.main:app --reload --port 8000
|
||||||
|
|||||||
@@ -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
|
Single-User, deutschsprachige Oberfläche, ausgelegt für den Betrieb im Homelab
|
||||||
hinter einem Reverse Proxy.
|
hinter einem Reverse Proxy.
|
||||||
|
|
||||||
> **Status:** in Entwicklung. Siehe [CHANGELOG.md](CHANGELOG.md).
|
> **Version 0.1.2.** Siehe [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
## Screenshots
|
## 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_ |
|
| <br>**Dashboard** – verfügbar nach Fixkosten, Kategorienring, Zwölf-Monats-Vorschau | <br>**Cashflow-Kalender** – Fälligkeiten je Tag mit Firmenlogo, darunter der Kontostandsverlauf |
|
||||||
|
| <br>**Wiederkehrend** – Abos, Verträge und Raten mit Klartext-Rhythmus | <br>**RRULE-Editor** – geführte Auswahl, Expertenmodus und Live-Vorschau der nächsten Termine |
|
||||||
|
| <br>**Firmen** – Kachelgrid mit Logo, Markenfarbe und Jahreskosten | <br>**Auswertungen** – laufende Kosten, Jahresvergleich und Export |
|
||||||
|
|
||||||
## Funktionsumfang
|
## Funktionsumfang
|
||||||
|
|
||||||
@@ -23,6 +26,8 @@ _Platzhalter – werden ergänzt, sobald die Oberfläche steht._
|
|||||||
- Ratenzahlungen mit Restschuld- und Restratenberechnung.
|
- Ratenzahlungen mit Restschuld- und Restratenberechnung.
|
||||||
- Rücklagenbildung für nicht-monatliche Posten.
|
- Rücklagenbildung für nicht-monatliche Posten.
|
||||||
- Firmenlogos und Markenfarben, lokal zwischengespeichert (siehe unten).
|
- 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,
|
- Auswertungen: Monatsübersicht, Cashflow-Kalender, 12-Monats-Forecast,
|
||||||
Kategorien, Abo-Übersicht, Jahresvergleich, Budgets, Sparziele.
|
Kategorien, Abo-Übersicht, Jahresvergleich, Budgets, Sparziele.
|
||||||
- Benachrichtigungen per SMTP und Apprise.
|
- Benachrichtigungen per SMTP und Apprise.
|
||||||
@@ -83,9 +88,63 @@ make fe-install
|
|||||||
make fe-dev # http://localhost:5173
|
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`,
|
Nützliche Ziele: `make check` (alle Prüfungen), `make test`, `make lint`,
|
||||||
`make format`, `make fe-lint`, `make fe-test`, `make fe-build`.
|
`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
|
## Konfiguration
|
||||||
|
|
||||||
Alle Einstellungen kommen aus Umgebungsvariablen; `.env.example` enthält die
|
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 |
|
| `DATABASE_URL` | – | PostgreSQL-DSN, `postgresql://` wird auf asyncpg umgestellt |
|
||||||
| `SECRET_KEY` | – | Signaturschlüssel für JWTs, mind. 32 Zeichen (`openssl rand -hex 32`) |
|
| `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 |
|
| `TIMEZONE` | `Europe/Berlin` | Zeitzone der gesamten Anwendung |
|
||||||
| `HOLIDAY_REGION` | `DE-NW` | Feiertagsregion für Werktagsverschiebungen |
|
| `HOLIDAY_REGION` | `DE-NW` | Feiertagsregion für Werktagsverschiebungen |
|
||||||
| `LOGO_STORAGE_DIR` | `/data/logos` | Verzeichnis des Logo-Caches |
|
| `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')
|
||||||
@@ -11,6 +11,7 @@ from app.core.security import decode_token
|
|||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import AppUser
|
from app.models import AppUser
|
||||||
from app.services.auth import get_user
|
from app.services.auth import get_user
|
||||||
|
from app.services.settings import month_start_day
|
||||||
|
|
||||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
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)]
|
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)]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.api.routes import (
|
|||||||
recurrences,
|
recurrences,
|
||||||
reports,
|
reports,
|
||||||
savings_goals,
|
savings_goals,
|
||||||
|
settings,
|
||||||
system,
|
system,
|
||||||
transactions,
|
transactions,
|
||||||
)
|
)
|
||||||
@@ -47,5 +48,6 @@ protected.include_router(savings_goals.router)
|
|||||||
protected.include_router(reports.router)
|
protected.include_router(reports.router)
|
||||||
protected.include_router(export.router)
|
protected.include_router(export.router)
|
||||||
protected.include_router(notifications.router)
|
protected.include_router(notifications.router)
|
||||||
|
protected.include_router(settings.router)
|
||||||
|
|
||||||
api_router.include_router(protected)
|
api_router.include_router(protected)
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
from fastapi import APIRouter, Request, Response, status
|
from fastapi import APIRouter, Request, Response, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, DbSession
|
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.core.errors import AuthError
|
||||||
from app.schemas.auth import ChangePasswordRequest, LoginRequest, UserOut
|
from app.schemas.auth import ChangePasswordRequest, LoginRequest, UserOut
|
||||||
from app.schemas.common import ErrorResponse, MessageResponse
|
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 "
|
description="Prüft die Zugangsdaten und legt Access- und Refresh-Token als "
|
||||||
"httpOnly-Cookies ab.",
|
"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")
|
provider = get_auth_provider("local")
|
||||||
user = await provider.authenticate(
|
user = await provider.authenticate(
|
||||||
session, Credentials(username=payload.username, password=payload.password)
|
session, Credentials(username=payload.username, password=payload.password)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from typing import Literal
|
|||||||
from fastapi import APIRouter, Query, Response, status
|
from fastapi import APIRouter, Query, Response, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api.deps import DbSession
|
from app.api.deps import DbSession, MonthStartDay
|
||||||
from app.core.clock import month_end, month_start, today
|
from app.core.clock import today
|
||||||
from app.core.errors import ValidationError
|
from app.core.errors import ValidationError
|
||||||
from app.models import Account, Merchant, Transaction
|
from app.models import Account, Merchant, Transaction
|
||||||
from app.models.enums import EntryKind
|
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.export import CONTENT_TYPES, filename, to_csv, to_xlsx
|
||||||
from app.services.reports import (
|
from app.services.reports import (
|
||||||
category_names,
|
category_names,
|
||||||
|
current_period,
|
||||||
flow_rows,
|
flow_rows,
|
||||||
flows,
|
flows,
|
||||||
month_report,
|
month_report,
|
||||||
|
period_of,
|
||||||
recurrence_rows,
|
recurrence_rows,
|
||||||
reserve_total,
|
reserve_total,
|
||||||
)
|
)
|
||||||
@@ -156,15 +158,16 @@ async def export_recurrences(
|
|||||||
)
|
)
|
||||||
async def export_month(
|
async def export_month(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
|
start_day: MonthStartDay,
|
||||||
fmt: ExportFormat = Query(default="xlsx", alias="format"),
|
fmt: ExportFormat = Query(default="xlsx", alias="format"),
|
||||||
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
||||||
) -> Response:
|
) -> 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)
|
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)
|
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]] = [
|
kennzahlen: list[dict[str, object]] = [
|
||||||
{"Kennzahl": "Einnahmen (Plan)", "Betrag": bericht.planned.income},
|
{"Kennzahl": "Einnahmen (Plan)", "Betrag": bericht.planned.income},
|
||||||
@@ -175,11 +178,11 @@ async def export_month(
|
|||||||
{"Kennzahl": "Saldo (Ist)", "Betrag": bericht.actual.balance},
|
{"Kennzahl": "Saldo (Ist)", "Betrag": bericht.actual.balance},
|
||||||
{"Kennzahl": "Fixkosten", "Betrag": bericht.fixed_costs},
|
{"Kennzahl": "Fixkosten", "Betrag": bericht.fixed_costs},
|
||||||
{"Kennzahl": "Variable Kosten", "Betrag": bericht.variable_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},
|
{"Kennzahl": "Verfügbar nach Fixkosten", "Betrag": bericht.available_after_fixed},
|
||||||
]
|
]
|
||||||
|
|
||||||
kennung = monat.strftime("%Y-%m")
|
kennung = zeitraum.key.strftime("%Y-%m")
|
||||||
if fmt == "csv":
|
if fmt == "csv":
|
||||||
# CSV kennt keine Blätter – die Kennzahlen folgen nach einer Leerzeile.
|
# CSV kennt keine Blätter – die Kennzahlen folgen nach einer Leerzeile.
|
||||||
inhalt = to_csv(zeilen, FLOW_COLUMNS)
|
inhalt = to_csv(zeilen, FLOW_COLUMNS)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from decimal import Decimal
|
|||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api.deps import DbSession
|
from app.api.deps import DbSession, MonthStartDay
|
||||||
from app.core.clock import month_end, month_start, today
|
from app.core.clock import today
|
||||||
from app.core.errors import ValidationError
|
from app.core.errors import ValidationError
|
||||||
from app.models import SavingsGoal
|
from app.models import SavingsGoal
|
||||||
from app.models.enums import EntryKind
|
from app.models.enums import EntryKind
|
||||||
@@ -43,10 +43,12 @@ from app.services.reports import (
|
|||||||
budget_status,
|
budget_status,
|
||||||
calendar_month,
|
calendar_month,
|
||||||
category_breakdown,
|
category_breakdown,
|
||||||
|
current_period,
|
||||||
flows,
|
flows,
|
||||||
forecast,
|
forecast,
|
||||||
month_report,
|
month_report,
|
||||||
months_between,
|
months_between,
|
||||||
|
period_of,
|
||||||
required_monthly_rate,
|
required_monthly_rate,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
year_comparison,
|
year_comparison,
|
||||||
@@ -67,6 +69,8 @@ def _totals(value: Totals) -> TotalsOut:
|
|||||||
def _month(report: MonthReport) -> MonthReportOut:
|
def _month(report: MonthReport) -> MonthReportOut:
|
||||||
return MonthReportOut(
|
return MonthReportOut(
|
||||||
month=report.month,
|
month=report.month,
|
||||||
|
period_start=report.period.start,
|
||||||
|
period_end=report.period.end,
|
||||||
planned=_totals(report.planned),
|
planned=_totals(report.planned),
|
||||||
actual=_totals(report.actual),
|
actual=_totals(report.actual),
|
||||||
previous_planned=_totals(report.previous_planned),
|
previous_planned=_totals(report.previous_planned),
|
||||||
@@ -89,6 +93,8 @@ def _month(report: MonthReport) -> MonthReportOut:
|
|||||||
def _forecast_month(entry: ForecastMonth) -> ForecastMonthOut:
|
def _forecast_month(entry: ForecastMonth) -> ForecastMonthOut:
|
||||||
return ForecastMonthOut(
|
return ForecastMonthOut(
|
||||||
month=entry.month,
|
month=entry.month,
|
||||||
|
period_start=entry.period_start,
|
||||||
|
period_end=entry.period_end,
|
||||||
income=entry.income,
|
income=entry.income,
|
||||||
expenses=entry.expenses,
|
expenses=entry.expenses,
|
||||||
balance=entry.balance,
|
balance=entry.balance,
|
||||||
@@ -216,11 +222,15 @@ async def _goals(session: DbSession, as_of: date) -> list[SavingsGoalProgressOut
|
|||||||
)
|
)
|
||||||
async def read_month_report(
|
async def read_month_report(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
|
start_day: MonthStartDay,
|
||||||
month: date | None = Query(
|
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:
|
) -> 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(
|
@router.get(
|
||||||
@@ -232,10 +242,13 @@ async def read_month_report(
|
|||||||
)
|
)
|
||||||
async def read_forecast(
|
async def read_forecast(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
|
start_day: MonthStartDay,
|
||||||
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
|
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:
|
) -> ForecastOut:
|
||||||
monate = await forecast(session, months, start)
|
monate = await forecast(session, months, start, start_day)
|
||||||
return ForecastOut(
|
return ForecastOut(
|
||||||
months=[_forecast_month(monat) for monat in monate],
|
months=[_forecast_month(monat) for monat in monate],
|
||||||
total_income=sum((monat.income for monat in monate), Decimal("0.00")),
|
total_income=sum((monat.income for monat in monate), Decimal("0.00")),
|
||||||
@@ -251,13 +264,14 @@ async def read_forecast(
|
|||||||
)
|
)
|
||||||
async def read_categories(
|
async def read_categories(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
|
start_day: MonthStartDay,
|
||||||
date_from: date | None = Query(default=None, alias="from"),
|
date_from: date | None = Query(default=None, alias="from"),
|
||||||
date_to: date | None = Query(default=None, alias="to"),
|
date_to: date | None = Query(default=None, alias="to"),
|
||||||
kind: EntryKind = Query(default=EntryKind.EXPENSE),
|
kind: EntryKind = Query(default=EntryKind.EXPENSE),
|
||||||
) -> CategoryReportOut:
|
) -> CategoryReportOut:
|
||||||
heute = today()
|
laufend = current_period(start_day)
|
||||||
start = date_from or month_start(heute)
|
start = date_from or laufend.start
|
||||||
ende = date_to or month_end(heute)
|
ende = date_to or laufend.end
|
||||||
if ende < start:
|
if ende < start:
|
||||||
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
||||||
|
|
||||||
@@ -274,9 +288,10 @@ async def read_categories(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/subscriptions",
|
"/subscriptions",
|
||||||
response_model=SubscriptionReportOut,
|
response_model=SubscriptionReportOut,
|
||||||
summary="Abo-Übersicht",
|
summary="Laufende Kosten",
|
||||||
description="Alle laufenden Ausgabenposten mit Jahreskosten. Ratenzahlungen sind "
|
description="Jede aktive wiederkehrende Ausgabe mit ihren Jahreskosten – von der "
|
||||||
"gekennzeichnet und zählen nicht in die Gesamtsumme.",
|
"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:
|
async def read_subscriptions(session: DbSession) -> SubscriptionReportOut:
|
||||||
bericht = await subscriptions(session)
|
bericht = await subscriptions(session)
|
||||||
@@ -326,11 +341,15 @@ async def read_year_comparison(
|
|||||||
)
|
)
|
||||||
async def read_calendar(
|
async def read_calendar(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
|
start_day: MonthStartDay,
|
||||||
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
||||||
) -> CalendarMonthOut:
|
) -> 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(
|
return CalendarMonthOut(
|
||||||
month=raster.month,
|
month=raster.month,
|
||||||
|
period_start=raster.period_start,
|
||||||
|
period_end=raster.period_end,
|
||||||
days=[
|
days=[
|
||||||
CalendarDayOut(
|
CalendarDayOut(
|
||||||
date=tag.on,
|
date=tag.on,
|
||||||
@@ -357,9 +376,11 @@ async def read_calendar(
|
|||||||
)
|
)
|
||||||
async def read_budget_status(
|
async def read_budget_status(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
|
start_day: MonthStartDay,
|
||||||
month: date | None = Query(default=None),
|
month: date | None = Query(default=None),
|
||||||
) -> list[BudgetStatusOut]:
|
) -> 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(
|
@router.get(
|
||||||
@@ -381,25 +402,29 @@ async def read_goal_progress(session: DbSession) -> list[SavingsGoalProgressOut]
|
|||||||
)
|
)
|
||||||
async def read_dashboard(
|
async def read_dashboard(
|
||||||
session: DbSession,
|
session: DbSession,
|
||||||
|
start_day: MonthStartDay,
|
||||||
month: date | None = Query(default=None),
|
month: date | None = Query(default=None),
|
||||||
) -> DashboardOut:
|
) -> DashboardOut:
|
||||||
heute = today()
|
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)
|
bericht = await month_report(session, zeitraum.key, start_day)
|
||||||
vorschau = await forecast(session, 12, monat)
|
vorschau = await forecast(session, 12, zeitraum.key, start_day)
|
||||||
gruppen = await category_breakdown(session, monat, month_end(monat))
|
gruppen = await category_breakdown(session, zeitraum.start, zeitraum.end)
|
||||||
abos = await subscriptions(session)
|
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))
|
naechste = await flows(session, heute, heute + timedelta(days=14))
|
||||||
|
|
||||||
return DashboardOut(
|
return DashboardOut(
|
||||||
|
month_start_day=start_day,
|
||||||
month=_month(bericht),
|
month=_month(bericht),
|
||||||
total_balance=await total_balance(session, heute),
|
total_balance=await total_balance(session, heute),
|
||||||
forecast=[_forecast_month(eintrag) for eintrag in vorschau],
|
forecast=[_forecast_month(eintrag) for eintrag in vorschau],
|
||||||
categories=[_slice(gruppe) for gruppe in gruppen],
|
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),
|
goals=await _goals(session, heute),
|
||||||
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
|
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
|
||||||
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
|
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -44,3 +44,45 @@ def add_months(day: date, months: int) -> date:
|
|||||||
month = total % 12 + 1
|
month = total % 12 + 1
|
||||||
last_day = month_end(date(year, month, 1)).day
|
last_day = month_end(date(year, month, 1)).day
|
||||||
return date(year, month, min(day.day, last_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
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# --- Allgemein -----------------------------------------------------------
|
# --- Allgemein -----------------------------------------------------------
|
||||||
app_name: str = "moneyfy"
|
app_name: str = "moneyfy"
|
||||||
app_version: str = "0.1.0"
|
app_version: str = "0.1.2"
|
||||||
environment: Literal["development", "production", "test"] = "development"
|
environment: Literal["development", "production", "test"] = "development"
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
timezone: str = "Europe/Berlin"
|
timezone: str = "Europe/Berlin"
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""Setzen und Löschen der Authentifizierungs-Cookies."""
|
"""Setzen und Löschen der Authentifizierungs-Cookies."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ACCESS_COOKIE = "moneyfy_access"
|
ACCESS_COOKIE = "moneyfy_access"
|
||||||
REFRESH_COOKIE = "moneyfy_refresh"
|
REFRESH_COOKIE = "moneyfy_refresh"
|
||||||
|
|
||||||
@@ -13,6 +16,38 @@ REFRESH_COOKIE = "moneyfy_refresh"
|
|||||||
REFRESH_COOKIE_PATH = "/api/auth"
|
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(
|
def set_auth_cookies(
|
||||||
response: Response,
|
response: Response,
|
||||||
access_token: str,
|
access_token: str,
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
"""SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig."""
|
"""SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig."""
|
||||||
|
|
||||||
from app.db.base import Base
|
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 (
|
from app.models.enums import (
|
||||||
AccountType,
|
AccountType,
|
||||||
BusinessDayShift,
|
BusinessDayShift,
|
||||||
@@ -29,6 +37,7 @@ __all__ = [
|
|||||||
"Account",
|
"Account",
|
||||||
"AccountType",
|
"AccountType",
|
||||||
"AmountVersion",
|
"AmountVersion",
|
||||||
|
"AppSetting",
|
||||||
"AppUser",
|
"AppUser",
|
||||||
"Base",
|
"Base",
|
||||||
"Budget",
|
"Budget",
|
||||||
|
|||||||
@@ -129,6 +129,24 @@ class Merchant(Base, CreatedAtMixin):
|
|||||||
__table_args__ = (Index("ix_merchant_normalized_name", "normalized_name"),)
|
__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):
|
class AppUser(Base, CreatedAtMixin):
|
||||||
"""Single-User-Betrieb; `external_subject` ist für eine spätere OIDC-Anbindung vorgesehen."""
|
"""Single-User-Betrieb; `external_subject` ist für eine spätere OIDC-Anbindung vorgesehen."""
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ class MonthComparisonOut(ApiModel):
|
|||||||
class MonthReportOut(ApiModel):
|
class MonthReportOut(ApiModel):
|
||||||
"""Monatsübersicht mit Plan-Ist-Vergleich."""
|
"""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.")
|
planned: TotalsOut = Field(description="Soll aus Fälligkeiten und Buchungen.")
|
||||||
actual: TotalsOut = Field(
|
actual: TotalsOut = Field(
|
||||||
description="Ist aus bestätigten Fälligkeiten und allen einmaligen Buchungen."
|
description="Ist aus bestätigten Fälligkeiten und allen einmaligen Buchungen."
|
||||||
@@ -48,14 +52,16 @@ class MonthReportOut(ApiModel):
|
|||||||
|
|
||||||
|
|
||||||
class ForecastMonthOut(ApiModel):
|
class ForecastMonthOut(ApiModel):
|
||||||
"""Ein Monat der Vorschau."""
|
"""Ein Abrechnungsmonat der Vorschau."""
|
||||||
|
|
||||||
month: date
|
month: date
|
||||||
|
period_start: date
|
||||||
|
period_end: date
|
||||||
income: Money
|
income: Money
|
||||||
expenses: Money
|
expenses: Money
|
||||||
balance: Money
|
balance: Money
|
||||||
cumulative_balance: Money = Field(
|
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
|
monthly_cost: Money
|
||||||
rrule: str
|
rrule: str
|
||||||
is_installment: bool = Field(
|
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
|
is_cancelled: bool
|
||||||
contract_term: ContractTermOut | None = None
|
contract_term: ContractTermOut | None = None
|
||||||
@@ -159,8 +165,13 @@ class CalendarDayOut(ApiModel):
|
|||||||
|
|
||||||
|
|
||||||
class CalendarMonthOut(ApiModel):
|
class CalendarMonthOut(ApiModel):
|
||||||
month: date
|
month: date = Field(description="Bezeichner des Abrechnungsmonats – immer der Monatserste.")
|
||||||
days: list[CalendarDayOut]
|
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
|
opening_balance: Money
|
||||||
closing_balance: Money
|
closing_balance: Money
|
||||||
lowest_balance: Money
|
lowest_balance: Money
|
||||||
@@ -210,6 +221,7 @@ class SavingsGoalProgressOut(ApiModel):
|
|||||||
class DashboardOut(ApiModel):
|
class DashboardOut(ApiModel):
|
||||||
"""Alles, was das Dashboard in einem Aufruf braucht."""
|
"""Alles, was das Dashboard in einem Aufruf braucht."""
|
||||||
|
|
||||||
|
month_start_day: int = Field(description="Der eingestellte Monatsbeginn.")
|
||||||
month: MonthReportOut
|
month: MonthReportOut
|
||||||
total_balance: Money
|
total_balance: Money
|
||||||
forecast: list[ForecastMonthOut]
|
forecast: list[ForecastMonthOut]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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))
|
||||||
|
)
|
||||||
@@ -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)))
|
||||||
@@ -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"]
|
||||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.core.config import settings
|
||||||
from app.models import LogoAsset, Merchant, NotificationLog, NotificationRule
|
from app.models import LogoAsset, Merchant, NotificationLog, NotificationRule
|
||||||
from app.models.enums import (
|
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.channels import Attachment, ChannelError, Notification, get_channel
|
||||||
from app.services.occurrences import load_recurrences
|
from app.services.occurrences import load_recurrences
|
||||||
from app.services.recurrence import contract_term
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -169,14 +170,15 @@ async def collect_notice_deadlines(
|
|||||||
async def collect_budget_exceeded(
|
async def collect_budget_exceeded(
|
||||||
session: AsyncSession, rule: NotificationRule, as_of: date
|
session: AsyncSession, rule: NotificationRule, as_of: date
|
||||||
) -> list[Event]:
|
) -> list[Event]:
|
||||||
"""Überschrittene Budgets – höchstens einmal je Monat und Kategorie."""
|
"""Überschrittene Budgets – höchstens einmal je Abrechnungsmonat und Kategorie."""
|
||||||
monat = month_start(as_of)
|
monatsbeginn = await month_start_day(session)
|
||||||
|
zeitraum = current_period(monatsbeginn, as_of)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Event(
|
Event(
|
||||||
ref_type=REF_BUDGET,
|
ref_type=REF_BUDGET,
|
||||||
ref_id=str(eintrag.category_id),
|
ref_id=str(eintrag.category_id),
|
||||||
dedupe_day=monat,
|
dedupe_day=zeitraum.key,
|
||||||
headline=eintrag.category_name,
|
headline=eintrag.category_name,
|
||||||
detail=(
|
detail=(
|
||||||
f"{_money(eintrag.spent)} von {_money(eintrag.available)} verbraucht "
|
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"
|
f"{_money(abs(eintrag.remaining))} zu viel"
|
||||||
),
|
),
|
||||||
amount=eintrag.spent,
|
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"
|
if eintrag.state == "exceeded"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+100
-35
@@ -14,7 +14,14 @@ from typing import Literal
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import Budget, BudgetTemplate, Category, Merchant, Recurrence, Transaction
|
||||||
from app.models.enums import EntryKind, OccurrenceStatus
|
from app.models.enums import EntryKind, OccurrenceStatus
|
||||||
from app.services.balances import total_balance
|
from app.services.balances import total_balance
|
||||||
@@ -83,6 +90,33 @@ class Totals:
|
|||||||
return self.income - self.expenses
|
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(
|
def totals_of(
|
||||||
entries: Iterable[FlowEntry],
|
entries: Iterable[FlowEntry],
|
||||||
*,
|
*,
|
||||||
@@ -180,9 +214,9 @@ async def flows(
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class MonthReport:
|
class MonthReport:
|
||||||
"""Kennzahlen eines Monats."""
|
"""Kennzahlen eines Abrechnungsmonats."""
|
||||||
|
|
||||||
month: date
|
period: Period
|
||||||
planned: Totals
|
planned: Totals
|
||||||
actual: Totals
|
actual: Totals
|
||||||
previous_planned: Totals
|
previous_planned: Totals
|
||||||
@@ -194,6 +228,11 @@ class MonthReport:
|
|||||||
open_count: int = 0
|
open_count: int = 0
|
||||||
skipped_count: int = 0
|
skipped_count: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def month(self) -> date:
|
||||||
|
"""Bezeichner des Zeitraums – immer ein Monatserster."""
|
||||||
|
return self.period.key
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available_after_fixed(self) -> Decimal:
|
def available_after_fixed(self) -> Decimal:
|
||||||
"""Einkünfte abzüglich Fixkosten und Rücklagen – die große Kennzahl im Dashboard."""
|
"""Einkünfte abzüglich Fixkosten und Rücklagen – die große Kennzahl im Dashboard."""
|
||||||
@@ -213,27 +252,29 @@ class MonthReport:
|
|||||||
|
|
||||||
|
|
||||||
async def _month_flows(
|
async def _month_flows(
|
||||||
session: AsyncSession, month: date, fixed_costs: dict[int, bool]
|
session: AsyncSession, period: Period, fixed_costs: dict[int, bool]
|
||||||
) -> list[FlowEntry]:
|
) -> 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."""
|
"""Summe der monatlichen Rücklagen aller Posten mit aktivierter Rücklagenbildung."""
|
||||||
total = ZERO
|
total = ZERO
|
||||||
for recurrence in await load_recurrences(session):
|
for recurrence in await load_recurrences(session):
|
||||||
if recurrence.reserve_enabled:
|
if recurrence.reserve_enabled:
|
||||||
total += monthly_reserve(
|
total += monthly_reserve(
|
||||||
recurrence, month_start(month), amount_versions=recurrence.amount_versions
|
recurrence, period.start, amount_versions=recurrence.amount_versions
|
||||||
)
|
)
|
||||||
return total
|
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."""
|
"""Monatsübersicht inklusive Vergleich zum Vormonat."""
|
||||||
fix = await fixed_cost_map(session)
|
fix = await fixed_cost_map(session)
|
||||||
aktuell = month_start(month)
|
aktuell = period_of(month, start_day)
|
||||||
vormonat = add_months(aktuell, -1)
|
vormonat = period_of(add_months(aktuell.key, -1), start_day)
|
||||||
|
|
||||||
bewegungen = await _month_flows(session, aktuell, fix)
|
bewegungen = await _month_flows(session, aktuell, fix)
|
||||||
vorherige = await _month_flows(session, vormonat, 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.
|
# Ausgelassene Fälligkeiten fehlen in `flows` und werden separat gezählt.
|
||||||
ausgelassen = sum(
|
ausgelassen = sum(
|
||||||
1
|
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
|
if item.planned.status is OccurrenceStatus.SKIPPED
|
||||||
)
|
)
|
||||||
serien = [entry for entry in bewegungen if entry.source == "recurrence"]
|
serien = [entry for entry in bewegungen if entry.source == "recurrence"]
|
||||||
|
|
||||||
return MonthReport(
|
return MonthReport(
|
||||||
month=aktuell,
|
period=aktuell,
|
||||||
planned=totals_of(bewegungen),
|
planned=totals_of(bewegungen),
|
||||||
actual=totals_of(bewegungen, basis="effective", only_confirmed=True),
|
actual=totals_of(bewegungen, basis="effective", only_confirmed=True),
|
||||||
previous_planned=totals_of(vorherige),
|
previous_planned=totals_of(vorherige),
|
||||||
@@ -270,13 +311,15 @@ async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ForecastMonth:
|
class ForecastMonth:
|
||||||
"""Ein Monat der Vorschau."""
|
"""Ein Abrechnungsmonat der Vorschau."""
|
||||||
|
|
||||||
month: date
|
month: date
|
||||||
|
period_start: date
|
||||||
|
period_end: date
|
||||||
income: Decimal
|
income: Decimal
|
||||||
expenses: Decimal
|
expenses: Decimal
|
||||||
cumulative_balance: Decimal
|
cumulative_balance: Decimal
|
||||||
"""Prognostizierter Kontostand am Monatsende über alle Konten."""
|
"""Prognostizierter Kontostand am Ende des Zeitraums über alle Konten."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def balance(self) -> Decimal:
|
def balance(self) -> Decimal:
|
||||||
@@ -284,28 +327,33 @@ class ForecastMonth:
|
|||||||
|
|
||||||
|
|
||||||
async def forecast(
|
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]:
|
) -> list[ForecastMonth]:
|
||||||
"""Vorschau über mehrere Monate.
|
"""Vorschau über mehrere Monate.
|
||||||
|
|
||||||
Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil die Reihe
|
Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil die Reihe
|
||||||
aus der tatsächlichen Expansion entsteht und nicht aus einem Durchschnitt.
|
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)
|
fix = await fixed_cost_map(session)
|
||||||
|
|
||||||
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Monat.
|
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Zeitraum.
|
||||||
laufend = await total_balance(session, beginn - timedelta(days=1))
|
laufend = await total_balance(session, erster.start - timedelta(days=1))
|
||||||
|
|
||||||
ergebnis: list[ForecastMonth] = []
|
ergebnis: list[ForecastMonth] = []
|
||||||
for versatz in range(max(1, months)):
|
for versatz in range(max(1, months)):
|
||||||
monat = add_months(beginn, versatz)
|
zeitraum = period_of(add_months(erster.key, versatz), start_day)
|
||||||
bewegungen = await flows(session, month_start(monat), month_end(monat), fixed_costs=fix)
|
bewegungen = await flows(session, zeitraum.start, zeitraum.end, fixed_costs=fix)
|
||||||
summen = totals_of(bewegungen, basis="effective")
|
summen = totals_of(bewegungen, basis="effective")
|
||||||
laufend += summen.balance
|
laufend += summen.balance
|
||||||
ergebnis.append(
|
ergebnis.append(
|
||||||
ForecastMonth(
|
ForecastMonth(
|
||||||
month=monat,
|
month=zeitraum.key,
|
||||||
|
period_start=zeitraum.start,
|
||||||
|
period_end=zeitraum.end,
|
||||||
income=summen.income,
|
income=summen.income,
|
||||||
expenses=summen.expenses,
|
expenses=summen.expenses,
|
||||||
cumulative_balance=laufend,
|
cumulative_balance=laufend,
|
||||||
@@ -567,6 +615,9 @@ class CalendarDay:
|
|||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class CalendarMonth:
|
class CalendarMonth:
|
||||||
month: date
|
month: date
|
||||||
|
"""Bezeichner des Abrechnungsmonats – immer ein Monatserster."""
|
||||||
|
period_start: date
|
||||||
|
period_end: date
|
||||||
days: list[CalendarDay]
|
days: list[CalendarDay]
|
||||||
opening_balance: Decimal
|
opening_balance: Decimal
|
||||||
closing_balance: Decimal
|
closing_balance: Decimal
|
||||||
@@ -575,11 +626,16 @@ class CalendarMonth:
|
|||||||
|
|
||||||
|
|
||||||
async def calendar_month(
|
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:
|
) -> CalendarMonth:
|
||||||
"""Monatsraster mit den Fälligkeiten je Tag und dem laufenden Kontostand."""
|
"""Tagesraster des Abrechnungsmonats mit den Fälligkeiten und dem laufenden Kontostand."""
|
||||||
beginn = month_start(month)
|
zeitraum = period_of(month, start_day)
|
||||||
ende = month_end(month)
|
beginn = zeitraum.start
|
||||||
|
ende = zeitraum.end
|
||||||
|
|
||||||
eroeffnung = await total_balance(session, beginn - timedelta(days=1))
|
eroeffnung = await total_balance(session, beginn - timedelta(days=1))
|
||||||
bewegungen = await flows(session, beginn, ende)
|
bewegungen = await flows(session, beginn, ende)
|
||||||
@@ -614,7 +670,9 @@ async def calendar_month(
|
|||||||
tag += timedelta(days=1)
|
tag += timedelta(days=1)
|
||||||
|
|
||||||
return CalendarMonth(
|
return CalendarMonth(
|
||||||
month=beginn,
|
month=zeitraum.key,
|
||||||
|
period_start=beginn,
|
||||||
|
period_end=ende,
|
||||||
days=tage,
|
days=tage,
|
||||||
opening_balance=eroeffnung,
|
opening_balance=eroeffnung,
|
||||||
closing_balance=laufend,
|
closing_balance=laufend,
|
||||||
@@ -697,14 +755,17 @@ async def _effective_limits(
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, Decimal]:
|
async def _spent_by_category(
|
||||||
"""Ausgaben eines Monats je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
|
session: AsyncSession, month: date, start_day: int
|
||||||
|
) -> dict[int, Decimal]:
|
||||||
|
"""Ausgaben eines Zeitraums je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
|
||||||
kategorien = {
|
kategorien = {
|
||||||
kategorie.id: kategorie.parent_id
|
kategorie.id: kategorie.parent_id
|
||||||
for kategorie in (await session.execute(select(Category))).scalars()
|
for kategorie in (await session.execute(select(Category))).scalars()
|
||||||
}
|
}
|
||||||
|
zeitraum = period_of(month, start_day)
|
||||||
ergebnis: dict[int, Decimal] = {}
|
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:
|
if entry.kind is not EntryKind.EXPENSE:
|
||||||
continue
|
continue
|
||||||
# Ein Budget auf der Oberkategorie umfasst auch deren Unterkategorien.
|
# 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
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus]:
|
async def budget_status(
|
||||||
"""Budgets eines Monats samt Verbrauch und Übertrag."""
|
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
|
||||||
|
) -> list[BudgetStatus]:
|
||||||
|
"""Budgets eines Abrechnungsmonats samt Verbrauch und Übertrag."""
|
||||||
monat = month_start(month)
|
monat = month_start(month)
|
||||||
limits = await _effective_limits(session, monat)
|
limits = await _effective_limits(session, monat)
|
||||||
if not limits:
|
if not limits:
|
||||||
@@ -725,7 +788,7 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
|
|||||||
namen = {
|
namen = {
|
||||||
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
|
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] = []
|
ergebnis: list[BudgetStatus] = []
|
||||||
for kategorie_id, (limit, rollover, aus_vorlage) in limits.items():
|
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:
|
if kategorie is None:
|
||||||
continue
|
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(
|
ergebnis.append(
|
||||||
BudgetStatus(
|
BudgetStatus(
|
||||||
category_id=kategorie_id,
|
category_id=kategorie_id,
|
||||||
@@ -752,7 +815,9 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
|
|||||||
return ergebnis
|
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.
|
"""Nicht verbrauchtes Budget aus den Vormonaten.
|
||||||
|
|
||||||
Es wird höchstens ein Jahr zurückgeschaut; ein Überschreiten setzt den
|
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
|
uebertrag = ZERO
|
||||||
continue
|
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)
|
uebertrag = max(limit + uebertrag - ausgaben, ZERO)
|
||||||
return uebertrag
|
return uebertrag
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "moneyfy-backend"
|
name = "moneyfy-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
description = "moneyfy – Planung monatlicher Kosten und Einkünfte"
|
description = "moneyfy – Planung monatlicher Kosten und Einkünfte"
|
||||||
requires-python = ">=3.12,<3.13"
|
requires-python = ">=3.12,<3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ os.environ.setdefault(
|
|||||||
)
|
)
|
||||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-mindestens-32-zeichen-lang")
|
os.environ.setdefault("SECRET_KEY", "test-secret-key-mindestens-32-zeichen-lang")
|
||||||
os.environ.setdefault("ENVIRONMENT", "test")
|
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.
|
# Die Hintergrundsuche liefe außerhalb der Testtransaktion – sie wird gezielt aufgerufen.
|
||||||
os.environ.setdefault("LOGO_AUTO_RESOLVE", "false")
|
os.environ.setdefault("LOGO_AUTO_RESOLVE", "false")
|
||||||
os.environ.setdefault("LOGO_STORAGE_DIR", tempfile.mkdtemp(prefix="moneyfy-logos-"))
|
os.environ.setdefault("LOGO_STORAGE_DIR", tempfile.mkdtemp(prefix="moneyfy-logos-"))
|
||||||
|
|||||||
@@ -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
@@ -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
|
`openssl rand -hex 32` erzeugt einen passenden Schlüssel. Wird er später
|
||||||
geändert, sind alle Sitzungen ungültig – das ist beabsichtigt.
|
geändert, sind alle Sitzungen ungültig – das ist beabsichtigt.
|
||||||
|
|
||||||
**Anmeldung schlägt fehl, obwohl das Passwort stimmt**
|
**Anmeldung schlägt fehl**
|
||||||
Meist fehlt HTTPS: Die Cookies sind `Secure` gesetzt und erreichen den Server
|
|
||||||
über eine reine HTTP-Verbindung nicht. Entweder über NPMplus zugreifen oder
|
Drei Ursachen kommen in Frage, in dieser Reihenfolge prüfen:
|
||||||
`COOKIE_SECURE=false` setzen.
|
|
||||||
|
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**
|
**Firmen bekommen kein Logo**
|
||||||
Ohne Netzzugang findet nur der lokale Markenkatalog etwas; unbekannte Firmen
|
Ohne Netzzugang findet nur der lokale Markenkatalog etwas; unbekannte Firmen
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "moneyfy-frontend",
|
"name": "moneyfy-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Routen der Anwendung und die Weichen für die Anmeldung. */
|
/** 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 { AppLayout } from "@/components/layout/AppLayout";
|
||||||
import { Skeleton } from "@/components/ui/Feedback";
|
import { Skeleton } from "@/components/ui/Feedback";
|
||||||
@@ -11,6 +11,7 @@ import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
|
|||||||
import { DashboardPage } from "@/pages/DashboardPage";
|
import { DashboardPage } from "@/pages/DashboardPage";
|
||||||
import { GoalsPage } from "@/pages/GoalsPage";
|
import { GoalsPage } from "@/pages/GoalsPage";
|
||||||
import { LoginPage } from "@/pages/LoginPage";
|
import { LoginPage } from "@/pages/LoginPage";
|
||||||
|
import { NotFoundPage } from "@/pages/NotFoundPage";
|
||||||
import { ReportsPage } from "@/pages/ReportsPage";
|
import { ReportsPage } from "@/pages/ReportsPage";
|
||||||
import { MerchantsPage } from "@/pages/MerchantsPage";
|
import { MerchantsPage } from "@/pages/MerchantsPage";
|
||||||
import { RecurrencesPage } from "@/pages/RecurrencesPage";
|
import { RecurrencesPage } from "@/pages/RecurrencesPage";
|
||||||
@@ -45,7 +46,7 @@ export function App() {
|
|||||||
<Route path="/goals" element={<GoalsPage />} />
|
<Route path="/goals" element={<GoalsPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,55 @@
|
|||||||
/** Rahmen der Anwendung: Seitenleiste, Kopfzeile und Inhaltsbereich. */
|
/** Rahmen der Anwendung: Seitenleiste, Kopfzeile und Inhaltsbereich. */
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
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 { Sidebar } from "@/components/layout/Sidebar";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { useLogout, useMe } from "@/hooks/useAuth";
|
import { useLogout, useMe } from "@/hooks/useAuth";
|
||||||
|
import { type Shortcut, useShortcuts } from "@/hooks/useShortcuts";
|
||||||
import { useThemeStore } from "@/store/theme";
|
import { useThemeStore } from "@/store/theme";
|
||||||
|
import { useUiStore } from "@/store/ui";
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [menuOffen, setMenuOffen] = useState(false);
|
const [menuOffen, setMenuOffen] = useState(false);
|
||||||
|
const [hilfeOffen, setHilfeOffen] = useState(false);
|
||||||
const { data: benutzer } = useMe();
|
const { data: benutzer } = useMe();
|
||||||
const abmelden = useLogout();
|
const abmelden = useLogout();
|
||||||
const theme = useThemeStore((zustand) => zustand.theme);
|
const theme = useThemeStore((zustand) => zustand.theme);
|
||||||
const toggleTheme = useThemeStore((zustand) => zustand.toggleTheme);
|
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 (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
@@ -54,6 +88,16 @@ export function AppLayout() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -87,6 +131,8 @@ export function AppLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ShortcutHelp open={hilfeOffen} onClose={() => setHilfeOffen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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() {
|
export function useLogin() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (daten: { username: string; password: string }) =>
|
mutationFn: async (daten: { username: string; password: string }) => {
|
||||||
api.post<User>("/auth/login", daten),
|
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) => {
|
onSuccess: (benutzer) => {
|
||||||
client.setQueryData(keys.me, benutzer);
|
client.setQueryData(keys.me, benutzer);
|
||||||
void client.invalidateQueries();
|
void client.invalidateQueries();
|
||||||
@@ -64,6 +94,7 @@ export function useChangePassword() {
|
|||||||
|
|
||||||
/** Übersetzt einen Anmeldefehler in eine Meldung für das Formular. */
|
/** Übersetzt einen Anmeldefehler in eine Meldung für das Formular. */
|
||||||
export function loginErrorMessage(error: unknown): string {
|
export function loginErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof SessionNotEstablishedError) return error.message;
|
||||||
if (error instanceof ApiError) return error.message;
|
if (error instanceof ApiError) return error.message;
|
||||||
return "Der Server ist nicht erreichbar.";
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -3,16 +3,20 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@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 {
|
:root {
|
||||||
--color-ground: 248 250 252;
|
--color-ground: 248 250 252;
|
||||||
--color-surface: 255 255 255;
|
--color-surface: 255 255 255;
|
||||||
--color-raised: 241 245 249;
|
--color-raised: 241 245 249;
|
||||||
--color-line: 226 232 240;
|
--color-line: 203 213 225;
|
||||||
--color-ink: 15 23 42;
|
--color-ink: 15 23 42;
|
||||||
--color-muted: 71 85 105;
|
--color-muted: 71 85 105;
|
||||||
--color-faint: 148 163 184;
|
--color-faint: 91 107 128;
|
||||||
--color-accent: 22 163 74;
|
--color-accent: 21 128 61;
|
||||||
--color-accent-ink: 255 255 255;
|
--color-accent-ink: 255 255 255;
|
||||||
--color-positive: 21 128 61;
|
--color-positive: 21 128 61;
|
||||||
--color-negative: 190 18 60;
|
--color-negative: 190 18 60;
|
||||||
@@ -42,10 +46,11 @@
|
|||||||
--color-ground: 15 17 21;
|
--color-ground: 15 17 21;
|
||||||
--color-surface: 24 27 33;
|
--color-surface: 24 27 33;
|
||||||
--color-raised: 33 37 45;
|
--color-raised: 33 37 45;
|
||||||
--color-line: 51 57 68;
|
--color-line: 59 66 80;
|
||||||
--color-ink: 226 232 240;
|
--color-ink: 226 232 240;
|
||||||
--color-muted: 148 163 184;
|
--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: 34 197 94;
|
||||||
--color-accent-ink: 4 20 11;
|
--color-accent-ink: 4 20 11;
|
||||||
--color-positive: 74 222 128;
|
--color-positive: 74 222 128;
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const api = {
|
|||||||
get: <T>(path: string, params?: Record<string, QueryValue>) => request<T>(path, { params }),
|
get: <T>(path: string, params?: Record<string, QueryValue>) => request<T>(path, { params }),
|
||||||
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||||
request<T>(path, { method: "POST", body, params }),
|
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 }),
|
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body }),
|
||||||
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||||
upload: <T>(path: string, formData: FormData) =>
|
upload: <T>(path: string, formData: FormData) =>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { BrowserRouter } from "react-router-dom";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { App } from "@/App";
|
import { App } from "@/App";
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
import { Toaster } from "@/components/ui/Toaster";
|
import { Toaster } from "@/components/ui/Toaster";
|
||||||
import "@/index.css";
|
import "@/index.css";
|
||||||
import { queryClient } from "@/lib/queryClient";
|
import { queryClient } from "@/lib/queryClient";
|
||||||
@@ -16,7 +17,9 @@ createRoot(wurzel).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ import {
|
|||||||
useSaveBudget,
|
useSaveBudget,
|
||||||
useSaveBudgetTemplate,
|
useSaveBudgetTemplate,
|
||||||
} from "@/hooks/useBudgets";
|
} from "@/hooks/useBudgets";
|
||||||
|
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
|
||||||
import { useBudgetStatus } from "@/hooks/useReports";
|
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";
|
import type { Budget, BudgetTemplate } from "@/types/api";
|
||||||
|
|
||||||
export function BudgetsPage() {
|
export function BudgetsPage() {
|
||||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation();
|
||||||
const [formular, setFormular] = useState<"budget" | "template" | null>(null);
|
const [formular, setFormular] = useState<"budget" | "template" | null>(null);
|
||||||
const [loeschen, setLoeschen] = useState<Budget | null>(null);
|
const [loeschen, setLoeschen] = useState<Budget | null>(null);
|
||||||
const [vorlageLoeschen, setVorlageLoeschen] = useState<BudgetTemplate | null>(null);
|
const [vorlageLoeschen, setVorlageLoeschen] = useState<BudgetTemplate | null>(null);
|
||||||
@@ -42,23 +43,23 @@ export function BudgetsPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Budgets"
|
title="Budgets"
|
||||||
description={formatMonth(monat)}
|
description={beschriftung}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
|
onClick={zurueck}
|
||||||
aria-label="Vorheriger Monat"
|
aria-label="Vorheriger Monat"
|
||||||
>
|
>
|
||||||
<ChevronLeft aria-hidden className="h-4 w-4" />
|
<ChevronLeft aria-hidden className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
|
<Button size="sm" onClick={heute}>
|
||||||
Heute
|
Heute
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
|
onClick={vor}
|
||||||
aria-label="Nächster Monat"
|
aria-label="Nächster Monat"
|
||||||
>
|
>
|
||||||
<ChevronRight aria-hidden className="h-4 w-4" />
|
<ChevronRight aria-hidden className="h-4 w-4" />
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ function mockApi() {
|
|||||||
if (url.includes("/api/reports/calendar")) {
|
if (url.includes("/api/reports/calendar")) {
|
||||||
return json({
|
return json({
|
||||||
month: "2026-03-01",
|
month: "2026-03-01",
|
||||||
|
period_start: "2026-03-01",
|
||||||
|
period_end: "2026-03-31",
|
||||||
days: maerz(),
|
days: maerz(),
|
||||||
opening_balance: "1000.00",
|
opening_balance: "1000.00",
|
||||||
closing_balance: "3250.00",
|
closing_balance: "3250.00",
|
||||||
@@ -115,11 +117,11 @@ describe("Cashflow-Kalender", () => {
|
|||||||
mockApi();
|
mockApi();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("zeigt die Kennzahlen des Monats", async () => {
|
it("zeigt die Kennzahlen des Zeitraums", async () => {
|
||||||
renderWithProviders(<CalendarPage />);
|
renderWithProviders(<CalendarPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
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(/1.000,00/)).toBeInTheDocument();
|
||||||
expect(screen.getByText("Tiefster Stand")).toBeInTheDocument();
|
expect(screen.getByText("Tiefster Stand")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Cashflow-Kalender.
|
* Cashflow-Kalender.
|
||||||
*
|
*
|
||||||
* Monatsraster mit den Fälligkeiten je Tag; darunter der Verlauf des
|
* Tagesraster des Abrechnungsmonats mit den Fälligkeiten je Tag; darunter der
|
||||||
* Kontostands über den Monat.
|
* Verlauf des Kontostands über den Zeitraum. Beginnt der Monat am Gehaltstag,
|
||||||
|
* reicht das Raster über zwei Kalendermonate.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -26,21 +27,23 @@ import { PageHeader } from "@/components/layout/AppLayout";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { EmptyState, Skeleton } from "@/components/ui/Feedback";
|
import { EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||||
import { useConfirmOccurrence, useMerchants, useSkipOccurrence } from "@/hooks/useEntities";
|
import { useConfirmOccurrence, useMerchants, useSkipOccurrence } from "@/hooks/useEntities";
|
||||||
|
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
|
||||||
import { useCalendar } from "@/hooks/useReports";
|
import { useCalendar } from "@/hooks/useReports";
|
||||||
import { cn } from "@/lib/cn";
|
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";
|
import type { CalendarDay, CalendarEntry, Merchant } from "@/types/api";
|
||||||
|
|
||||||
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||||
|
|
||||||
/** Führende Leerfelder, damit der Monat am richtigen Wochentag beginnt. */
|
/** Führende Leerfelder, damit der erste Tag im richtigen Wochentag steht. */
|
||||||
function fuehrendeLeerfelder(erster: string): number {
|
function fuehrendeLeerfelder(erster: string | undefined): number {
|
||||||
|
if (!erster) return 0;
|
||||||
const datum = new Date(erster);
|
const datum = new Date(erster);
|
||||||
return (datum.getDay() + 6) % 7;
|
return (datum.getDay() + 6) % 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarPage() {
|
export function CalendarPage() {
|
||||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
const { monat, beschriftung, zurueck, vor, heute: aufHeute } = useMonthNavigation();
|
||||||
const [gewaehlterTag, setGewaehlterTag] = useState<string | null>(null);
|
const [gewaehlterTag, setGewaehlterTag] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data, isLoading } = useCalendar(monat);
|
const { data, isLoading } = useCalendar(monat);
|
||||||
@@ -55,13 +58,13 @@ export function CalendarPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Cashflow-Kalender"
|
title="Cashflow-Kalender"
|
||||||
description={formatMonth(monat)}
|
description={beschriftung}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMonat((alt) => addMonthsIso(alt, -1));
|
zurueck();
|
||||||
setGewaehlterTag(null);
|
setGewaehlterTag(null);
|
||||||
}}
|
}}
|
||||||
aria-label="Vorheriger Monat"
|
aria-label="Vorheriger Monat"
|
||||||
@@ -71,7 +74,7 @@ export function CalendarPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMonat(firstOfMonth());
|
aufHeute();
|
||||||
setGewaehlterTag(null);
|
setGewaehlterTag(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -80,7 +83,7 @@ export function CalendarPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMonat((alt) => addMonthsIso(alt, 1));
|
vor();
|
||||||
setGewaehlterTag(null);
|
setGewaehlterTag(null);
|
||||||
}}
|
}}
|
||||||
aria-label="Nächster Monat"
|
aria-label="Nächster Monat"
|
||||||
@@ -99,11 +102,16 @@ export function CalendarPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<StatTile label="Stand zu Monatsbeginn" value={formatMoney(data.opening_balance)} />
|
|
||||||
<StatTile
|
<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)}
|
value={formatMoney(data.closing_balance)}
|
||||||
tone={toNumber(data.closing_balance) < 0 ? "negative" : "default"}
|
tone={toNumber(data.closing_balance) < 0 ? "negative" : "default"}
|
||||||
|
hint={formatDate(data.period_end)}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Tiefster Stand"
|
label="Tiefster Stand"
|
||||||
@@ -123,7 +131,7 @@ export function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1.5">
|
<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 />
|
<div key={`leer-${index}`} aria-hidden />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -150,13 +158,14 @@ export function CalendarPage() {
|
|||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Verlauf des Kontostands"
|
title="Verlauf des Kontostands"
|
||||||
description="Fortgeschrieben aus dem Stand zu Monatsbeginn."
|
description="Fortgeschrieben aus dem Stand zu Beginn des Zeitraums."
|
||||||
>
|
>
|
||||||
<div className="h-48">
|
<div className="h-48">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart
|
<AreaChart
|
||||||
data={tage.map((tag) => ({
|
data={tage.map((tag) => ({
|
||||||
tag: new Date(tag.date).getDate(),
|
tag: new Date(tag.date).getDate(),
|
||||||
|
datum: tag.date,
|
||||||
stand: toNumber(tag.running_balance),
|
stand: toNumber(tag.running_balance),
|
||||||
}))}
|
}))}
|
||||||
margin={{ top: 8, right: 8, bottom: 0, left: 4 }}
|
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} />
|
<ReferenceLine y={0} stroke="var(--viz-axis)" strokeWidth={1} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
|
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
|
||||||
content={({ active, payload, label }) =>
|
content={({ active, payload }) =>
|
||||||
active && payload?.length ? (
|
active && payload?.length ? (
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
title={`${label}. ${formatMonth(monat)}`}
|
title={formatDate(payload[0]?.payload.datum)}
|
||||||
rows={[
|
rows={[
|
||||||
{
|
{
|
||||||
label: "Kontostand",
|
label: "Kontostand",
|
||||||
|
|||||||
@@ -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 { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { AlertTriangle, ChevronLeft, ChevronRight, PiggyBank, Wallet } from "lucide-react";
|
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 { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||||
import { useMerchants } from "@/hooks/useEntities";
|
import { useMerchants } from "@/hooks/useEntities";
|
||||||
|
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
|
||||||
import { useDashboard } from "@/hooks/useReports";
|
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() {
|
export function DashboardPage() {
|
||||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation();
|
||||||
const { data, isLoading } = useDashboard(monat);
|
const { data, isLoading } = useDashboard(monat);
|
||||||
const { data: firmenSeite } = useMerchants();
|
const { data: firmenSeite } = useMerchants();
|
||||||
const kategorieName = useCategoryLookup();
|
const kategorieName = useCategoryLookup();
|
||||||
@@ -30,22 +30,22 @@ export function DashboardPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
description={formatMonth(monat)}
|
description={beschriftung}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
|
onClick={zurueck}
|
||||||
aria-label="Vorheriger Monat"
|
aria-label="Vorheriger Monat"
|
||||||
>
|
>
|
||||||
<ChevronLeft aria-hidden className="h-4 w-4" />
|
<ChevronLeft aria-hidden className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
|
<Button size="sm" onClick={heute}>
|
||||||
Heute
|
Heute
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
|
onClick={vor}
|
||||||
aria-label="Nächster Monat"
|
aria-label="Nächster Monat"
|
||||||
>
|
>
|
||||||
<ChevronRight aria-hidden className="h-4 w-4" />
|
<ChevronRight aria-hidden className="h-4 w-4" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Firmen als Kachelgrid mit Logo, Markenfarbe und Jahreskosten. */
|
/** 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";
|
import { Building2, ImageIcon, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
useRecurrences,
|
useRecurrences,
|
||||||
useSaveMerchant,
|
useSaveMerchant,
|
||||||
} from "@/hooks/useEntities";
|
} from "@/hooks/useEntities";
|
||||||
|
import { useSearchFocus } from "@/hooks/usePageShortcuts";
|
||||||
import { formatMoney, toNumber } from "@/lib/format";
|
import { formatMoney, toNumber } from "@/lib/format";
|
||||||
import { annualCostOf } from "@/lib/recurrenceMath";
|
import { annualCostOf } from "@/lib/recurrenceMath";
|
||||||
import { useThemeStore } from "@/store/theme";
|
import { useThemeStore } from "@/store/theme";
|
||||||
@@ -32,6 +33,9 @@ export function MerchantsPage() {
|
|||||||
const { data: posten = [] } = useRecurrences({ active: true });
|
const { data: posten = [] } = useRecurrences({ active: true });
|
||||||
const entfernen = useDeleteMerchant();
|
const entfernen = useDeleteMerchant();
|
||||||
|
|
||||||
|
const suchfeld = useRef<HTMLInputElement>(null);
|
||||||
|
useSearchFocus(suchfeld);
|
||||||
|
|
||||||
// Jahreskosten und Vertragszahl je Firma aus den aktiven Posten.
|
// Jahreskosten und Vertragszahl je Firma aus den aktiven Posten.
|
||||||
const kennzahlen = useMemo(() => {
|
const kennzahlen = useMemo(() => {
|
||||||
const werte = new Map<number, { jahr: number; anzahl: number }>();
|
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"
|
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
ref={suchfeld}
|
||||||
value={suche}
|
value={suche}
|
||||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||||
placeholder="Firma suchen"
|
placeholder="Firma suchen"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
/** Wiederkehrende Posten: Liste, Formular und Detailansicht. */
|
/** Wiederkehrende Posten: Liste, Formular und Detailansicht. */
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
import { Plus, Repeat, Search } from "lucide-react";
|
import { Plus, Repeat, Search } from "lucide-react";
|
||||||
|
|
||||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||||
|
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
|
||||||
import { MerchantLogo } from "@/components/MerchantLogo";
|
import { MerchantLogo } from "@/components/MerchantLogo";
|
||||||
import { RecurrenceDetailDrawer } from "@/components/RecurrenceDetail";
|
import { RecurrenceDetailDrawer } from "@/components/RecurrenceDetail";
|
||||||
import { RecurrenceFormDialog } from "@/components/RecurrenceForm";
|
import { RecurrenceFormDialog } from "@/components/RecurrenceForm";
|
||||||
@@ -28,6 +29,10 @@ export function RecurrencesPage() {
|
|||||||
const { data: firmenSeite } = useMerchants();
|
const { data: firmenSeite } = useMerchants();
|
||||||
const kategorieName = useCategoryLookup();
|
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 firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
|
||||||
const begriff = suche.trim().toLowerCase();
|
const begriff = suche.trim().toLowerCase();
|
||||||
const sichtbar = begriff
|
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"
|
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
ref={suchfeld}
|
||||||
value={suche}
|
value={suche}
|
||||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||||
placeholder="Posten suchen"
|
placeholder="Posten suchen"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PageHeader } from "@/components/layout/AppLayout";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||||
import { Select } from "@/components/ui/Field";
|
import { Select } from "@/components/ui/Field";
|
||||||
|
import { useMonthStartDay } from "@/hooks/useAppSettings";
|
||||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||||
import {
|
import {
|
||||||
exportUrl,
|
exportUrl,
|
||||||
@@ -19,14 +20,15 @@ import {
|
|||||||
useYearComparison,
|
useYearComparison,
|
||||||
} from "@/hooks/useReports";
|
} from "@/hooks/useReports";
|
||||||
import { cn } from "@/lib/cn";
|
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 { describeRRule } from "@/lib/rrule";
|
||||||
import type { Subscription } from "@/types/api";
|
import type { Subscription } from "@/types/api";
|
||||||
|
|
||||||
type Reiter = "subscriptions" | "categories" | "year" | "export";
|
type Reiter = "subscriptions" | "categories" | "year" | "export";
|
||||||
|
|
||||||
const REITER: { id: Reiter; label: string }[] = [
|
const REITER: { id: Reiter; label: string }[] = [
|
||||||
{ id: "subscriptions", label: "Abos" },
|
{ id: "subscriptions", label: "Laufende Kosten" },
|
||||||
{ id: "categories", label: "Kategorien" },
|
{ id: "categories", label: "Kategorien" },
|
||||||
{ id: "year", label: "Jahresvergleich" },
|
{ id: "year", label: "Jahresvergleich" },
|
||||||
{ id: "export", label: "Export" },
|
{ id: "export", label: "Export" },
|
||||||
@@ -104,7 +106,12 @@ function SubscriptionsTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<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="Entspricht pro Monat" value={formatMoney(data.total_monthly)} />
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Laufende Posten"
|
label="Laufende Posten"
|
||||||
@@ -135,7 +142,9 @@ function SubscriptionsTab() {
|
|||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Alle laufenden Posten"
|
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={
|
actions={
|
||||||
<Select
|
<Select
|
||||||
aria-label="Sortierung"
|
aria-label="Sortierung"
|
||||||
@@ -223,13 +232,13 @@ function SubscriptionRow({
|
|||||||
|
|
||||||
function CategoriesTab() {
|
function CategoriesTab() {
|
||||||
const [zeitraum, setZeitraum] = useState<"month" | "year">("month");
|
const [zeitraum, setZeitraum] = useState<"month" | "year">("month");
|
||||||
|
const startDay = useMonthStartDay();
|
||||||
const heute = new Date();
|
const heute = new Date();
|
||||||
const von =
|
|
||||||
zeitraum === "month" ? firstOfMonth() : `${heute.getFullYear()}-01-01`;
|
const laufend = periodKey(startDay);
|
||||||
const bis =
|
const grenzen = periodBounds(startDay, laufend);
|
||||||
zeitraum === "month"
|
const von = zeitraum === "month" ? grenzen.start : `${heute.getFullYear()}-01-01`;
|
||||||
? new Date(heute.getFullYear(), heute.getMonth() + 1, 0).toISOString().slice(0, 10)
|
const bis = zeitraum === "month" ? grenzen.end : `${heute.getFullYear()}-12-31`;
|
||||||
: `${heute.getFullYear()}-12-31`;
|
|
||||||
|
|
||||||
const { data, isLoading } = useCategoryReport(von, bis);
|
const { data, isLoading } = useCategoryReport(von, bis);
|
||||||
|
|
||||||
@@ -240,7 +249,7 @@ function CategoriesTab() {
|
|||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["month", formatMonth(von)],
|
["month", periodLabel(startDay, laufend)],
|
||||||
["year", String(heute.getFullYear())],
|
["year", String(heute.getFullYear())],
|
||||||
] as const
|
] as const
|
||||||
).map(([wert, beschriftung]) => (
|
).map(([wert, beschriftung]) => (
|
||||||
@@ -293,7 +302,9 @@ function YearTab() {
|
|||||||
/* --- Export --------------------------------------------------------------- */
|
/* --- Export --------------------------------------------------------------- */
|
||||||
|
|
||||||
function ExportTab() {
|
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();
|
const jahr = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -318,13 +329,13 @@ function ExportTab() {
|
|||||||
|
|
||||||
<ExportCard
|
<ExportCard
|
||||||
title="Monatsauswertung"
|
title="Monatsauswertung"
|
||||||
description="Alle Bewegungen des Monats samt Kennzahlen."
|
description={`Alle Bewegungen samt Kennzahlen: ${periodLabel(startDay, monat)}.`}
|
||||||
extra={
|
extra={
|
||||||
<input
|
<input
|
||||||
type="month"
|
type="month"
|
||||||
aria-label="Monat der Auswertung"
|
aria-label="Monat der Auswertung"
|
||||||
value={monat.slice(0, 7)}
|
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"
|
className="mb-3 w-full rounded-lg border border-line bg-raised px-3 py-2 text-sm text-ink"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { 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 { NotificationSettings } from "@/components/NotificationSettings";
|
||||||
import { PageHeader } from "@/components/layout/AppLayout";
|
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 { Checkbox, Field, Input, Select } from "@/components/ui/Field";
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { MoneyInput } from "@/components/ui/MoneyInput";
|
import { MoneyInput } from "@/components/ui/MoneyInput";
|
||||||
|
import { useAppSettings, useSaveAppSettings } from "@/hooks/useAppSettings";
|
||||||
import { useChangePassword, useMe } from "@/hooks/useAuth";
|
import { useChangePassword, useMe } from "@/hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
useAccountBalance,
|
useAccountBalance,
|
||||||
@@ -21,7 +22,14 @@ import {
|
|||||||
useSaveAccount,
|
useSaveAccount,
|
||||||
useSaveCategory,
|
useSaveCategory,
|
||||||
} from "@/hooks/useEntities";
|
} 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 { useThemeStore } from "@/store/theme";
|
||||||
import type { Account, AccountType, Category, CategoryTree, EntryKind } from "@/types/api";
|
import type { Account, AccountType, Category, CategoryTree, EntryKind } from "@/types/api";
|
||||||
|
|
||||||
@@ -32,11 +40,12 @@ const KONTOARTEN: Record<AccountType, string> = {
|
|||||||
cash: "Bargeld",
|
cash: "Bargeld",
|
||||||
};
|
};
|
||||||
|
|
||||||
type Reiter = "accounts" | "categories" | "notifications" | "account";
|
type Reiter = "accounts" | "categories" | "period" | "notifications" | "account";
|
||||||
|
|
||||||
const REITER: { id: Reiter; label: string }[] = [
|
const REITER: { id: Reiter; label: string }[] = [
|
||||||
{ id: "accounts", label: "Konten" },
|
{ id: "accounts", label: "Konten" },
|
||||||
{ id: "categories", label: "Kategorien" },
|
{ id: "categories", label: "Kategorien" },
|
||||||
|
{ id: "period", label: "Monatsbeginn" },
|
||||||
{ id: "notifications", label: "Benachrichtigungen" },
|
{ id: "notifications", label: "Benachrichtigungen" },
|
||||||
{ id: "account", label: "Konto & Darstellung" },
|
{ id: "account", label: "Konto & Darstellung" },
|
||||||
];
|
];
|
||||||
@@ -69,12 +78,107 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
{reiter === "accounts" && <AccountsSection />}
|
{reiter === "accounts" && <AccountsSection />}
|
||||||
{reiter === "categories" && <CategoriesSection />}
|
{reiter === "categories" && <CategoriesSection />}
|
||||||
|
{reiter === "period" && <PeriodSection />}
|
||||||
{reiter === "notifications" && <NotificationSettings />}
|
{reiter === "notifications" && <NotificationSettings />}
|
||||||
{reiter === "account" && <UserSection />}
|
{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 --------------------------------------------------------------- */
|
/* --- Konten --------------------------------------------------------------- */
|
||||||
|
|
||||||
function AccountsSection() {
|
function AccountsSection() {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/** Einmalige Buchungen: Liste, Filter und Formular. */
|
/** 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 { Pencil, Plus, Receipt, Search, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
|
import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
|
||||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||||
|
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
|
||||||
import { MerchantLogo } from "@/components/MerchantLogo";
|
import { MerchantLogo } from "@/components/MerchantLogo";
|
||||||
import { PageHeader } from "@/components/layout/AppLayout";
|
import { PageHeader } from "@/components/layout/AppLayout";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
@@ -35,6 +36,10 @@ export function TransactionsPage() {
|
|||||||
const entfernen = useDeleteTransaction();
|
const entfernen = useDeleteTransaction();
|
||||||
const kategorieName = useCategoryLookup();
|
const kategorieName = useCategoryLookup();
|
||||||
|
|
||||||
|
const suchfeld = useRef<HTMLInputElement>(null);
|
||||||
|
useSearchFocus(suchfeld);
|
||||||
|
usePendingAction("new-transaction", () => setBearbeiten(null));
|
||||||
|
|
||||||
const buchungen = data?.items ?? [];
|
const buchungen = data?.items ?? [];
|
||||||
const gesamt = data?.total ?? 0;
|
const gesamt = data?.total ?? 0;
|
||||||
const seite = Math.floor((filter.offset ?? 0) / SEITENGROESSE) + 1;
|
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"
|
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
ref={suchfeld}
|
||||||
value={suche}
|
value={suche}
|
||||||
onChange={(ereignis) => {
|
onChange={(ereignis) => {
|
||||||
setSuche(ereignis.target.value);
|
setSuche(ereignis.target.value);
|
||||||
|
|||||||
@@ -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 })),
|
||||||
|
}));
|
||||||
@@ -368,7 +368,10 @@ export interface SavingsGoalInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MonthReport {
|
export interface MonthReport {
|
||||||
|
/** Bezeichner des Abrechnungsmonats – immer ein Monatserster. */
|
||||||
month: IsoDate;
|
month: IsoDate;
|
||||||
|
period_start: IsoDate;
|
||||||
|
period_end: IsoDate;
|
||||||
planned: Totals;
|
planned: Totals;
|
||||||
actual: Totals;
|
actual: Totals;
|
||||||
previous_planned: Totals;
|
previous_planned: Totals;
|
||||||
@@ -385,6 +388,8 @@ export interface MonthReport {
|
|||||||
|
|
||||||
export interface ForecastMonth {
|
export interface ForecastMonth {
|
||||||
month: IsoDate;
|
month: IsoDate;
|
||||||
|
period_start: IsoDate;
|
||||||
|
period_end: IsoDate;
|
||||||
income: Money;
|
income: Money;
|
||||||
expenses: Money;
|
expenses: Money;
|
||||||
balance: Money;
|
balance: Money;
|
||||||
@@ -478,6 +483,8 @@ export interface CalendarDay {
|
|||||||
|
|
||||||
export interface CalendarMonth {
|
export interface CalendarMonth {
|
||||||
month: IsoDate;
|
month: IsoDate;
|
||||||
|
period_start: IsoDate;
|
||||||
|
period_end: IsoDate;
|
||||||
days: CalendarDay[];
|
days: CalendarDay[];
|
||||||
opening_balance: Money;
|
opening_balance: Money;
|
||||||
closing_balance: Money;
|
closing_balance: Money;
|
||||||
@@ -520,6 +527,7 @@ export interface SavingsGoalProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Dashboard {
|
export interface Dashboard {
|
||||||
|
month_start_day: number;
|
||||||
month: MonthReport;
|
month: MonthReport;
|
||||||
total_balance: Money;
|
total_balance: Money;
|
||||||
forecast: ForecastMonth[];
|
forecast: ForecastMonth[];
|
||||||
@@ -601,3 +609,15 @@ export interface NotificationRunResult {
|
|||||||
skipped: number;
|
skipped: number;
|
||||||
failed: 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user