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)

  1. 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).
  2. 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.
  3. 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).
  4. 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_role ausschliesslich server-seitig (§6).
  5. Stabile Codes, übersetzte Labels. Nie Fachbegriffe in Logik verdrahten — interne Codes (EXPENSE, CASH, EMPTY_DELIVERED) sind die Wahrheit, DE/ES/EN sind Anzeige (§7).
  6. Revisionssicherheit by design (OR 957/GeBüV): append-only audit_log, gesperrte Perioden, kein hartes DELETE finanzrelevanter Daten (§6.6, Modul 6/8).
  7. Inkrementell ab Bestand. Das Monorepo enthält bereits apps/web (Live-Website) + /api/send-contact (Resend) + /api/og. Caja wird additiv als apps/caja ergä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.md18-design-mobile.md, 20-architektur.md (hier)
├── api/                               ← Vercel Serverless Functions der Website
│   ├── send-contact.jsLead-IntakeResend (produktiv) → speist später contacts
│   └── og.jsOG-Image-Generator (@vercel/og)
├── dokumente/                         ← gitignored (sensibel → SharePoint), NICHT im Build
├── scripts/                           ← Build-Helfer (build-gallery, check-todo …)
├── vercel.jsonHost-Routing + CSP/Security-Header (Website)
├── package.jsonRoot (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 in fra1 — 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-Segmente3)
│       ├── 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/container sind 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.ts pro Segment kapselt die Server Actions (Mutationen) modul-lokal → Action-State-Forms + Toasts (Atlas-Konvention).

3.2 Rendering- & Daten-Strategie

BedarfMechanismusBegründung
Listen/Detail-ReadsReact 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/FilterClient Components (TanStack Table) auf RSC-gelieferten DatenSortier-/Filter-UX client-seitig, Daten server-geladen
Realtime (optional)Supabase Realtime (Channel-Subscribe) für Dashboard/Sendungs-StatusLive-Updates ohne Polling — später, nicht MVP-kritisch
Schwere Jobs (OCR, PDF, Notify, Auto-Posting-Korrektur-Läufe)Supabase Edge Functions (Deno) / Vercel Functionsbrauchen 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_strings und Fach-Lookups (movement_types, payment_methods, tracking_statuses) sind cachebar mit Tag-Invalidierung beim Admin-Edit. Mutierende Server Actions rufen revalidatePath/revalidateTag fü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 ein movements-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-sync und werden über denselben Idempotenz-Vertrag (source_type='revolut', source_id=transaction_id) als movements gebucht — inkl. Originalwährung + fx_rate aus 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 eine audit_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):

SchrittLäuft inClient-Schlüssel?
Lead-Intake Websitebestehende /api/send-contact (Vercel Function) → später Supabase-Insertnein (server)
Kontakt/Offerte/Auftrag erfassenServer Action (RLS-Client, auth.uid())RLS-gebunden
OCR-ExtraktionEdge 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_rolenein, server-only
Revolut-Import (Transaktion → movements, DOP/Multi-Währung)Edge Function revolut-sync (service_role, Webhook/Cron) → Revolut Business APInein, server-only
Notification-VersandEdge Function (Cron-Worker) → Resend/WhatsAppnein, server-only
PDF-GenerierungNext Route Handler / Edge Functionnein, 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_role ausschliesslich 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 ein useBreakpoint()-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, mobileCardRenderer fü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 (nicht unsafe-eval); Kamera über media-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/ui tree-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)

ClientSchlüsselWoRLS
Browser-ClientAnon/PublishableClient Componentsgreift (= auth.uid())
Server-ClientAnon/Publishable + Session-CookieRSC, Server Actions, Route Handlersgreift (User-Kontext)
Service-Client ⚠️service_rolenur Edge/Server Functions (Auto-Posting, OCR, Notify, signed-upload)bypass

Der Service-Client lebt isoliert in @caja/db → client.service.ts und 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_strings mit UPPERCASE-Rollencodes (ADMIN, BUCHHALTUNG, OPERATIONS, FAHRER, AFFILIATE, READONLY) und RLS-Helfern has_role()/is_internal()/current_affiliate_id(). Die zuvor abweichenden Skizzen sind nachgezogen: Modul 18 §3.2/§3.3 nutzt jetzt app_users.preferred_locale+theme statt user_preferences (F-10) und den roles-TEXT-Lookup statt caja_role-ENUM (F-09); Modul 6 §3.7 nutzt has_role('CODE') statt Personennamen/auth_role() (F-09); i18n_labels (Modul 18/16) ist durch locale_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)

ArtSpeicherZugriffBeispiel
Fach-Lookup-Labels (selten ändernd, oft in Listen gejoint)label_de/es/en-Spalten direkt am Lookupmit der Zeile mitgeladen, kein Extra-Joinmovement_types, payment_methods, tracking_statuses, roles
Freie UI-Texte (Buttons, Empty-States, Toasts, Formfehler, E-Mail-Templates)Tabelle locale_strings (namespace,key,localevalue)Bulk-Load pro Namespace/Locale, gecachtui.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 key selbst 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 persistiert app_users.preferred_locale; Re-Render in der Zielsprache.
  • Logik-Regel (hart): kein Modul vergleicht je label-Text; Entscheidungen immer über code (code === 'EXPENSE', nie label === '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, damit service_role nie in der App-Bundle-Nähe ist und Retries/Cron entkoppelt sind.
  • Idempotenz-Vertrag (normativ, Modul 6 §3.2): movements hat unique (source_type, source_id). Die Engine macht upsert auf dieses Target. Folge: eine Buchung je Event; Korrektur am Ursprung → erneutes Event → überschreibt dieselbe Zeile (kein Duplikat). source_type='manual' (NULL source_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)

Eventsource_typeerzeugter movement_typeausgelöst durch
Rechnung gestelltinvoice_issuedINVOICEServer Action „Rechnung" (Modul 5/6)
Kundenzahlungdebtor_paymentINVOICE_PAYMENT (+ payment_links)Quick-Entry / Zahlungs-Action
Depot angelegtdeposit_orderDEPOSIT_CASH (Soll)Box-/Auftrags-Anlage (Modul 5)
Depot-Zahlungdeposit_paymentDEPOSIT_CASH (Abono)Logistik/Finanz
Depot-Erstattung/-Verfalldeposit_refundEXPENSE/INCOMEBox-Rückgabe (Modul 5)
Affiliate-Payout bezahltcommission_payoutSUPPLIER_PAYMENTProvisions-Lauf (Modul 7)
Revolut-Transaktion (DOP/Multi-Währung)revolutEXPENSE/SUPPLIER_PAYMENT/INCOME/Transferrevolut-sync Edge Function (Webhook/CSV, Modul 6 §10)
Lieferantenrechnung/-zahlung (F-02)manualSUPPLIER_DEBT/EXPENSE/SUPPLIER_PAYMENTmanuelle Finanz-Erfassung (kein Auto-Posting)

8.3 Auslöse-Mechanismus (zwei Optionen)

  1. 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.
  2. 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) + herausgerechneter vat_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 berechnet total_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.sqlhas_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 security auf allen public-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_role umgeht RLS und macht die privilegierten Writes.
  • audit_log ist 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)

BucketInhaltLesenSchreibenAufbewahrung
kycAusweis-Scans (Modul 2)ADMIN, OPERATIONS, BUCHHALTUNGOPERATIONS (+ OCR-Job)minimal, DSG-Löschung nach Zweck
documentsOfferte-/Rechnung-PDFs, Liefernachweiseintern; AFFILIATE nur eigene; READONLY lesendSystem, OPERATIONS, BUCHHALTUNG10 Jahre (OR 958f)
receiptsBelege zu movementsADMIN, BUCHHALTUNGBUCHHALTUNG, OPERATIONS10 Jahre
public-assetsi18n-Flags, generische Bilderalle (auch anon)ADMIN
  • Uploads über signierte Upload-URLs via Edge Function (signed-upload) → der Client sieht nie den Service-Key; Edge validiert mime_type/byte_size/bucket/doc_class vor Persistenz und legt die storage_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)

FunctionZweckSchlüssel
post-movementAuto-Posting (§8)service_role
revolut-syncRevolut-Business-Transaktionen → movements (DOP/Multi-Währung, idempotent über transaction_id; Webhook/Cron, Modul 6 §10)service_role + REVOLUT_API_*
ocr-extractID-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-workerOutbox 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-uploadsignierte Storage-Upload-URLs + Validierungservice_role
whatsapp-inboundWhatsApp Cloud API Webhook (Meta): Verify-Challenge (GET) + eingehende Nachrichten/Medien für OCR-Intake (POST) + Delivery-/Read-Status-Callbacks; HMAC-Verifikation X-Hub-Signature-256service_role + WHATSAPP_*

10. Deployment (Vercel: caja.* + cajaspec.*)

10.1 Drei unabhängige Vercel-Projekte, ein Repo

ProjektRootDomainBuildBestand?
WebsiteRepo-Root (apps/web via vercel.json)www.dominicanoexpress.comstatisch (npm run build)✅ live, unverändert
Spec-Siteapps/cajaspeccajaspec.dominicanoexpress.comMarkdown→statischneu/leichtgewichtig
Caja-Appapps/cajacaja.dominicanoexpress.comNext.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)

VariableWoScope
NEXT_PUBLIC_SUPABASE_URLVercel (Caja)Client+Server
NEXT_PUBLIC_SUPABASE_ANON_KEY (Publishable)Vercel (Caja)Client+Server (RLS-gebunden)
SUPABASE_SERVICE_ROLE_KEY ⚠️Vercel (Caja, Server) + Supabase Edgenur server
RESEND_API_KEY / RESEND_FROM (…@dominicanoexpress.com)Vercel/Supabasenur server
REVOLUT_API_KEY / REVOLUT_API_SECRET / REVOLUT_WEBHOOK_SECRET ⚠️ (Revolut Business, Modul 6 §10)Vercel (Server) + Supabase Edgenur 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 Edgenur server
OCR_SELFHOST_URL / OCR_SELFHOST_TOKEN (self-hosted OCR auf Proxmox, Modul 2; kein Cloud-Provider-Key)Edgenur server

Env-Snapshot-Regel (aus Bestand gelernt): Vercel snapshottet Env pro Deployment — eine Key-Rotation oder ein neuer RESEND_FROM wird 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-src muss die Supabase-URL (REST/Realtime/Storage) + Vercel-Analytics erlauben,
  • wasm-unsafe-eval für den ZXing-Fallback (nicht unsafe-eval),
  • media-src 'self' + camera=(self) (Permissions-Policy) für Kamera-Flows,
  • img-src muss 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:

  1. Ein Datenzugriffs-Package (@caja/db): jede dieser Entitäten hat eine typisierte Query-Datei; Module importieren diese, statt eigene SQL/Clients zu bauen.
  2. Generierte Typen sind die Wahrheit (types.gen.ts aus dem realen Schema) — keine handgepflegten Parallel-Typen, die driften.
  3. Domänenregeln zentral (@caja/domain): die Kanal-Regel (payment_methods.channel entscheidet Kasse/Bank), die Status-Ampel, die 10 Tracking-Schritte, Geld als numeric(12,2)/CHF — einmal als reine Funktionen + Zod-Schemas, von Server Actions und Tests genutzt.
  4. Stabile Codes als Konstanten/Union-Types, nie als Magic-Strings verstreut (MovementTypeCode, TrackingStatusCode, RoleCode).
  5. 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

EbeneWerkzeugGegenstand (Caja-spezifisch)
StatischTypeScript strict, ESLint (jsx-a11y), PrettierTypsicherheit; a11y-Lint erzwingt ≥44px-Targets/ARIA (Modul 18 §7.2); ESLint-Regel „kein Service-Client im Client/RSC"
UnitVitest@caja/domain-Regeln: Kanal-Klassifikation, Status-Ampel, Restschuld, period_key, Geld-Rundung, i18n-Fallback-Kette
DB/IntegrationpgTAP + ephemerer Supabase-BranchRLS-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
KomponentenTesting LibraryAdaptiveTable (Karten <sm / Tabelle ≥lg), BottomSheetSelect, Action-State-Form-Zustände (pending/success/error)
E2EPlaywright (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
a11yaxe (in Playwright)WCAG 2.1 AA: Kontrast (auch Dark-Mode 🔲), Focus-Ring, Screenreader-Sheets
PerfLighthouse-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; manual bleibt 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).
  • Revisionssicherheitaudit_log lä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 jetzt app_users.preferred_locale+theme (statt user_preferences) und roles-Lookup (statt caja_role-ENUM); Modul 6 nutzt has_role('CODE') (statt auth_role()/Personennamen); i18n_labels ist durch locale_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-extract ruft 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/cajaspec als 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