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

15 KiB

Statistik-Endpunkte: Clean-Cut auf dateFrom/dateTo + bucketSize

Beschreibung und Kontext

Folge-Arbeit zum PeriodPicker. 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 (periodbucketSize).
    • 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.
  • 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.