Compare commits
| 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. |
|
||||
| `build.yml` | Push auf `main`, Tags `v*` | Baut beide Images für `linux/amd64` und lädt sie in die Gitea-Registry. **Kein Deploy** – das Ausrollen erfolgt von Hand. |
|
||||
|
||||
## Benötigte Repo-Secrets
|
||||
## Anmeldung an der Registry
|
||||
|
||||
`build.yml` versucht die Anmeldung in dieser Reihenfolge:
|
||||
|
||||
1. das Repo-Secret **`REGISTRY_TOKEN`**, falls vorhanden,
|
||||
2. sonst `GITEA_TOKEN` – den Token, den Gitea jedem Lauf automatisch mitgibt.
|
||||
|
||||
Damit läuft der Workflow im Normalfall ohne jede manuelle Einrichtung. Reicht
|
||||
das Recht des automatischen Tokens auf der Instanz nicht aus, bricht der Schritt
|
||||
mit `denied` oder `unauthorized` ab; dann das Secret von Hand anlegen:
|
||||
|
||||
| Name | Zweck | Woher |
|
||||
|---|---|---|
|
||||
| `REGISTRY_TOKEN` | Anmeldung an `git.menzel.center` zum Hochladen der Images | Gitea → Benutzereinstellungen → Applications → **Generate New Token** mit dem Recht `write:package` |
|
||||
| `REGISTRY_TOKEN` | Anmeldung an `git.menzel.center` zum Hochladen der Images | Gitea → Benutzereinstellungen → Applications → **Generate New Token**, bei *package* auf `Read and Write` |
|
||||
|
||||
Anlegen unter *Repository → Settings → Actions → Secrets → Add Secret*.
|
||||
|
||||
@@ -19,6 +28,9 @@ Als Benutzername verwendet der Workflow `${{ github.actor }}`, also den Auslöse
|
||||
des Laufs. Dieser Benutzer braucht Schreibrecht auf die Pakete des Namensraums
|
||||
`menzeljonas`.
|
||||
|
||||
> Fehlt beides, meldet der Schritt `::error::Password required` und es entsteht
|
||||
> **kein Image** – `docker compose pull` läuft dann in ein `not found`.
|
||||
|
||||
## Erzeugte Tags
|
||||
|
||||
| Auslöser | Tags |
|
||||
|
||||
@@ -38,7 +38,10 @@ jobs:
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
# Bevorzugt das eigens angelegte Secret. Fehlt es, greift der Token,
|
||||
# den Gitea jedem Lauf ohnehin mitgibt – damit läuft der Workflow ohne
|
||||
# jede manuelle Einrichtung.
|
||||
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Tags und Beschriftungen ermitteln
|
||||
id: meta
|
||||
@@ -74,6 +77,16 @@ jobs:
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/moneyfy-${{ matrix.component }}:buildcache,mode=max
|
||||
provenance: false
|
||||
|
||||
- name: Hinweis bei fehlgeschlagener Anmeldung
|
||||
if: failure()
|
||||
run: |
|
||||
echo "Die Anmeldung an ${{ env.REGISTRY }} ist fehlgeschlagen." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Lege ein Repo-Secret **REGISTRY_TOKEN** an:" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "1. Benutzereinstellungen → Applications → Generate New Token" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "2. Bei *package* auf \`Read and Write\` stellen" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "3. Repository → Settings → Actions → Secrets → Add Secret" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Ergebnis zusammenfassen
|
||||
run: |
|
||||
echo "### moneyfy-${{ matrix.component }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
+69
-2
@@ -7,13 +7,78 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Noch keine Änderungen seit 0.1.0.
|
||||
Noch keine Änderungen seit 0.1.2.
|
||||
|
||||
## [0.1.2] – 2026-09-10
|
||||
|
||||
Der Monat beginnt nicht mehr zwangsläufig am Ersten.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- Einstellbarer Monatsbeginn: Wer am Gehaltstag rechnet, setzt unter
|
||||
*Einstellungen › Monatsbeginn* den Tag, ab dem ein neuer Monat zählt. Der
|
||||
Zeitraum läuft dann vom Gehaltstag bis zum Vortag des nächsten und trägt den
|
||||
Namen des Monats, in dem er beginnt – der 25. September eröffnet also
|
||||
„September“ und schließt am 24. Oktober. Ein Starttag jenseits der
|
||||
Monatslänge rutscht auf den Monatsletzten, sodass 31 verlässlich „letzter Tag
|
||||
des Monats“ bedeutet. Dashboard, Cashflow-Kalender, Budgets, Vorschau,
|
||||
Kategorien, Monatsexport und die Budget-Benachrichtigung rechnen mit diesem
|
||||
Zeitraum. Neue Endpunkte `GET`/`PUT /api/settings`; die Monatsauswertungen
|
||||
liefern zusätzlich `period_start` und `period_end`. Bestandsinstallationen
|
||||
bleiben beim Monatsersten.
|
||||
- `python -m app.scripts.reset_password` setzt das Passwort eines Benutzers oder
|
||||
legt ihn an. Nötig, weil die Erstanlage beim Start nur bei leerer
|
||||
Benutzertabelle greift und ein nachträglich geändertes
|
||||
`MONEYFY_ADMIN_PASSWORD` danach wirkungslos bleibt.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Eine Anmeldung über HTTP bei aktivem `COOKIE_SECURE` fiel stillschweigend auf
|
||||
die Anmeldemaske zurück: Der Browser verwirft das `Secure`-Cookie, die
|
||||
Anmeldung selbst meldet aber Erfolg. Die Oberfläche prüft die Sitzung jetzt
|
||||
unmittelbar nach der Anmeldung und erklärt den Fall samt beider Auswege; das
|
||||
Backend protokolliert dieselbe Kombination als Warnung.
|
||||
|
||||
## [0.1.1] – 2026-09-09
|
||||
|
||||
Politur und ein Fehler, den erst die CI zutage gefördert hat.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- Demo-Seed mit einem realistischen deutschen Haushalt: drei Konten, zehn Firmen
|
||||
samt Logos, zwölf wiederkehrende Posten (Miete, Strom, Netflix, Spotify,
|
||||
jährliche Kfz-Versicherung, Handyvertrag mit Mindestlaufzeit, Kredit über 36
|
||||
Raten, Gehalt), bestätigte Historie, Buchungen, Budgets und Sparziele. Alle
|
||||
Termine liegen relativ zum heutigen Monat. Aufruf über `make seed-demo`.
|
||||
- Tastaturkürzel: `n` neue Buchung, `r` neuer Posten, `/` Suche, `?` Übersicht.
|
||||
Sie greifen nur, solange kein Eingabefeld den Fokus hat.
|
||||
- Fehlergrenze für unerwartete Renderfehler und eine eigene 404-Seite.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Farbtoken beider Modi gegen die WCAG-Kontraste geprüft und nachgezogen: der
|
||||
Hilfstext lag im hellen Modus bei 2,56:1, Weiß auf dem Primärknopf bei 3,30:1.
|
||||
Jetzt erreicht jeder Text mindestens 4,5:1 und jedes Bedienelement 3:1.
|
||||
- Die Abo-Übersicht heißt „Laufende Kosten" – sie enthält jede wiederkehrende
|
||||
Ausgabe, auch Miete und Sparplan, und nicht nur Abonnements.
|
||||
- `build.yml` weicht auf den automatischen `GITEA_TOKEN` aus, wenn das Secret
|
||||
`REGISTRY_TOKEN` fehlt, und schreibt bei einer gescheiterten Anmeldung die
|
||||
nötigen Schritte in die Job-Zusammenfassung.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Testsuite hing an einer nicht eingecheckten lokalen `.env`: ohne sie galt
|
||||
`COOKIE_SECURE=true`, httpx verwarf die Secure-Cookies über http, und 106
|
||||
Tests scheiterten mit 401. Aufgefallen ist das erst in der CI.
|
||||
|
||||
## [0.1.0] – 2026-09-09
|
||||
|
||||
Erste lauffähige Fassung: Planung, Auswertung und Benachrichtigung stehen,
|
||||
die Anwendung lässt sich per Compose betreiben.
|
||||
|
||||
> Aus diesem Tag sind keine Images entstanden – der Build scheiterte am noch
|
||||
> nicht angelegten Registry-Secret. Verwende 0.1.1.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- Projektgerüst mit getrennten Bereichen für Backend, Frontend, Dokumentation und CI.
|
||||
@@ -140,5 +205,7 @@ die Anwendung lässt sich per Compose betreiben.
|
||||
- Der Bildtyp eines Uploads wird ausschließlich am Inhalt bestimmt; die vom
|
||||
Aufrufer gemeldete Kopfzeile wird nicht mehr als Nachweis akzeptiert.
|
||||
|
||||
[Unreleased]: https://git.menzel.center/menzeljonas/moneyfy/compare/v0.1.0...HEAD
|
||||
[Unreleased]: https://git.menzel.center/menzeljonas/moneyfy/compare/v0.1.2...HEAD
|
||||
[0.1.2]: https://git.menzel.center/menzeljonas/moneyfy/compare/v0.1.1...v0.1.2
|
||||
[0.1.1]: https://git.menzel.center/menzeljonas/moneyfy/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://git.menzel.center/menzeljonas/moneyfy/releases/tag/v0.1.0
|
||||
|
||||
@@ -32,6 +32,22 @@ seed: ## Stammdaten (Kategoriebaum, Benachrichtigungsregeln) anlegen
|
||||
vendor-icons: ## simple-icons-Index neu erzeugen (benötigt npm und Netzzugang)
|
||||
cd $(BACKEND) && .venv/bin/python scripts/vendor_simple_icons.py
|
||||
|
||||
.PHONY: reset-password
|
||||
reset-password: ## Passwort setzen oder Administrator anlegen: make reset-password u=admin
|
||||
cd $(BACKEND) && .venv/bin/python -m app.scripts.reset_password $(if $(u),--username $(u),)
|
||||
|
||||
.PHONY: seed-demo
|
||||
seed-demo: ## Realistische Demodaten anlegen (vorhandene Daten bleiben, sonst --reset)
|
||||
cd $(BACKEND) && .venv/bin/python -m app.scripts.seed_demo
|
||||
|
||||
.PHONY: reset-password
|
||||
reset-password: ## Passwort setzen oder Administrator anlegen: make reset-password u=admin
|
||||
cd $(BACKEND) && .venv/bin/python -m app.scripts.reset_password $(if $(u),--username $(u),)
|
||||
|
||||
.PHONY: seed-demo-reset
|
||||
seed-demo-reset: ## Demodaten neu aufsetzen; löscht Konten, Firmen, Posten und Buchungen
|
||||
cd $(BACKEND) && .venv/bin/python -m app.scripts.seed_demo --reset
|
||||
|
||||
.PHONY: dev
|
||||
dev: ## Backend mit Hot Reload starten
|
||||
cd $(BACKEND) && .venv/bin/uvicorn app.main:app --reload --port 8000
|
||||
|
||||
@@ -4,15 +4,18 @@ Selbst gehostete Web-Anwendung zur Planung monatlicher Kosten und Einkünfte.
|
||||
Single-User, deutschsprachige Oberfläche, ausgelegt für den Betrieb im Homelab
|
||||
hinter einem Reverse Proxy.
|
||||
|
||||
> **Status:** in Entwicklung. Siehe [CHANGELOG.md](CHANGELOG.md).
|
||||
> **Version 0.1.2.** Siehe [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Screenshots
|
||||
|
||||
_Platzhalter – werden ergänzt, sobald die Oberfläche steht._
|
||||
Die Bilder entstehen aus dem Demo-Datensatz (`make seed-demo`), damit sie ohne
|
||||
echte Kontostände auskommen. Ablegen unter `docs/screenshots/`.
|
||||
|
||||
| Dashboard | Cashflow-Kalender | Firmen |
|
||||
|---|---|---|
|
||||
| _folgt_ | _folgt_ | _folgt_ |
|
||||
| | |
|
||||
|---|---|
|
||||
| <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
|
||||
|
||||
@@ -23,6 +26,8 @@ _Platzhalter – werden ergänzt, sobald die Oberfläche steht._
|
||||
- Ratenzahlungen mit Restschuld- und Restratenberechnung.
|
||||
- Rücklagenbildung für nicht-monatliche Posten.
|
||||
- Firmenlogos und Markenfarben, lokal zwischengespeichert (siehe unten).
|
||||
- Einstellbarer Monatsbeginn: Der Monat startet wahlweise am Ersten oder am
|
||||
Gehaltstag; alle Monatsansichten folgen diesem Zeitraum (siehe unten).
|
||||
- Auswertungen: Monatsübersicht, Cashflow-Kalender, 12-Monats-Forecast,
|
||||
Kategorien, Abo-Übersicht, Jahresvergleich, Budgets, Sparziele.
|
||||
- Benachrichtigungen per SMTP und Apprise.
|
||||
@@ -83,9 +88,63 @@ make fe-install
|
||||
make fe-dev # http://localhost:5173
|
||||
```
|
||||
|
||||
Für einen gefüllten Stand:
|
||||
|
||||
```bash
|
||||
make seed-demo # realistischer Beispielhaushalt, Termine relativ zu heute
|
||||
make seed-demo-reset # setzt Konten, Firmen, Posten und Buchungen vorher zurück
|
||||
```
|
||||
|
||||
Der Demo-Seed legt drei Konten an, zehn Firmen samt Logos, zwölf wiederkehrende
|
||||
Posten (Miete, Strom, Netflix, Spotify, jährliche Kfz-Versicherung, Handyvertrag
|
||||
mit Mindestlaufzeit, Kredit über 36 Raten, Gehalt), bestätigt die vergangenen
|
||||
Fälligkeiten, ergänzt Buchungen, Budgets und Sparziele.
|
||||
|
||||
Nützliche Ziele: `make check` (alle Prüfungen), `make test`, `make lint`,
|
||||
`make format`, `make fe-lint`, `make fe-test`, `make fe-build`.
|
||||
|
||||
## Bedienung
|
||||
|
||||
| Kürzel | Wirkung |
|
||||
|---|---|
|
||||
| `n` | Neue Buchung |
|
||||
| `r` | Neuer wiederkehrender Posten |
|
||||
| `/` | Suchfeld der aktuellen Seite |
|
||||
| `?` | Übersicht der Kürzel |
|
||||
| `Esc` | Dialog schließen |
|
||||
|
||||
Kürzel greifen nur, solange kein Eingabefeld den Fokus hat.
|
||||
|
||||
### Monatsbeginn
|
||||
|
||||
Standardmäßig ist ein Monat der Kalendermonat. Wer nach dem Gehaltseingang
|
||||
plant, stellt unter *Einstellungen › Monatsbeginn* den Tag ein, ab dem ein neuer
|
||||
Monat zählt. Der Zeitraum läuft dann vom Gehaltstag bis zum Vortag des nächsten
|
||||
und heißt nach dem Monat, in dem er beginnt: Mit dem 25. als Beginn umfasst
|
||||
„September 2026“ die Tage vom 25.09. bis zum 24.10.
|
||||
|
||||
Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten – die 31
|
||||
bedeutet also verlässlich „letzter Tag des Monats“, auch im Februar.
|
||||
|
||||
Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die
|
||||
Kategorienauswertung, der Monatsexport und die Benachrichtigung über
|
||||
überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben dabei je
|
||||
Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der Schnitt
|
||||
verschiebt sich. Die Einstellung gilt für die gesamte Installation und wirkt
|
||||
sofort, ohne die Daten anzufassen.
|
||||
|
||||
## Barrierefreiheit
|
||||
|
||||
Die Farbtoken sind gegen die WCAG-Kontraste geprüft – in beiden Modi erreicht
|
||||
jeder Text mindestens 4,5:1, jedes Bedienelement 3:1. Zwei Werte weichen
|
||||
deshalb bewusst von den naheliegenden Tailwind-Stufen ab: `slate-400` als
|
||||
Hilfstext käme auf hellem Grund nur auf 2,56:1, und Weiß auf `green-600` nur
|
||||
auf 3,30:1.
|
||||
|
||||
Die Serienfarben der Diagramme sind zusätzlich auf Farbfehlsichtigkeit geprüft
|
||||
(siehe [Diagramme](#diagramme)). Jeder Zustand – Budget-Ampel, Versandstatus,
|
||||
Vertragswarnung – trägt neben der Farbe ein Symbol und einen Wortlaut.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Alle Einstellungen kommen aus Umgebungsvariablen; `.env.example` enthält die
|
||||
@@ -95,7 +154,7 @@ vollständige, kommentierte Liste. Die wichtigsten:
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | – | PostgreSQL-DSN, `postgresql://` wird auf asyncpg umgestellt |
|
||||
| `SECRET_KEY` | – | Signaturschlüssel für JWTs, mind. 32 Zeichen (`openssl rand -hex 32`) |
|
||||
| `MONEYFY_ADMIN_USER` / `MONEYFY_ADMIN_PASSWORD` | `admin` / – | Beim Erststart angelegter Benutzer |
|
||||
| `MONEYFY_ADMIN_USER` / `MONEYFY_ADMIN_PASSWORD` | `admin` / – | Beim Erststart angelegter Benutzer. Es gibt **kein** eingebautes Standardpasswort; ohne diesen Wert wird niemand angelegt. Die Anlage greift nur, solange die Benutzertabelle leer ist – danach hilft `app.scripts.reset_password`. |
|
||||
| `TIMEZONE` | `Europe/Berlin` | Zeitzone der gesamten Anwendung |
|
||||
| `HOLIDAY_REGION` | `DE-NW` | Feiertagsregion für Werktagsverschiebungen |
|
||||
| `LOGO_STORAGE_DIR` | `/data/logos` | Verzeichnis des Logo-Caches |
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""app setting
|
||||
|
||||
Revision ID: 5f2a91c0d7e4
|
||||
Revises: 07d8d62011b3
|
||||
Create Date: 2026-09-10 09:00:00.000000+02:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '5f2a91c0d7e4'
|
||||
down_revision: str | None = '07d8d62011b3'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('app_setting',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('month_start_day', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.CheckConstraint('id = 1', name=op.f('ck_app_setting_single_row')),
|
||||
sa.CheckConstraint('month_start_day BETWEEN 1 AND 31', name=op.f('ck_app_setting_month_start_day_range')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_app_setting'))
|
||||
)
|
||||
# Bestandsinstallationen rechnen bisher ab dem Monatsersten – dabei bleibt es,
|
||||
# bis der Gehaltstag in den Einstellungen geändert wird.
|
||||
op.execute(
|
||||
"INSERT INTO app_setting (id, month_start_day, created_at, updated_at) "
|
||||
"VALUES (1, 1, now(), now())"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('app_setting')
|
||||
@@ -11,6 +11,7 @@ from app.core.security import decode_token
|
||||
from app.db.session import get_session
|
||||
from app.models import AppUser
|
||||
from app.services.auth import get_user
|
||||
from app.services.settings import month_start_day
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
@@ -53,3 +54,11 @@ async def get_active_user(user: CurrentUser) -> AppUser:
|
||||
|
||||
|
||||
ActiveUser = Annotated[AppUser, Depends(get_active_user)]
|
||||
|
||||
|
||||
async def get_month_start_day(session: DbSession) -> int:
|
||||
"""Der eingestellte Monatsbeginn – jede monatsbezogene Auswertung richtet sich danach."""
|
||||
return await month_start_day(session)
|
||||
|
||||
|
||||
MonthStartDay = Annotated[int, Depends(get_month_start_day)]
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.api.routes import (
|
||||
recurrences,
|
||||
reports,
|
||||
savings_goals,
|
||||
settings,
|
||||
system,
|
||||
transactions,
|
||||
)
|
||||
@@ -47,5 +48,6 @@ protected.include_router(savings_goals.router)
|
||||
protected.include_router(reports.router)
|
||||
protected.include_router(export.router)
|
||||
protected.include_router(notifications.router)
|
||||
protected.include_router(settings.router)
|
||||
|
||||
api_router.include_router(protected)
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
from fastapi import APIRouter, Request, Response, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core.cookies import REFRESH_COOKIE, clear_auth_cookies, set_auth_cookies
|
||||
from app.core.cookies import (
|
||||
REFRESH_COOKIE,
|
||||
clear_auth_cookies,
|
||||
set_auth_cookies,
|
||||
warn_if_cookie_unusable,
|
||||
)
|
||||
from app.core.errors import AuthError
|
||||
from app.schemas.auth import ChangePasswordRequest, LoginRequest, UserOut
|
||||
from app.schemas.common import ErrorResponse, MessageResponse
|
||||
@@ -29,7 +34,12 @@ UNAUTHORIZED = {status.HTTP_401_UNAUTHORIZED: {"model": ErrorResponse}}
|
||||
description="Prüft die Zugangsdaten und legt Access- und Refresh-Token als "
|
||||
"httpOnly-Cookies ab.",
|
||||
)
|
||||
async def login(payload: LoginRequest, response: Response, session: DbSession) -> UserOut:
|
||||
async def login(
|
||||
payload: LoginRequest, request: Request, response: Response, session: DbSession
|
||||
) -> UserOut:
|
||||
# Meldet die Kombination, bei der der Browser das Cookie stillschweigend verwirft.
|
||||
warn_if_cookie_unusable(request)
|
||||
|
||||
provider = get_auth_provider("local")
|
||||
user = await provider.authenticate(
|
||||
session, Credentials(username=payload.username, password=payload.password)
|
||||
|
||||
@@ -6,8 +6,8 @@ from typing import Literal
|
||||
from fastapi import APIRouter, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.clock import month_end, month_start, today
|
||||
from app.api.deps import DbSession, MonthStartDay
|
||||
from app.core.clock import today
|
||||
from app.core.errors import ValidationError
|
||||
from app.models import Account, Merchant, Transaction
|
||||
from app.models.enums import EntryKind
|
||||
@@ -15,9 +15,11 @@ from app.schemas.common import ErrorResponse
|
||||
from app.services.export import CONTENT_TYPES, filename, to_csv, to_xlsx
|
||||
from app.services.reports import (
|
||||
category_names,
|
||||
current_period,
|
||||
flow_rows,
|
||||
flows,
|
||||
month_report,
|
||||
period_of,
|
||||
recurrence_rows,
|
||||
reserve_total,
|
||||
)
|
||||
@@ -156,15 +158,16 @@ async def export_recurrences(
|
||||
)
|
||||
async def export_month(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
fmt: ExportFormat = Query(default="xlsx", alias="format"),
|
||||
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
||||
) -> Response:
|
||||
monat = month_start(month or today())
|
||||
zeitraum = period_of(month, start_day) if month else current_period(start_day)
|
||||
namen = await category_names(session)
|
||||
|
||||
bewegungen = await flows(session, monat, month_end(monat))
|
||||
bewegungen = await flows(session, zeitraum.start, zeitraum.end)
|
||||
zeilen = flow_rows(bewegungen, namen)
|
||||
bericht = await month_report(session, monat)
|
||||
bericht = await month_report(session, zeitraum.key, start_day)
|
||||
|
||||
kennzahlen: list[dict[str, object]] = [
|
||||
{"Kennzahl": "Einnahmen (Plan)", "Betrag": bericht.planned.income},
|
||||
@@ -175,11 +178,11 @@ async def export_month(
|
||||
{"Kennzahl": "Saldo (Ist)", "Betrag": bericht.actual.balance},
|
||||
{"Kennzahl": "Fixkosten", "Betrag": bericht.fixed_costs},
|
||||
{"Kennzahl": "Variable Kosten", "Betrag": bericht.variable_costs},
|
||||
{"Kennzahl": "Rücklagen", "Betrag": await reserve_total(session, monat)},
|
||||
{"Kennzahl": "Rücklagen", "Betrag": await reserve_total(session, zeitraum)},
|
||||
{"Kennzahl": "Verfügbar nach Fixkosten", "Betrag": bericht.available_after_fixed},
|
||||
]
|
||||
|
||||
kennung = monat.strftime("%Y-%m")
|
||||
kennung = zeitraum.key.strftime("%Y-%m")
|
||||
if fmt == "csv":
|
||||
# CSV kennt keine Blätter – die Kennzahlen folgen nach einer Leerzeile.
|
||||
inhalt = to_csv(zeilen, FLOW_COLUMNS)
|
||||
|
||||
@@ -6,8 +6,8 @@ from decimal import Decimal
|
||||
from fastapi import APIRouter, Query
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.clock import month_end, month_start, today
|
||||
from app.api.deps import DbSession, MonthStartDay
|
||||
from app.core.clock import today
|
||||
from app.core.errors import ValidationError
|
||||
from app.models import SavingsGoal
|
||||
from app.models.enums import EntryKind
|
||||
@@ -43,10 +43,12 @@ from app.services.reports import (
|
||||
budget_status,
|
||||
calendar_month,
|
||||
category_breakdown,
|
||||
current_period,
|
||||
flows,
|
||||
forecast,
|
||||
month_report,
|
||||
months_between,
|
||||
period_of,
|
||||
required_monthly_rate,
|
||||
subscriptions,
|
||||
year_comparison,
|
||||
@@ -67,6 +69,8 @@ def _totals(value: Totals) -> TotalsOut:
|
||||
def _month(report: MonthReport) -> MonthReportOut:
|
||||
return MonthReportOut(
|
||||
month=report.month,
|
||||
period_start=report.period.start,
|
||||
period_end=report.period.end,
|
||||
planned=_totals(report.planned),
|
||||
actual=_totals(report.actual),
|
||||
previous_planned=_totals(report.previous_planned),
|
||||
@@ -89,6 +93,8 @@ def _month(report: MonthReport) -> MonthReportOut:
|
||||
def _forecast_month(entry: ForecastMonth) -> ForecastMonthOut:
|
||||
return ForecastMonthOut(
|
||||
month=entry.month,
|
||||
period_start=entry.period_start,
|
||||
period_end=entry.period_end,
|
||||
income=entry.income,
|
||||
expenses=entry.expenses,
|
||||
balance=entry.balance,
|
||||
@@ -216,11 +222,15 @@ async def _goals(session: DbSession, as_of: date) -> list[SavingsGoalProgressOut
|
||||
)
|
||||
async def read_month_report(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(
|
||||
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
|
||||
default=None,
|
||||
description="Beliebiger Tag im gewünschten Monat; ausschlaggebend ist dessen "
|
||||
"Monatserster. Vorgabe ist der laufende Abrechnungsmonat.",
|
||||
),
|
||||
) -> MonthReportOut:
|
||||
return _month(await month_report(session, month or today()))
|
||||
monat = month or current_period(start_day).key
|
||||
return _month(await month_report(session, monat, start_day))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -232,10 +242,13 @@ async def read_month_report(
|
||||
)
|
||||
async def read_forecast(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
|
||||
start: date | None = Query(default=None, description="Erster Monat; Vorgabe ist heute."),
|
||||
start: date | None = Query(
|
||||
default=None, description="Erster Monat; Vorgabe ist der laufende Abrechnungsmonat."
|
||||
),
|
||||
) -> ForecastOut:
|
||||
monate = await forecast(session, months, start)
|
||||
monate = await forecast(session, months, start, start_day)
|
||||
return ForecastOut(
|
||||
months=[_forecast_month(monat) for monat in monate],
|
||||
total_income=sum((monat.income for monat in monate), Decimal("0.00")),
|
||||
@@ -251,13 +264,14 @@ async def read_forecast(
|
||||
)
|
||||
async def read_categories(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
date_from: date | None = Query(default=None, alias="from"),
|
||||
date_to: date | None = Query(default=None, alias="to"),
|
||||
kind: EntryKind = Query(default=EntryKind.EXPENSE),
|
||||
) -> CategoryReportOut:
|
||||
heute = today()
|
||||
start = date_from or month_start(heute)
|
||||
ende = date_to or month_end(heute)
|
||||
laufend = current_period(start_day)
|
||||
start = date_from or laufend.start
|
||||
ende = date_to or laufend.end
|
||||
if ende < start:
|
||||
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
||||
|
||||
@@ -274,9 +288,10 @@ async def read_categories(
|
||||
@router.get(
|
||||
"/subscriptions",
|
||||
response_model=SubscriptionReportOut,
|
||||
summary="Abo-Übersicht",
|
||||
description="Alle laufenden Ausgabenposten mit Jahreskosten. Ratenzahlungen sind "
|
||||
"gekennzeichnet und zählen nicht in die Gesamtsumme.",
|
||||
summary="Laufende Kosten",
|
||||
description="Jede aktive wiederkehrende Ausgabe mit ihren Jahreskosten – von der "
|
||||
"Miete bis zum Streamingdienst. Ratenzahlungen sind gekennzeichnet und zählen nicht "
|
||||
"in `total_annual`, weil sie ein Ende haben.",
|
||||
)
|
||||
async def read_subscriptions(session: DbSession) -> SubscriptionReportOut:
|
||||
bericht = await subscriptions(session)
|
||||
@@ -326,11 +341,15 @@ async def read_year_comparison(
|
||||
)
|
||||
async def read_calendar(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
||||
) -> CalendarMonthOut:
|
||||
raster = await calendar_month(session, month or today())
|
||||
monat = month or current_period(start_day).key
|
||||
raster = await calendar_month(session, monat, start_day=start_day)
|
||||
return CalendarMonthOut(
|
||||
month=raster.month,
|
||||
period_start=raster.period_start,
|
||||
period_end=raster.period_end,
|
||||
days=[
|
||||
CalendarDayOut(
|
||||
date=tag.on,
|
||||
@@ -357,9 +376,11 @@ async def read_calendar(
|
||||
)
|
||||
async def read_budget_status(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(default=None),
|
||||
) -> list[BudgetStatusOut]:
|
||||
return [_budget(eintrag) for eintrag in await budget_status(session, month or today())]
|
||||
monat = month or current_period(start_day).key
|
||||
return [_budget(eintrag) for eintrag in await budget_status(session, monat, start_day)]
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -381,25 +402,29 @@ async def read_goal_progress(session: DbSession) -> list[SavingsGoalProgressOut]
|
||||
)
|
||||
async def read_dashboard(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(default=None),
|
||||
) -> DashboardOut:
|
||||
heute = today()
|
||||
monat = month_start(month or heute)
|
||||
zeitraum = period_of(month, start_day) if month else current_period(start_day, heute)
|
||||
|
||||
bericht = await month_report(session, monat)
|
||||
vorschau = await forecast(session, 12, monat)
|
||||
gruppen = await category_breakdown(session, monat, month_end(monat))
|
||||
bericht = await month_report(session, zeitraum.key, start_day)
|
||||
vorschau = await forecast(session, 12, zeitraum.key, start_day)
|
||||
gruppen = await category_breakdown(session, zeitraum.start, zeitraum.end)
|
||||
abos = await subscriptions(session)
|
||||
|
||||
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Monat.
|
||||
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Zeitraum.
|
||||
naechste = await flows(session, heute, heute + timedelta(days=14))
|
||||
|
||||
return DashboardOut(
|
||||
month_start_day=start_day,
|
||||
month=_month(bericht),
|
||||
total_balance=await total_balance(session, heute),
|
||||
forecast=[_forecast_month(eintrag) for eintrag in vorschau],
|
||||
categories=[_slice(gruppe) for gruppe in gruppen],
|
||||
budgets=[_budget(eintrag) for eintrag in await budget_status(session, monat)],
|
||||
budgets=[
|
||||
_budget(eintrag) for eintrag in await budget_status(session, zeitraum.key, start_day)
|
||||
],
|
||||
goals=await _goals(session, heute),
|
||||
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
|
||||
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
|
||||
|
||||
@@ -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
|
||||
last_day = month_end(date(year, month, 1)).day
|
||||
return date(year, month, min(day.day, last_day))
|
||||
|
||||
|
||||
# --- Abrechnungsmonat ----------------------------------------------------------
|
||||
#
|
||||
# Wer am Gehaltstag rechnet, für den beginnt der Monat nicht am Ersten. Ein
|
||||
# Abrechnungsmonat läuft vom Gehaltstag bis zum Vortag des nächsten und trägt
|
||||
# den Namen des Monats, in dem er beginnt: Startet er am 25. September, heißt
|
||||
# er „September“ und endet am 24. Oktober.
|
||||
|
||||
MIN_MONTH_START_DAY = 1
|
||||
MAX_MONTH_START_DAY = 31
|
||||
DEFAULT_MONTH_START_DAY = 1
|
||||
|
||||
|
||||
def month_anchor(day: date, start_day: int) -> date:
|
||||
"""Der Gehaltstag im Monat von `day`.
|
||||
|
||||
Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten – so
|
||||
trifft der 31. in jedem Monat den letzten Tag.
|
||||
"""
|
||||
return day.replace(day=min(start_day, month_end(day).day))
|
||||
|
||||
|
||||
def period_start(day: date, start_day: int) -> date:
|
||||
"""Beginn des Abrechnungsmonats, in dem `day` liegt."""
|
||||
anchor = month_anchor(day, start_day)
|
||||
if day >= anchor:
|
||||
return anchor
|
||||
return month_anchor(add_months(month_start(day), -1), start_day)
|
||||
|
||||
|
||||
def period_key(day: date, start_day: int) -> date:
|
||||
"""Bezeichner des Abrechnungsmonats, in dem `day` liegt – immer ein Monatserster."""
|
||||
return month_start(period_start(day, start_day))
|
||||
|
||||
|
||||
def period_bounds(month: date, start_day: int) -> tuple[date, date]:
|
||||
"""Erster und letzter Tag des Abrechnungsmonats mit dem Bezeichner `month`."""
|
||||
schluessel = month_start(month)
|
||||
beginn = month_anchor(schluessel, start_day)
|
||||
ende = month_anchor(add_months(schluessel, 1), start_day) - timedelta(days=1)
|
||||
return beginn, ende
|
||||
|
||||
@@ -23,7 +23,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# --- Allgemein -----------------------------------------------------------
|
||||
app_name: str = "moneyfy"
|
||||
app_version: str = "0.1.0"
|
||||
app_version: str = "0.1.2"
|
||||
environment: Literal["development", "production", "test"] = "development"
|
||||
debug: bool = False
|
||||
timezone: str = "Europe/Berlin"
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Setzen und Löschen der Authentifizierungs-Cookies."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import Response
|
||||
from fastapi import Request, Response
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACCESS_COOKIE = "moneyfy_access"
|
||||
REFRESH_COOKIE = "moneyfy_refresh"
|
||||
|
||||
@@ -13,6 +16,38 @@ REFRESH_COOKIE = "moneyfy_refresh"
|
||||
REFRESH_COOKIE_PATH = "/api/auth"
|
||||
|
||||
|
||||
def request_is_secure(request: Request) -> bool:
|
||||
"""Kam die Anfrage über HTTPS?
|
||||
|
||||
Hinter einem Reverse Proxy steht das echte Schema in `X-Forwarded-Proto`;
|
||||
das Schema der Verbindung zum Container ist dort immer `http`.
|
||||
"""
|
||||
weitergeleitet = request.headers.get("x-forwarded-proto", "")
|
||||
if weitergeleitet:
|
||||
# Bei mehreren Proxies steht das ursprüngliche Schema vorne.
|
||||
return weitergeleitet.split(",")[0].strip().lower() == "https"
|
||||
return request.url.scheme == "https"
|
||||
|
||||
|
||||
def warn_if_cookie_unusable(request: Request) -> bool:
|
||||
"""Meldet die Kombination, bei der der Browser das Cookie verwirft.
|
||||
|
||||
`Secure`-Cookies über eine reine HTTP-Verbindung werden stillschweigend
|
||||
weggeworfen: Die Anmeldung meldet Erfolg, die nächste Anfrage gilt aber
|
||||
wieder als nicht angemeldet. Ohne diesen Hinweis ist das kaum zu erkennen.
|
||||
"""
|
||||
if not settings.cookie_secure or request_is_secure(request):
|
||||
return False
|
||||
|
||||
logger.warning(
|
||||
"Anmeldung über %s ohne HTTPS bei COOKIE_SECURE=true – der Browser "
|
||||
"verwirft das Sitzungs-Cookie und die Anmeldemaske erscheint erneut. "
|
||||
"Entweder über HTTPS zugreifen oder COOKIE_SECURE=false setzen.",
|
||||
request.url.hostname or "unbekannt",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def set_auth_cookies(
|
||||
response: Response,
|
||||
access_token: str,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"""SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig."""
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.core import Account, AppUser, Category, LogoAsset, Merchant, RefreshToken
|
||||
from app.models.core import (
|
||||
Account,
|
||||
AppSetting,
|
||||
AppUser,
|
||||
Category,
|
||||
LogoAsset,
|
||||
Merchant,
|
||||
RefreshToken,
|
||||
)
|
||||
from app.models.enums import (
|
||||
AccountType,
|
||||
BusinessDayShift,
|
||||
@@ -29,6 +37,7 @@ __all__ = [
|
||||
"Account",
|
||||
"AccountType",
|
||||
"AmountVersion",
|
||||
"AppSetting",
|
||||
"AppUser",
|
||||
"Base",
|
||||
"Budget",
|
||||
|
||||
@@ -129,6 +129,24 @@ class Merchant(Base, CreatedAtMixin):
|
||||
__table_args__ = (Index("ix_merchant_normalized_name", "normalized_name"),)
|
||||
|
||||
|
||||
class AppSetting(Base, TimestampMixin):
|
||||
"""Anwendungsweite Einstellungen. Es gibt genau eine Zeile mit `id = 1`.
|
||||
|
||||
`month_start_day` legt den Gehaltstag fest: Ab diesem Tag rechnet moneyfy
|
||||
einen neuen Monat. Ein Wert jenseits der Monatslänge trifft den Monatsletzten.
|
||||
"""
|
||||
|
||||
__tablename__ = "app_setting"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
month_start_day: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("id = 1", name="single_row"),
|
||||
CheckConstraint("month_start_day BETWEEN 1 AND 31", name="month_start_day_range"),
|
||||
)
|
||||
|
||||
|
||||
class AppUser(Base, CreatedAtMixin):
|
||||
"""Single-User-Betrieb; `external_subject` ist für eine spätere OIDC-Anbindung vorgesehen."""
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ class MonthComparisonOut(ApiModel):
|
||||
class MonthReportOut(ApiModel):
|
||||
"""Monatsübersicht mit Plan-Ist-Vergleich."""
|
||||
|
||||
month: date = Field(description="Immer der Monatserste.")
|
||||
month: date = Field(
|
||||
description="Bezeichner des Abrechnungsmonats – immer der Monatserste des Startmonats."
|
||||
)
|
||||
period_start: date = Field(description="Erster Tag des Zeitraums, also der Monatsbeginn.")
|
||||
period_end: date = Field(description="Letzter Tag des Zeitraums.")
|
||||
planned: TotalsOut = Field(description="Soll aus Fälligkeiten und Buchungen.")
|
||||
actual: TotalsOut = Field(
|
||||
description="Ist aus bestätigten Fälligkeiten und allen einmaligen Buchungen."
|
||||
@@ -48,14 +52,16 @@ class MonthReportOut(ApiModel):
|
||||
|
||||
|
||||
class ForecastMonthOut(ApiModel):
|
||||
"""Ein Monat der Vorschau."""
|
||||
"""Ein Abrechnungsmonat der Vorschau."""
|
||||
|
||||
month: date
|
||||
period_start: date
|
||||
period_end: date
|
||||
income: Money
|
||||
expenses: Money
|
||||
balance: Money
|
||||
cumulative_balance: Money = Field(
|
||||
description="Prognostizierter Kontostand am Monatsende über alle Konten."
|
||||
description="Prognostizierter Kontostand am Ende des Zeitraums über alle Konten."
|
||||
)
|
||||
|
||||
|
||||
@@ -98,7 +104,7 @@ class SubscriptionOut(ApiModel):
|
||||
monthly_cost: Money
|
||||
rrule: str
|
||||
is_installment: bool = Field(
|
||||
description="Ratenzahlungen zählen nicht in die Summe „Abos gesamt p. a.“."
|
||||
description="Ratenzahlungen zählen nicht in `total_annual`, weil sie enden."
|
||||
)
|
||||
is_cancelled: bool
|
||||
contract_term: ContractTermOut | None = None
|
||||
@@ -159,8 +165,13 @@ class CalendarDayOut(ApiModel):
|
||||
|
||||
|
||||
class CalendarMonthOut(ApiModel):
|
||||
month: date
|
||||
days: list[CalendarDayOut]
|
||||
month: date = Field(description="Bezeichner des Abrechnungsmonats – immer der Monatserste.")
|
||||
period_start: date
|
||||
period_end: date
|
||||
days: list[CalendarDayOut] = Field(
|
||||
description="Alle Tage des Zeitraums; bei abweichendem Monatsbeginn über zwei "
|
||||
"Kalendermonate hinweg."
|
||||
)
|
||||
opening_balance: Money
|
||||
closing_balance: Money
|
||||
lowest_balance: Money
|
||||
@@ -210,6 +221,7 @@ class SavingsGoalProgressOut(ApiModel):
|
||||
class DashboardOut(ApiModel):
|
||||
"""Alles, was das Dashboard in einem Aufruf braucht."""
|
||||
|
||||
month_start_day: int = Field(description="Der eingestellte Monatsbeginn.")
|
||||
month: MonthReportOut
|
||||
total_balance: Money
|
||||
forecast: list[ForecastMonthOut]
|
||||
|
||||
@@ -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.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import add_months, month_end, month_start, today, utcnow
|
||||
from app.core.clock import add_months, month_end, today, utcnow
|
||||
from app.core.config import settings
|
||||
from app.models import LogoAsset, Merchant, NotificationLog, NotificationRule
|
||||
from app.models.enums import (
|
||||
@@ -29,7 +29,8 @@ from app.models.enums import (
|
||||
from app.services.channels import Attachment, ChannelError, Notification, get_channel
|
||||
from app.services.occurrences import load_recurrences
|
||||
from app.services.recurrence import contract_term
|
||||
from app.services.reports import budget_status, flows
|
||||
from app.services.reports import budget_status, current_period, flows
|
||||
from app.services.settings import month_start_day
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -169,14 +170,15 @@ async def collect_notice_deadlines(
|
||||
async def collect_budget_exceeded(
|
||||
session: AsyncSession, rule: NotificationRule, as_of: date
|
||||
) -> list[Event]:
|
||||
"""Überschrittene Budgets – höchstens einmal je Monat und Kategorie."""
|
||||
monat = month_start(as_of)
|
||||
"""Überschrittene Budgets – höchstens einmal je Abrechnungsmonat und Kategorie."""
|
||||
monatsbeginn = await month_start_day(session)
|
||||
zeitraum = current_period(monatsbeginn, as_of)
|
||||
|
||||
return [
|
||||
Event(
|
||||
ref_type=REF_BUDGET,
|
||||
ref_id=str(eintrag.category_id),
|
||||
dedupe_day=monat,
|
||||
dedupe_day=zeitraum.key,
|
||||
headline=eintrag.category_name,
|
||||
detail=(
|
||||
f"{_money(eintrag.spent)} von {_money(eintrag.available)} verbraucht "
|
||||
@@ -184,9 +186,9 @@ async def collect_budget_exceeded(
|
||||
f"{_money(abs(eintrag.remaining))} zu viel"
|
||||
),
|
||||
amount=eintrag.spent,
|
||||
on=month_end(monat),
|
||||
on=zeitraum.end,
|
||||
)
|
||||
for eintrag in await budget_status(session, monat)
|
||||
for eintrag in await budget_status(session, zeitraum.key, monatsbeginn)
|
||||
if eintrag.state == "exceeded"
|
||||
]
|
||||
|
||||
|
||||
+100
-35
@@ -14,7 +14,14 @@ from typing import Literal
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import add_months, month_end, month_start, today
|
||||
from app.core.clock import (
|
||||
DEFAULT_MONTH_START_DAY,
|
||||
add_months,
|
||||
month_start,
|
||||
period_bounds,
|
||||
period_key,
|
||||
today,
|
||||
)
|
||||
from app.models import Budget, BudgetTemplate, Category, Merchant, Recurrence, Transaction
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
from app.services.balances import total_balance
|
||||
@@ -83,6 +90,33 @@ class Totals:
|
||||
return self.income - self.expenses
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Period:
|
||||
"""Ein Abrechnungsmonat.
|
||||
|
||||
Er läuft vom eingestellten Monatsbeginn – dem Gehaltstag – bis zum Vortag des
|
||||
nächsten und trägt den Namen des Monats, in dem er beginnt. Bei Monatsbeginn 1
|
||||
ist er deckungsgleich mit dem Kalendermonat.
|
||||
"""
|
||||
|
||||
key: date
|
||||
"""Bezeichner: der Erste des Monats, in dem der Zeitraum beginnt."""
|
||||
start: date
|
||||
end: date
|
||||
|
||||
|
||||
def period_of(month: date, start_day: int = DEFAULT_MONTH_START_DAY) -> Period:
|
||||
"""Der Abrechnungsmonat mit dem Bezeichner `month`."""
|
||||
schluessel = month_start(month)
|
||||
beginn, ende = period_bounds(schluessel, start_day)
|
||||
return Period(key=schluessel, start=beginn, end=ende)
|
||||
|
||||
|
||||
def current_period(start_day: int = DEFAULT_MONTH_START_DAY, as_of: date | None = None) -> Period:
|
||||
"""Der Abrechnungsmonat, in dem `as_of` liegt – Vorgabe ist heute."""
|
||||
return period_of(period_key(as_of or today(), start_day), start_day)
|
||||
|
||||
|
||||
def totals_of(
|
||||
entries: Iterable[FlowEntry],
|
||||
*,
|
||||
@@ -180,9 +214,9 @@ async def flows(
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MonthReport:
|
||||
"""Kennzahlen eines Monats."""
|
||||
"""Kennzahlen eines Abrechnungsmonats."""
|
||||
|
||||
month: date
|
||||
period: Period
|
||||
planned: Totals
|
||||
actual: Totals
|
||||
previous_planned: Totals
|
||||
@@ -194,6 +228,11 @@ class MonthReport:
|
||||
open_count: int = 0
|
||||
skipped_count: int = 0
|
||||
|
||||
@property
|
||||
def month(self) -> date:
|
||||
"""Bezeichner des Zeitraums – immer ein Monatserster."""
|
||||
return self.period.key
|
||||
|
||||
@property
|
||||
def available_after_fixed(self) -> Decimal:
|
||||
"""Einkünfte abzüglich Fixkosten und Rücklagen – die große Kennzahl im Dashboard."""
|
||||
@@ -213,27 +252,29 @@ class MonthReport:
|
||||
|
||||
|
||||
async def _month_flows(
|
||||
session: AsyncSession, month: date, fixed_costs: dict[int, bool]
|
||||
session: AsyncSession, period: Period, fixed_costs: dict[int, bool]
|
||||
) -> list[FlowEntry]:
|
||||
return await flows(session, month_start(month), month_end(month), fixed_costs=fixed_costs)
|
||||
return await flows(session, period.start, period.end, fixed_costs=fixed_costs)
|
||||
|
||||
|
||||
async def reserve_total(session: AsyncSession, month: date) -> Decimal:
|
||||
async def reserve_total(session: AsyncSession, period: Period) -> Decimal:
|
||||
"""Summe der monatlichen Rücklagen aller Posten mit aktivierter Rücklagenbildung."""
|
||||
total = ZERO
|
||||
for recurrence in await load_recurrences(session):
|
||||
if recurrence.reserve_enabled:
|
||||
total += monthly_reserve(
|
||||
recurrence, month_start(month), amount_versions=recurrence.amount_versions
|
||||
recurrence, period.start, amount_versions=recurrence.amount_versions
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
async def month_report(
|
||||
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
|
||||
) -> MonthReport:
|
||||
"""Monatsübersicht inklusive Vergleich zum Vormonat."""
|
||||
fix = await fixed_cost_map(session)
|
||||
aktuell = month_start(month)
|
||||
vormonat = add_months(aktuell, -1)
|
||||
aktuell = period_of(month, start_day)
|
||||
vormonat = period_of(add_months(aktuell.key, -1), start_day)
|
||||
|
||||
bewegungen = await _month_flows(session, aktuell, fix)
|
||||
vorherige = await _month_flows(session, vormonat, fix)
|
||||
@@ -245,13 +286,13 @@ async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
# Ausgelassene Fälligkeiten fehlen in `flows` und werden separat gezählt.
|
||||
ausgelassen = sum(
|
||||
1
|
||||
for item in await due_items(session, month_start(month), month_end(month))
|
||||
for item in await due_items(session, aktuell.start, aktuell.end)
|
||||
if item.planned.status is OccurrenceStatus.SKIPPED
|
||||
)
|
||||
serien = [entry for entry in bewegungen if entry.source == "recurrence"]
|
||||
|
||||
return MonthReport(
|
||||
month=aktuell,
|
||||
period=aktuell,
|
||||
planned=totals_of(bewegungen),
|
||||
actual=totals_of(bewegungen, basis="effective", only_confirmed=True),
|
||||
previous_planned=totals_of(vorherige),
|
||||
@@ -270,13 +311,15 @@ async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ForecastMonth:
|
||||
"""Ein Monat der Vorschau."""
|
||||
"""Ein Abrechnungsmonat der Vorschau."""
|
||||
|
||||
month: date
|
||||
period_start: date
|
||||
period_end: date
|
||||
income: Decimal
|
||||
expenses: Decimal
|
||||
cumulative_balance: Decimal
|
||||
"""Prognostizierter Kontostand am Monatsende über alle Konten."""
|
||||
"""Prognostizierter Kontostand am Ende des Zeitraums über alle Konten."""
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
@@ -284,28 +327,33 @@ class ForecastMonth:
|
||||
|
||||
|
||||
async def forecast(
|
||||
session: AsyncSession, months: int = 12, start: date | None = None
|
||||
session: AsyncSession,
|
||||
months: int = 12,
|
||||
start: date | None = None,
|
||||
start_day: int = DEFAULT_MONTH_START_DAY,
|
||||
) -> list[ForecastMonth]:
|
||||
"""Vorschau über mehrere Monate.
|
||||
|
||||
Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil die Reihe
|
||||
aus der tatsächlichen Expansion entsteht und nicht aus einem Durchschnitt.
|
||||
"""
|
||||
beginn = month_start(start or today())
|
||||
erster = period_of(start, start_day) if start is not None else current_period(start_day)
|
||||
fix = await fixed_cost_map(session)
|
||||
|
||||
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Monat.
|
||||
laufend = await total_balance(session, beginn - timedelta(days=1))
|
||||
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Zeitraum.
|
||||
laufend = await total_balance(session, erster.start - timedelta(days=1))
|
||||
|
||||
ergebnis: list[ForecastMonth] = []
|
||||
for versatz in range(max(1, months)):
|
||||
monat = add_months(beginn, versatz)
|
||||
bewegungen = await flows(session, month_start(monat), month_end(monat), fixed_costs=fix)
|
||||
zeitraum = period_of(add_months(erster.key, versatz), start_day)
|
||||
bewegungen = await flows(session, zeitraum.start, zeitraum.end, fixed_costs=fix)
|
||||
summen = totals_of(bewegungen, basis="effective")
|
||||
laufend += summen.balance
|
||||
ergebnis.append(
|
||||
ForecastMonth(
|
||||
month=monat,
|
||||
month=zeitraum.key,
|
||||
period_start=zeitraum.start,
|
||||
period_end=zeitraum.end,
|
||||
income=summen.income,
|
||||
expenses=summen.expenses,
|
||||
cumulative_balance=laufend,
|
||||
@@ -567,6 +615,9 @@ class CalendarDay:
|
||||
@dataclass(slots=True)
|
||||
class CalendarMonth:
|
||||
month: date
|
||||
"""Bezeichner des Abrechnungsmonats – immer ein Monatserster."""
|
||||
period_start: date
|
||||
period_end: date
|
||||
days: list[CalendarDay]
|
||||
opening_balance: Decimal
|
||||
closing_balance: Decimal
|
||||
@@ -575,11 +626,16 @@ class CalendarMonth:
|
||||
|
||||
|
||||
async def calendar_month(
|
||||
session: AsyncSession, month: date, *, holiday_region: str = "DE-NW"
|
||||
session: AsyncSession,
|
||||
month: date,
|
||||
*,
|
||||
start_day: int = DEFAULT_MONTH_START_DAY,
|
||||
holiday_region: str = "DE-NW",
|
||||
) -> CalendarMonth:
|
||||
"""Monatsraster mit den Fälligkeiten je Tag und dem laufenden Kontostand."""
|
||||
beginn = month_start(month)
|
||||
ende = month_end(month)
|
||||
"""Tagesraster des Abrechnungsmonats mit den Fälligkeiten und dem laufenden Kontostand."""
|
||||
zeitraum = period_of(month, start_day)
|
||||
beginn = zeitraum.start
|
||||
ende = zeitraum.end
|
||||
|
||||
eroeffnung = await total_balance(session, beginn - timedelta(days=1))
|
||||
bewegungen = await flows(session, beginn, ende)
|
||||
@@ -614,7 +670,9 @@ async def calendar_month(
|
||||
tag += timedelta(days=1)
|
||||
|
||||
return CalendarMonth(
|
||||
month=beginn,
|
||||
month=zeitraum.key,
|
||||
period_start=beginn,
|
||||
period_end=ende,
|
||||
days=tage,
|
||||
opening_balance=eroeffnung,
|
||||
closing_balance=laufend,
|
||||
@@ -697,14 +755,17 @@ async def _effective_limits(
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, Decimal]:
|
||||
"""Ausgaben eines Monats je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
|
||||
async def _spent_by_category(
|
||||
session: AsyncSession, month: date, start_day: int
|
||||
) -> dict[int, Decimal]:
|
||||
"""Ausgaben eines Zeitraums je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
|
||||
kategorien = {
|
||||
kategorie.id: kategorie.parent_id
|
||||
for kategorie in (await session.execute(select(Category))).scalars()
|
||||
}
|
||||
zeitraum = period_of(month, start_day)
|
||||
ergebnis: dict[int, Decimal] = {}
|
||||
for entry in await flows(session, month_start(month), month_end(month)):
|
||||
for entry in await flows(session, zeitraum.start, zeitraum.end):
|
||||
if entry.kind is not EntryKind.EXPENSE:
|
||||
continue
|
||||
# Ein Budget auf der Oberkategorie umfasst auch deren Unterkategorien.
|
||||
@@ -715,8 +776,10 @@ async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, De
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus]:
|
||||
"""Budgets eines Monats samt Verbrauch und Übertrag."""
|
||||
async def budget_status(
|
||||
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
|
||||
) -> list[BudgetStatus]:
|
||||
"""Budgets eines Abrechnungsmonats samt Verbrauch und Übertrag."""
|
||||
monat = month_start(month)
|
||||
limits = await _effective_limits(session, monat)
|
||||
if not limits:
|
||||
@@ -725,7 +788,7 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
|
||||
namen = {
|
||||
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
|
||||
}
|
||||
ausgaben = await _spent_by_category(session, monat)
|
||||
ausgaben = await _spent_by_category(session, monat, start_day)
|
||||
|
||||
ergebnis: list[BudgetStatus] = []
|
||||
for kategorie_id, (limit, rollover, aus_vorlage) in limits.items():
|
||||
@@ -733,7 +796,7 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
|
||||
if kategorie is None:
|
||||
continue
|
||||
|
||||
uebertrag = await _carry_over(session, kategorie_id, monat) if rollover else ZERO
|
||||
uebertrag = await _carry_over(session, kategorie_id, monat, start_day) if rollover else ZERO
|
||||
ergebnis.append(
|
||||
BudgetStatus(
|
||||
category_id=kategorie_id,
|
||||
@@ -752,7 +815,9 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def _carry_over(session: AsyncSession, category_id: int, month: date) -> Decimal:
|
||||
async def _carry_over(
|
||||
session: AsyncSession, category_id: int, month: date, start_day: int
|
||||
) -> Decimal:
|
||||
"""Nicht verbrauchtes Budget aus den Vormonaten.
|
||||
|
||||
Es wird höchstens ein Jahr zurückgeschaut; ein Überschreiten setzt den
|
||||
@@ -772,7 +837,7 @@ async def _carry_over(session: AsyncSession, category_id: int, month: date) -> D
|
||||
uebertrag = ZERO
|
||||
continue
|
||||
|
||||
ausgaben = (await _spent_by_category(session, vormonat)).get(category_id, ZERO)
|
||||
ausgaben = (await _spent_by_category(session, vormonat, start_day)).get(category_id, ZERO)
|
||||
uebertrag = max(limit + uebertrag - ausgaben, ZERO)
|
||||
return uebertrag
|
||||
|
||||
|
||||
@@ -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]
|
||||
name = "moneyfy-backend"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
description = "moneyfy – Planung monatlicher Kosten und Einkünfte"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
|
||||
@@ -13,6 +13,10 @@ os.environ.setdefault(
|
||||
)
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-mindestens-32-zeichen-lang")
|
||||
os.environ.setdefault("ENVIRONMENT", "test")
|
||||
# Der Testclient spricht über http; `Secure`-Cookies würde httpx verwerfen und
|
||||
# jede geschützte Route liefe in einen 401. Ohne diese Zeile hinge die Suite an
|
||||
# einer lokalen .env – die CI hat genau das aufgedeckt.
|
||||
os.environ.setdefault("COOKIE_SECURE", "false")
|
||||
# Die Hintergrundsuche liefe außerhalb der Testtransaktion – sie wird gezielt aufgerufen.
|
||||
os.environ.setdefault("LOGO_AUTO_RESOLVE", "false")
|
||||
os.environ.setdefault("LOGO_STORAGE_DIR", tempfile.mkdtemp(prefix="moneyfy-logos-"))
|
||||
|
||||
@@ -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
|
||||
geändert, sind alle Sitzungen ungültig – das ist beabsichtigt.
|
||||
|
||||
**Anmeldung schlägt fehl, obwohl das Passwort stimmt**
|
||||
Meist fehlt HTTPS: Die Cookies sind `Secure` gesetzt und erreichen den Server
|
||||
über eine reine HTTP-Verbindung nicht. Entweder über NPMplus zugreifen oder
|
||||
`COOKIE_SECURE=false` setzen.
|
||||
**Anmeldung schlägt fehl**
|
||||
|
||||
Drei Ursachen kommen in Frage, in dieser Reihenfolge prüfen:
|
||||
|
||||
1. **Der Benutzer wurde mit einem anderen Passwort angelegt.** Die Erstanlage
|
||||
greift nur, solange die Benutzertabelle leer ist. Wer `.env.example` zuerst
|
||||
unverändert übernimmt und startet, bekommt einen Benutzer mit dem Platzhalter
|
||||
– ein späteres Ändern von `MONEYFY_ADMIN_PASSWORD` bleibt dann wirkungslos.
|
||||
Abhilfe:
|
||||
|
||||
```bash
|
||||
docker compose exec moneyfy-backend python -m app.scripts.reset_password \
|
||||
--username admin --password "neues-passwort"
|
||||
```
|
||||
|
||||
2. **Der Container kennt den neuen Wert noch nicht.** `docker compose restart`
|
||||
behält die alte Umgebung. Nach einer Änderung an der `.env` muss der
|
||||
Container neu erstellt werden:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate moneyfy-backend
|
||||
docker compose exec moneyfy-backend printenv MONEYFY_ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
3. **Kein HTTPS.** Die Cookies sind `Secure` gesetzt; über eine reine
|
||||
HTTP-Verbindung verwirft der Browser sie. Typisches Bild: **der Bildschirm
|
||||
flackert kurz und die Anmeldemaske erscheint erneut, ohne Fehlermeldung.**
|
||||
Seit 0.1.2 erklärt die Oberfläche das ausdrücklich, und im Log steht:
|
||||
|
||||
> `Anmeldung über … ohne HTTPS bei COOKIE_SECURE=true – der Browser verwirft
|
||||
> das Sitzungs-Cookie …`
|
||||
|
||||
Abhilfe: über NPMplus mit HTTPS zugreifen, oder für einen Test auf HTTP:
|
||||
|
||||
```bash
|
||||
# in der .env
|
||||
COOKIE_SECURE=false
|
||||
```
|
||||
|
||||
```bash
|
||||
# restart genügt nicht – nur up -d übernimmt die geänderte Umgebung
|
||||
docker compose up -d --force-recreate moneyfy-backend
|
||||
```
|
||||
|
||||
Welcher Fall vorliegt, verrät das Log:
|
||||
|
||||
```bash
|
||||
docker compose logs moneyfy-backend | grep -iE "administrator|MONEYFY_ADMIN"
|
||||
```
|
||||
|
||||
**Firmen bekommen kein Logo**
|
||||
Ohne Netzzugang findet nur der lokale Markenkatalog etwas; unbekannte Firmen
|
||||
|
||||
@@ -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",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Routen der Anwendung und die Weichen für die Anmeldung. */
|
||||
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
|
||||
import { AppLayout } from "@/components/layout/AppLayout";
|
||||
import { Skeleton } from "@/components/ui/Feedback";
|
||||
@@ -11,6 +11,7 @@ import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { GoalsPage } from "@/pages/GoalsPage";
|
||||
import { LoginPage } from "@/pages/LoginPage";
|
||||
import { NotFoundPage } from "@/pages/NotFoundPage";
|
||||
import { ReportsPage } from "@/pages/ReportsPage";
|
||||
import { MerchantsPage } from "@/pages/MerchantsPage";
|
||||
import { RecurrencesPage } from "@/pages/RecurrencesPage";
|
||||
@@ -45,7 +46,7 @@ export function App() {
|
||||
<Route path="/goals" element={<GoalsPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Tests des Auffangnetzes für Renderfehler. */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
|
||||
function Kaputt({ werfen }: { werfen: boolean }) {
|
||||
if (werfen) throw new Error("Etwas ist entzwei");
|
||||
return <p>Alles in Ordnung</p>;
|
||||
}
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
beforeEach(() => {
|
||||
// React protokolliert den abgefangenen Fehler zusätzlich selbst.
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("reicht den Inhalt im Normalfall durch", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Kaputt werfen={false} />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Alles in Ordnung")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fängt einen Fehler ab und erklärt ihn", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Kaputt werfen />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Da ist etwas schiefgegangen")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Daten sind davon nicht betroffen/)).toBeInTheDocument();
|
||||
// Die Ursache bleibt einsehbar, ohne sie aufzudrängen.
|
||||
expect(screen.getByText("Etwas ist entzwei")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bietet einen erneuten Versuch an", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Kaputt werfen={false} />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Alles in Ordnung")).toBeInTheDocument();
|
||||
|
||||
// Nach einem Fehler steht der Knopf bereit.
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Kaputt werfen />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
const knopf = screen.getByRole("button", { name: /Erneut versuchen/ });
|
||||
expect(knopf).toBeInTheDocument();
|
||||
await nutzer.click(knopf);
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
|
||||
import { LogOut, Menu, Moon, Sun, X } from "lucide-react";
|
||||
import { Keyboard, LogOut, Menu, Moon, Sun, X } from "lucide-react";
|
||||
|
||||
import { ShortcutHelp } from "@/components/ShortcutHelp";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useLogout, useMe } from "@/hooks/useAuth";
|
||||
import { type Shortcut, useShortcuts } from "@/hooks/useShortcuts";
|
||||
import { useThemeStore } from "@/store/theme";
|
||||
import { useUiStore } from "@/store/ui";
|
||||
|
||||
export function AppLayout() {
|
||||
const [menuOffen, setMenuOffen] = useState(false);
|
||||
const [hilfeOffen, setHilfeOffen] = useState(false);
|
||||
const { data: benutzer } = useMe();
|
||||
const abmelden = useLogout();
|
||||
const theme = useThemeStore((zustand) => zustand.theme);
|
||||
const toggleTheme = useThemeStore((zustand) => zustand.toggleTheme);
|
||||
const navigate = useNavigate();
|
||||
const requestAction = useUiStore((zustand) => zustand.requestAction);
|
||||
const requestSearchFocus = useUiStore((zustand) => zustand.requestSearchFocus);
|
||||
|
||||
const shortcuts: Shortcut[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "n",
|
||||
description: "Neue Buchung",
|
||||
run: () => {
|
||||
// Erst auf die Seite wechseln, dort holt sie die Absicht ab.
|
||||
navigate("/transactions");
|
||||
requestAction("new-transaction");
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "r",
|
||||
description: "Neuer wiederkehrender Posten",
|
||||
run: () => {
|
||||
navigate("/recurrences");
|
||||
requestAction("new-recurrence");
|
||||
},
|
||||
},
|
||||
{ key: "/", description: "Suche", run: requestSearchFocus },
|
||||
{ key: "?", description: "Übersicht der Kürzel", run: () => setHilfeOffen(true) },
|
||||
],
|
||||
[navigate, requestAction, requestSearchFocus],
|
||||
);
|
||||
|
||||
useShortcuts(shortcuts, !hilfeOffen);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
@@ -54,6 +88,16 @@ export function AppLayout() {
|
||||
</Button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden sm:inline-flex"
|
||||
onClick={() => setHilfeOffen(true)}
|
||||
aria-label="Tastaturkürzel anzeigen"
|
||||
>
|
||||
<Keyboard aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -87,6 +131,8 @@ export function AppLayout() {
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ShortcutHelp open={hilfeOffen} onClose={() => setHilfeOffen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (daten: { username: string; password: string }) =>
|
||||
api.post<User>("/auth/login", daten),
|
||||
mutationFn: async (daten: { username: string; password: string }) => {
|
||||
const benutzer = await api.post<User>("/auth/login", daten);
|
||||
|
||||
// Gegenprobe: hält die Sitzung wirklich? Ohne sie liefe der Nutzer in eine
|
||||
// stille Schleife zurück zur Anmeldemaske, ohne je einen Grund zu sehen.
|
||||
try {
|
||||
await api.get<User>("/me");
|
||||
} catch (fehler) {
|
||||
if (isUnauthorized(fehler)) throw new SessionNotEstablishedError();
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
return benutzer;
|
||||
},
|
||||
onSuccess: (benutzer) => {
|
||||
client.setQueryData(keys.me, benutzer);
|
||||
void client.invalidateQueries();
|
||||
@@ -64,6 +94,7 @@ export function useChangePassword() {
|
||||
|
||||
/** Übersetzt einen Anmeldefehler in eine Meldung für das Formular. */
|
||||
export function loginErrorMessage(error: unknown): string {
|
||||
if (error instanceof SessionNotEstablishedError) return error.message;
|
||||
if (error instanceof ApiError) return error.message;
|
||||
return "Der Server ist nicht erreichbar.";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/** Die Monatsansichten müssen den Abrechnungsmonat abfragen, nicht den Kalendermonat. */
|
||||
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
|
||||
import { mockFetch, renderWithProviders } from "@/test/utils";
|
||||
|
||||
function Seite() {
|
||||
const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation();
|
||||
return (
|
||||
<div>
|
||||
<p data-testid="monat">{monat}</p>
|
||||
<p data-testid="beschriftung">{beschriftung}</p>
|
||||
<button onClick={zurueck}>zurück</button>
|
||||
<button onClick={vor}>vor</button>
|
||||
<button onClick={heute}>heute</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function einstellung(tag: number) {
|
||||
mockFetch({
|
||||
"/api/settings": {
|
||||
month_start_day: tag,
|
||||
current_month: "2026-09-01",
|
||||
current_period_start: "2026-09-01",
|
||||
current_period_end: "2026-09-30",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("Monatsnavigation", () => {
|
||||
beforeEach(() => {
|
||||
// Fester Stichtag: der 10. liegt vor einem Gehaltstag am 25.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date(2026, 8, 10, 12));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("zeigt ohne abweichende Einstellung den Kalendermonat", async () => {
|
||||
einstellung(1);
|
||||
renderWithProviders(<Seite />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("beschriftung")).toHaveTextContent("September 2026");
|
||||
});
|
||||
expect(screen.getByTestId("monat")).toHaveTextContent("2026-09-01");
|
||||
});
|
||||
|
||||
it("springt bei Gehaltstag 25 auf den laufenden Zeitraum", async () => {
|
||||
einstellung(25);
|
||||
renderWithProviders(<Seite />);
|
||||
|
||||
// Der 10.09. gehört noch zum Zeitraum, der am 25.08. begonnen hat.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01");
|
||||
});
|
||||
expect(screen.getByTestId("beschriftung")).toHaveTextContent(
|
||||
"August 2026 · 25.08.2026 – 24.09.2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("blättert monatsweise und kehrt zum laufenden Zeitraum zurück", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
einstellung(25);
|
||||
renderWithProviders(<Seite />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01"));
|
||||
|
||||
await nutzer.click(screen.getByText("vor"));
|
||||
expect(screen.getByTestId("monat")).toHaveTextContent("2026-09-01");
|
||||
|
||||
await nutzer.click(screen.getByText("zurück"));
|
||||
await nutzer.click(screen.getByText("zurück"));
|
||||
expect(screen.getByTestId("monat")).toHaveTextContent("2026-07-01");
|
||||
|
||||
await nutzer.click(screen.getByText("heute"));
|
||||
expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@layer base {
|
||||
/* Heller Modus */
|
||||
/* Heller Modus.
|
||||
Die Werte sind gegen die WCAG-Kontraste geprüft: Text erreicht überall
|
||||
mindestens 4,5:1, Bedienelemente 3:1. Insbesondere `faint` und `accent`
|
||||
sind bewusst dunkler als die naheliegenden Tailwind-Stufen – slate-400
|
||||
käme auf 2,56:1 und Weiß auf green-600 nur auf 3,30:1. */
|
||||
:root {
|
||||
--color-ground: 248 250 252;
|
||||
--color-surface: 255 255 255;
|
||||
--color-raised: 241 245 249;
|
||||
--color-line: 226 232 240;
|
||||
--color-line: 203 213 225;
|
||||
--color-ink: 15 23 42;
|
||||
--color-muted: 71 85 105;
|
||||
--color-faint: 148 163 184;
|
||||
--color-accent: 22 163 74;
|
||||
--color-faint: 91 107 128;
|
||||
--color-accent: 21 128 61;
|
||||
--color-accent-ink: 255 255 255;
|
||||
--color-positive: 21 128 61;
|
||||
--color-negative: 190 18 60;
|
||||
@@ -42,10 +46,11 @@
|
||||
--color-ground: 15 17 21;
|
||||
--color-surface: 24 27 33;
|
||||
--color-raised: 33 37 45;
|
||||
--color-line: 51 57 68;
|
||||
--color-line: 59 66 80;
|
||||
--color-ink: 226 232 240;
|
||||
--color-muted: 148 163 184;
|
||||
--color-faint: 100 116 139;
|
||||
/* Heller als slate-500: dort läge der Hilfstext bei 3,62:1. */
|
||||
--color-faint: 129 142 163;
|
||||
--color-accent: 34 197 94;
|
||||
--color-accent-ink: 4 20 11;
|
||||
--color-positive: 74 222 128;
|
||||
|
||||
@@ -146,6 +146,7 @@ export const api = {
|
||||
get: <T>(path: string, params?: Record<string, QueryValue>) => request<T>(path, { params }),
|
||||
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||
request<T>(path, { method: "POST", body, params }),
|
||||
put: <T>(path: string, body: unknown) => request<T>(path, { method: "PUT", body }),
|
||||
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body }),
|
||||
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
upload: <T>(path: string, formData: FormData) =>
|
||||
|
||||
@@ -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 { App } from "@/App";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
import { Toaster } from "@/components/ui/Toaster";
|
||||
import "@/index.css";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
@@ -16,7 +17,9 @@ createRoot(wurzel).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -21,12 +21,13 @@ import {
|
||||
useSaveBudget,
|
||||
useSaveBudgetTemplate,
|
||||
} from "@/hooks/useBudgets";
|
||||
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
|
||||
import { useBudgetStatus } from "@/hooks/useReports";
|
||||
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth } from "@/lib/format";
|
||||
import { formatDate, formatMoney, formatMonth } from "@/lib/format";
|
||||
import type { Budget, BudgetTemplate } from "@/types/api";
|
||||
|
||||
export function BudgetsPage() {
|
||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
||||
const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation();
|
||||
const [formular, setFormular] = useState<"budget" | "template" | null>(null);
|
||||
const [loeschen, setLoeschen] = useState<Budget | null>(null);
|
||||
const [vorlageLoeschen, setVorlageLoeschen] = useState<BudgetTemplate | null>(null);
|
||||
@@ -42,23 +43,23 @@ export function BudgetsPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="Budgets"
|
||||
description={formatMonth(monat)}
|
||||
description={beschriftung}
|
||||
actions={
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
|
||||
onClick={zurueck}
|
||||
aria-label="Vorheriger Monat"
|
||||
>
|
||||
<ChevronLeft aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
|
||||
<Button size="sm" onClick={heute}>
|
||||
Heute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
|
||||
onClick={vor}
|
||||
aria-label="Nächster Monat"
|
||||
>
|
||||
<ChevronRight aria-hidden className="h-4 w-4" />
|
||||
|
||||
@@ -90,6 +90,8 @@ function mockApi() {
|
||||
if (url.includes("/api/reports/calendar")) {
|
||||
return json({
|
||||
month: "2026-03-01",
|
||||
period_start: "2026-03-01",
|
||||
period_end: "2026-03-31",
|
||||
days: maerz(),
|
||||
opening_balance: "1000.00",
|
||||
closing_balance: "3250.00",
|
||||
@@ -115,11 +117,11 @@ describe("Cashflow-Kalender", () => {
|
||||
mockApi();
|
||||
});
|
||||
|
||||
it("zeigt die Kennzahlen des Monats", async () => {
|
||||
it("zeigt die Kennzahlen des Zeitraums", async () => {
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Stand zu Monatsbeginn")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stand zu Beginn")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/1.000,00/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Tiefster Stand")).toBeInTheDocument();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Cashflow-Kalender.
|
||||
*
|
||||
* Monatsraster mit den Fälligkeiten je Tag; darunter der Verlauf des
|
||||
* Kontostands über den Monat.
|
||||
* Tagesraster des Abrechnungsmonats mit den Fälligkeiten je Tag; darunter der
|
||||
* Verlauf des Kontostands über den Zeitraum. Beginnt der Monat am Gehaltstag,
|
||||
* reicht das Raster über zwei Kalendermonate.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
@@ -26,21 +27,23 @@ import { PageHeader } from "@/components/layout/AppLayout";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||
import { useConfirmOccurrence, useMerchants, useSkipOccurrence } from "@/hooks/useEntities";
|
||||
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
|
||||
import { useCalendar } from "@/hooks/useReports";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, todayIso, toNumber } from "@/lib/format";
|
||||
import { formatDate, formatMoney, todayIso, toNumber } from "@/lib/format";
|
||||
import type { CalendarDay, CalendarEntry, Merchant } from "@/types/api";
|
||||
|
||||
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
/** Führende Leerfelder, damit der Monat am richtigen Wochentag beginnt. */
|
||||
function fuehrendeLeerfelder(erster: string): number {
|
||||
/** Führende Leerfelder, damit der erste Tag im richtigen Wochentag steht. */
|
||||
function fuehrendeLeerfelder(erster: string | undefined): number {
|
||||
if (!erster) return 0;
|
||||
const datum = new Date(erster);
|
||||
return (datum.getDay() + 6) % 7;
|
||||
}
|
||||
|
||||
export function CalendarPage() {
|
||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
||||
const { monat, beschriftung, zurueck, vor, heute: aufHeute } = useMonthNavigation();
|
||||
const [gewaehlterTag, setGewaehlterTag] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useCalendar(monat);
|
||||
@@ -55,13 +58,13 @@ export function CalendarPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="Cashflow-Kalender"
|
||||
description={formatMonth(monat)}
|
||||
description={beschriftung}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMonat((alt) => addMonthsIso(alt, -1));
|
||||
zurueck();
|
||||
setGewaehlterTag(null);
|
||||
}}
|
||||
aria-label="Vorheriger Monat"
|
||||
@@ -71,7 +74,7 @@ export function CalendarPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMonat(firstOfMonth());
|
||||
aufHeute();
|
||||
setGewaehlterTag(null);
|
||||
}}
|
||||
>
|
||||
@@ -80,7 +83,7 @@ export function CalendarPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMonat((alt) => addMonthsIso(alt, 1));
|
||||
vor();
|
||||
setGewaehlterTag(null);
|
||||
}}
|
||||
aria-label="Nächster Monat"
|
||||
@@ -99,11 +102,16 @@ export function CalendarPage() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatTile label="Stand zu Monatsbeginn" value={formatMoney(data.opening_balance)} />
|
||||
<StatTile
|
||||
label="Stand zum Monatsende"
|
||||
label="Stand zu Beginn"
|
||||
value={formatMoney(data.opening_balance)}
|
||||
hint={formatDate(data.period_start)}
|
||||
/>
|
||||
<StatTile
|
||||
label="Stand am Ende"
|
||||
value={formatMoney(data.closing_balance)}
|
||||
tone={toNumber(data.closing_balance) < 0 ? "negative" : "default"}
|
||||
hint={formatDate(data.period_end)}
|
||||
/>
|
||||
<StatTile
|
||||
label="Tiefster Stand"
|
||||
@@ -123,7 +131,7 @@ export function CalendarPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{Array.from({ length: fuehrendeLeerfelder(data.month) }, (_, index) => (
|
||||
{Array.from({ length: fuehrendeLeerfelder(tage[0]?.date) }, (_, index) => (
|
||||
<div key={`leer-${index}`} aria-hidden />
|
||||
))}
|
||||
|
||||
@@ -150,13 +158,14 @@ export function CalendarPage() {
|
||||
|
||||
<ChartCard
|
||||
title="Verlauf des Kontostands"
|
||||
description="Fortgeschrieben aus dem Stand zu Monatsbeginn."
|
||||
description="Fortgeschrieben aus dem Stand zu Beginn des Zeitraums."
|
||||
>
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={tage.map((tag) => ({
|
||||
tag: new Date(tag.date).getDate(),
|
||||
datum: tag.date,
|
||||
stand: toNumber(tag.running_balance),
|
||||
}))}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 4 }}
|
||||
@@ -182,10 +191,10 @@ export function CalendarPage() {
|
||||
<ReferenceLine y={0} stroke="var(--viz-axis)" strokeWidth={1} />
|
||||
<Tooltip
|
||||
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
|
||||
content={({ active, payload, label }) =>
|
||||
content={({ active, payload }) =>
|
||||
active && payload?.length ? (
|
||||
<ChartTooltip
|
||||
title={`${label}. ${formatMonth(monat)}`}
|
||||
title={formatDate(payload[0]?.payload.datum)}
|
||||
rows={[
|
||||
{
|
||||
label: "Kontostand",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** Dashboard: die Kennzahlen des Monats auf einen Blick. */
|
||||
/** Dashboard: die Kennzahlen des Abrechnungsmonats auf einen Blick. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, PiggyBank, Wallet } from "lucide-react";
|
||||
@@ -15,11 +14,12 @@ import { Button } from "@/components/ui/Button";
|
||||
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||
import { useMerchants } from "@/hooks/useEntities";
|
||||
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
|
||||
import { useDashboard } from "@/hooks/useReports";
|
||||
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, relativeDays } from "@/lib/format";
|
||||
import { formatDate, formatMoney, formatMonth, relativeDays } from "@/lib/format";
|
||||
|
||||
export function DashboardPage() {
|
||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
||||
const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation();
|
||||
const { data, isLoading } = useDashboard(monat);
|
||||
const { data: firmenSeite } = useMerchants();
|
||||
const kategorieName = useCategoryLookup();
|
||||
@@ -30,22 +30,22 @@ export function DashboardPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description={formatMonth(monat)}
|
||||
description={beschriftung}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
|
||||
onClick={zurueck}
|
||||
aria-label="Vorheriger Monat"
|
||||
>
|
||||
<ChevronLeft aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
|
||||
<Button size="sm" onClick={heute}>
|
||||
Heute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
|
||||
onClick={vor}
|
||||
aria-label="Nächster Monat"
|
||||
>
|
||||
<ChevronRight aria-hidden className="h-4 w-4" />
|
||||
|
||||
@@ -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. */
|
||||
|
||||
import { type FormEvent, useMemo, useState } from "react";
|
||||
import { type FormEvent, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Building2, ImageIcon, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useRecurrences,
|
||||
useSaveMerchant,
|
||||
} from "@/hooks/useEntities";
|
||||
import { useSearchFocus } from "@/hooks/usePageShortcuts";
|
||||
import { formatMoney, toNumber } from "@/lib/format";
|
||||
import { annualCostOf } from "@/lib/recurrenceMath";
|
||||
import { useThemeStore } from "@/store/theme";
|
||||
@@ -32,6 +33,9 @@ export function MerchantsPage() {
|
||||
const { data: posten = [] } = useRecurrences({ active: true });
|
||||
const entfernen = useDeleteMerchant();
|
||||
|
||||
const suchfeld = useRef<HTMLInputElement>(null);
|
||||
useSearchFocus(suchfeld);
|
||||
|
||||
// Jahreskosten und Vertragszahl je Firma aus den aktiven Posten.
|
||||
const kennzahlen = useMemo(() => {
|
||||
const werte = new Map<number, { jahr: number; anzahl: number }>();
|
||||
@@ -62,6 +66,7 @@ export function MerchantsPage() {
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
|
||||
/>
|
||||
<Input
|
||||
ref={suchfeld}
|
||||
value={suche}
|
||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||
placeholder="Firma suchen"
|
||||
|
||||
@@ -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. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { Plus, Repeat, Search } from "lucide-react";
|
||||
|
||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
|
||||
import { MerchantLogo } from "@/components/MerchantLogo";
|
||||
import { RecurrenceDetailDrawer } from "@/components/RecurrenceDetail";
|
||||
import { RecurrenceFormDialog } from "@/components/RecurrenceForm";
|
||||
@@ -28,6 +29,10 @@ export function RecurrencesPage() {
|
||||
const { data: firmenSeite } = useMerchants();
|
||||
const kategorieName = useCategoryLookup();
|
||||
|
||||
const suchfeld = useRef<HTMLInputElement>(null);
|
||||
useSearchFocus(suchfeld);
|
||||
usePendingAction("new-recurrence", () => setFormularFuer(null));
|
||||
|
||||
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
|
||||
const begriff = suche.trim().toLowerCase();
|
||||
const sichtbar = begriff
|
||||
@@ -52,6 +57,7 @@ export function RecurrencesPage() {
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
|
||||
/>
|
||||
<Input
|
||||
ref={suchfeld}
|
||||
value={suche}
|
||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||
placeholder="Posten suchen"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PageHeader } from "@/components/layout/AppLayout";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||
import { Select } from "@/components/ui/Field";
|
||||
import { useMonthStartDay } from "@/hooks/useAppSettings";
|
||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||
import {
|
||||
exportUrl,
|
||||
@@ -19,14 +20,15 @@ import {
|
||||
useYearComparison,
|
||||
} from "@/hooks/useReports";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { firstOfMonth, formatDate, formatMoney, formatMonth, toNumber } from "@/lib/format";
|
||||
import { formatDate, formatMoney, toNumber } from "@/lib/format";
|
||||
import { periodBounds, periodKey, periodLabel } from "@/lib/period";
|
||||
import { describeRRule } from "@/lib/rrule";
|
||||
import type { Subscription } from "@/types/api";
|
||||
|
||||
type Reiter = "subscriptions" | "categories" | "year" | "export";
|
||||
|
||||
const REITER: { id: Reiter; label: string }[] = [
|
||||
{ id: "subscriptions", label: "Abos" },
|
||||
{ id: "subscriptions", label: "Laufende Kosten" },
|
||||
{ id: "categories", label: "Kategorien" },
|
||||
{ id: "year", label: "Jahresvergleich" },
|
||||
{ id: "export", label: "Export" },
|
||||
@@ -104,7 +106,12 @@ function SubscriptionsTab() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatTile label="Abos gesamt p. a." value={formatMoney(data.total_annual)} large />
|
||||
<StatTile
|
||||
label="Laufende Kosten p. a."
|
||||
value={formatMoney(data.total_annual)}
|
||||
large
|
||||
hint="Alle wiederkehrenden Ausgaben ohne Ratenzahlungen."
|
||||
/>
|
||||
<StatTile label="Entspricht pro Monat" value={formatMoney(data.total_monthly)} />
|
||||
<StatTile
|
||||
label="Laufende Posten"
|
||||
@@ -135,7 +142,9 @@ function SubscriptionsTab() {
|
||||
|
||||
<ChartCard
|
||||
title="Alle laufenden Posten"
|
||||
description="Ratenzahlungen sind gekennzeichnet und zählen nicht in die Gesamtsumme."
|
||||
description="Jede wiederkehrende Ausgabe mit ihren Jahreskosten – von der Miete bis
|
||||
zum Streamingdienst. Ratenzahlungen sind gekennzeichnet und zählen nicht in die
|
||||
Gesamtsumme, weil sie enden."
|
||||
actions={
|
||||
<Select
|
||||
aria-label="Sortierung"
|
||||
@@ -223,13 +232,13 @@ function SubscriptionRow({
|
||||
|
||||
function CategoriesTab() {
|
||||
const [zeitraum, setZeitraum] = useState<"month" | "year">("month");
|
||||
const startDay = useMonthStartDay();
|
||||
const heute = new Date();
|
||||
const von =
|
||||
zeitraum === "month" ? firstOfMonth() : `${heute.getFullYear()}-01-01`;
|
||||
const bis =
|
||||
zeitraum === "month"
|
||||
? new Date(heute.getFullYear(), heute.getMonth() + 1, 0).toISOString().slice(0, 10)
|
||||
: `${heute.getFullYear()}-12-31`;
|
||||
|
||||
const laufend = periodKey(startDay);
|
||||
const grenzen = periodBounds(startDay, laufend);
|
||||
const von = zeitraum === "month" ? grenzen.start : `${heute.getFullYear()}-01-01`;
|
||||
const bis = zeitraum === "month" ? grenzen.end : `${heute.getFullYear()}-12-31`;
|
||||
|
||||
const { data, isLoading } = useCategoryReport(von, bis);
|
||||
|
||||
@@ -240,7 +249,7 @@ function CategoriesTab() {
|
||||
<div className="flex gap-1">
|
||||
{(
|
||||
[
|
||||
["month", formatMonth(von)],
|
||||
["month", periodLabel(startDay, laufend)],
|
||||
["year", String(heute.getFullYear())],
|
||||
] as const
|
||||
).map(([wert, beschriftung]) => (
|
||||
@@ -293,7 +302,9 @@ function YearTab() {
|
||||
/* --- Export --------------------------------------------------------------- */
|
||||
|
||||
function ExportTab() {
|
||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
||||
const startDay = useMonthStartDay();
|
||||
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
|
||||
const monat = gewaehlt ?? periodKey(startDay);
|
||||
const jahr = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
@@ -318,13 +329,13 @@ function ExportTab() {
|
||||
|
||||
<ExportCard
|
||||
title="Monatsauswertung"
|
||||
description="Alle Bewegungen des Monats samt Kennzahlen."
|
||||
description={`Alle Bewegungen samt Kennzahlen: ${periodLabel(startDay, monat)}.`}
|
||||
extra={
|
||||
<input
|
||||
type="month"
|
||||
aria-label="Monat der Auswertung"
|
||||
value={monat.slice(0, 7)}
|
||||
onChange={(ereignis) => setMonat(`${ereignis.target.value}-01`)}
|
||||
onChange={(ereignis) => setGewaehlt(`${ereignis.target.value}-01`)}
|
||||
className="mb-3 w-full rounded-lg border border-line bg-raised px-3 py-2 text-sm text-ink"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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 { KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
|
||||
import { CalendarRange, KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
|
||||
|
||||
import { NotificationSettings } from "@/components/NotificationSettings";
|
||||
import { PageHeader } from "@/components/layout/AppLayout";
|
||||
@@ -11,6 +11,7 @@ import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feed
|
||||
import { Checkbox, Field, Input, Select } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { MoneyInput } from "@/components/ui/MoneyInput";
|
||||
import { useAppSettings, useSaveAppSettings } from "@/hooks/useAppSettings";
|
||||
import { useChangePassword, useMe } from "@/hooks/useAuth";
|
||||
import {
|
||||
useAccountBalance,
|
||||
@@ -21,7 +22,14 @@ import {
|
||||
useSaveAccount,
|
||||
useSaveCategory,
|
||||
} from "@/hooks/useEntities";
|
||||
import { formatDate, formatMoney, todayIso } from "@/lib/format";
|
||||
import { addMonthsIso, formatDate, formatMoney, formatMonth, todayIso } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_MONTH_START_DAY,
|
||||
MAX_MONTH_START_DAY,
|
||||
MIN_MONTH_START_DAY,
|
||||
periodBounds,
|
||||
periodKey,
|
||||
} from "@/lib/period";
|
||||
import { useThemeStore } from "@/store/theme";
|
||||
import type { Account, AccountType, Category, CategoryTree, EntryKind } from "@/types/api";
|
||||
|
||||
@@ -32,11 +40,12 @@ const KONTOARTEN: Record<AccountType, string> = {
|
||||
cash: "Bargeld",
|
||||
};
|
||||
|
||||
type Reiter = "accounts" | "categories" | "notifications" | "account";
|
||||
type Reiter = "accounts" | "categories" | "period" | "notifications" | "account";
|
||||
|
||||
const REITER: { id: Reiter; label: string }[] = [
|
||||
{ id: "accounts", label: "Konten" },
|
||||
{ id: "categories", label: "Kategorien" },
|
||||
{ id: "period", label: "Monatsbeginn" },
|
||||
{ id: "notifications", label: "Benachrichtigungen" },
|
||||
{ id: "account", label: "Konto & Darstellung" },
|
||||
];
|
||||
@@ -69,12 +78,107 @@ export function SettingsPage() {
|
||||
|
||||
{reiter === "accounts" && <AccountsSection />}
|
||||
{reiter === "categories" && <CategoriesSection />}
|
||||
{reiter === "period" && <PeriodSection />}
|
||||
{reiter === "notifications" && <NotificationSettings />}
|
||||
{reiter === "account" && <UserSection />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Monatsbeginn --------------------------------------------------------- */
|
||||
|
||||
/** Auswahl 1 bis 31; der 31. meint verlässlich den letzten Tag des Monats. */
|
||||
const STARTTAGE = Array.from(
|
||||
{ length: MAX_MONTH_START_DAY - MIN_MONTH_START_DAY + 1 },
|
||||
(_, index) => MIN_MONTH_START_DAY + index,
|
||||
);
|
||||
|
||||
function PeriodSection() {
|
||||
const { data, isLoading } = useAppSettings();
|
||||
const speichern = useSaveAppSettings();
|
||||
const [entwurf, setEntwurf] = useState<number | null>(null);
|
||||
|
||||
const gespeichert = data?.month_start_day ?? DEFAULT_MONTH_START_DAY;
|
||||
const gewaehlt = entwurf ?? gespeichert;
|
||||
const geaendert = data !== undefined && gewaehlt !== gespeichert;
|
||||
|
||||
const laufend = periodKey(gewaehlt);
|
||||
|
||||
function absenden(ereignis: FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
if (!geaendert) return;
|
||||
speichern.mutate({ month_start_day: gewaehlt }, { onSuccess: () => setEntwurf(null) });
|
||||
}
|
||||
|
||||
if (isLoading) return <Skeleton className="h-64 w-full" />;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="card p-4">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||
<CalendarRange aria-hidden className="h-4 w-4" />
|
||||
Erster Tag des Monats
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
Wer am Gehaltstag rechnet, für den beginnt der Monat nicht am Ersten. Dashboard,
|
||||
Kalender, Budgets, Vorschau und Export richten sich nach diesem Tag.
|
||||
</p>
|
||||
|
||||
<form onSubmit={absenden} className="mt-3 space-y-3">
|
||||
<Field
|
||||
label="Monatsbeginn"
|
||||
hint="Ein Tag jenseits der Monatslänge rückt auf den Monatsletzten – 31 meint also
|
||||
den letzten Tag jedes Monats."
|
||||
>
|
||||
{(id) => (
|
||||
<Select
|
||||
id={id}
|
||||
className="w-40"
|
||||
value={gewaehlt}
|
||||
onChange={(ereignis) => setEntwurf(Number(ereignis.target.value))}
|
||||
>
|
||||
{STARTTAGE.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}.
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" variant="primary" disabled={!geaendert || speichern.isPending}>
|
||||
Speichern
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-ink">Vorschau</h2>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
So werden die Zeiträume mit dem gewählten Tag geschnitten.
|
||||
</p>
|
||||
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
{[laufend, addMonthsIso(laufend, 1)].map((schluessel, index) => {
|
||||
const grenzen = periodBounds(gewaehlt, schluessel);
|
||||
return (
|
||||
<div key={schluessel} className="flex items-baseline justify-between gap-3">
|
||||
<dt className="text-muted">
|
||||
{formatMonth(schluessel)}
|
||||
{index === 0 && <Badge className="ml-2">laufend</Badge>}
|
||||
</dt>
|
||||
<dd className="tabular text-ink">
|
||||
{formatDate(grenzen.start)} – {formatDate(grenzen.end)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Konten --------------------------------------------------------------- */
|
||||
|
||||
function AccountsSection() {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/** Einmalige Buchungen: Liste, Filter und Formular. */
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { type FormEvent, useRef, useState } from "react";
|
||||
|
||||
import { Pencil, Plus, Receipt, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
|
||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
|
||||
import { MerchantLogo } from "@/components/MerchantLogo";
|
||||
import { PageHeader } from "@/components/layout/AppLayout";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
@@ -35,6 +36,10 @@ export function TransactionsPage() {
|
||||
const entfernen = useDeleteTransaction();
|
||||
const kategorieName = useCategoryLookup();
|
||||
|
||||
const suchfeld = useRef<HTMLInputElement>(null);
|
||||
useSearchFocus(suchfeld);
|
||||
usePendingAction("new-transaction", () => setBearbeiten(null));
|
||||
|
||||
const buchungen = data?.items ?? [];
|
||||
const gesamt = data?.total ?? 0;
|
||||
const seite = Math.floor((filter.offset ?? 0) / SEITENGROESSE) + 1;
|
||||
@@ -53,6 +58,7 @@ export function TransactionsPage() {
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
|
||||
/>
|
||||
<Input
|
||||
ref={suchfeld}
|
||||
value={suche}
|
||||
onChange={(ereignis) => {
|
||||
setSuche(ereignis.target.value);
|
||||
|
||||
@@ -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 {
|
||||
/** Bezeichner des Abrechnungsmonats – immer ein Monatserster. */
|
||||
month: IsoDate;
|
||||
period_start: IsoDate;
|
||||
period_end: IsoDate;
|
||||
planned: Totals;
|
||||
actual: Totals;
|
||||
previous_planned: Totals;
|
||||
@@ -385,6 +388,8 @@ export interface MonthReport {
|
||||
|
||||
export interface ForecastMonth {
|
||||
month: IsoDate;
|
||||
period_start: IsoDate;
|
||||
period_end: IsoDate;
|
||||
income: Money;
|
||||
expenses: Money;
|
||||
balance: Money;
|
||||
@@ -478,6 +483,8 @@ export interface CalendarDay {
|
||||
|
||||
export interface CalendarMonth {
|
||||
month: IsoDate;
|
||||
period_start: IsoDate;
|
||||
period_end: IsoDate;
|
||||
days: CalendarDay[];
|
||||
opening_balance: Money;
|
||||
closing_balance: Money;
|
||||
@@ -520,6 +527,7 @@ export interface SavingsGoalProgress {
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
month_start_day: number;
|
||||
month: MonthReport;
|
||||
total_balance: Money;
|
||||
forecast: ForecastMonth[];
|
||||
@@ -601,3 +609,15 @@ export interface NotificationRunResult {
|
||||
skipped: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
/** Tag, an dem der Abrechnungsmonat beginnt – der Gehaltstag. */
|
||||
month_start_day: number;
|
||||
current_month: IsoDate;
|
||||
current_period_start: IsoDate;
|
||||
current_period_end: IsoDate;
|
||||
}
|
||||
|
||||
export interface AppSettingsInput {
|
||||
month_start_day: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user