Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d182d0054 | ||
|
|
0a6261fc55 | ||
|
|
998d5867df | ||
|
|
c110340628 |
+33
-2
@@ -7,7 +7,37 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Noch keine Änderungen seit 0.1.1.
|
||||
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
|
||||
|
||||
@@ -175,6 +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.1...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,10 +32,18 @@ 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
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.
|
||||
|
||||
> **Version 0.1.0.** Siehe [CHANGELOG.md](CHANGELOG.md).
|
||||
> **Version 0.1.2.** Siehe [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -26,6 +26,8 @@ echte Kontostände auskommen. Ablegen unter `docs/screenshots/`.
|
||||
- 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.
|
||||
@@ -113,6 +115,24 @@ Nützliche Ziele: `make check` (alle Prüfungen), `make test`, `make lint`,
|
||||
|
||||
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
|
||||
@@ -134,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")
|
||||
|
||||
@@ -327,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,
|
||||
@@ -358,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(
|
||||
@@ -382,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.1"
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
@@ -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.1"
|
||||
version = "0.1.2"
|
||||
description = "moneyfy – Planung monatlicher Kosten und Einkünfte"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "moneyfy-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,7 +20,8 @@ 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";
|
||||
|
||||
@@ -230,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);
|
||||
|
||||
@@ -247,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]) => (
|
||||
@@ -300,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 (
|
||||
@@ -325,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() {
|
||||
|
||||
@@ -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