Technische Architektur — Monorepo · App-Struktur · Datenfluss · Auth/RLS · i18n · Mobile/PWA · Auto-Posting · Deployment · Tests
Querschnitt-Kapitel des Pflichtenhefts „Caja". Hier wird festgelegt, wie die acht Fachmodule (1 CRM, 2 WhatsApp/OCR, 3 Produkte/Preise, 4 Offerten, 5 Logistik, 6 Finanzen, 7 Affiliate, 8 Plattform) technisch zu einer App zusammenwachsen — auf dem fixen „Atlas"-Stack (Next.js App Router · TypeScript · Tailwind 4 + shadcn/ui · Supabase · Vercel). Es ist die normative Heimat für Repo-Layout, App-Router-Segmentierung, Auth/RLS-Strategie, i18n-Architektur, Mobile/PWA-Shell, die Auto-Posting-Engine als Service, Deployment und Test-/Qualitätsstrategie. Wo Modul 8 (Plattform) die Datenmodelle für Identität/RLS/i18n/Audit/Storage/Notifications definiert, beschreibt dieses Kapitel deren technische Verdrahtung im Code. Kein Produktionscode — Schema-/Typ-Snippets nur zur Illustration.
1. Leitprinzipien (woraus alles folgt)
- Mobile/Tablet-First ist harte Kern-Pflicht, nicht Responsive-Nachgedanke. Die Layout-Shell, Navigation und jede Liste werden ab 375px gedacht; Tablet ist der Haupt-Formfaktor. → bestimmt App-Shell (§5), adaptive Daten-Komponente, PWA (§8).
- Eine geteilte Wahrheit pro Domäne.
contacts(Parteien),movements(Finanz-Ledger),boxes(UUID) usw. existieren einmal und werden modulübergreifend referenziert. Code-seitig spiegelt das ein einziges Datenzugriffs-Package (@caja/db), nicht pro-Modul-Duplikate. - Operative Events erzeugen Finanzbuchungen automatisch (Kerngewinn ggü. Excel). Die Auto-Posting-Engine ist ein server-seitiger Service mit Idempotenz über
(source_type, source_id)(§9). - Sicherheit in der Datenbank, nicht in der UI. Row-Level-Security (RLS) ist die primäre Verteidigungslinie (deny-by-default); die UI ist nur Komfort.
service_roleausschliesslich server-seitig (§6). - Stabile Codes, übersetzte Labels. Nie Fachbegriffe in Logik verdrahten — interne Codes (
EXPENSE,CASH,EMPTY_DELIVERED) sind die Wahrheit, DE/ES/EN sind Anzeige (§7). - Revisionssicherheit by design (OR 957/GeBüV): append-only
audit_log, gesperrte Perioden, kein hartes DELETE finanzrelevanter Daten (§6.6, Modul 6/8). - Inkrementell ab Bestand. Das Monorepo enthält bereits
apps/web(Live-Website) +/api/send-contact(Resend) +/api/og. Caja wird additiv alsapps/cajaergänzt, ohne die Website zu brechen (§2, §10).
2. Monorepo-Layout
2.1 Ist-Zustand (Bestand, nicht anfassen ausser additiv)
dominicanoexpress.com/ ← Repo-Root (privat, Familien-GmbH)
├── apps/
│ ├── web/ ← LIVE: statische Website (HTML/CSS/JS, Vercel)
│ │ ├── index.html · datenschutz.html · agb.html · impressum.html · 404.html
│ │ ├── js/ · img/ · fonts/ · flags/ · videos/
│ └── cajaspec/ ← DIESE Spec-Site (publiziert auf cajaspec.dominicanoexpress.com)
│ └── content/ ← 10-crm.md … 18-design-mobile.md, 20-architektur.md (hier)
├── api/ ← Vercel Serverless Functions der Website
│ ├── send-contact.js ← Lead-Intake → Resend (produktiv) → speist später contacts
│ └── og.js ← OG-Image-Generator (@vercel/og)
├── dokumente/ ← gitignored (sensibel → SharePoint), NICHT im Build
├── scripts/ ← Build-Helfer (build-gallery, check-todo …)
├── vercel.json ← Host-Routing + CSP/Security-Header (Website)
├── package.json ← Root (ESM, npm, Node ≥20; .nvmrc = 22)
└── package-lock.json
Wichtig: Das Root ist heute ein statisches Vercel-Projekt (vercel.json mit Host-Routing und strengen CSP-Headern). Caja ist eine eigenständige Next.js-App und bekommt ein eigenes Vercel-Projekt mit eigener Domain — sie teilt sich das Repo, nicht die Build-Pipeline der Website (§10). Das hält die risikoarme Live-Website unberührt.
Datenresidenz (entschieden 2026-06-28): Das Supabase-Projekt liegt in der Region EU (Frankfurt,
eu-central-1), Vercel-Funktionen infra1— Primärdaten und Backups in der EU (revDSG/DSGVO-Datenresidenz). Verbindliche Vorgabe + DPA-Liste der Auftragsbearbeiter: Dok 45 §4.4.2.
2.2 Soll-Zustand (Caja ergänzt)
dominicanoexpress.com/
├── apps/
│ ├── web/ ← unverändert (Website)
│ ├── cajaspec/ ← unverändert (Spec-Site)
│ └── caja/ ← NEU: die App (Next.js App Router)
│ ├── app/ ← App-Router-Segmente (§3)
│ ├── components/ ← App-spezifische Komponenten (nicht wiederverwendbar)
│ ├── public/ ← manifest.json, icons/, fonts/inter/ (self-hosted)
│ ├── next.config.ts · tailwind.config.ts · tsconfig.json
│ └── package.json
├── packages/ ← NEU: geteilte, versionierte Workspaces (@caja/*)
│ ├── db/ ← @caja/db: Supabase-Clients, generierte Typen, Query-Helfer, RLS-Helper-Referenz
│ │ ├── migrations/ ← versionierte SQL-Migrationen (Supabase CLI)
│ │ ├── seed/ ← Seeds inkl. Glossar-Import (locale_strings, Lookups)
│ │ ├── src/
│ │ │ ├── client.server.ts ← createServerClient (RLS-gebunden, Cookie-Session)
│ │ │ ├── client.service.ts ← Service-Role-Client (NUR server, RLS-bypass) ⚠️
│ │ │ ├── types.gen.ts ← `supabase gen types typescript` Output
│ │ │ └── queries/ ← typisierte Query-Funktionen pro Domäne
│ ├── ui/ ← @caja/ui: shadcn/ui-Basis + Caja-Patterns (AdaptiveTable, BottomSheetSelect, KpiCard, CameraScanner …)
│ │ └── src/ ← Design-Tokens (Anhang Modul 18), Theme-Provider
│ ├── i18n/ ← @caja/i18n: Locale-Resolver, useT()-Hook, Namespace-Loader, pref_lang-Cookie-Logik
│ ├── domain/ ← @caja/domain: reine Fach-Typen + Zod-Schemas + Geschäftsregeln OHNE I/O
│ │ └── src/ ← z.B. payment-channel-Regel, status-ampel, tracking-9-schritte, money(numeric)
│ ├── auth/ ← @caja/auth: Session-Helfer, requireRole(), RLS-Helper-Typen, Middleware-Guards
│ └── config/ ← @caja/config: tsconfig-base, eslint, tailwind-preset, prettier (geteilt)
├── api/ ← unverändert (Website-Functions)
├── supabase/ ← NEU (optional Wurzel-Spiegel): config.toml, Edge-Functions-Quelle
│ └── functions/ ← Deno Edge Functions (post-movement, ocr, notify-worker, signed-upload)
├── turbo.json ← NEU: Turborepo-Pipeline (build/lint/test/typecheck-Caching)
├── pnpm-workspace.yaml | package.json workspaces ← Workspace-Definition
└── …
Workspace-Manager: Empfehlung pnpm + Turborepo (schnelle, deterministische Installs; Build-Caching über apps/* + packages/*). 🔲 zu bestätigen — Alternativ npm-Workspaces (bleibt beim heutigen npm/package-lock.json, weniger Tooling, langsamer). Da das Root heute npm nutzt, ist npm-Workspaces der reibungsärmste Start; pnpm lohnt sich, sobald mehrere @caja/*-Packages aktiv gebaut werden.
Trennlinie packages/ vs. apps/caja/components/:
packages/*= wiederverwendbar, getestet, stabil-versioniert (DB-Zugriff, UI-Primitives, i18n, Domänenregeln). Kandidaten für spätere Wiederverwendung (CRM-Erweiterung, Affiliate-Mini-Portal).apps/caja/components/= App-/Screen-spezifisch, nicht zur Wiederverwendung gedacht (z.B. das konkrete Finanz-Dashboard-Layout).
Warum die Spec-Site (cajaspec) NICHT Next.js sein muss: sie rendert nur Markdown. Sie kann eine schlanke statische Generierung bleiben (oder eine minimale Next-/MD-Pipeline). Sie teilt mit Caja keine Runtime, nur das Repo. Das hält den Spec-Build trivial.
3. Next.js-App-Struktur (App Router, ein Segment pro Modul)
3.1 Route-Gruppen-Strategie
Drei Route-Gruppen trennen die fundamental verschiedenen Layout-/Auth-Kontexte:
apps/caja/app/
├── layout.tsx ← Root: <html lang>, ThemeProvider, LocaleProvider, Analytics, PWA-Register
├── globals.css ← Tailwind 4 + Design-Tokens (CSS Custom Properties, Modul 18 Anhang A)
├── manifest.ts ← PWA-Manifest (route handler → /manifest.webmanifest)
│
├── (auth)/ ← UNAUTHENTIFIZIERT: Magic-Link/OTP, kein App-Chrome
│ ├── layout.tsx ← zentrierte Karte + Sprachumschalter, kein Sidebar/Tab-Bar
│ ├── login/page.tsx ← E-Mail → Magic-Link + OTP-Fallback (Modul 8 §4.1)
│ └── auth/callback/route.ts ← Supabase Auth-Callback (Code-Exchange → Session-Cookie)
│
├── (app)/ ← AUTHENTIFIZIERT (intern): die App-Shell (Sidebar/Tab-Bar/⌘K)
│ ├── layout.tsx ← AppShell: Auth-Guard + is_active-Check + adaptive Navigation
│ ├── page.tsx ← Dashboard (Redirect-Ziel nach Login)
│ │
│ ├── kontakte/ ← MODUL 1 CRM
│ │ ├── page.tsx ← Liste (AdaptiveTable: Karten ≤sm, Tabelle ≥lg)
│ │ ├── [id]/page.tsx ← Kontakt-Detail (Rollen, Empfänger, Historie)
│ │ ├── duplikate/page.tsx ← Dedup-Queue (Golden Record)
│ │ └── actions.ts ← Server Actions (create/merge/role-toggle)
│ │
│ ├── kyc/ ← MODUL 2 WhatsApp + OCR
│ │ ├── page.tsx ← Scan-Queue / WhatsApp-Inbound
│ │ ├── scan/page.tsx ← Camera-First-Flow (ID-Scan → OCR → Vorausfüllung)
│ │ └── actions.ts
│ │
│ ├── produkte/ ← MODUL 3 Produkte & Preise
│ │ ├── page.tsx ← Box-/Fass-Typen
│ │ ├── preislisten/page.tsx ← effective-dated Preislisten, Zonen, Depot-Sätze
│ │ └── actions.ts
│ │
│ ├── offerten/ ← MODUL 4 Offerten
│ │ ├── page.tsx ← Liste (Status draft→sent→accepted)
│ │ ├── neu/page.tsx ← Editor (Katalog + Rabatt + Affiliate-Code)
│ │ ├── [id]/page.tsx ← Detail + PDF + Versand (WhatsApp/E-Mail)
│ │ └── actions.ts
│ │
│ ├── auftraege/ ← MODUL 5 (kaufm. Klammer)
│ │ ├── page.tsx
│ │ └── [id]/page.tsx
│ ├── sendungen/ ← MODUL 5 (logistische Klammer)
│ │ ├── page.tsx ← Sendungs-/Tracking-Übersicht
│ │ └── [id]/page.tsx ← 10-Schritt-Timeline
│ ├── boxen/ ← MODUL 5 (Box-UUID)
│ │ ├── page.tsx
│ │ └── [uuid]/page.tsx ← Box-Detail (Scan-Landing-Ziel)
│ ├── container/ ← MODUL 5
│ │ └── page.tsx ← „próxima salida"-Management
│ │
│ ├── finanzen/ ← MODUL 6 (Finanz-Kern)
│ │ ├── page.tsx ← Finanz-Dashboard (v_dashboard)
│ │ ├── kasse/page.tsx ← Cashflow-Journal (v_kasse)
│ │ ├── debitoren/page.tsx ← offene Forderungen (v_debitoren)
│ │ ├── kreditoren/page.tsx ← offene Schulden (v_kreditoren)
│ │ ├── anzahlungen/page.tsx ← Depot (v_anzahlungen)
│ │ ├── abschluss/page.tsx ← Monatsabschluss (monthly_closings)
│ │ ├── export/page.tsx ← Treuhänder-Export
│ │ └── actions.ts ← Quick-Entry (manuelle movements)
│ │
│ ├── affiliates/ ← MODUL 7
│ │ ├── page.tsx ← Affiliates + referral_codes
│ │ ├── [id]/page.tsx ← Abrechnung/Payout
│ │ └── actions.ts
│ │
│ ├── audit/page.tsx ← MODUL 8 (read-only Journal)
│ ├── dokumente/page.tsx ← MODUL 8 (Storage-Browser, Camera-First)
│ ├── benachrichtigungen/page.tsx ← MODUL 8 (Notification-Outbox-Protokoll)
│ └── einstellungen/ ← MODUL 8
│ ├── profil/page.tsx ← preferred_locale, theme, Telefon
│ └── team/page.tsx ← Nutzer/Rollen/Affiliate-Verknüpfung (nur ADMIN)
│
├── (portal)/ ← AUTHENTIFIZIERT (extern): Affiliate-Mini-Portal (später, Modul 7)
│ ├── layout.tsx ← reduzierte Shell, RLS „nur Eigenes"
│ └── portal/… ← eigene vermittelte Sendungen + Abrechnung
│
└── api/ ← Next Route Handlers (nur wo nötig; Default = Server Actions)
├── webhooks/whatsapp/route.ts ← WhatsApp Cloud API Webhook (Meta): GET-Verify + POST eingehende Nachrichten/Medien (OCR-Intake Modul 2) + Status-Callbacks (sent/delivered/read → notifications.provider_message_id, Modul 8)
├── webhooks/revolut/route.ts ← Revolut-Business-Transaktions-Webhook → revolut-sync (Modul 6 §10)
├── pdf/[type]/route.ts ← Offerte-/Rechnung-PDF-Generierung (Modul 4/6)
└── health/route.ts
Begründung der Aufteilung:
(auth)vs.(app)vs.(portal)als Route-Gruppen, weil die drei komplett verschiedene Layouts + Auth-Anforderungen haben.(portal)ist die saubere Trennung für externe Affiliates (Mandantentrennung, Modul 8 §8).- Ein Top-Level-Segment je Modul.
auftraege/sendungen/boxen/containersind getrennte Segmente, weil sie zwar zu Modul 5 gehören, aber unterschiedliche Listen-/Detail-Bedürfnisse haben (kaufmännisch vs. logistisch vs. physisch). Navigation bündelt sie unter „Logistik". actions.tspro Segment kapselt die Server Actions (Mutationen) modul-lokal → Action-State-Forms + Toasts (Atlas-Konvention).
3.2 Rendering- & Daten-Strategie
| Bedarf | Mechanismus | Begründung |
|---|---|---|
| Listen/Detail-Reads | React Server Components (RSC) mit createServerClient (RLS-gebunden) | Datenzugriff server-seitig, RLS greift, kein Client-Bundle für DB |
| Mutationen (erfassen/ändern) | Server Actions ('use server') | Action-State-Forms, Progressive Enhancement, kein eigener API-Layer |
| Interaktive Tabellen/Filter | Client Components (TanStack Table) auf RSC-gelieferten Daten | Sortier-/Filter-UX client-seitig, Daten server-geladen |
| Realtime (optional) | Supabase Realtime (Channel-Subscribe) für Dashboard/Sendungs-Status | Live-Updates ohne Polling — später, nicht MVP-kritisch |
| Schwere Jobs (OCR, PDF, Notify, Auto-Posting-Korrektur-Läufe) | Supabase Edge Functions (Deno) / Vercel Functions | brauchen service_role und/oder lange Laufzeit → server-only |
| Statische/Lookup-Daten (i18n-Strings) | RSC + Cache (unstable_cache/revalidateTag) | Labels ändern selten → cachebar, kein Roundtrip pro Render |
Caching-Hinweis (Next.js): Finanz- und Logistik-Reads sind dynamisch (per-User, RLS) → kein aggressives Full-Route-Caching. i18n-
locale_stringsund Fach-Lookups (movement_types,payment_methods,tracking_statuses) sind cachebar mit Tag-Invalidierung beim Admin-Edit. Mutierende Server Actions rufenrevalidatePath/revalidateTagfür die betroffenen Segmente.
4. Datenfluss-Architektur (durchgängiger Fluss → Auto-Posting → Ledger)
Der im Kontext-Brief geforderte durchgängige Fluss ist die zentrale Architektur-Eigenschaft: jedes operative Ereignis ist mit dem nächsten verbunden, und finanzwirksame Ereignisse erzeugen automatisch eine Buchung. Code-seitig wird das so verdrahtet:
Diagramm wird geladen …
Lesart:
- Durchgezogene Pfeile = operative Daten-Erzeugung (Lead → Kontakt → Offerte → Auftrag → Boxen/Sendung → Rechnung/Depot → Zahlung).
- Beschriftete Pfeile zu
AP= Domain-Events, die die Auto-Posting-Engine anstossen → genau einmovements-Eintrag je Quelle (idempotent). - Revolut Business ist eine externe Konto-/Zahlungsquelle (DOP-Auslagen/-Zahlungen in der DR, Multi-Währung): Transaktionen kommen per Webhook (Fallback CSV) in die Edge Function
revolut-syncund werden über denselben Idempotenz-Vertrag (source_type='revolut',source_id=transaction_id) alsmovementsgebucht — inkl. Originalwährung +fx_rateaus Revolut (Berichtswährung bleibt CHF). Details: Modul 6 §10. - Pfeile zu
NOTIF= Outbox-Notifications (Resend/WhatsApp) für die Schlüsselereignisse. - Gestrichelte Pfeile zu
AUD= jede revisionsrelevante Mutation schreibt eineaudit_log-Zeile (DB-Trigger, nicht App-Code). - Affiliate-Code zieht sich durch Offerte → Auftrag → Provision (eigener Auto-Posting-Pfad
commission_payout, F-05 / K-05).
Wo genau läuft was (Trust-Boundary):
| Schritt | Läuft in | Client-Schlüssel? |
|---|---|---|
| Lead-Intake Website | bestehende /api/send-contact (Vercel Function) → später Supabase-Insert | nein (server) |
| Kontakt/Offerte/Auftrag erfassen | Server Action (RLS-Client, auth.uid()) | RLS-gebunden |
| OCR-Extraktion | Edge Function (service_role, liest kyc-Bucket) → ruft self-hosted OCR-Service auf eigener Proxmox-Infra (kein Cloud-Provider, Modul 2; Entscheid 2026-06-28 Teil 3) | nein, server-only |
Auto-Posting (Event → movements) | Edge/Server Function mit service_role | nein, server-only |
Revolut-Import (Transaktion → movements, DOP/Multi-Währung) | Edge Function revolut-sync (service_role, Webhook/Cron) → Revolut Business API | nein, server-only |
| Notification-Versand | Edge Function (Cron-Worker) → Resend/WhatsApp | nein, server-only |
| PDF-Generierung | Next Route Handler / Edge Function | nein, server-only |
Architektur-Garantie: Clients können niemals direkt ins Ledger schreiben oder Buchungen fälschen — alle privilegierten Writes (Auto-Posting, Notifications, OCR, signierte Uploads) laufen über
service_roleausschliesslich server-seitig (Modul 6 §8, Modul 8 §7). Der Client sieht nur den Publishable/Anon-Key und ist immer RLS-gebunden.
5. Mobile/PWA-Architektur (adaptive Shell + Offline)
5.1 Adaptive Layout-Shell (ein Layout, drei Formfaktoren)
Die App-Shell in (app)/layout.tsx rendert dieselbe Navigation in drei Ausprägungen (Breakpoints aus Modul 18: Mobile <640px, Tablet 640–1023px, Desktop ≥1024px):
AppShell
├── <Header> (sticky, 56px) — Logo · ⌘K-Trigger · Avatar/Locale
├── <SidebarNav> (≥lg: 240px persistent / kollabierbar 64px; sm–md: Mini-Rail optional)
├── <main> (Screen-Inhalt; Container-Query-fähig)
├── <BottomTabBar> (<sm: 5 Tabs — Home · Sendungen · [+] · Finanzen · Konto; ≥lg: hidden)
├── <Drawer> (<sm: sekundäre Bereiche CRM/Affiliate/Admin via „Mehr")
└── <CommandPalette> (⌘K überall; mobil: Header-Suchfeld)
- Server-seitige Formfaktor-Heuristik vermeiden — die Shell rendert alle Navigations-Varianten und blendet per CSS (Tailwind
sm:/lg:) ein/aus; kein User-Agent-Sniffing, kein Layout-Flash. Ergänzend einuseBreakpoint()-Hook (Modul 18) für client-seitige Verzweigungen (z.B. Bottom-Sheet vs. Popover). - Adaptive Daten-Darstellung ist eine Komponente
@caja/ui → AdaptiveTable: TanStack Table als Datenschicht,mobileCardRendererfür Karten (<sm), Tabelle (≥lg). Regel aus Modul 18 §7.2: kein Horizontal-Scroll am Handy — Tabellen müssen in den Karten-Modus wechseln. - Camera-First als geteilte
CameraScanner-Komponente (BarcodeDetector nativ →@zxing/browser-Fallback; Rückkamera; 5–10 fps throttled für Mittelklasse-Android).
5.2 PWA / Service-Worker / Offline-Queue
Service-Worker (scope: /, registriert in Root-Layout)
├── Precache: App-Shell, JS/CSS, Fonts(woff2), Icons → CacheFirst
├── Lookups (locale_strings, movement_types …) → StaleWhileRevalidate (TTL ~24h)
├── Daten-Reads (GET, RLS-gebunden) → StaleWhileRevalidate (TTL ~5min)
├── Mutationen (Server Actions/POST) → NetworkFirst → bei offline: Write-Queue
└── Auth-Tokens → NIE cachen (Modul 18 §8.3)
Offline-Write-Queue (IndexedDB, AES-GCM-verschlüsselt — Modul 18 §8.3)
├── Scope MVP: movements-Erfassung (Depot/Zahlung) + boxes-Status-Update (Modul 18 §9 🔲)
├── Flush: Background-Sync API bei network:online
└── Konflikt: Server-Stand inzwischen geändert → Conflict-Toast + Merge-Dialog
- Tooling:
next-pwa/Serwist (Workbox-basiert) oder ein handgeschriebener SW. Da Caja Finanzdaten offline puffert, ist die verschlüsselte Queue + expliziter Konflikt-Flow Pflicht (kein „last-write-wins" stillschweigend). - Manifest als Route-Handler (
app/manifest.ts) — Inhalt aus Modul 18 Anhang C (display: standalone, Brand-Navy#00205B, maskable Icons 🔲 Asset). - CSP-Spannung mit WASM: der ZXing-WASM-Fallback braucht
wasm-unsafe-eval(nichtunsafe-eval); Kamera übermedia-src 'self';camera=(self)in Permissions-Policy. Diese Caja-CSP ist eigenständig (eigenes Vercel-Projekt) und weicht bewusst von der strengeren Website-CSP ab (§10.3). - Performance-Budget (Modul 18 §7.5, Ziel Mittelklasse-Android): initial JS gzip <200KB, FCP <2.5s/3G. → Konsequenz für die Architektur: Server-Components-First (wenig Client-JS), Code-Splitting pro Segment,
@caja/uitree-shakebar, schwere Libs (Charts) lazy.
6. Auth- & RLS-Strategie
6.1 Authentifizierung (Supabase Magic-Link/OTP, passwortlos)
Browser → (auth)/login → supabase.auth.signInWithOtp(email)
→ Supabase sendet Magic-Link + 6-stelligen OTP (E-Mail; Templates DE/ES/EN)
→ Klick/Code → (auth)/auth/callback → Code-Exchange → HttpOnly-Session-Cookie
→ handle_new_user()-Trigger legt app_users-Profil an (preferred_locale='es')
→ (app)/layout.tsx Guard: Session? is_active? Rolle vorhanden? → sonst „Pending"/Logout
- Session im Cookie (HttpOnly, via
@supabase/ssr), nicht im LocalStorage → SW cached keine Tokens. - Erst-Provisionierung & Edge-Cases sind in Modul 8 §4.1 normativ (neue Person ohne Rolle = „wartet auf Freischaltung"; deaktiviert = globaler Guard wirft raus; Magic-Link 1h → OTP-Fallback).
- 2FA für ADMIN/BUCHHALTUNG (TOTP) ist offen (Modul 8 §9 🔲) — angesichts Finanzdaten empfohlen, aber nicht MVP-blockierend.
6.2 Drei Supabase-Clients (klare Trennung)
| Client | Schlüssel | Wo | RLS |
|---|---|---|---|
| Browser-Client | Anon/Publishable | Client Components | greift (= auth.uid()) |
| Server-Client | Anon/Publishable + Session-Cookie | RSC, Server Actions, Route Handlers | greift (User-Kontext) |
| Service-Client ⚠️ | service_role | nur Edge/Server Functions (Auto-Posting, OCR, Notify, signed-upload) | bypass |
Der Service-Client lebt isoliert in
@caja/db → client.service.tsund wird nie aus Client- oder RSC-Render-Pfaden importiert (ESLint-Regel + Code-Review-Gate). Das ist die wichtigste Sicherheits-Invariante der gesamten App.
6.3 RLS-Modell (deny-by-default, normativ in Modul 8)
Die modulübergreifende RLS-Grammatik sind drei SECURITY DEFINER-Funktionen (Modul 8 §3.5), in denen jede Tabelle jedes Moduls ihre Policies formuliert:
public.has_role(p_role text) -- aktueller Nutzer hat Rolle X?
public.is_internal() -- eine der internen Rollen (alles ausser AFFILIATE)?
public.current_affiliate_id() -- affiliate_id des Nutzers (NULL = kein Affiliate)
Rollen (stabile UPPERCASE-Codes, Modul 8 §3.1): ADMIN (Marcel) · BUCHHALTUNG (Mariela) · OPERATIONS (Markus) · FAHRER (Arkys) · AFFILIATE · READONLY (Treuhänder/Gast). Mehrfachrollen sind schema-seitig möglich (user_roles n:m).
Affiliate-Isolation ist das Kern-Pattern (Modul 8 §3.12): ein Affiliate sieht via current_affiliate_id() ausschliesslich Zeilen, deren Owner-Offerte/-Sendung ihm zugeordnet ist — kein Kunden- oder Finanz-Gesamtzugriff. Das ist in (portal) zusätzlich durch ein eigenes Layout abgesichert.
Schema-Konsistenz (reconciled — F-09/F-10/F-11): Modul 8 (Plattform, normativ als Fundament-Modul) ist die Quelle der Wahrheit:
app_users+user_roles+roles+locale_stringsmit UPPERCASE-Rollencodes (ADMIN, BUCHHALTUNG, OPERATIONS, FAHRER, AFFILIATE, READONLY) und RLS-Helfernhas_role()/is_internal()/current_affiliate_id(). Die zuvor abweichenden Skizzen sind nachgezogen: Modul 18 §3.2/§3.3 nutzt jetztapp_users.preferred_locale+themestattuser_preferences(F-10) und denroles-TEXT-Lookup stattcaja_role-ENUM (F-09); Modul 6 §3.7 nutzthas_role('CODE')statt Personennamen/auth_role()(F-09);i18n_labels(Modul 18/16) ist durchlocale_strings+ Inline-label_<locale>ersetzt (F-11). Mapping:admin_office → BUCHHALTUNG,operations → OPERATIONS,driver → FAHRER.
6.4 Auth-Code-seitig (Guards & Helfer in @caja/auth)
// Illustration — nicht-normative Signaturen
getSession() // Cookie-Session in RSC/Action
requireUser() // wirft → redirect (auth)/login, wenn keine Session
requireActiveUser() // + app_users.is_active === true
requireRole('ADMIN' | 'BUCHHALTUNG' | …) // serverseitige Rollen-Härtung (zusätzlich zu RLS)
UI-seitige Rollenprüfungen (Menüpunkte ein-/ausblenden) sind reiner Komfort; die echte Durchsetzung ist RLS (DB) + requireRole (Server Action). Niemals nur UI.
7. i18n-Architektur (stabile Codes + Label-Lookups + Umschaltung)
Überträgt das auf der Website gelebte Muster (pref_lang-Cookie, ES-priorisierter Fallback) in die App und macht es typsicher.
7.1 Zwei Label-Quellen (Hybrid, Modul 8 §3.6 — empfohlen)
| Art | Speicher | Zugriff | Beispiel |
|---|---|---|---|
| Fach-Lookup-Labels (selten ändernd, oft in Listen gejoint) | label_de/es/en-Spalten direkt am Lookup | mit der Zeile mitgeladen, kein Extra-Join | movement_types, payment_methods, tracking_statuses, roles |
| Freie UI-Texte (Buttons, Empty-States, Toasts, Formfehler, E-Mail-Templates) | Tabelle locale_strings (namespace,key,locale→value) | Bulk-Load pro Namespace/Locale, gecacht | ui.common, email_template, Validierungstexte |
Beide werden aus einer Quelle befüllt: GLOSSAR-ERP_DE-ES-EN.csv → Seed-Skript in packages/db/seed. Das garantiert eine einzige Übersetzungs-Wahrheit. (Die Alternative „alles in locale_strings" — mehr Konsistenz, mehr Joins — ist Modul 8 §9 🔲; und die Modul-18-i18n_labels-Variante ist die zu vereinheitlichende Doppelung, §6.3.)
7.2 Auflösung (kein Flash, server-first)
Request → (app)/layout.tsx (RSC)
1. pref_lang-Cookie lesen → Fallback-Kette: Cookie → app_users.preferred_locale
→ navigator.languages (ES-priorisiert, client) → 'es'
2. benötigte locale_strings-Namespaces der Route bulk-laden (gecacht, revalidateTag('i18n'))
3. <html lang={locale}> + LocaleProvider mit Strings server-seitig gerendert (kein Client-Flash)
4. Fach-Labels kommen direkt aus den Lookup-Spalten (label_<locale>) der Daten-Queries
useT()-Hook (@caja/i18n) für Client-Komponenten; in RSC direkt der gelieferte String-Record.- Fehlender Key: gibt den
keyselbst zurück (+ Telemetrie-Warnung), nie leer/Crash; fehlende Übersetzungen landen im „i18n-Lücken"-Report für ADMIN (Modul 8 §4.3). - Umschalten: Segmented-Control ES/DE/EN setzt
pref_lang-Cookie und persistiertapp_users.preferred_locale; Re-Render in der Zielsprache. - Logik-Regel (hart): kein Modul vergleicht je
label-Text; Entscheidungen immer übercode(code === 'EXPENSE', nielabel === 'Ausgabe'). CI-Check (Modul 18 §7.1).
8. Auto-Posting-Engine (als Service)
Der Kerngewinn ggü. Excel: operative Events erzeugen die Finanzbuchung automatisch und idempotent. Architektur als eigener server-seitiger Service (kein verstreuter App-Code).
8.1 Verortung & Vertrag
- Heimat:
supabase/functions/post-movement(Deno Edge Function) oder ein gekapseltes Modul in@caja/db/queries/auto-posting(von Server Actions aufgerufen). Empfehlung: Edge Function als einziger Schreibpfad ins Ledger aus Quellen, damitservice_rolenie in der App-Bundle-Nähe ist und Retries/Cron entkoppelt sind. - Idempotenz-Vertrag (normativ, Modul 6 §3.2):
movementshatunique (source_type, source_id). Die Engine machtupsertauf dieses Target. Folge: eine Buchung je Event; Korrektur am Ursprung → erneutes Event → überschreibt dieselbe Zeile (kein Duplikat).source_type='manual'(NULLsource_id) ist absichtlich nicht unique-geschützt.
// Illustration — der eine Eintrittspunkt
type PostMovementInput = {
// F-05: commission_payout (kanonisch K-05); F-02: kein creditor_invoice/creditor_payment (Kreditoren manuell)
// 2026-06-28: 'revolut' (Multi-Währungs-/DOP-Import, Modul 6 §10)
sourceType: 'invoice_issued' | 'debtor_payment' | 'deposit_order'
| 'deposit_payment' | 'deposit_refund'
| 'commission_payout' | 'quote_accepted' | 'revolut';
sourceId: string; // UUID bzw. Revolut transaction_id → Idempotenz-Schlüssel
entryDate: string; // ISO-Datum → period_key = MM/YYYY (generated)
contactId: string | null;
movementTypeCode: string; // 'INVOICE' | 'INVOICE_PAYMENT' | 'DEPOSIT_CASH' | …
paymentMethodCode: string; // channel entscheidet Kasse/Bank
totalChf?: number; // Berichtswährung CHF (= totalOrig*fxRate)
paidChf?: number;
// Mehrwährung (Modul 6 §3.9): Default CHF/fxRate=1; DOP-Buchungen tragen Original + Kurs
currency?: string; // 'CHF' | 'DOP' | …
fxRate?: number; // Originalwährung → CHF zum Buchungszeitpunkt
totalOrig?: number;
// MWST inclusive (Modul 6 §3.8): Satz wird im Schreibpfad eingefroren + herausgerechnet
vatCode?: string;
note?: string;
paymentLink?: { targetId: string; amountChf: number }; // Zahlung↔Rechnung explizit
};
// → upsert movements ON CONFLICT (source_type, source_id) DO UPDATE
// → optional payment_links-Insert (Modul 6 §3.3)
// → audit_log entsteht automatisch via DB-Trigger (kein Engine-Code)
8.2 Event-Quellen (Tabelle aus Modul 6 §2, hier als Service-Mapping)
| Event | source_type | erzeugter movement_type | ausgelöst durch |
|---|---|---|---|
| Rechnung gestellt | invoice_issued | INVOICE | Server Action „Rechnung" (Modul 5/6) |
| Kundenzahlung | debtor_payment | INVOICE_PAYMENT (+ payment_links) | Quick-Entry / Zahlungs-Action |
| Depot angelegt | deposit_order | DEPOSIT_CASH (Soll) | Box-/Auftrags-Anlage (Modul 5) |
| Depot-Zahlung | deposit_payment | DEPOSIT_CASH (Abono) | Logistik/Finanz |
| Depot-Erstattung/-Verfall | deposit_refund | EXPENSE/INCOME | Box-Rückgabe (Modul 5) |
| Affiliate-Payout bezahlt | commission_payout | SUPPLIER_PAYMENT | Provisions-Lauf (Modul 7) |
| Revolut-Transaktion (DOP/Multi-Währung) | revolut | EXPENSE/SUPPLIER_PAYMENT/INCOME/Transfer | revolut-sync Edge Function (Webhook/CSV, Modul 6 §10) |
| Lieferantenrechnung/-zahlung (F-02) | manual | SUPPLIER_DEBT/EXPENSE/SUPPLIER_PAYMENT | manuelle Finanz-Erfassung (kein Auto-Posting) |
8.3 Auslöse-Mechanismus (zwei Optionen)
- Synchron aus Server Action (Default, einfach): die Action, die z.B. eine Zahlung speichert, ruft die Auto-Posting-Funktion in derselben Transaktion (oder unmittelbar danach) → sofort konsistentes Ledger. Vorteil: Einfachheit, sofortige KPI-Aktualität. Risiko: Action-Latenz.
- Asynchron via DB-Trigger → Queue → Worker (robuster bei Volumen): Quell-Insert feuert einen Trigger, der ein Job-Event schreibt; ein Cron-Worker (Edge Function) verarbeitet die Queue. Vorteil: Entkopplung, Retry. Risiko: Eventual Consistency (Dashboard kurz hinterher).
Empfehlung MVP = Option 1 (synchron in Server Action, ein Eintrittspunkt
@caja/db/queries/auto-posting, der den Service-Client nutzt), Migrationspfad zu Option 2, sobald Erfassungs-Volumen oder Notification-Last steigt. Beide nutzen denselben Idempotenz-Vertrag, der Wechsel ist transparent. 🔲 zu bestätigen
8.4 Abgeleitete Werte bleiben Views (nicht materialisiert)
Die Engine schreibt nur die rohen Eingabewerte ins Ledger. Alle abgeleiteten Grössen (Kasse-Eingang/-Ausgang, Bank, offene Schuld, Status-Ampel, laufender Saldo) sind Views (v_movements, v_kasse, v_debitoren, …, Modul 6 §3.5/3.6) bzw. die eine period_key-Generated-Column. → Single Point der Routing-Logik, keine Redundanz-Drift.
Ausnahme — bewusst persistiert, nicht View: Der eingefrorene MWST-Satz (
vat_rate_percent) + herausgerechnetervat_amount_chf(Modul 6 §3.8, inclusive/brutto) und die Mehrwährungs-Felder (currency/fx_rate/total_orig, Modul 6 §3.9) gehören zu den rohen Schreibwerten: Satz und Kurs gelten zum Buchungszeitpunkt und dürfen nicht „mitwandern", also keine View/Generated-Column. Die Engine berechnettotal_chf = round(total_orig*fx_rate,2)und rechnet die MWST aus dem CHF-Brutto heraus — beides idempotent beim Upsert.
8.5 Periodensperre & Revisionssicherheit
Auto-Posting respektiert fn_guard_closed_period() (Modul 8 §3.9): Buchungen in eine closed-Periode werden abgewiesen (Ausnahme: dokumentiertes ADMIN-Reopen). Bulk-Import (source_type='import') läuft über service_role, schreibt trotzdem Audit, ist aber von der Sperre ausgenommen, solange die Periode open ist (Modul 8 §4.4).
9. Supabase-Setup (Migrations · RLS · Storage · Edge)
9.1 Migrations & Umgebungen
packages/db/migrations/ ← versionierte SQL (Supabase CLI: `supabase migration new`)
0001_extensions.sql ← pgcrypto (gen_random_uuid), pg_trgm (Fuzzy-Dedup Modul 1)
0002_lookups.sql ← movement_types, payment_methods, vat_rates, tracking_statuses, roles …
0003_platform.sql ← app_users, user_roles, affiliate_users, locale_strings, audit_log, notifications, storage_objects, monthly_closings
0004_rls_helpers.sql ← has_role(), is_internal(), current_affiliate_id(), fn_audit(), fn_guard_closed_period()
0005_crm.sql · 0006_finanzen.sql · 0007_logistik.sql · … (ein File pro Modul)
0099_rls_policies.sql ← Policies gebündelt (oder pro-Modul am Tabellenende)
packages/db/seed/
seed_glossar.sql ← Import GLOSSAR-ERP_DE-ES-EN.csv → locale_strings + label_<locale>
seed_lookups.sql ← Codes + Flags (channel, cash_sign, is_internal_transfer …)
- Drei Umgebungen: lokal (
supabase start, Docker) → Preview-Branch (Supabase Branching pro Vercel-Preview, ephemer) → Production. Migrationen sind die einzige Quelle für Schema-Änderungen (kein manuelles Klicken im Dashboard → sonst Drift). - Typgenerierung:
supabase gen types typescript→@caja/db/src/types.gen.ts, in CI verifiziert (Drift-Check: generierte Typen müssen mit committed übereinstimmen). - Migrations-Disziplin (GeBüV-relevant): Schema-Änderungen an finanz-/revisionsrelevanten Tabellen sind selbst nachvollziehbar (Git-Historie der Migrationen).
9.2 RLS (Aktivierung & Default)
enable row level securityauf allenpublic-Tabellen. Ohne passende Policy ⇒ kein Zugriff (deny-by-default). Review-Gate: jede neue Tabelle bringt ihre Policies in derselben Migration mit (Modul 8 §7).- Policies formuliert in den drei RLS-Helfern (§6.3).
service_roleumgeht RLS und macht die privilegierten Writes. audit_logist append-only:revoke update, delete+do instead nothing-Rules — auch für ADMIN/service_role(Modul 8 §3.8).
9.3 Storage-Buckets (alle privat ausser public-assets)
| Bucket | Inhalt | Lesen | Schreiben | Aufbewahrung |
|---|---|---|---|---|
kyc | Ausweis-Scans (Modul 2) | ADMIN, OPERATIONS, BUCHHALTUNG | OPERATIONS (+ OCR-Job) | minimal, DSG-Löschung nach Zweck |
documents | Offerte-/Rechnung-PDFs, Liefernachweise | intern; AFFILIATE nur eigene; READONLY lesend | System, OPERATIONS, BUCHHALTUNG | 10 Jahre (OR 958f) |
receipts | Belege zu movements | ADMIN, BUCHHALTUNG | BUCHHALTUNG, OPERATIONS | 10 Jahre |
public-assets | i18n-Flags, generische Bilder | alle (auch anon) | ADMIN | — |
- Uploads über signierte Upload-URLs via Edge Function (
signed-upload) → der Client sieht nie den Service-Key; Edge validiertmime_type/byte_size/bucket/doc_classvor Persistenz und legt diestorage_objects-Metazeile an (Modul 8 §4.5). - OCR self-hosted (Proxmox): Die
ocr-extract-Edge-Function reicht das Ausweisbild ausschliesslich an den internen OCR-Service auf eigener Proxmox-Infra (kein Cloud-Provider, kein Drittland-Transfer; Entscheid 2026-06-28 Teil 3) und schreibt die Felder zurück. Aufbewahrung des Rohbilds: rollierend max. 24 M nach letzter Interaktion (Compliance §4.6), Lifecycle/Job (Modul 18 §8.1).
9.4 Edge Functions (Deno, server-only)
| Function | Zweck | Schlüssel |
|---|---|---|
post-movement | Auto-Posting (§8) | service_role |
revolut-sync | Revolut-Business-Transaktionen → movements (DOP/Multi-Währung, idempotent über transaction_id; Webhook/Cron, Modul 6 §10) | service_role + REVOLUT_API_* |
ocr-extract | ID-Scan → strukturierte Felder (Modul 2); ruft den self-hosted OCR-Service (Proxmox) auf — kein Cloud-Provider (Entscheid 2026-06-28 Teil 3) | service_role + interner OCR-Endpoint (OCR_SELFHOST_*) |
notify-worker | Outbox notifications abarbeiten → Resend (E-Mail) / WhatsApp Cloud API (Meta Graph POST /{phone-number-id}/messages, genehmigte Templates); schreibt provider_message_id zurück (Cron) | service_role + RESEND_API_KEY + WHATSAPP_* |
signed-upload | signierte Storage-Upload-URLs + Validierung | service_role |
whatsapp-inbound | WhatsApp Cloud API Webhook (Meta): Verify-Challenge (GET) + eingehende Nachrichten/Medien für OCR-Intake (POST) + Delivery-/Read-Status-Callbacks; HMAC-Verifikation X-Hub-Signature-256 | service_role + WHATSAPP_* |
10. Deployment (Vercel: caja.* + cajaspec.*)
10.1 Drei unabhängige Vercel-Projekte, ein Repo
| Projekt | Root | Domain | Build | Bestand? |
|---|---|---|---|---|
| Website | Repo-Root (apps/web via vercel.json) | www.dominicanoexpress.com | statisch (npm run build) | ✅ live, unverändert |
| Spec-Site | apps/cajaspec | cajaspec.dominicanoexpress.com | Markdown→statisch | neu/leichtgewichtig |
| Caja-App | apps/caja | caja.dominicanoexpress.com | Next.js (App Router) | neu |
- Getrennte Projekte, weil: (a) die Website risikoarm statisch bleiben soll, (b) Caja eine völlig andere Runtime + CSP + Env-Matrix hat, (c) unabhängige Deploy-Kadenz/Rollbacks. Alle drei teilen nur das Git-Repo (Monorepo) via Root Directory pro Vercel-Projekt + Ignored Build Step (nur bauen, wenn der eigene Pfad sich änderte — Turborepo
--filter/turbo-ignore). - Caja-Build nutzt Turborepo-Caching über
apps/caja+packages/*. PR-Previews ziehen einen ephemeren Supabase-Branch (9.1).
10.2 Environment-Matrix (Vercel-/Supabase-Env, nie im Repo)
| Variable | Wo | Scope |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | Vercel (Caja) | Client+Server |
NEXT_PUBLIC_SUPABASE_ANON_KEY (Publishable) | Vercel (Caja) | Client+Server (RLS-gebunden) |
SUPABASE_SERVICE_ROLE_KEY ⚠️ | Vercel (Caja, Server) + Supabase Edge | nur server |
RESEND_API_KEY / RESEND_FROM (…@dominicanoexpress.com) | Vercel/Supabase | nur server |
REVOLUT_API_KEY / REVOLUT_API_SECRET / REVOLUT_WEBHOOK_SECRET ⚠️ (Revolut Business, Modul 6 §10) | Vercel (Server) + Supabase Edge | nur server |
WHATSAPP_ACCESS_TOKEN / WHATSAPP_PHONE_NUMBER_ID / WHATSAPP_WABA_ID / WHATSAPP_VERIFY_TOKEN / WHATSAPP_APP_SECRET ⚠️ (WhatsApp Cloud API, Meta — Versand + Webhook-HMAC, Modul 2/8) | Vercel (Server) + Supabase Edge | nur server |
OCR_SELFHOST_URL / OCR_SELFHOST_TOKEN (self-hosted OCR auf Proxmox, Modul 2; kein Cloud-Provider-Key) | Edge | nur server |
Env-Snapshot-Regel (aus Bestand gelernt): Vercel snapshottet Env pro Deployment — eine Key-Rotation oder ein neuer
RESEND_FROMwird erst nach Redeploy aktiv (genau wie bei der Website). Caja erbt diese Betriebsregel.
10.3 CSP / Security-Header (Caja eigenständig)
Die Website-CSP (vercel.json) ist bewusst streng (default-src 'self', nur va.vercel-scripts.com). Caja braucht eine eigene, etwas weitere CSP (eigenes Projekt, eigene Header-Config), weil:
connect-srcmuss die Supabase-URL (REST/Realtime/Storage) + Vercel-Analytics erlauben,wasm-unsafe-evalfür den ZXing-Fallback (nichtunsafe-eval),media-src 'self'+camera=(self)(Permissions-Policy) für Kamera-Flows,img-srcmuss Supabase-Storage (signierte URLs) +data:/blob:(Kamera-Previews) erlauben. Ansonsten gelten dieselben Härtungen wie bei der Website (HSTS,X-Content-Type-Options: nosniff,frame-ancestors 'self',object-src 'none').
10.4 Analytics
Vercel Web Analytics (wie Website, va.vercel-scripts.com / vitals.vercel-insights.com) im Root-Layout — privacy-freundlich, kein Cookie-Consent-Zwang.
11. Geteilte Kern-Entitäten — Code-seitige Konsistenzregeln
Damit die im Brief geforderten geteilten Entitäten (contacts, movements, payment_methods, movement_types, box_products, boxes, shipments, containers, quotes, orders, invoices, deposits, affiliates, referral_codes, audit_log, i18n-Lookups) modulübergreifend konsistent bleiben:
- Ein Datenzugriffs-Package (
@caja/db): jede dieser Entitäten hat eine typisierte Query-Datei; Module importieren diese, statt eigene SQL/Clients zu bauen. - Generierte Typen sind die Wahrheit (
types.gen.tsaus dem realen Schema) — keine handgepflegten Parallel-Typen, die driften. - Domänenregeln zentral (
@caja/domain): die Kanal-Regel (payment_methods.channelentscheidet Kasse/Bank), die Status-Ampel, die 10 Tracking-Schritte, Geld alsnumeric(12,2)/CHF — einmal als reine Funktionen + Zod-Schemas, von Server Actions und Tests genutzt. - Stabile Codes als Konstanten/Union-Types, nie als Magic-Strings verstreut (
MovementTypeCode,TrackingStatusCode,RoleCode). - Polymorphe Owner-Referenzen (
storage_objects,notifications) als(owner_type, owner_id)+ CHECK (Modul 8 §2) — kein FK-Wildwuchs über 8 Module.
12. Test- & Qualitätsstrategie
12.1 Test-Pyramide
| Ebene | Werkzeug | Gegenstand (Caja-spezifisch) |
|---|---|---|
| Statisch | TypeScript strict, ESLint (jsx-a11y), Prettier | Typsicherheit; a11y-Lint erzwingt ≥44px-Targets/ARIA (Modul 18 §7.2); ESLint-Regel „kein Service-Client im Client/RSC" |
| Unit | Vitest | @caja/domain-Regeln: Kanal-Klassifikation, Status-Ampel, Restschuld, period_key, Geld-Rundung, i18n-Fallback-Kette |
| DB/Integration | pgTAP + ephemerer Supabase-Branch | RLS-Policies (Affiliate sieht nur Eigenes; READONLY schreibt nicht; closed-Periode blockt), audit_log-Unveränderbarkeit, Auto-Posting-Idempotenz (upsert dedupe), View-Korrektheit vs. Excel-Referenzwerte |
| Komponenten | Testing Library | AdaptiveTable (Karten <sm / Tabelle ≥lg), BottomSheetSelect, Action-State-Form-Zustände (pending/success/error) |
| E2E | Playwright (Mobile-Viewport 375px als Default-Projekt + Desktop) | Kern-Flüsse: Login(Magic-Link/OTP) → Offerte → Auftrag → Box-Scan → Zahlung → Ledger-Buchung sichtbar; Offline-Queue-Flush; Sprachumschaltung |
| a11y | axe (in Playwright) | WCAG 2.1 AA: Kontrast (auch Dark-Mode 🔲), Focus-Ring, Screenreader-Sheets |
| Perf | Lighthouse-CI (gedrosselt, Mittelklasse-Android-Profil) | Budget: initial JS gzip <200KB, FCP <2.5s, LCP <4s (Modul 18 §7.5) — Build-Gate |
12.2 Was zwingend getestet wird (Caja-Risikoherde)
- RLS ist Sicherheit → eigene pgTAP-Suite, die für jede Rolle gegen jede geteilte Tabelle Zugriff/Verweigerung assertet (negative Tests zentral: „AFFILIATE darf KEINE fremden movements/quotes/contacts sehen").
- Auto-Posting-Idempotenz → doppeltes Event derselben Quelle erzeugt genau eine Zeile; Korrektur überschreibt;
manualbleibt multipel. - Finanz-Views = Excel-Parität → Golden-Master-Tests mit Referenzwerten aus dem Live-
Erfassung_Digitar, damit die Migration nachweislich identisch rechnet (Kasse/Debitoren/Status). - Revisionssicherheit →
audit_loglässt sich nicht updaten/löschen;closed-Periode blockt Insert/Update/Datumswechsel-in-Periode. - Mobile-Realität → E2E-Default-Viewport ist das Handy (375px); „kein Horizontal-Scroll"-Assertion auf Listen.
12.3 CI/CD-Pipeline (GitHub Actions → Vercel)
PR geöffnet
├─ Turborepo: nur betroffene Workspaces (apps/caja, packages/*) bauen/testen
├─ typecheck + lint + Typ-Drift-Check (types.gen.ts == Schema)
├─ Unit (Vitest) + Komponenten
├─ ephemerer Supabase-Branch → Migrationen anwenden → pgTAP (RLS/Audit/Auto-Posting)
├─ Playwright (mobile+desktop) + axe + Lighthouse-CI-Budget
└─ Vercel Preview-Deploy (Caja) gegen den Supabase-Branch
Merge auf main
└─ Migrationen auf Prod-Supabase → Vercel Production-Deploy (caja.*)
- Branch-Disziplin (aus Memory): hands-off-Loops — verifizieren, dann direkt auf
main; aber Schema-Migrationen laufen nie ungetestet auf Prod (Branch + pgTAP davor). guard:todo(bestehendes Root-Skript) bleibt als Commit-Gate gegen offene TODO-Marker im Auslieferungsstand.
13. Risiken & offene Architektur-Punkte
- 🔲 Workspace-Manager: pnpm+Turborepo (empfohlen, skaliert) vs. npm-Workspaces (reibungsärmster Start ab heutigem npm). — zu bestätigen
- ✅ Schema-Vereinheitlichung Modul 8 ↔ 18 ↔ 6 (erledigt — F-09/F-10/F-11):
app_users/user_roles/roles/locale_strings+ UPPERCASE-Codes (Modul 8, normativ) sind Single Source. Modul 18 nutzt jetztapp_users.preferred_locale+theme(stattuser_preferences) undroles-Lookup (stattcaja_role-ENUM); Modul 6 nutzthas_role('CODE')(stattauth_role()/Personennamen);i18n_labelsist durchlocale_strings+ Inline-label_<locale>ersetzt. (§6.3, §7.1) - 🔲 Auto-Posting synchron (Server Action) vs. asynchron (Trigger→Queue→Worker): MVP synchron empfohlen, Migrationspfad zu async. — zu bestätigen (§8.3)
- 🔲 Offline-Queue-Scope: nur
movements-Erfassung +boxes-Status offline-fähig (empfohlen) vs. mehr. — zu bestätigen (Modul 18 §9) - 🔲 Realtime ja/nein im MVP: Supabase Realtime für Live-Dashboard/Sendungsstatus — Komfort, nicht kritisch. — zu bestätigen
- ✅ OCR-Provider ENTSCHIEDEN (2026-06-28, Teil 3): self-hosted auf eigener Proxmox-Infra (
ocr_provider='SELF_HOSTED', Modul 2 §9) — kein Cloud-Provider, kein Drittland-Transfer der Ausweisbilder fürs OCR (DSG-Plus).ocr-extractruft einen internen OCR-Endpoint (OCR_SELFHOST_*). Verbleibend: konkrete Open-Source-Vision/OCR-Lösung 🔲 TBD. — erledigt (Modell-Wahl offen) - 🔲 Revolut-Integration (entschieden 2026-06-28 als externe Integration, Modul 6 §10): offen bleiben Anbindungstiefe (Import-only read vs. payment-initiation write), Webhook- vs. CSV-/Polling-Start, API-Tier/Quota, Counterparty→
contacts-Mapping. — zu bestätigen - 🔲 2FA (TOTP) für ADMIN/BUCHHALTUNG angesichts Finanzdaten (Modul 8 §9). — zu bestätigen
- 🔲 Spec-Site-Pipeline:
apps/cajaspecals reine statische MD-Generierung vs. minimale Next-App. — zu bestätigen
Spec-Version 1.1 · Modul 20 (Technische Architektur) · 2026-06-28 (Finanz-Entscheide: MWST scharf/inclusive, Mehrwährung CHF+DOP, Revolut-Business-Integration) · Dominicano Express GmbH · Stack „Atlas": Next.js App Router · TypeScript · Tailwind 4 + shadcn/ui · Supabase · Vercel