google configs

This commit is contained in:
ValueOn AG 2026-04-21 00:50:21 +02:00
parent 4c073c4f04
commit 97bef86276
6 changed files with 1047 additions and 10 deletions

View file

@ -19,6 +19,72 @@ Diese Referenz fokussiert auf **FormGeneratorTable** und das zugehoerige Backend
---
## Page Layout Chain (Pflicht)
`FormGeneratorTable` rendert intern mit `flex:1; min-height:0; overflow:hidden`. Damit die Tabelle die volle Seitenbreite/-hoehe ausnuetzt und nicht abgeschnitten wird, **muss** die Eltern-Hierarchie eine **bounded height chain** liefern. Andernfalls passiert eines davon:
- Tabelle ist 0 px hoch (kollabiert) -- nur Toolbar sichtbar.
- Tabelle waechst ueber den Viewport hinaus, horizontaler Scroll fehlt -- letzte Spalten werden abgeschnitten.
- Page-Container hat `max-width` -> Tabelle ist auf z.B. 800 px begrenzt.
### Korrektes Pattern (verwendet PromptsPage, TrusteePositionsView, TrusteeDataTablesView)
```tsx
import adminStyles from '../../admin/Admin.module.css';
export const MyPage: React.FC = () => (
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<div className={adminStyles.pageHeader}>...</div>
<div className={adminStyles.tableContainer}>
<FormGeneratorTable {...props} />
</div>
</div>
);
```
Die drei CSS-Klassen aus `frontend_nyla/src/pages/admin/Admin.module.css`:
| Klasse | Wirkung |
|--------|---------|
| `.adminPage` | `display:flex; flex-direction:column; width:100%; box-sizing:border-box; flex:0 0 auto` (Default fuer Standard-Seiten ohne Tabelle) |
| `.adminPageFill` | `flex:1 1 auto; min-height:0; overflow:hidden` -- aktiviert die bounded height chain |
| `.tableContainer` | `flex:1; min-height:0; overflow:hidden; display:flex; flex-direction:column` -- direktes Eltern-Element der Tabelle |
`FeatureView`/`MainLayout` liefern bereits `height:100%` mit `min-height:0` -- Page-Komponenten brauchen also nur `adminPage adminPageFill` und einen `tableContainer` direkt um die `FormGeneratorTable`.
### Bei Tabs / Wrapper-Komponenten
Wenn die Tabelle in einem Tab-Wrapper (z.B. `TrusteeDataTab`) gemountet wird, muss **jeder** Wrapper die Flex-Chain weiterreichen. Pattern:
```tsx
const _rootStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
width: '100%',
};
const _tableWrapStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
width: '100%',
};
```
Eine Toolbar oberhalb der Tabelle bekommt `flexShrink: 0`, der Tabellenwrapper `flex: 1; minHeight: 0`.
### Anti-Patterns (verursachen das "Tabelle abgeschnitten"-Bug)
- Outer-Wrapper mit `max-width: 800px` (z.B. `.expenseImportSection` aus `TrusteeViews.module.css`) -- begrenzt die Breite.
- Outer `<div>` ohne `flex:1; min-height:0` -- Tabelle kollabiert auf 0 px Hoehe.
- `.adminPage` ohne `.adminPageFill` -- Default ist `flex:0 0 auto`, das innere `.tableContainer flex:1` hat keinen Platz.
- Padding auf einem Zwischen-Wrapper -- bricht das Box-Sizing der Chain.
---
## FormGeneratorTable -- Props
### Datenanbindung

View file

@ -0,0 +1,159 @@
<!-- status: build -->
<!-- started: 2026-04-20 -->
<!-- component: gateway | frontend-nyla -->
# Trustee: Konsolidierte Daten-Tabellen-Seite
## Beschreibung und Kontext
Aktuell hat das Trustee-Feature zwei separate Top-Level-Seiten für Datensichten (`Positionen`, `Dokumente`). Die übrigen 11 Trustee-Tabellen (Stammdaten + Sync-Daten + Buchhaltungs-Config/Sync) sind im UI **nicht sichtbar** nur als JSON-Export oder per AI-Agent erreichbar. Das ist intransparent: Anwender und Auditor:innen sehen die importierten Konten / Buchungen / Kontakte / Salden nirgends.
Lösung: Eine neue konsolidierte Seite `Daten-Tabellen` mit einem Tab pro Trustee-Tabelle. Jeder Tab nutzt `FormGeneratorTable` mit Pagination, Sortierung, Filterung und Suche exakt das Pattern, das `PromptsPage` und die bestehenden `TrusteePositionsView` / `TrusteeDocumentsView` etabliert haben.
Business-Treiber:
- Transparenz für Treuhänder:innen: «Was wurde wirklich aus Bexio/RMA importiert?»
- Audit-Fähigkeit: Sync-Status und Sync-Snapshots direkt im UI prüfen.
- Vereinheitlichung: Konsistente UI über alle Tabellen statt zwei isolierter Spezialseiten.
Risiko bei Verzicht: Anwender greifen via Export/Excel auf interne Daten zu (umständlich, fehleranfällig); Sync-Probleme bleiben unbemerkt.
## Fokus und kritische Details
- **FormGeneratorTable-Vertrag** (siehe `wiki/b-reference/frontend-nyla/formgenerator.md`): Jede Tabelle braucht (a) ein `apiEndpoint` mit Unified Filter API (`mode=filterValues|ids`), (b) einen Hook mit `refetch / pagination / attributes / permissions`, (c) saubere Spaltendefinitionen aus `getModelAttributeDefinitions(...)`.
- **`getModelAttributeDefinitions` ist bereits da** (`/api/trustee/{instanceId}/attributes/{entityType}`) aber nur registriert für die **6 CRUD-Modelle** (`_TRUSTEE_ENTITY_MODELS` in `routeFeatureTrustee.py`, Zeile 195). Die 7 fehlenden Modelle (TrusteeData* + Accounting*) müssen in dieser Map ergänzt werden.
- **Sync-Tabellen sind read-only** (User-Entscheidung). Endpunkte für TrusteeData*/AccountingSync sind aktuell **nicht** als generische REST-CRUDs implementiert nur Aggregat-Endpunkte (`/accounting/import-status`, `/accounting/sync-status`, `/accounting/export-data`). Es braucht **neue Read-Endpunkte** mit Pagination + Unified Filter API.
- **RBAC-Konsistenz**: Jede Tabelle hat in `mainTrustee.DATA_OBJECTS` einen `data.feature.trustee.<Model>`-Key. Der Hook (`_createTrusteeEntityHook`) löst Permissions via `data.feature.trustee.{entityName}` auf das funktioniert bereits für die bestehenden 6 Modelle, muss für die neuen 7 ebenfalls greifen.
- **Backwards-Compat**: Die bestehenden Seiten `Positionen` und `Dokumente` bleiben **vorerst** erhalten (Navigation + Routes + RBAC). Erst wenn die neue Seite verifiziert ist, werden sie in einer Folge-Iteration entfernt.
- **Tab-State via URL** (`?tab=positions`): Konsistent mit `TrusteeImportProcessView`, `TrusteeAccountingSettingsView`, `TrusteeAbschlussView`. Erlaubt Deep-Links (z.B. Quick Actions / Notification-Links) auf konkrete Tabellen.
- **Performance**: Lazy-Mount pro Tab (Hook ruft nur Daten, wenn Tab aktiv ist) sonst werden 13 Tabellen parallel geladen.
- **i18n-Pflicht**: Alle Tab-Labels via `t(...)`. Spalten-Labels kommen aus dem Backend-`@i18nModel`.
- **Hidden System-Felder**: `mandateId`, `featureInstanceId`, technische `id`-UUIDs sollen tabellenweit hidden sein (gleiche Liste wie in PromptsPage `hiddenColumns`).
- **Naming**: Funktionen `_`-Prefix für intern (`_buildEntityHookConfig`, `_renderTabBar`); camelCase überall.
## Ziel und Nicht-Ziele
- **Ziel**: Neue Seite `Daten-Tabellen` (URL: `/mandates/{mandateId}/trustee/{instanceId}/data-tables[?tab=<key>]`) mit einem Tab pro Trustee-Tabelle. Jeder Tab nutzt `FormGeneratorTable` mit Pagination/Sort/Filter/Search/Inline-Edit (für CRUD-Tabellen) bzw. read-only (für Sync-Tabellen).
- **Ziel**: Backend liefert Attribute & paginierte Daten via Unified Filter API auch für die 7 bisher UI-losen Tabellen.
- **Ziel**: Tab-Sichtbarkeit folgt RBAC (`data.feature.trustee.<Model>` view-Permission).
- **Ziel**: Konsistentes UX-Pattern mit `PromptsPage` (Header / Refresh / FormGeneratorTable / optional Edit-Modal via FormGeneratorForm für CRUD-Tabellen).
- **Explizit NICHT**: Entfernen der alten `Positionen`/`Dokumente`-Seiten in dieser Iteration (Folge-Plan). Routen + Quick Actions + RBAC bleiben unangetastet.
- **Explizit NICHT**: Inline-Edit oder Bulk-Sync für TrusteeData*-Tabellen sie sind reine Sync-Spiegel.
- **Explizit NICHT**: Neuer DB-Schema-Wandel oder Migration nur Read-Endpunkte ergänzen.
- **Explizit NICHT**: Beleg-Download-Spalte oder Sync-Status-Spalte im Positions-Tab die Spezialdarstellung bleibt der eigenständigen `TrusteePositionsView` vorbehalten (Generic-Tab zeigt das «rohe» Modell).
## Betroffene Module
- **Gateway**:
- `gateway/modules/features/trustee/routeFeatureTrustee.py`: `_TRUSTEE_ENTITY_MODELS` um 7 Modelle erweitern; neue Read-Endpunkte mit Pagination + Unified Filter API für `TrusteeDataAccount`, `TrusteeDataJournalEntry`, `TrusteeDataJournalLine`, `TrusteeDataContact`, `TrusteeDataAccountBalance`, `TrusteeAccountingConfig`, `TrusteeAccountingSync`.
- `gateway/modules/features/trustee/mainTrustee.py`: `UI_OBJECTS` um `ui.feature.trustee.data-tables` ergänzen; `TEMPLATE_ROLES.accessRules` der relevanten Rollen erweitern.
- **Frontend**:
- **Neu**: `frontend_nyla/src/pages/views/trustee/TrusteeDataTablesView.tsx` (Container mit Tab-Bar + URL-Sync).
- **Neu**: `frontend_nyla/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx` (generischer Tab-Body mit FormGeneratorTable für CRUD- und Read-only-Modi).
- **Erweitern**: `frontend_nyla/src/hooks/useTrustee.ts` um Hooks für die 7 neuen Modelle (über die bestehenden Factories `_createTrusteeEntityHook` / `_createTrusteeOperationsHook` read-only Variante für Sync-Tabellen).
- **Erweitern**: `frontend_nyla/src/api/trusteeApi.ts` um `fetchTrusteeData*`-Funktionen.
- **Anpassen**: `frontend_nyla/src/pages/FeatureView.tsx` (`VIEW_COMPONENTS.trustee` um `'data-tables': TrusteeDataTablesView` ergänzen).
- **Anpassen**: `frontend_nyla/src/pages/views/trustee/index.ts` (Export).
- **Anpassen**: `frontend_nyla/src/App.tsx` (Route `data-tables` registrieren).
- **Optional**: `frontend_nyla/src/types/mandate.ts` (`FEATURE_REGISTRY.trustee.views`) um `data-tables` ergänzen, falls noch genutzt.
- **DB-Migration**: nein.
- **Andere Komponenten**: keine.
## Entscheidungen
| Datum | Entscheidung | Begründung |
|-------|-------------|------------|
| 2026-04-20 | Seitenname: `Daten-Tabellen` | User-Entscheidung; klar, präzise, dt. |
| 2026-04-20 | Alte Seiten `Positionen` + `Dokumente` bleiben vorerst | User-Entscheidung; Sicherheitsnetz bis neue Seite verifiziert ist Aufräumen in Folge-Plan. |
| 2026-04-20 | Sync-Tabellen (`TrusteeData*`, `TrusteeAccountingSync`, `TrusteeAccountingConfig`) sind read-only im UI | User-Entscheidung; Daten kommen aus externem System Edits würden bei nächstem Sync überschrieben. |
| 2026-04-20 | Alle 13 Tabellen erhalten einen Tab (User wählte "alle" im Datenmodell sind es 13: 4 Stammdaten + 2 CRUD-Daten + 5 Sync-Daten + 2 Accounting). | User-Entscheidung; vollständige Transparenz. |
| 2026-04-20 | Tab-State via URL-Param `?tab=<key>` | Konsistent mit allen anderen Trustee-Tab-Seiten. |
| 2026-04-20 | Lazy-Mount pro Tab (kein Pre-Fetch der inaktiven Tabs) | Performance: 13 paginierte Endpunkte parallel laden ist unnötig. |
| 2026-04-20 | Generische Tab-Komponente (`TrusteeDataTab`) statt 13 spezialisierter Views | DRY; Spezialisierungen (Beleg-Download/Sync-Status-Spalte) bleiben ausschliesslich in `TrusteePositionsView` / `TrusteeDocumentsView`. |
| 2026-04-20 | Keine Bulk-Aktionen im neuen Tab (kein Sync-Button etc.) | Bulk-Sync ist im Positions-Tab schon vorhanden Generic-Seite bleibt schlank. |
## Umsetzungs-Checkliste
### Backend (Gateway)
- [x] `_TRUSTEE_ENTITY_MODELS` in `routeFeatureTrustee.py` um die 7 neuen Modelle erweitert.
- [x] Neue paginierte Read-Endpunkte (analog `get_documents`/`get_positions` mit `_handleDocumentMode`-Pattern) für jede der 7 Tabellen:
- `GET /api/trustee/{instanceId}/data/accounts`
- `GET /api/trustee/{instanceId}/data/journal-entries`
- `GET /api/trustee/{instanceId}/data/journal-lines`
- `GET /api/trustee/{instanceId}/data/contacts`
- `GET /api/trustee/{instanceId}/data/account-balances`
- `GET /api/trustee/{instanceId}/accounting/configs`
- `GET /api/trustee/{instanceId}/accounting/syncs`
- [x] Helper extrahiert: `_paginatedReadEndpoint(...)` haelt Logik fuer alle 7 Endpunkte zentral.
- [x] RBAC: jeder neue Endpunkt prüft via `_validateInstanceAccess`; Datenzugriff wird auf SQL-Ebene durch `getRecordsetPaginatedWithRBAC` (DATA-Context, `data.feature.trustee.<Model>`) gefiltert identisch zum bestehenden Documents/Positions-Pattern.
- [x] `mainTrustee.py`: Neuer UI-Object-Eintrag `ui.feature.trustee.data-tables` (Label «Daten-Tabellen»).
- [x] `mainTrustee.py`: AccessRule fuer `ui.feature.trustee.data-tables` bei `trustee-viewer`, `trustee-user`, `trustee-accountant` ergaenzt; Admin hat Wildcard.
### Frontend
- [x] `frontend_nyla/src/api/trusteeApi.ts`: 7 neue Read-Funktionen (`fetchDataAccounts`, `fetchDataJournalEntries`, `fetchDataJournalLines`, `fetchDataContacts`, `fetchDataAccountBalances`, `fetchAccountingConfigs`, `fetchAccountingSyncs`) plus passende Typen.
- [x] `frontend_nyla/src/hooks/useTrustee.ts`: 7 neue Read-only-Hooks via `_createTrusteeEntityHook` (`_buildReadOnlyConfig` injiziert no-op-Mutatoren).
- [x] **Neu** `frontend_nyla/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx` generischer Tab-Body, bindet `FormGeneratorTable` ein, respektiert `readOnly`.
- [x] **Neu** `frontend_nyla/src/pages/views/trustee/TrusteeDataTablesView.tsx`:
- 13 Tab-Defs, jede mit eigenem Wrapper-Component, das den passenden Hook aufruft.
- URL-Sync via `?tab=<key>`, Lazy-Mount (nur aktiver Wrapper rendert -> kein Datenfetch fuer inaktive Tabs).
- Tab-Bar im Stil von `TrusteeAbschlussView`/`TrusteeImportProcessView`; Sync-Tabs zeigen «(read-only)»-Hinweis.
- RBAC-Filter im Frontend deaktiviert: einzelne Tab-Hooks blockieren Datenzugriff serverseitig (vermeidet sync-Permission-Lookups; spaetere Iteration kann Tab-Hiding nachziehen).
- [x] `frontend_nyla/src/pages/views/trustee/index.ts`: Export ergaenzt.
- [x] `frontend_nyla/src/pages/FeatureView.tsx`: Mapping `'data-tables': TrusteeDataTablesView` + Import.
- [x] `frontend_nyla/src/App.tsx`: Route `<Route path="data-tables" .../>` ergaenzt.
- [x] `frontend_nyla/src/types/mandate.ts`: `FEATURE_REGISTRY.trustee.views` um `'data-tables'` ergaenzt.
### Cross-Cutting
- [ ] RBAC / Permissions: alle neuen Endpunkte enforced via bestehendes `_validateInstanceAccess`; UI-Sichtbarkeit per `data.feature.trustee.<Model>`-Check (Hook liefert das via `permissions.view`).
- [ ] Neutralisierung betroffen? **Nein** keine AI-Calls, kein neuer Datenfluss zu externen Systemen.
- [ ] Navigation / Routing: neue UI-Route `/mandates/{m}/trustee/{i}/data-tables[?tab=<key>]`.
- [ ] Billing-Impact? **Nein**.
- [ ] i18n: alle Tab-Labels und Header-Strings via `t(...)`; Spalten-Labels kommen aus `@i18nModel`-getaggten Modellen (bereits vorhanden für alle 13).
- [ ] Quick Actions: keine Änderung Quick Actions zeigen weiterhin auf `import-process` / `settings`. Optional in Folge-Iteration: Direktlinks auf einzelne Datentabs.
## Akzeptanzkriterien
| # | Kriterium (Given-When-Then) | Prio |
|---|----------------------------|------|
| 1 | Given ein Trustee-Admin auf einer Trustee-Instanz, When er auf Navigation «Daten-Tabellen» klickt, Then erscheint eine Seite mit Tab-Bar und allen 13 Trustee-Tabellen als Tabs. | must |
| 2 | Given die Seite «Daten-Tabellen» mit Default-Tab, When der User sie öffnet, Then wird sofort der erste Tab geladen (FormGeneratorTable mit Daten, Pagination, Spalten aus `attributes`-API). | must |
| 3 | Given ein Tab mit Sync-Daten (z.B. «Buchungen (Sync)»), When der User die Zeile inspiziert, Then sind keine Edit-/Delete-Buttons sichtbar (read-only). | must |
| 4 | Given ein Tab mit CRUD-Daten (z.B. «Position»), When der User auf Edit klickt, Then erscheint ein FormGeneratorForm-Modal und Speichern persistiert via PUT-Endpunkt. | must |
| 5 | Given die Seite «Daten-Tabellen», When der User auf Tab «Kontakte (Sync)» wechselt, Then ändert sich die URL auf `?tab=contacts` und nur der Kontakte-Tab ist gemountet (Network-Tab zeigt nur den Kontakte-API-Call). | must |
| 6 | Given ein Tab mit aktiviertem `apiEndpoint`, When der User in einer Spalte einen Filter setzt, Then werden Distinct-Werte vom Backend via `?mode=filterValues&column=<x>` geladen. | must |
| 7 | Given ein User OHNE Permission `data.feature.trustee.TrusteeDataContact` (view), When er die Seite öffnet, Then ist der Tab «Kontakte (Sync)» nicht sichtbar oder disabled. | should |
| 8 | Given die Seite mit Deep-Link `?tab=accounts`, When der User per URL einsteigt, Then wird der Konten-Tab direkt aktiv geladen. | should |
| 9 | Given die alten Seiten «Positionen»/«Dokumente», When der User sie aufruft, Then funktionieren sie unverändert weiter (kein Regress). | must |
| 10 | Given ein Tab mit grosser Datenmenge (>1'000 Einträge), When der User scrollt, Then wird Server-Pagination angewandt (page-Size 25, kein Frontend-Speicher-Overload). | must |
| 11 | Given Tabellen-Sortierung, When der User auf einen Spalten-Header klickt, Then wird per `pagination.sort` serverseitig sortiert. | must |
## Testplan
| ID | AC | Art | Automatisiert | Repo-Pfad | Status |
|----|----|-----|--------------|-----------|--------|
| T1 | 1 | manual UI | nein | | pending |
| T2 | 2,5 | api | ja | gateway/tests/test_routeFeatureTrustee_data_tables.py | pending |
| T3 | 3,4 | api | ja | gateway/tests/test_routeFeatureTrustee_data_tables.py | pending |
| T4 | 6 | api | ja | gateway/tests/test_routeFeatureTrustee_data_tables.py (Unified Filter API: mode=filterValues / mode=ids) | pending |
| T5 | 7 | api | ja | gateway/tests/test_routeFeatureTrustee_data_tables.py (RBAC-Gate) | pending |
| T6 | 8 | manual UI | nein | | pending |
| T7 | 9 | manual + e2e | teils | bestehende Position/Document Tests laufen weiter | pending |
| T8 | 10,11 | api | ja | gateway/tests/test_routeFeatureTrustee_data_tables.py (Pagination/Sort) | pending |
## Links
- PR:
- Issue:
- Vorlage: `wiki/b-reference/frontend-nyla/formgenerator.md`
- Vorbild-Seite: `frontend_nyla/src/pages/basedata/PromptsPage.tsx`
- Vorbild-Tabs: `frontend_nyla/src/pages/views/trustee/TrusteeAbschlussView.tsx`, `TrusteeAccountingSettingsView.tsx`
## Abschluss
- [ ] `b-reference/gateway/features/trustee.md` (oder analog) aktualisieren: neue Endpunkte + neue UI-Seite dokumentieren.
- [ ] `b-reference/frontend-nyla/architecture.md`: kurze Erwähnung der konsolidierten `TrusteeDataTablesView` + Tab-Pattern für mehrere Modelle.
- [ ] `TOPICS.md`: keine Änderung nötig (kein neues Thema).
- [ ] Folge-Plan `2026-MM-trustee-cleanup-positions-documents.md`: Aufräumen der alten Seiten, sobald Daten-Tabellen produktiv verifiziert.
- [ ] Dieses Dokument → `c-work/2-build/` (bei Umsetzungsbeginn) → `c-work/3-validate/``z-archive/`.

View file

@ -0,0 +1,267 @@
<!-- 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.

View file

@ -1,7 +1,7 @@
# Google OAuth 2.0 Setup Guide for PowerOn
# Google OAuth 2.0 Setup Guide for Porta
## Overview
This guide explains how to set up Google OAuth 2.0 authentication for the PowerOn application.
This guide explains how to set up Google OAuth 2.0 authentication for the Porta application.
## Prerequisites
- A Google account
@ -12,7 +12,7 @@ This guide explains how to set up Google OAuth 2.0 authentication for the PowerO
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Click on the project dropdown at the top of the page
3. Click "New Project"
4. Enter a project name (e.g., "PowerOn OAuth")
4. Enter a project name (e.g., "Porta OAuth")
5. Click "Create"
## Step 2: Configure OAuth Consent Screen
@ -33,7 +33,7 @@ This guide explains how to set up Google OAuth 2.0 authentication for the PowerO
4. Back to creating OAuth client ID:
- Application type: "Web application"
- Name: "PowerOn Web Client"
- Name: "Porta Web Client"
- Authorized redirect URIs: Add **two** redirect URIs per environment:
- Login callback: `http://localhost:8000/api/google/auth/login/callback`
- Data connect callback: `http://localhost:8000/api/google/auth/connect/callback`
@ -42,7 +42,7 @@ This guide explains how to set up Google OAuth 2.0 authentication for the PowerO
5. Click "Create"
6. **Important**: Copy the Client ID and Client Secret - you'll need these for the next step
## Step 4: Configure PowerOn Application
## Step 4: Configure Porta Application
1. Open your environment file (`gateway/env_dev.env` for development)
2. Replace the placeholder values with your actual Google OAuth credentials:
@ -62,15 +62,15 @@ Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect
> **Hinweis:** Das Gateway unterscheidet zwei OAuth-Apps: eine fuer Login (Auth) und eine fuer Daten-Connections (Data). Details: `routeSecurityGoogle.py`.
3. Save the file
4. Restart your PowerOn gateway server
4. Restart your Porta gateway server
## Step 5: Test the Configuration
1. Start your PowerOn application
1. Start your Porta application
2. Go to the Connections module
3. Click "Connect Google"
4. You should be redirected to Google's OAuth consent screen
5. After authorization, you should be redirected back to PowerOn
5. After authorization, you should be redirected back to Porta
## Troubleshooting
@ -90,7 +90,7 @@ Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect
### Debug Steps
1. Check the PowerOn gateway logs for OAuth configuration details
1. Check the Porta gateway logs for OAuth configuration details
2. Verify environment variables are loaded correctly
3. Ensure the Google OAuth client is configured for "Web application" type
4. Check that the redirect URI includes the full path: `/api/google/auth/callback`
@ -115,7 +115,7 @@ For production deployment:
If you continue to experience issues:
1. Check the PowerOn gateway logs for detailed error messages
1. Check the Porta gateway logs for detailed error messages
2. Verify your Google OAuth configuration in Google Cloud Console
3. Test with a simple OAuth flow to isolate the issue
4. Ensure your Google Cloud project has billing enabled (required for some APIs)

View file

@ -0,0 +1,390 @@
# Google OAuth Verification — Boilerplates für Porta
> Begleitdokument zu `google-oauth-setup.md`. Enthält copy-paste-fähige
> Bausteine für die Google Trust-&-Safety-Verifizierung der **Porta**-App
> (`porta.poweron.swiss`, Developer: `p.motsch@poweron.swiss`,
> GCP-Projekt `<GCP-Neu-Generieren>`).
## App-Daten (zur konsistenten Wiederverwendung)
| Feld | Wert |
|----------------------------|-----------------------------------------------------------------------------------------------|
| App-Name | Porta |
| Anbieter (Legal Entity) | PowerOn AG |
| Verifizierte Domain | `poweron.swiss` |
| Homepage-URL | `https://porta.poweron.swiss/poweron-home.html` |
| Privacy-Policy-URL | `https://porta.poweron.swiss/poweron-privacy.html` |
| Terms-of-Service-URL | `https://porta.poweron.swiss/poweron-terms.html` |
| Support-E-Mail | `support@poweron.swiss` |
| Developer-Kontakt | `p.motsch@poweron.swiss` |
| GCP-Projekt-Nummer | `<GCP-Neu-Generieren>` |
| GCP-Projekt-ID | (siehe `env_prod.env`, `Service_GOOGLE_DATA_CLIENT_ID`) |
| Verwendete Google-Scopes | `openid`, `userinfo.email`, `userinfo.profile`, `gmail.readonly`, `drive.readonly` |
> Bei jeder Änderung an Scopes (`gateway/modules/auth/oauthProviderConfig.py::googleDataScopes`)
> diese Tabelle, die OAuth-Consent-Screen-Konfiguration in der GCP-Console
> **und** die Privacy-Policy synchron halten — Google bouncet sonst die
> Verification.
---
## 1. Privacy-Policy-Sektion zu Google-Daten
Die folgenden zwei Sektionen müssen **wortgleich** auf `https://porta.poweron.swiss/poweron-privacy.html`
unter denselben Domain-/HTTPS-Bedingungen erreichbar sein wie die Homepage.
Beide Sprachen anbieten (deutsche und englische Fassung), englische Fassung ist
für Google maßgeblich.
### 1.1 Deutsche Fassung
```markdown
## Verarbeitung von Daten aus Google-Diensten
Porta ist eine Plattform für KI-gestützte Geschäftsworkflows. Wenn Sie eine
Verbindung zu Ihrem Google-Konto herstellen, greift Porta ausschließlich auf
diejenigen Daten zu, die für die von Ihnen ausgewählte Funktionalität
erforderlich sind, und ausschließlich mit den von Ihnen über den Google-OAuth-
Consent-Bildschirm freigegebenen Berechtigungen (Scopes).
### Welche Google-Berechtigungen wir anfordern
| Scope | Wozu Porta diese Berechtigung nutzt |
|----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| `openid`, `userinfo.email`, `userinfo.profile` | Anmeldung an Porta (Single-Sign-On) und eindeutige Zuordnung Ihres Benutzerkontos. |
| `https://www.googleapis.com/auth/drive.readonly` | Lesezugriff auf Ihre Google-Drive-Dateien, damit Sie Dateien aus Drive im Porta-Workspace auswählen, vorschauen und der KI als Quelle übergeben können. Porta schreibt **nicht** in Ihr Drive. |
| `https://www.googleapis.com/auth/gmail.readonly` | Lesezugriff auf Ihre Gmail-Labels und -Nachrichten, damit Sie E-Mails in Porta auflisten, vorschauen und einer KI-Konversation als Quelle anhängen können. Porta versendet, ändert oder löscht **keine** E-Mails. |
### Was mit den Daten geschieht
* **Speicherung**: Porta speichert die OAuth-Refresh-Tokens verschlüsselt in
der eigenen Datenbank, um ohne erneuten Login auf Ihre freigegebenen Daten
zugreifen zu können. Inhalte aus Drive oder Gmail werden nur dann persistiert,
wenn Sie sie aktiv als „Quelle" oder „Datei" in Porta anlegen; ansonsten
werden sie nur transient für die jeweilige Anfrage geladen.
* **Verwendung**: Drive- und Gmail-Daten werden ausschließlich genutzt, um
Ihre direkten Anfragen in Porta zu beantworten (z.B. Dokument anzeigen,
durchsuchen, einer KI-Konversation beifügen, automatisierte Workflows
ausführen, die Sie selbst konfiguriert haben).
* **Keine Weitergabe**: Porta gibt Daten aus Ihrem Google-Konto **nicht** an
Dritte weiter, mit Ausnahme von Sub-Prozessoren, die für den Betrieb der
Plattform notwendig sind (Cloud-Hosting, KI-Inferenz). Diese Sub-Prozessoren
sind in Anhang A dieser Richtlinie aufgeführt und vertraglich auf den Schutz
Ihrer Daten verpflichtet.
* **Keine Werbung, kein Verkauf, kein Modelltraining**: Porta nutzt Daten
aus Ihrem Google-Konto **nicht** für personalisierte Werbung, **nicht** für
den Verkauf an Dritte und **nicht** für das Training generischer KI-Modelle.
* **Menschlicher Zugriff**: Auf Ihre Google-Daten wird kein routinemäßiger
menschlicher Zugriff genommen. Mitarbeiter der PowerOn AG können nur in den
folgenden eng begrenzten Fällen Einsicht nehmen: (a) mit Ihrer ausdrücklichen
Einwilligung, (b) zur Sicherheitsuntersuchung eines konkreten Vorfalls,
(c) zur Erfüllung gesetzlicher Verpflichtungen, oder (d) wo dies zur Behebung
eines technischen Fehlers in einer einzelnen Anfrage zwingend nötig ist.
### Limited Use Disclosure
Porta hält die Anforderungen der **Google API Services User Data Policy**
einschließlich der **Limited-Use-Anforderungen** ein.
> *Porta's use and transfer of information received from Google APIs to any
> other app will adhere to Google API Services User Data Policy, including the
> Limited Use requirements.*
> *(<https://developers.google.com/terms/api-services-user-data-policy>)*
### Verbindung trennen und Daten löschen
Sie können Ihre Google-Verbindung jederzeit beenden:
1. **In Porta**: Profil → Verbindungen → Google → „Trennen". Damit werden die
bei Porta gespeicherten OAuth-Tokens unverzüglich widerrufen und gelöscht.
2. **In Ihrem Google-Konto**: <https://myaccount.google.com/permissions>
Porta auswählen → „Zugriff entfernen".
Auf Anfrage an `support@poweron.swiss` löschen wir innerhalb von 30 Tagen
sämtliche von Ihrem Google-Konto stammende Inhalte aus unseren Systemen
(unbeschadet gesetzlicher Aufbewahrungspflichten).
```
### 1.2 English version
```markdown
## Processing of Data from Google Services
Porta is a platform for AI-assisted business workflows. When you connect
your Google account, Porta accesses only the data that is required for the
functionality you have selected, and only within the permissions (scopes) you
have explicitly granted on the Google OAuth consent screen.
### Google permissions we request
| Scope | How Porta uses this permission |
|----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `openid`, `userinfo.email`, `userinfo.profile` | Sign-in to Porta (Single Sign-On) and unambiguous identification of your user account. |
| `https://www.googleapis.com/auth/drive.readonly` | Read-only access to your Google Drive files, so that you can browse, preview and attach Drive files as sources to AI conversations and workflows in Porta. Porta does **not** write to your Drive. |
| `https://www.googleapis.com/auth/gmail.readonly` | Read-only access to your Gmail labels and messages, so that you can list, preview and attach emails as sources to AI conversations in Porta. Porta does **not** send, modify or delete emails. |
### How we handle the data
* **Storage**: Porta stores OAuth refresh tokens encrypted in its own
database in order to access your authorized data without forcing you to
re-authenticate. Content from Drive or Gmail is persisted only when you
actively register it as a "source" or "file" in Porta; otherwise it is
loaded transiently for the duration of the request only.
* **Usage**: Drive and Gmail data are used exclusively to fulfill your direct
requests within Porta (e.g., display, search, attach to an AI
conversation, run an automated workflow you yourself have configured).
* **No third-party sharing**: Porta does **not** share your Google data
with third parties, except sub-processors strictly necessary to operate the
platform (cloud hosting, AI inference). Sub-processors are listed in
Annex A of this policy and are contractually bound to protect your data.
* **No advertising, no sale, no model training**: Porta does **not** use
your Google account data for personalized advertising, **not** for sale to
third parties, and **not** to train generalized AI models.
* **Human access**: Routine human access to your Google data does not occur.
PowerOn AG personnel may access your Google data only in narrowly defined
cases: (a) with your explicit consent, (b) for security investigation of a
specific incident, (c) to comply with applicable law, or (d) where strictly
necessary to fix a technical defect in a specific request.
### Limited Use Disclosure
Porta complies with the requirements of the **Google API Services User
Data Policy**, including the **Limited Use** requirements.
> *Porta's use and transfer of information received from Google APIs to any
> other app will adhere to the Google API Services User Data Policy,
> including the Limited Use requirements.*
> *(<https://developers.google.com/terms/api-services-user-data-policy>)*
### Disconnecting and deleting your data
You may revoke your Google connection at any time:
1. **In Porta**: Profile → Connections → Google → "Disconnect". This
immediately revokes and deletes the OAuth tokens stored at Porta.
2. **In your Google account**: <https://myaccount.google.com/permissions>
select Porta → "Remove access".
On request to `support@poweron.swiss`, we will delete all content originating
from your Google account from our systems within 30 days (subject to
statutory retention obligations).
```
---
## 2. Justifications für jeden Scope (Verification-Formular)
Google verlangt im Antrag pro Scope eine konkrete Begründung. **Wörtlich
einkopierbar** in das Antragsformular:
### 2.1 `openid`, `userinfo.email`, `userinfo.profile`
> Required to authenticate the user into Porta via Google Single Sign-On
> and to identify their account uniquely. We display the user's email and
> name within the application; we do not use these for any other purpose.
### 2.2 `https://www.googleapis.com/auth/drive.readonly`
> Porta provides a unified data source panel where users can browse their
> own Google Drive folders and attach selected Drive files (e.g., contracts,
> reports, spreadsheets) as inputs to AI-driven conversations and to
> automation workflows they configure themselves. We need read-only access
> in order to (a) list folders and files the user owns or has shared access
> to, (b) display file metadata such as name, type and modification date in
> our source picker, and (c) download the file content on demand when the
> user explicitly attaches a file to a chat or a workflow step. We do not
> request write or full-Drive access; Porta never modifies or deletes the
> user's Drive content.
### 2.3 `https://www.googleapis.com/auth/gmail.readonly`
> Porta lets users attach individual Gmail messages as conversation
> sources to its AI assistant (for example: "summarize this email",
> "extract the invoice data from this thread") and use email content as
> input to user-configured automation workflows. We need read-only access
> in order to (a) list the user's labels in our source picker, (b) list
> messages within a chosen label with metadata (subject, sender, date), and
> (c) fetch a specific message body and attachments when the user
> explicitly selects it. We do not request modify, send or full-mailbox
> access; Porta never sends, alters or deletes the user's email.
---
## 3. Demo-Video — Drehbuch
**Format**: YouTube unlisted, Bildschirmaufzeichnung mit Voice-Over (oder
On-Screen-Text), Länge **2:30 4:00 min**, 1080p, Englisch (Google reviewt
auf Englisch). Mauszeiger sichtbar, ruhiger Cursor.
**Vor Aufnahme prüfen**: App in einem **frischen Inkognito-Fenster** öffnen,
mit einem **echten Google-Account** (nicht „test@…"), in der URL-Leiste
muss `porta.poweron.swiss` deutlich lesbar sein.
### Szene-für-Szene-Skript
#### Szene 1 — Homepage & App-Identität (0:00 0:25)
* **Bild**: Browser zeigt `https://porta.poweron.swiss`. Logo, App-Name
„Porta", kurzer Marketing-Text sichtbar.
* **Voice / On-Screen**:
> *"This is Porta, available at porta.poweron.swiss. Porta is a
> platform for AI-assisted business workflows. The OAuth client ID we are
> submitting for verification belongs to this application."*
#### Szene 2 — Sign-in starten (0:25 0:45)
* **Bild**: User klickt „Sign in with Google".
* **Voice / On-Screen**:
> *"To use Porta, the user signs in with their Google account."*
#### Szene 3 — OAuth-Consent-Screen (0:45 1:15)
* **Bild**: Google-Consent-Bildschirm öffnet sich. **Klar lesbar**:
App-Name „Porta", angeforderte Scopes (alle, einzeln aufgeführt),
Privacy-Policy- und ToS-Link.
* **Voice / On-Screen**:
> *"Here Google shows the consent screen for Porta. The user can review
> each requested permission individually: read access to Google Drive,
> read access to Gmail, and basic profile information. The user grants
> consent."*
* User klickt „Continue" / „Allow".
#### Szene 4 — Inside the app (1:15 1:30)
* **Bild**: Nach erfolgreichem Login zeigt Porta das Workspace-Dashboard
mit dem User-Namen oben rechts.
* **Voice / On-Screen**:
> *"After consent, the user is redirected back to Porta and is signed
> in."*
#### Szene 5 — Drive-Scope in action (1:30 2:15)
* **Bild**:
1. User öffnet die UDB („Unified Data Bar") → Tab „Sources".
2. Wählt die Google-Connection.
3. Klickt auf „Drive". Die Liste der Drive-Ordner und -Dateien
erscheint.
4. User wählt eine Datei (z.B. ein PDF) und klickt „Attach to chat".
5. Im Chat-Eingabefeld erscheint die Datei als Anhang-Chip. User tippt
"Summarize this document" und sendet.
6. Die KI-Antwort referenziert den Inhalt der Datei.
* **Voice / On-Screen**:
> *"Porta uses the Drive read-only scope to let the user browse their
> Drive, attach a specific file as a source for the AI conversation, and
> then have the AI process exactly that file. Porta does not modify or
> delete anything in the user's Drive."*
#### Szene 6 — Gmail-Scope in action (2:15 3:00)
* **Bild**:
1. Zurück in „Sources" → Google-Connection → Klick auf „Gmail".
2. Die Liste der Gmail-Labels („INBOX", „SENT", eigene Labels)
erscheint.
3. User öffnet ein Label, eine Liste von Mails (Subject, From, Date)
wird angezeigt.
4. User wählt eine Mail und klickt „Attach to chat".
5. Im Chat erscheint die Mail als Quelle. User tippt z.B. „Extract the
order details from this email" und sendet.
6. Die KI-Antwort zeigt strukturierte Felder aus der Mail.
* **Voice / On-Screen**:
> *"The Gmail read-only scope is used so the user can pick a specific
> email to feed into an AI conversation. Porta lists labels and
> messages and fetches the body only of the message the user explicitly
> selects. Porta never sends, modifies or deletes email."*
#### Szene 7 — Disconnect (3:00 3:30)
* **Bild**: User öffnet Profil → Connections → Google-Eintrag → klickt
„Disconnect". Bestätigungsdialog. Der Eintrag verschwindet.
* **Voice / On-Screen**:
> *"At any time, the user can disconnect their Google account in
> Porta. This immediately revokes and deletes the stored OAuth
> tokens."*
#### Szene 8 — Privacy Policy (3:30 3:50)
* **Bild**: Browser navigiert zu `https://porta.poweron.swiss/poweron-privacy.html`.
Scrollen zur Sektion „Processing of Data from Google Services" und zur
„Limited Use Disclosure".
* **Voice / On-Screen**:
> *"Our privacy policy at porta.poweron.swiss/poweron-privacy.html explicitly lists
> each Google scope, the purpose of each scope, and includes the Limited
> Use Disclosure as required by the Google API Services User Data Policy."*
#### Szene 9 — Outro (3:50 4:00)
* **Bild**: Porta-Logo, App-Name, Submission-Datum.
* **Voice / On-Screen**:
> *"Thank you for reviewing Porta for OAuth verification."*
### Aufnahme-Checkliste
* [ ] URL-Bar in jedem Frame zeigt `porta.poweron.swiss` (kein localhost,
kein staging).
* [ ] Browser-Inkognito, keine Browser-Extensions sichtbar.
* [ ] Consent-Screen zeigt korrekten App-Namen „Porta" und korrektes Logo.
* [ ] Jeder im Antrag deklarierte Scope ist in mindestens einer Szene
*aktiv genutzt* zu sehen (nicht nur erwähnt).
* [ ] Kein „Lorem-ipsum", keine Demo-Daten mit „test", „dummy", „foo".
* [ ] Audio sauber oder Sprache als On-Screen-Text eingeblendet.
* [ ] Video als **unlisted** auf YouTube hochladen, Link ins
Verification-Formular eintragen.
---
## 4. Pre-Submission-Checkliste
Vor dem (erneuten) Klick auf „Submit for verification" alles abhaken:
* [ ] Domain `poweron.swiss` in der **Google Search Console** verifiziert
(DNS-TXT, gleicher Google-Account wie OAuth-Owner).
* [ ] `https://porta.poweron.swiss` öffentlich erreichbar, HTTPS, kein
Login-Wall, beschreibt klar was Porta ist.
* [ ] `https://porta.poweron.swiss/poweron-privacy.html` öffentlich erreichbar,
enthält die beiden Sektionen aus Kapitel 1 dieses Dokuments.
* [ ] `https://porta.poweron.swiss/poweron-terms.html` öffentlich erreichbar.
* [ ] OAuth-Consent-Screen in der GCP-Console:
* App-Name „Porta", App-Logo (120×120 PNG, kein Google-Branding).
* Support-E-Mail `support@poweron.swiss`.
* Authorized domain: `poweron.swiss`.
* Application home page: `https://porta.poweron.swiss`.
* Application privacy policy link: `https://porta.poweron.swiss/poweron-privacy.html`.
* Application terms of service link: `https://porta.poweron.swiss/poweron-terms.html`.
* [ ] Scope-Liste im Consent-Screen identisch zu `googleDataScopes` in
`gateway/modules/auth/oauthProviderConfig.py`.
* [ ] Jeder Scope hat eine Justification (Kapitel 2 dieses Dokuments).
* [ ] Demo-Video (Kapitel 3) auf YouTube unlisted, Link im Antrag.
* [ ] Developer-E-Mail `p.motsch@poweron.swiss` empfangsbereit; Eingangs-Mail
vom Trust-&-Safety-Team kommt erfahrungsgemäß innerhalb 3-5 Tagen.
---
## 5. Was tun, wenn Google rejected
Erfahrungsgemäß werden mindestens 1-2 Iterationen bis zur Approval nötig.
Häufigste Reject-Gründe und Fixes:
| Reject-Grund | Fix |
|--------------------------------------------------------|------------------------------------------------------------------------------------------------------|
| „Privacy policy does not enumerate all requested scopes" | Sektion 1.2 dieses Dokuments wörtlich übernehmen, jeden Scope aus Tabelle erwähnen. |
| „Demo video does not show scope X being used" | Eine zusätzliche Szene im Video, in der Scope X aktiv ausgeübt wird (siehe Szene 5/6). |
| „Homepage does not describe what the app does" | Auf `porta.poweron.swiss` einen klaren „What is Porta"-Block oberhalb des Login-Buttons. |
| „Brand verification — domain not verified" | Domain in Google Search Console verifizieren mit demselben Account wie OAuth-Owner. |
| „Limited Use disclosure missing" | Wörtliche Limited-Use-Klausel aus Sektion 1.2 in die Privacy-Policy aufnehmen. |
| „App requests scopes broader than necessary" | Prüfen ob `gmail.readonly` durch `gmail.metadata` ersetzt werden kann (nicht für Porta — wir lesen Inhalte). Begründen. |
Bei jedem Re-Submit nur die beanstandeten Punkte fixen, kein „While we're
at it" — das verlängert den Loop.
---
## 6. Wenn Verification durch ist
* GCP-Console → OAuth Consent Screen → Publishing Status
„**In Production**".
* Test-Users-Liste kann geleert werden (nicht nötig zu entfernen, schadet
aber auch nicht).
* Externe User sehen ab sofort keinen Warn-Bildschirm mehr, nur den
normalen, signierten Consent-Screen mit App-Logo.
* Bei jeder zukünftigen Scope-Änderung in `googleDataScopes`: erneut
durch Verification, **mit derselben Vorgehensweise**. Daher Scopes
bewusst minimal halten.

155
d-guides/stripe-ch-vat.md Normal file
View file

@ -0,0 +1,155 @@
<!-- status: canonical -->
<!-- lastReviewed: 2026-04-20 (rev 2: structured invoice fields) -->
# Stripe-Rechnungen CH-Treuhand-konform aufsetzen
Schweizer Treuhand-Praxis verlangt fuer jede PowerOn-Rechnung (Top-Up &
Subscription) drei Dinge, die Stripe nicht von sich aus liefert:
1. **MWST 8.1 % (CH)** muss separat ausgewiesen werden -- "obendrauf", nicht
inkludiert.
2. **Vollstaendige Empfaenger-Adresse** (Firma, Strasse, PLZ/Ort, Land,
optional UID-Nr.) muss auf der Rechnung erscheinen.
3. **Zahlungsstatus** ("bereits bezahlt via Kreditkarte") muss vermerkt
sein -- die reine Stripe-Quittung reicht nicht aus.
Der Code-Pfad in PowerOn wurde so vorbereitet, dass nur noch die
Stripe-Konfiguration gemacht werden muss; danach werden alle neuen
Top-Up-Rechnungen automatisch konform erstellt.
---
## 1. App-seitig: was ist bereits implementiert (Stand 2026-04-20)
| Bereich | Datei | Was passiert |
|---------|-------|--------------|
| Mandant-Modell | `gateway/modules/datamodels/datamodelUam.py` -- `Mandate.invoice*` (10 strukturierte Felder) | Rechnungsadresse als einzelne Felder: `invoiceCompanyName`, `invoiceContactName`, `invoiceEmail`, `invoiceLine1`, `invoiceLine2`, `invoicePostalCode`, `invoiceCity`, `invoiceState`, `invoiceCountry` (default `CH`), `invoiceVatNumber`. Der FormGenerator rendert die Felder ueber `order: 200..209` automatisch gruppiert am Ende des Edit-Formulars. |
| Stripe-Customer | `serviceCenter/services/serviceBilling/stripeCheckout.py` -- `_ensureStripeCustomer` | Bei jedem Checkout wird ein Stripe-Customer angelegt/aktualisiert: `name` = `invoiceCompanyName` (Fallback Mandant-Label), `email` = `invoiceEmail`, `address` = strukturiertes `{ line1, line2, postal_code, city, state, country }`, `shipping` = z. H. + Adresse, beim _Create_ ausserdem `tax_id_data` aus `invoiceVatNumber` (CHE -> `ch_vat`, LI -> `li_uid`, EU -> `eu_vat`). Die `stripeCustomerId` wird im `BillingSettings` gecacht. |
| Rechnungserzeugung | `create_checkout_session` setzt `invoice_creation: { enabled: true, invoice_data: {...} }` | Stripe erzeugt automatisch eine **Rechnung** (statt nur einer Quittung) mit Status `paid`, der vollstaendigen Customer-Adresse, unserem Footer-Hinweis "bereits via Kreditkarte bezahlt" und (bei vorhandener UID/z.H.) `invoice_data.custom_fields` mit `UID-Nr. Empfaenger` / `z. H.`. |
| MWST | `create_checkout_session` -- `automatic_tax` ODER `tax_rates` | Wenn `STRIPE_AUTOMATIC_TAX_ENABLED=true`, wird Stripe Tax verwendet. Andernfalls wird der Tax-Rate aus `STRIPE_TAX_RATE_ID_CH_VAT` an jede Line-Item gehaengt. |
Das alles passiert pro Top-Up automatisch -- der App-Code ist fertig. **Was
zu tun bleibt, ist die Stripe-Dashboard-Konfiguration und das Hinterlegen der
Rechnungsadresse pro Mandant.**
---
## 2. Stripe-Dashboard: einmalige Einrichtung
### 2a. MWST 8.1 % aktivieren (Empfehlung: Stripe Tax)
**Variante A -- Stripe Tax (empfohlen, automatisch):**
1. Stripe Dashboard -> **Tax** -> Origin address: PowerOn-Hauptsitz
(Schweiz) eintragen.
2. Tax registrations -> **Switzerland** hinzufuegen, Steuernummer (UID)
hinterlegen.
3. **Default tax behavior** auf `exclusive` (= "MWST kommt obendrauf").
4. **Default tax category** fuer unser Produkt: `Software as a Service
(SaaS)` -- damit greift automatisch CH-MWST 8.1 % (Standard-Rate).
5. APP_CONFIG / `.env` setzen: `STRIPE_AUTOMATIC_TAX_ENABLED=true`.
**Variante B -- Manueller Tax-Rate (Fallback ohne Stripe Tax):**
1. Stripe Dashboard -> **Products** -> **Tax rates** -> **Add tax rate**.
- Display name: `MWST CH`
- Region: `Switzerland (CH)`
- Tax rate: `8.1` %
- Inclusive: **NEIN** (= "MWST kommt obendrauf")
- Description: `Schweizer Mehrwertsteuer 8.1%`
2. Tax-Rate-ID kopieren (Format `txr_...`).
3. APP_CONFIG / `.env` setzen:
- `STRIPE_AUTOMATIC_TAX_ENABLED=false`
- `STRIPE_TAX_RATE_ID_CH_VAT=txr_xxxxxxxxxxxxxx`
### 2b. Rechnungs-Template
1. Stripe Dashboard -> **Settings** -> **Invoice template**.
2. **Public business name**: `PowerOn` (oder offizieller Firmenname inkl. AG/GmbH).
3. **Business address**: vollstaendige Schweizer Geschaeftsadresse.
4. **Tax IDs to display**: PowerOn-UID-Nr (z. B. `CHE-123.456.789 MWST`)
anhaken -- erscheint dann automatisch im Header jeder Rechnung.
5. **Footer / Memo**: ggf. zusaetzlicher Hinweis "Bezahlt via Kreditkarte
am Rechnungsdatum" (wir setzen ihn auch im Code als invoice-spezifischen
Footer, das hier ist nur Fallback).
6. **Default payment terms**: `Due upon receipt` (sollte fuer Top-Ups eh
irrelevant sein, da Status sofort `paid`).
### 2c. Webhook fuer Invoice-Status (optional)
Wenn Sie wollen, dass die App-DB protokolliert, dass die Rechnung erfolgreich
ausgestellt wurde, koennen Sie das Event `invoice.finalized` und
`invoice.paid` zusaetzlich abonnieren -- die Webhook-Route ist bereits
vorbereitet (`/api/billing/webhook/stripe`). Aktuell genuegt fuer die reine
CH-Konformitaet aber `checkout.session.completed`.
---
## 3. Pro Mandant: Rechnungsadresse erfassen
Damit die Stripe-Rechnung die korrekte Empfaengeranschrift traegt,
muessen die strukturierten Adressfelder am Mandanten befuellt sein.
Seit 2026-04-20 (rev 2) sind das **einzelne Felder** -- der frueher
verwendete mehrzeilige Freitext wurde wieder gesplittet, damit Stripe
sie 1:1 in `customer.address` ablegen kann (Stripe verlangt strukturierte
Adressen, kein Freitext).
| Feld am Mandanten | Pflicht | Stripe-Mapping | Beispiel |
|-------------------|---------|----------------|----------|
| `invoiceCompanyName` | empfohlen | `customer.name` (Fallback: Mandant-Label) | `Muster Treuhand AG` |
| `invoiceContactName` | optional | `customer.shipping.name` (`"<z.H.> (<Firma>)"`) + `invoice_data.custom_fields[z. H.]` + `metadata.contactName` | `Buchhaltung` |
| `invoiceEmail` | empfohlen | `customer.email` (Stripe verschickt darauf die Rechnung) | `rechnungen@muster-treuhand.ch` |
| `invoiceLine1` | **ja** | `customer.address.line1` | `Bahnhofstrasse 1` |
| `invoiceLine2` | optional | `customer.address.line2` | `c/o Buchhaltung` |
| `invoicePostalCode` | empfohlen | `customer.address.postal_code` | `8000` |
| `invoiceCity` | **ja** | `customer.address.city` | `Zuerich` |
| `invoiceState` | optional | `customer.address.state` | `ZH` |
| `invoiceCountry` | ja (default `CH`) | `customer.address.country` (ISO-3166 Alpha-2) | `CH` |
| `invoiceVatNumber` | bei B2B empfohlen | `customer.tax_id_data` (CHE -> `ch_vat`, LI -> `li_uid`, andere -> `eu_vat`) + `invoice_data.custom_fields[UID-Nr. Empfaenger]` + `metadata.vatNumber` | `CHE-123.456.789 MWST` |
> **Pflichtminimum fuer eine "echte" Stripe-Customer-Adresse:**
> `invoiceLine1` **und** `invoiceCity`. Fehlt eines davon, faellt
> `_buildStripeAddress` auf `None` zurueck und Stripe erfragt die Adresse
> beim Checkout selbst (`billing_address_collection: required`); die
> Rechnung enthaelt dann die vom Endkunden eingegebene Adresse statt der
> hinterlegten.
> **`tax_id_data` ist nur beim Customer-_Create_ wirksam.** Aenderst du
> `invoiceVatNumber` an einem Mandanten, dessen Stripe-Customer bereits
> existiert, musst du die UID einmalig in Stripe haendisch setzen
> (Customers -> Tax IDs) -- die App ruft `tax_ids.create` aktuell nicht
> auf einem bestehenden Customer auf, weil das `customer.tax_ids` zur
> Vermeidung von Duplikaten erfordern wuerde.
> **Hinweis Bestandsdaten (vor 2026-04-20):** Die alte JSONB-Spalte
> `invoiceAddress` (Freitext oder strukturiertes Dict) wird vom
> Schema-Reconciler **nicht** automatisch in die neuen Felder
> umgeschrieben. Sie bleibt in der DB liegen, wird aber nicht mehr
> gelesen oder geschrieben. Bei Bedarf manuell ein einmaliges
> SQL-Update fahren oder die Adresse pro Mandant neu im Form erfassen
> (Empfehlung fuer Dev-Umgebungen).
---
## 4. Test-Checkliste vor Go-Live
1. Stripe-Account in **Test-Modus** schalten.
2. Mandant `Demo Treuhand` anlegen und alle `invoice*`-Felder befuellen
(mindestens `invoiceLine1`, `invoiceCity`, `invoiceCountry`).
3. Top-Up 25 CHF ausfuehren (Test-Karte 4242 4242 4242 4242).
4. Stripe Dashboard -> Customers -> `Demo Treuhand`:
- `name` = `invoiceCompanyName`
- `email` = `invoiceEmail`
- `address` = strukturiert mit line1/postal_code/city/country
- `tax_ids` enthaelt die UID-Nr (Typ `ch_vat`)
- `metadata.mandateId` / `metadata.mandateLabel` gesetzt
5. Stripe Dashboard -> Invoices -> letzte Rechnung oeffnen:
- Status `Paid`
- Empfaenger-Block oben links zeigt strukturierte Adresse + UID
- `Custom fields` zeigen `UID-Nr. Empfaenger` und ggf. `z. H.`
- Zeile `MWST 8.1%` separat ausgewiesen, Total = Netto + MWST
- Footer `Diese Rechnung wurde bereits via Kreditkarte bezahlt`
- Header zeigt PowerOn-UID
6. PDF herunterladen und mit Treuhand abgleichen.
Wenn alle 6 Punkte stimmen: Live-Modus aktivieren und Roll-out.