wiki/c-work/4-done/2026-04-period-picker-billing-audit-migration.md
2026-04-21 00:50:21 +02:00

267 lines
15 KiB
Markdown

<!-- status: done -->
<!-- started: 2026-04-20 -->
<!-- finished: 2026-04-20 -->
<!-- component: gateway, frontend-nyla -->
# Statistik-Endpunkte: Clean-Cut auf `dateFrom`/`dateTo` + `bucketSize`
## Beschreibung und Kontext
Folge-Arbeit zum [`PeriodPicker`](../../local/notes/changelog.txt). Die
Komponente liefert semantisch sauber `{ preset, fromDate, toDate }`. Drei
Statistik-Endpunkte im Gateway erwarten heute aber Legacy-Parameter, die
Date-Range mit anderen Konzepten vermischen:
| Endpunkt | Heute | Problem |
|---|---|---|
| `GET /api/audit/stats` | `?timeRange={days}` (relative Tagesanzahl ab heute) | Kein echter Bereich; im Frontend muss `PeriodValue` per Adapter `_periodToDays` auf eine Tagesanzahl reduziert werden, dadurch wird "01.04 - 03.04" als "letzte 3 Tage ab heute" ausgewertet → falsche KPIs. |
| `GET /api/billing/statistics/{period}` | `period: 'day' \| 'month' \| 'year'`, `year`, `month?` | `period` mischt zwei Verantwortungen: Date-Range-Berechnung **und** Bucket-Granularitaet; aerbitraere Ranges (z.B. "letzte 12 Monate", Custom) sind nicht ausdrueckbar. |
| `GET /api/billing/view/statistics` | `period: 'day' \| 'month'`, `year`, `month?` | Dito; zusaetzlich gibt `period` an `getTransactionStatisticsAggregated(..., period=)` die Bucket-Groesse weiter. |
Konsequenz heute: der PeriodPicker-Rollout in `BillingDashboard` und
`BillingDataView` ist blockiert; in `ComplianceAuditPage` arbeiten wir mit
einem dokumentierten Hack (`_periodToDays`).
**Diese Arbeit ist ein Clean-Cut**: die Legacy-Parameter werden entfernt, die
Endpunkte akzeptieren ausschliesslich `dateFrom`/`dateTo` (+ `bucketSize`,
wo Time-Series ausgegeben werden), und alle Frontend-Caller werden in derselben
Aenderung migriert. Keine Backwards-Compat-Aliase, kein "altes UND neues",
kein toleranter Fallback im Backend.
## Fokus und kritische Details
- **Eine Aenderung = eine PR**: Backend-Refactor + alle Frontend-Caller
zusammen, da ohne Aliase ein gemischter Zustand nicht laeuft.
- **`/api/billing/view/statistics` mischt zwei Konzepte**: (a) Date-Range
(`startTs/endTs`) und (b) Bucket-Granularitaet fuer Time-Series
(`getTransactionStatisticsAggregated(..., period=)` -> `day` vs `month`).
Diese **muessen getrennt werden**: Date-Range = `dateFrom`/`dateTo`,
Bucket-Groesse = neuer expliziter Param `bucketSize: 'day' | 'month' | 'year'`.
Default-Heuristik nur im Frontend, nicht im Backend.
- **Timezones**: alle Audit-Logger-Records sind UTC-Epoch-Sekunden; PeriodPicker
liefert lokale ISO-Daten (`YYYY-MM-DD`) ohne Timezone. Konversion eindeutig
und an genau einer Stelle (im Endpunkt selbst, vor der DB-Abfrage):
`dateFrom` -> `00:00:00 UTC` desselben Tages, `dateTo` -> `23:59:59.999 UTC`
desselben Tages (inklusive). Sonst fehlt der letzte Tag in der Aggregation.
→ Ein gemeinsamer Helper `_isoDateRangeToUtcEpoch(dateFrom, dateTo)` in
`gateway/modules/shared/dateRange.py`.
- **Validierung im Backend**: `dateFrom <= dateTo`, beide Pflicht (kein
optional/None), `bucketSize` aus geschlossenem Enum. Bei Verletzung HTTP 400
mit klarer Message. Keine impliziten Defaults.
- **Aufrufende Stellen vollstaendig erfassen** (geprueft per ripgrep, siehe
unten - keine externen Konsumenten ausserhalb des Frontends).
## Ziel und Nicht-Ziele
- **Ziel:**
- `/api/audit/stats` akzeptiert ausschliesslich `dateFrom`/`dateTo` (ISO
`YYYY-MM-DD`, Pflicht). Antwort enthaelt `dateFrom`/`dateTo`/`days` statt
`timeRangeDays`.
- `/api/billing/statistics` (Endpunkt-Pfad-Param `{period}` entfaellt, neuer
Pfad `GET /api/billing/statistics`) akzeptiert ausschliesslich
`dateFrom`/`dateTo` (Pflicht) + optional `bucketSize`.
- `/api/billing/view/statistics` analog.
- `aiAuditLogger.getAiAuditStats(mandateId, *, fromTs, toTs, groupBy)` -
Pflicht-Range.
- `getTransactionStatisticsAggregated(..., bucketSize=)` - Param `period`
umbenannt zu `bucketSize`.
- Frontend: `BillingDashboard`, `BillingDataView`, `ComplianceAuditPage`
nutzen `<PeriodPicker>` und schicken `dateFrom`/`dateTo`. Adapter
`_periodToDays` ersatzlos entfernt. `useBilling.loadStatistics`-Signatur
nur noch `({ dateFrom, dateTo, bucketSize? })`.
- **Explizit NICHT:**
- Aliase `timeRange` / `period` / `year` / `month` im Backend behalten.
- Stripe-`current_period_*` umbenennen oder beruehren - Stripe-API.
- Andere Stat-Endpunkte (`/api/admin/database-health/stats`, etc.) anfassen -
eigener Scope, sobald Bedarf.
- DB-Schema aendern.
## Betroffene Module
- **Gateway:**
- `gateway/modules/routes/routeAudit.py` (`getAuditStats`, Z. 303-317)
- `gateway/modules/shared/aiAuditLogger.py` (`getAiAuditStats`, Z. 195-249)
- `gateway/modules/routes/routeBilling.py`
(`getStatistics` Z. 526-604, `getUserViewStatistics` Z. 1619 ff.,
`UsageReportResponse` Z. 260)
- `gateway/modules/interfaces/interfaceBilling.py` (oder konkrete Impl):
`calculateStatisticsFromTransactions` (akzeptiert bereits `startDate`/`endDate`
→ keine Aenderung), `getTransactionStatisticsAggregated`
(`period` → `bucketSize`).
- **Neu**: `gateway/modules/shared/dateRange.py` - kleiner Helper
`parseIsoDateRange(dateFrom, dateTo) -> (date, date)` mit Validierung,
`isoDateRangeToUtcEpoch(dateFrom, dateTo) -> (float, float)`.
- **Frontend:**
- `frontend_nyla/src/api/billingApi.ts` (`fetchStatistics`,
`fetchViewStatistics`)
- `frontend_nyla/src/hooks/useBilling.ts` (`loadStatistics` Signatur
aendern)
- `frontend_nyla/src/pages/billing/BillingDashboard.tsx` (Selects raus,
PeriodPicker rein, optional separater `bucketSize`-Toggle)
- `frontend_nyla/src/pages/billing/BillingDataView.tsx`
(FormGeneratorReport: `periodSelector` raus, `dateRangeSelector` rein)
- `frontend_nyla/src/pages/ComplianceAuditPage.tsx`: `_periodToDays`
entfernen, `_loadStats({ dateFrom, dateTo })`.
- `frontend_nyla/src/api/auditApi.ts` (oder direkt in der Page, je nach
aktueller Struktur).
- **DB-Migration:** nein.
- **Tests:** Vorhandene Tests auf den 3 Endpunkten mitziehen (siehe
Aufwandsschaetzung).
## Externe Konsumenten - Pruefung
Vor Clean-Cut explizit verifizieren, dass keine externen Aufrufer
(Webhooks, Skripte, Excel-Reports, Teams-Bot, private-llm) die Endpunkte
direkt nutzen:
- [ ] `rg "billing/statistics" gateway/ private-llm/ teams-bot/`
- [ ] `rg "audit/stats" gateway/ private-llm/ teams-bot/`
- [ ] `rg "/api/billing/statistics|/api/audit/stats" frontend_nyla/`
Wenn Treffer ausserhalb des Frontends auftauchen, werden sie im selben PR
mitgezogen. Wenn nicht, kann der Clean-Cut erfolgen.
## Entscheidungen
| Datum | Entscheidung | Begründung |
|-------|-------------|------------|
| 2026-04-20 | Clean-Cut, keine Backwards-Compat-Aliase | Vermischte Param-Konzepte sind die Wurzel der UX-Probleme; sie zu konservieren reproduziert genau das Problem, das wir loesen. Es gibt keine externen Konsumenten ausserhalb unseres Frontends (zu verifizieren, siehe oben). |
| 2026-04-20 | `dateFrom`/`dateTo` als ISO `YYYY-MM-DD` String, **nicht** Epoch | Konsistent mit `PeriodValue`, lesbar in Logs/Browsernetzwerk, eindeutig (Tagesgrenze, kein Sub-Tag). Audit-Logger arbeitet zwar intern mit Epoch, aber die Konversion macht der Endpunkt zentral. |
| 2026-04-20 | `dateFrom`/`dateTo` Pflicht, kein impliziter Default | Verhindert "vergessene Filter" (z.B. globale Aggregation ueber alle Zeit, schlecht fuer Performance). Caller muss bewusst einen Bereich waehlen. Defaults gehoeren ins Frontend. |
| 2026-04-20 | `bucketSize` separater Param, nicht in `dateFrom`/`dateTo` impliziert | Eine Verantwortung pro Param. Erlaubt "letzte 30 Tage in Tagen" UND "letzte 30 Tage in Wochen" ohne Backend-Aenderung. |
| 2026-04-20 | `bucketSize` ohne Default | Fuer Endpunkte mit Time-Series Antwort: Caller muss explizit waehlen, sonst HTTP 400. Frontend kapselt sinnvolle Heuristik. |
| 2026-04-20 | Pfad `GET /api/billing/statistics` ohne `{period}` | `period` war Pfad-Param, mit Wegfall hat es im Pfad nichts mehr verloren. Saubere Resource-URL. |
## Umsetzungs-Checkliste
### A. Vorbereitung (~30 min)
- [ ] Konsumenten-Pruefung wie oben (drei `rg`-Aufrufe).
- [ ] `gateway/modules/shared/dateRange.py` anlegen mit
`parseIsoDateRange(dateFrom: str, dateTo: str) -> tuple[date, date]`
(HTTP 400 bei ungueltig oder `from > to`) und
`isoDateRangeToUtcEpoch(dateFrom: str, dateTo: str) -> tuple[float, float]`.
Unit-Tests dazu.
### B. Backend Audit-Stats (~1 h)
- [ ] `aiAuditLogger.getAiAuditStats(mandateId, *, fromTs: float, toTs: float, groupBy: str = "model")`:
Cutoff-Berechnung raus, ersetzt durch Range-Filter `fromTs <= ts <= toTs`.
`timeRangeDays`-Feld in der Antwort durch `dateFrom`, `dateTo`, `days`
(`(toTs - fromTs) / 86400`, gerundet) ersetzen.
- [ ] `routeAudit.getAuditStats`:
```python
dateFrom: str = Query(..., description="ISO YYYY-MM-DD")
dateTo: str = Query(..., description="ISO YYYY-MM-DD")
groupBy: str = Query("model", regex="^(model|user|feature|day)$")
```
→ ueber `isoDateRangeToUtcEpoch` an Logger weitergeben. `timeRange`
Param weg.
### C. Backend Billing-Stats (~2 h)
- [ ] `getTransactionStatisticsAggregated`: Param `period` umbenennen zu
`bucketSize` (Enum-Validierung im Endpunkt, hier nur Type-Hint Literal).
Alle Aufrufe im Modul mitziehen.
- [ ] `routeBilling.getStatistics`:
- Pfad-Param `{period}` raus, neuer Pfad `GET /api/billing/statistics`.
- Neue Query-Params `dateFrom: date = Query(...)`, `dateTo: date = Query(...)`,
`bucketSize: Literal['day','month','year'] = Query(...)`.
- Range-Berechnung Z. 570-582 ersetzen durch `parseIsoDateRange`.
- `UsageReportResponse.period: str` → entfernen oder umbenennen zu
`bucketSize`. Anderen Felder bleiben.
- [ ] `routeBilling.getUserViewStatistics`:
- Query-Params analog. `period` und `month`/`year` raus.
- `bucketSize` an `getTransactionStatisticsAggregated` weitergeben.
### D. Frontend (~2 h)
- [ ] `billingApi.fetchStatistics` neu signiert:
`fetchStatistics(request, { dateFrom, dateTo, bucketSize })`. Alte
Signatur weg.
- [ ] `billingApi.fetchViewStatistics` analog.
- [ ] `useBilling.loadStatistics({ dateFrom, dateTo, bucketSize })` -
bestehende Aufrufer auf neue Signatur umstellen.
- [ ] `BillingDashboard.tsx`:
- `selectedPeriod`, `selectedYear`, `selectedMonth` State raus.
- `<PeriodPicker direction="past" defaultPreset={{ kind: 'thisMonth' }}
enabledPresets={['thisMonth','lastMonth','thisQuarter','lastQuarter','ytd','lastYear','last12Months','lastN','custom']} />`.
- Separater kleiner `<select>` `bucketSize` (Tag/Monat/Jahr) mit
Frontend-Heuristik als Default (z.B. range <= 60 Tage → 'day',
<= 24 Monate → 'month', sonst 'year'); user kann uebersteuern.
- [ ] `BillingDataView.tsx`: `periodSelectorConfig` raus,
`dateRangeSelector={{ enabled: true, direction: 'past', defaultPresetKind: 'thisMonth' }}`
rein. Bucket-Size-Toggle separat im Toolbar (eigene
`FormGeneratorReport`-Erweiterung oder ausserhalb).
- [ ] `ComplianceAuditPage.tsx`: `_periodToDays` und das `_DEFAULT_STATS_PRESET`
raus, `_loadStats({ dateFrom, dateTo })`. PeriodPicker-onChange uebergibt
direkt `next.fromDate` / `next.toDate`.
### E. Tests (~1 h)
- [ ] `gateway/tests/test_routeAudit.py` aktualisieren: alte `timeRange`-Tests
ersetzen durch `dateFrom`/`dateTo`-Tests (positiv + 400 bei from>to + 400
bei missing).
- [ ] `gateway/tests/test_routeBilling.py` aktualisieren: alte `period+year+month`-Tests
ersetzen.
- [ ] Unit-Tests fuer `dateRange.py`-Helper (Parsing, Timezone, Inklusivitaet
des letzten Tages).
## Akzeptanzkriterien
| # | Kriterium (Given-When-Then) | Prio |
|---|---------------------------|------|
| 1 | **Given** ein User auf `BillingDashboard` **when** er im PeriodPicker "01.03.2026 - 15.04.2026" waehlt **then** zeigen die Charts genau diesen Bereich, mit korrektem `bucketSize`-Toggle daneben. | must |
| 2 | **Given** `GET /api/billing/statistics?dateFrom=2026-04-01&dateTo=2026-04-15&bucketSize=day` **then** liefert genau Transaktionen vom 01.04 00:00 bis 15.04 23:59:59 lokal, in Tages-Buckets. | must |
| 3 | **Given** `GET /api/billing/statistics?dateFrom=2026-04-15&dateTo=2026-04-01&bucketSize=day` **then** Antwort HTTP 400 mit Message "dateFrom must be <= dateTo". | must |
| 4 | **Given** `GET /api/billing/statistics?dateFrom=2026-04-01&dateTo=2026-04-15` (kein `bucketSize`) **then** Antwort HTTP 400. | must |
| 5 | **Given** `GET /api/audit/stats?dateFrom=2026-04-01&dateTo=2026-04-03` **then** Antwort enthaelt `dateFrom`, `dateTo`, `days=3`, KPIs nur fuer diese 3 Tage. | must |
| 6 | **Given** ein alter Aufruf `GET /api/audit/stats?timeRange=7` ohne neue Params **then** Antwort HTTP 422 (unknown query param) - Legacy-Param ist explizit entfernt. | must |
| 7 | **Given** ein User im `ComplianceAuditPage` waehlt "Custom: 01.04 - 03.04" **then** sieht er KPIs fuer genau 3 Tage; der Adapter `_periodToDays` existiert nicht mehr im Code. | must |
| 8 | **Given** ein PeriodPicker-Range "letzte 24 Monate" **when** der User im `BillingDashboard` `bucketSize=year` waehlt **then** zeigt das Chart 3 Jahresbalken. | should |
## Testplan
| ID | AC | Art | Automatisiert | Repo-Pfad | Status |
|----|----|-----|--------------|-----------|--------|
| T1 | 2 | api | ja | `gateway/tests/test_routeBilling.py::test_getStatistics_dateRange_dayBucket` | pending |
| T2 | 3 | api | ja | `gateway/tests/test_routeBilling.py::test_getStatistics_invertedRange_400` | pending |
| T3 | 4 | api | ja | `gateway/tests/test_routeBilling.py::test_getStatistics_missingBucketSize_400` | pending |
| T4 | 5 | api | ja | `gateway/tests/test_routeAudit.py::test_getAuditStats_dateRange` | pending |
| T5 | 6 | api | ja | `gateway/tests/test_routeAudit.py::test_getAuditStats_legacyParam_rejected` | pending |
| T6 | - | unit | ja | `gateway/tests/test_dateRange.py::test_inclusive_endOfDay` | pending |
| T7 | 1, 8 | manuell | nein | `BillingDashboard` smoke | pending |
| T8 | 7 | manuell | nein | `ComplianceAuditPage` smoke + grep `_periodToDays` muss leer sein | pending |
## Reihenfolge der Umsetzung
Der Clean-Cut zwingt zu einer atomaren PR (Backend + alle Caller in einem
Commit). Empfohlene lokale Arbeits-Reihenfolge:
1. A: `dateRange`-Helper inkl. Tests.
2. B: Audit-Backend.
3. C: Billing-Backend.
4. D: Frontend (alle drei Pages parallel).
5. E: Tests gruen, manueller Smoke aller drei Pages.
6. PR.
## Links
- Vorarbeit: PeriodPicker-Komponente + Schritt-1+2-Rollout (Changelog
2026-04-20)
- Code-Anker:
- `gateway/modules/routes/routeAudit.py:303`
- `gateway/modules/shared/aiAuditLogger.py:195`
- `gateway/modules/routes/routeBilling.py:526`
- `gateway/modules/routes/routeBilling.py:1619`
- `frontend_nyla/src/pages/billing/BillingDashboard.tsx` (`selectedPeriod`)
- `frontend_nyla/src/pages/billing/BillingDataView.tsx` (`_loadViewStatistics`)
- `frontend_nyla/src/pages/ComplianceAuditPage.tsx` (`_periodToDays`)
## Abschluss
- [ ] `b-reference/gateway/billing.md` Statistik-Section neu schreiben
(`dateFrom`/`dateTo`/`bucketSize`).
- [ ] `b-reference/platform/audit.md` Stats-Section neu schreiben.
- [ ] TOPICS.md - keine Aenderung noetig.
- [ ] Dieses Dokument → `z-archive/` verschieben.