Modul 7 — Affiliate-Programm

1. Zweck & Scope

Das Affiliate-Modul ermöglicht es der Dominicano Express GmbH, ein strukturiertes Vermittlernetzwerk aufzubauen: Affiliates (Selbstständige, Communitymitglieder, bestehende Kunden) erhalten persönliche Referral-Codes, die sie an potenzielle Kunden weitergeben. Wird ein Code bei einer Offerte oder einem Auftrag eingetragen, erhält der Kunde einen sichtbaren Rabatt (Prozentsatz oder Fixbetrag, mindert den Gesamtbetrag und erscheint auf dem Offerten-PDF) und der Affiliate eine Provision auf die vermittelte Sendung. Die Provision ist ein Prozentsatz, der je Affiliate (bzw. je Referral-Code) individuell konfigurierbar ist (Geschäftsentscheid 2026-06-28 #18): jeder Affiliate hat seinen eigenen %-Satz, mit einem Default von 5 % (best practice, §10), der je Affiliate/Code übersteuerbar ist. Das Modul umfasst die Stammdaten der Affiliates (verknüpft mit contacts), die Verwaltung der Referral-Codes mit ihren Rabatten und Provisions-Regeln, die automatische Erfassung von commission_entries je vermittelter Sendung sowie die Affiliate-Abrechnung (Payout = Kreditorenbuchung im movements-Ledger). Ein späteres Affiliate-Mini-Portal (eigene Rolle/RLS) ist als Erweiterungsstufe vorgesehen und wird in §9 als offener Punkt festgehalten. Die Kernaufgabe heute: den vollständigen Fluss Code → Offerte → Auftrag → Provision → Auszahlung lückenlos in Caja abzubilden und damit die bisher manuelle Excel-Erfassung zu ersetzen.

Geschäftsentscheid 2026-06-28 (#18/#19) — Provisions-Modell vereinheitlicht:

  • #18 — %-Satz je Affiliate: Provision = commission_kind='percent'; commission_value ist der Prozentsatz je Affiliate/Sublieferant (Default auf affiliates, pro Code über referral_codes überschreibbar). Kein globaler Einheitssatz. Default-commission_value = 5 % (best practice, übersteuerbar je Affiliate/Code — Begründung + Quellen §10).
  • #19 — Fälligkeit bei DELIVERED (Revision!): Die Provision entsteht (status='pending') bereits bei Auftragsannahme/Offerte-Konversion (quotes ACCEPTED → CONVERTED). Die Freigabe/Fälligkeit (status → 'approved') erfolgt automatisch per DB-Trigger beim Tracking-DELIVERED der vermittelten Sendung — nicht mehr bei invoices.status → 'PAID'. Auszahlung danach wie gehabt (Payout → movements).
  • Code-Rabatt = Kundenrabatt: Der Code-Rabatt mindert quotes.grand_total_chf und wird auf dem Offerten-PDF ausgewiesen (sichtbarer Preisnachlass für den Kunden), nicht nur als interne Provisionsbasis.

2. Domänenmodell

Entitäten

EntitätRolle im Fluss
contactsStammdatensatz des Affiliates (Name, Telefon, E-Mail, Adresse); Affiliate ist ein Kontakt-Typ
affiliatesAffiliate-spezifische Ergänzung: Vertragsdaten, Status, Payout-Methode, Provision-Default
referral_codesEin oder mehrere Codes pro Affiliate; trägt Rabatt- und Provisions-Regel, Gültigkeitszeitraum, Limit
quotesOfferte, in der ein Code eingetragen wird → Rabatt wird berechnet
ordersAuftrag, der aus einer akzeptierten Offerte entsteht; referenziert referral_code_id
commission_entriesProvisionseintrag; entsteht pending bei Offerte-Konversion (Auftragsannahme) und wird approved beim Tracking-DELIVERED der Sendung (#19)
commission_payoutsGebündelte Auszahlung an einen Affiliate (mehrere commission_entries)
movementsPayout-Buchung als Kreditor-Eintrag (Vorgangsart SUPPLIER_PAYMENT / SUPPLIER_DEBT)

ER-Skizze

contacts (1) ──── (1) affiliates
                         │
                   (0..N) referral_codes
                         │
           ┌─────────────┼──────────────┐
        quotes         orders      commission_entries
        (code_id)      (code_id)       (order_id,
                                        affiliate_id,
                                        payout_id)
                                        │
                               commission_payouts ── movements

3. Supabase-Schema

3.1 Enum-Typen

-- Affiliate-Status
CREATE TYPE affiliate_status AS ENUM (
  'active',    -- DE: Aktiv     | ES: Activo     | EN: Active
  'paused',    -- DE: Pausiert  | ES: Pausado    | EN: Paused
  'terminated' -- DE: Beendet  | ES: Terminado  | EN: Terminated
);

-- Provisions-Art
CREATE TYPE commission_type AS ENUM (
  'percentage', -- DE: Prozentsatz | ES: Porcentaje | EN: Percentage
  'fixed'       -- DE: Fixbetrag   | ES: Fijo       | EN: Fixed amount
);

-- Rabatt-Art (für Kunden)
CREATE TYPE discount_type AS ENUM (
  'percentage', -- DE: Prozentsatz | ES: Porcentaje | EN: Percentage
  'fixed'       -- DE: Fixbetrag   | ES: Fijo       | EN: Fixed amount
);

-- Provisions-Status (commission_entries)
CREATE TYPE commission_status AS ENUM (
  'pending',   -- DE: Ausstehend | ES: Pendiente | EN: Pending
  'approved',  -- DE: Freigegeben | ES: Aprobado | EN: Approved
  'paid',      -- DE: Ausbezahlt | ES: Pagado    | EN: Paid
  'cancelled'  -- DE: Storniert  | ES: Cancelado | EN: Cancelled
);

-- Payout-Status
CREATE TYPE payout_status AS ENUM (
  'draft',     -- DE: Entwurf   | ES: Borrador   | EN: Draft
  'approved',  -- DE: Freigegeben | ES: Aprobado | EN: Approved
  'paid'       -- DE: Ausbezahlt | ES: Pagado    | EN: Paid
);

3.2 Tabelle affiliates

CREATE TABLE affiliates (
  id                    uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  contact_id            uuid NOT NULL REFERENCES contacts(id) ON DELETE RESTRICT,
  status                affiliate_status NOT NULL DEFAULT 'active',
  -- Vertragsdetails
  contract_start        date NOT NULL,
  contract_end          date,                    -- NULL = unbefristet
  notes                 text,
  -- Provisions-Default je Affiliate (#18: %-Satz individuell je Affiliate; überschreibbar per referral_code)
  default_commission_type    commission_type NOT NULL DEFAULT 'percentage',  -- Standard 'percentage' (#18); 'fixed' nur Ausnahme
  default_commission_value   numeric(10, 4) NOT NULL DEFAULT 5.00,  -- Default (best practice) 5.00 = 5 %, je Affiliate übersteuerbar (§10); pro Code via referral_codes überschreibbar
  -- Payout-Präferenz
  payout_method         text,                   -- 'BANK' | 'TWINT' | 'CASH'
  payout_iban           text,                   -- verschlüsselt via pgsodium/Supabase Vault (Entscheid 2026-06-28); Entschlüsselung nur server-seitig (ADMIN/BUCHHALTUNG)
  payout_notes          text,
  -- Audit
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now(),
  created_by            uuid REFERENCES auth.users(id),
  CONSTRAINT affiliates_contact_id_unique UNIQUE (contact_id)
);

CREATE INDEX idx_affiliates_status ON affiliates(status);
CREATE INDEX idx_affiliates_contact ON affiliates(contact_id);

RLS affiliates: (Rollen-Codes UPPERCASE, F-09 / K-17; Prüfung via has_role('CODE'))

  • ADMIN (Marcel): SELECT + INSERT + UPDATE + DELETE
  • BUCHHALTUNG (Mariela): SELECT + INSERT + UPDATE + DELETE
  • OPERATIONS (Markus): SELECT + UPDATE (kein DELETE)
  • FAHRER (Arkys): kein Zugriff
  • AFFILIATE (Stufe 2): SELECT eigene Zeile (current_affiliate_id())
  • READONLY: SELECT

3.3 Tabelle referral_codes

CREATE TABLE referral_codes (
  id                    uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  affiliate_id          uuid NOT NULL REFERENCES affiliates(id) ON DELETE RESTRICT,
  code                  text NOT NULL,           -- z.B. 'MARIA2026', eindeutig
  description           text,                    -- interne Notiz
  -- Rabatt für den Kunden
  discount_type         discount_type NOT NULL DEFAULT 'percentage',
  discount_value        numeric(10, 4) NOT NULL DEFAULT 0,   -- z.B. 5.00 = 5 % oder 20.00 CHF
  discount_max_chf      numeric(10, 2),          -- optionale Obergrenze bei %-Rabatt
  -- Provisions-Regel je Code (#18: überschreibt affiliates.default_*; %-Satz individuell je Affiliate/Code)
  commission_type       commission_type,         -- NULL = nimm affiliates.default (i.d.R. 'percentage')
  commission_value      numeric(10, 4),          -- NULL = nimm affiliates.default; %-Satz je Affiliate/Code
  -- Gültigkeitszeitraum
  valid_from            date NOT NULL DEFAULT CURRENT_DATE,
  valid_until           date,                    -- NULL = unbegrenzt
  -- Nutzungslimit
  max_uses              integer,                 -- NULL = unbegrenzt
  uses_count            integer NOT NULL DEFAULT 0,  -- inkrementiert via Trigger
  -- Status
  is_active             boolean NOT NULL DEFAULT true,
  -- Audit
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now(),
  created_by            uuid REFERENCES auth.users(id),
  CONSTRAINT referral_codes_code_unique UNIQUE (code)
);

CREATE INDEX idx_referral_codes_affiliate ON referral_codes(affiliate_id);
CREATE INDEX idx_referral_codes_code ON referral_codes(code);
CREATE INDEX idx_referral_codes_active ON referral_codes(is_active, valid_from, valid_until);

RLS referral_codes: (Rollen-Codes UPPERCASE, F-09)

  • ADMIN, BUCHHALTUNG: SELECT + INSERT + UPDATE + DELETE
  • OPERATIONS: SELECT + UPDATE (is_active, description, valid_until)
  • FAHRER: kein Zugriff
  • AFFILIATE: SELECT eigene Codes (current_affiliate_id())
  • READONLY: SELECT (nur aktive, nicht-abgelaufene)

3.4 Tabelle commission_entries

CREATE TABLE commission_entries (
  id                    uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  affiliate_id          uuid NOT NULL REFERENCES affiliates(id) ON DELETE RESTRICT,
  referral_code_id      uuid REFERENCES referral_codes(id) ON DELETE SET NULL,
  -- Quelle (verknüpft mit vermittelter Sendung)
  order_id              uuid REFERENCES orders(id) ON DELETE RESTRICT,
  invoice_id            uuid REFERENCES invoices(id) ON DELETE SET NULL,
  -- Berechnungsgrundlage
  base_amount_chf       numeric(10, 2) NOT NULL,  -- Betrag, auf den Provision berechnet wurde
  commission_type       commission_type NOT NULL,
  commission_value      numeric(10, 4) NOT NULL,
  commission_amount_chf numeric(10, 2) NOT NULL,  -- berechneter Betrag
  -- Status
  status                commission_status NOT NULL DEFAULT 'pending',
  approved_at           timestamptz,
  approved_by           uuid REFERENCES auth.users(id),
  -- Payout-Zuordnung
  payout_id             uuid REFERENCES commission_payouts(id) ON DELETE SET NULL,
  -- Buchungsreferenz
  movement_id           uuid REFERENCES movements(id) ON DELETE SET NULL,
  -- Notiz
  notes                 text,
  -- Audit
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT commission_entries_order_unique UNIQUE (order_id)  -- eine Provision pro Auftrag
);

CREATE INDEX idx_commission_entries_affiliate ON commission_entries(affiliate_id);
CREATE INDEX idx_commission_entries_status ON commission_entries(status);
CREATE INDEX idx_commission_entries_payout ON commission_entries(payout_id);

RLS commission_entries: (Rollen-Codes UPPERCASE, F-09)

  • ADMIN, BUCHHALTUNG: SELECT + INSERT + UPDATE + DELETE
  • OPERATIONS: SELECT + UPDATE (status, notes)
  • FAHRER: kein Zugriff
  • AFFILIATE: SELECT eigene Einträge (current_affiliate_id())
  • READONLY: SELECT

3.5 Tabelle commission_payouts

CREATE TABLE commission_payouts (
  id                    uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  affiliate_id          uuid NOT NULL REFERENCES affiliates(id) ON DELETE RESTRICT,
  -- Abrechnungszeitraum
  period_from           date NOT NULL,
  period_to             date NOT NULL,
  -- Betrag
  total_amount_chf      numeric(10, 2) NOT NULL,  -- Summe aller commission_entries
  -- Payout-Status
  status                payout_status NOT NULL DEFAULT 'draft',
  approved_at           timestamptz,
  approved_by           uuid REFERENCES auth.users(id),
  paid_at               timestamptz,
  paid_by               uuid REFERENCES auth.users(id),
  -- Zahlungsreferenz
  payment_method        text,                    -- aus payment_methods.code
  payment_reference     text,
  -- Buchungsreferenz im movements-Ledger
  movement_id           uuid REFERENCES movements(id) ON DELETE SET NULL,
  -- Notiz
  notes                 text,
  -- Audit
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now(),
  created_by            uuid REFERENCES auth.users(id)
);

CREATE INDEX idx_commission_payouts_affiliate ON commission_payouts(affiliate_id);
CREATE INDEX idx_commission_payouts_status ON commission_payouts(status);

RLS commission_payouts: (Rollen-Codes UPPERCASE, F-09)

  • ADMIN, BUCHHALTUNG: SELECT + INSERT + UPDATE + DELETE
  • OPERATIONS: SELECT + UPDATE (notes)
  • FAHRER: kein Zugriff
  • AFFILIATE: SELECT eigene Payouts
  • READONLY: SELECT

3.6 Anpassungen geteilter Tabellen

quotes (Ergänzung):

ALTER TABLE quotes ADD COLUMN referral_code_id uuid REFERENCES referral_codes(id) ON DELETE SET NULL;
ALTER TABLE quotes ADD COLUMN discount_from_code_chf numeric(10,2);  -- berechneter Rabatt aus Code
ALTER TABLE quotes ADD COLUMN discount_from_code_type discount_type; -- snapshot bei Anwendung
ALTER TABLE quotes ADD COLUMN discount_from_code_value numeric(10,4); -- snapshot bei Anwendung

orders (Ergänzung):

ALTER TABLE orders ADD COLUMN referral_code_id uuid REFERENCES referral_codes(id) ON DELETE SET NULL;
ALTER TABLE orders ADD COLUMN affiliate_id uuid REFERENCES affiliates(id) ON DELETE SET NULL;
-- affiliate_id wird beim Code-Lookup denormalisiert, um JOIN zu vereinfachen

3.7 i18n der Status-Labels (locale_strings)

i18n_labels wird nicht gebaut (Dok 30 K-16, F-11). Freie/aufzählbare Labels gehen nach locale_strings (namespace = Entität, key = Fachcode, locale, value):

-- Status-Labels in locale_strings (statt i18n_labels)
INSERT INTO locale_strings (namespace, key, locale, value) VALUES
  ('affiliate_status', 'active',    'de', 'Aktiv'),
  ('affiliate_status', 'active',    'es', 'Activo'),
  ('affiliate_status', 'active',    'en', 'Active'),
  ('affiliate_status', 'paused',    'de', 'Pausiert'),
  ('affiliate_status', 'paused',    'es', 'Pausado'),
  ('affiliate_status', 'paused',    'en', 'Paused'),
  ('affiliate_status', 'terminated','de', 'Beendet'),
  ('affiliate_status', 'terminated','es', 'Terminado'),
  ('affiliate_status', 'terminated','en', 'Terminated'),
  ('commission_status','pending',   'de', 'Ausstehend'),
  ('commission_status','pending',   'es', 'Pendiente'),
  ('commission_status','pending',   'en', 'Pending'),
  ('commission_status','approved',  'de', 'Freigegeben'),
  ('commission_status','approved',  'es', 'Aprobado'),
  ('commission_status','approved',  'en', 'Approved'),
  ('commission_status','paid',      'de', 'Ausbezahlt'),
  ('commission_status','paid',      'es', 'Pagado'),
  ('commission_status','paid',      'en', 'Paid'),
  ('commission_status','cancelled', 'de', 'Storniert'),
  ('commission_status','cancelled', 'es', 'Cancelado'),
  ('commission_status','cancelled', 'en', 'Cancelled');

4. Kern-Workflows

4.1 Affiliate anlegen

  1. Nutzer öffnet „Affiliates → Neu".
  2. Kontakt suchen oder neu anlegen (Feld contact_id); bestehender Kontakt wird als Affiliate markiert.
  3. Vertragsdaten eingeben: contract_start, optionales contract_end, Provisions-Default (Typ + Wert), Payout-Methode.
  4. Speichern → INSERT in affiliates; contacts.type wird auf affiliate gesetzt (oder zusätzliche Rolle in contact_roles).
  5. Audit-Log-Eintrag.

Edge-Case: Kontakt ist bereits Affiliate → Fehlermeldung „Kontakt ist bereits als Affiliate erfasst" (UNIQUE Constraint auf contact_id).

4.2 Referral-Code erstellen

  1. Im Affiliate-Datensatz „Code hinzufügen".
  2. Code-String eingeben (z.B. MARIA2026); System prüft Einzigartigkeit sofort (Echtzeit-Validierung über referral_codes_code_unique).
  3. Rabatt-Regel für Kunden: Typ (% oder Fix), Wert, optionale CHF-Obergrenze.
  4. Provisions-Regel: leer lassen = Affiliate-Default übernehmen; oder explizit überschreiben.
  5. Gültigkeitszeitraum und Nutzungslimit festlegen.
  6. Speichern → INSERT in referral_codes.

Edge-Case: Code bereits vergeben → Inline-Fehler „Code bereits vorhanden", Vorschlag alternativer Code (MARIA2026-2). Edge-Case: valid_until < valid_from → Validierungsfehler. Edge-Case: commission_value = 0 bei leerem Default → Warnung, kein Fehler (Code ohne Provision ist erlaubt, z.B. reiner Rabatt-Code).

4.3 Code bei Offerte anwenden

  1. Sachbearbeiter erstellt Offerte (Modul 4); Feld „Referral-Code" (optional).
  2. Code eingeben → System validiert:
    • Code existiert und is_active = true
    • Heutiges Datum liegt in [valid_from, valid_until]
    • uses_count < max_uses (falls Limit gesetzt)
  3. Wenn gültig: Rabatt berechnen und in discount_from_code_chf speichern; Snapshot der Regel (discount_from_code_type, discount_from_code_value) in der Offerte speichern.
  4. Zeilenrabatt oder Gesamtrabatt je nach Typ auf Offerten-Summe anwenden.
  5. Code-Inhaber (affiliate_id) wird in der Offerte angezeigt (Readonly-Info).

Edge-Case: Code abgelaufen → Fehlermeldung mit Ablaufdatum, Code kann nicht gesetzt werden. Edge-Case: Limit erschöpft → Fehlermeldung „Code hat maximale Nutzungen erreicht". Edge-Case: Code wird aus Offerte entfernt → discount_from_code_chf = NULL, kein Eintrag in commission_entries.

4.4 Offerte → Auftrag (Code-Übertragung)

  1. Offerte wird akzeptiert (Status accepted) → Auftrag entsteht (Modul 5).
  2. referral_code_id und affiliate_id werden 1:1 aus der Offerte in den Auftrag übernommen.
  3. referral_codes.uses_count wird via Trigger um 1 erhöht.
  4. Noch KEIN commission_entry — dieser entsteht erst bei Abrechnung/Zahlung (§4.5).

Edge-Case: Offerte ohne Code → referral_code_id = NULL in Order, kein Trigger. Edge-Case: Code zwischen Offerten-Erstellung und Auftrags-Erstellung deaktiviert → Warnung an Sachbearbeiter, Auftrag trotzdem möglich (Snapshot ist in Offerte festgehalten).

4.5 Commission-Entry erzeugen (pending) und freigeben (approved bei DELIVERED)

Geschäftsentscheid 2026-06-28 (#19): Die Provision entsteht bei der Offerte-Konversion (Auftragsannahme) als pending; sie wird fällig/freigegeben (status → 'approved') automatisch beim Tracking-DELIVERED der vermittelten Sendung — nicht bei invoices.status → 'PAID'.

Schritt A — Entstehung (pending) bei Offerte-Konversion: Wenn eine akzeptierte Offerte mit referral_code_id IS NOT NULL zu einem Auftrag konvertiert wird (quotes ACCEPTED → CONVERTED, Modul 4 §4.4), legt die Server-Action den Provisionseintrag an:

  1. base_amount_chf = Netto-Betrag der Sendung (Auftragswert ohne MWST, abgeleitet aus quotes.grand_total_chf herausgerechnet).
  2. Provisions-Regel ermitteln: nimm referral_codes.commission_type/value falls gesetzt, sonst affiliates.default_commission_type/value (#18: %-Satz je Affiliate).
  3. commission_amount_chf berechnen: bei percentagebase_amount_chf × commission_value / 100; bei fixedcommission_value.
  4. INSERT in commission_entries mit status = 'pending'.
  5. Audit-Log-Eintrag mit source_type = 'commission_auto', source_id = order_id.

Schritt B — Freigabe/Fälligkeit (approved) bei DELIVERED (DB-Trigger): Wenn die vermittelte Sendung den Tracking-Status DELIVERED erreicht, schreibt ein DB-Trigger (auf tracking_events / beim Sendungsabschluss, Modul 5) den zugehörigen commission_entries-Satz von pending auf approved (setzt approved_at, approved_by = system). Ab diesem Zeitpunkt ist die Provision abrechnungsfähig und fliesst in den Payout (§4.6).

  • Findet der Trigger den Provisionseintrag über die Auftrags-/Sendungs-Kette (shipments.order_id → commission_entries.order_id). Mehrere Sendungen je Auftrag: Freigabe, sobald die Sendung(en) des Auftrags DELIVERED sind (🔲 bei Teil-Sendungen: anteilige vs. Gesamt-Freigabe — Geschäftsentscheid, §9).
  • Eine manuelle Freigabe durch BUCHHALTUNG/ADMIN bleibt als Override möglich (§4.6), ist aber im Normalfall durch den DELIVERED-Trigger automatisiert.

F-13 — Lifecycle eindeutig (#19, konsistent mit Dok 30 §8): Entstehung = pending bei Offerte-Konversion (Schritt A). Freigabe/Fälligkeit (→ approved) = automatisch per DB-Trigger beim Tracking-DELIVERED der Sendung (Schritt B) — die Zustellung ist der Auslöser, nicht nur eine fachliche Voraussetzung. Auszahlung (→ paid) beim Payout (§4.6 Schritt 7 / AP-9). Die frühere Bindung an invoices.status → 'PAID' ist mit dem Entscheid 2026-06-28 aufgehoben.

Edge-Case: Auftrag ohne affiliate_id → kein Commission-Entry. Edge-Case: Sendung wird vor DELIVERED storniert / Rechnung storniert → commission_entries.status = 'cancelled', falls noch pending oder approved. Edge-Case: Provision wäre 0 CHF (commission_value = 0) → Entry trotzdem anlegen (Nachweis), mit Hinweis im UI.

4.6 Affiliate-Abrechnung (Payout erstellen)

  1. Admin öffnet „Affiliates → Abrechnungen → Neu".
  2. Affiliate und Abrechnungszeitraum wählen.
  3. System listet alle commission_entries mit Status approved im Zeitraum (regulär per DELIVERED-Trigger freigegeben, §4.5 Schritt B; manuelle Freigaben als Override eingeschlossen).
  4. Summe wird berechnet → INSERT commission_payouts mit status = 'draft'.
  5. Commission-Entries werden mit payout_id verknüpft.
  6. Admin prüft, bestätigt → status = 'approved'.
  7. Bei Auszahlung: status = 'paid'; Buchung ins movements-Ledger als SUPPLIER_PAYMENT mit payment_method des Affiliates → movement_id wird gesetzt; commission_entries.movement_id ebenfalls.

Edge-Case: Keine freigegeben Entries im Zeitraum → Hinweis „Keine abrechnungsfähigen Provisionen", kein Payout angelegt. Edge-Case: Payout zurückgesetzt → nur von approved auf draft, nicht von paid (unveränderliche Buchung). Edge-Case: Affiliate hat kein gültiges Payout-Konto → Warnung bei Payout-Erstellung, blockiert nicht den Entwurf.


5. UI-Screens (Mobile/Tablet-First)

5.1 Affiliates-Liste

Zweck: Übersicht aller Affiliates mit Status und offenen Provisionen.

FormfaktorPattern
Handy (≤ 767px)Karten-Liste: Name, Status-Badge, Anzahl offener Provisions-Entries, letzter Payout. Kein Horizontal-Scroll. Swipe-links → Auftrag/Details, Swipe-rechts → Code erstellen. FAB unten rechts: „Neuer Affiliate".
Tablet (768–1279px)Tabelle mit Spalten: Name, Telefon, Status, aktive Codes, offene Provision (CHF), letzter Payout. TanStack Table, sortierbar. Inline-Status-Toggle.
Desktop (≥ 1280px)Sidebar + Tabelle + Detailpanel (3-Spalten-Layout). Filter nach Status, Zeitraum.

Bottom-Tab-Bar (Handy): Kontakte / Affiliates / Codes / Abrechnungen / Einstellungen.

5.2 Affiliate-Detailansicht

Zweck: Vollständiges Profil: Stammdaten, Codes, Provisions-History, Payouts.

FormfaktorPattern
HandyTabs (Profil / Codes / Provisionen / Abrechnungen) als Bottom-Sheet-Drawer. Jeder Tab scrollbar. Aktionen als Fixed-Button am unteren Rand (z.B. „Code erstellen").
Tablet2-Spalten: links Stammdaten + Codes, rechts Provisions-Tabelle + KPI-Kacheln (Gesamtprovision YTD, offene Provision, bezahlte Provision).
DesktopWie Tablet, aber mit Recharts-Monatschart der Provisionen.

KPI-Kacheln: „Provisionen offen (CHF)", „Provisionen freigegeben", „Provisionen ausbezahlt YTD", „Aktive Codes".

5.3 Referral-Code-Verwaltung

Zweck: Codes eines Affiliates erstellen, aktivieren/deaktivieren, Nutzungsstand überwachen.

FormfaktorPattern
HandyKarten-Liste: Code-Text (groß, font-mono), Rabatt, Provision, Gültigkeit, Nutzungen (uses_count / max_uses). Bottom-Sheet zum Erstellen/Bearbeiten (Felder: Code, Rabatt-Typ, Wert, Provisions-Override, Gültigkeitsdatumspicker, Limit). Swipe-links → Deaktivieren.
Tablet/DesktopTabelle + modaler Dialog zum Erstellen/Bearbeiten. Spalten: Code, Rabatt, Provision, gültig bis, Nutzungen, Status-Toggle.

Code-Einzigartigkeit: Echtzeit-Inline-Validierung (Debounce 400 ms, API-Check).

5.4 Commission-Entries-Liste

Zweck: Alle Provisions-Einträge, filterbar nach Status/Affiliate/Zeitraum. Freigabe-Workflow.

FormfaktorPattern
HandyKarten: Auftragsnummer, Kunde, Betrag, Provision, Status-Badge. Swipe-rechts → Freigeben (wenn pending). Bottom-Sheet mit Details und Aktionen.
Tablet/DesktopTanStack-Tabelle. Bulk-Auswahl + Bulk-Freigabe-Button. Statusfilter-Chips oben.

Status-Farben: pending = Gelb, approved = Blau, paid = Grün, cancelled = Grau.

5.5 Payout-Erstellung (Abrechnungs-Wizard)

Zweck: Affiliate-Abrechnung für einen Zeitraum bündeln und auszahlen.

FormfaktorPattern
HandyStep-by-Step Bottom-Sheet (3 Schritte): 1. Affiliate + Zeitraum wählen, 2. Entries-Vorschau (scrollbare Karte, Summe fixed oben), 3. Zahlungsart + Bestätigung. ≥ 44px Targets, inputmode numeric für CHF-Felder.
Tablet/DesktopEinseitiger Dialog: linke Spalte Entries, rechte Spalte Zusammenfassung + Zahlungsdetails + Aktions-Buttons.

Bestätigungsdialog: zeigt Summe, Affiliate-Name, Payout-Methode und warnt bei fehlendem IBAN/Konto.

5.6 Code-Eingabe in Offerten-Screen (Integration)

Zweck: Feld im Offerten-Formular (Modul 4), das Referral-Code aufnimmt und sofort validiert/Rabatt zeigt.

FormfaktorPattern
HandyEinzeiliges Input-Feld im Bottom-Sheet des Offerten-Formulars. Nach Eingabe (onBlur / Enter): Inline-Feedback (grüner Haken = gültig, roter Text = Fehler, gelbe Warnung = bald ablaufend). Rabatt wird live unter der Summe angezeigt.
Tablet/DesktopSeitenpanel der Offerte: Code-Feld mit Autocomplete-Vorschlägen (eigene aktive Codes), Rabatt-Preview, Affiliate-Name als Readonly-Info.

6. Integrationen & Verbindungen zu anderen Modulen

Geteilte Entitäten

EntitätVerbindung
contactsAffiliate ist ein Kontakt (contact_id). Beim Lead-Intake (Website-Formular, type = 'Programa Afiliación') wird ein Kontakt angelegt; Admin wandelt ihn manuell in Affiliate um.
quotesCode-Feld + berechneter Rabatt; Affiliate-Info readonly.
ordersreferral_code_id + affiliate_id denormalisiert; uses_count-Trigger; Entstehung des commission_entries (pending) bei Offerte→Auftrag-Konversion (§4.5 A, #19).
tracking_events / shipmentsFreigabe-Trigger (#19): beim Tracking-DELIVERED der Sendung wird commission_entries.status → 'approved' gesetzt (§4.5 B). Verknüpfung über shipments.order_id → commission_entries.order_id (Modul 5).
invoicesReine Forderungs-/Zahlungsabbildung; kein Auslöser mehr für die Provision (frühere PAID-Bindung mit #19 aufgehoben).
movementsPayout → movements-Eintrag mit movement_type = 'SUPPLIER_PAYMENT' (Kreditor); source_type = 'commission_payout', source_id = commission_payouts.id. Auto-Posting verhindert Doppelbuchung via Unique-Constraint.
audit_logAlle Statusänderungen (Code-Erstellung, Freigabe, Payout) werden revisionssicher geloggt.

Events & Auto-Posting

quote.status → 'CONVERTED'   (Offerte-Konversion, Modul 4 §4.4)
  └─► IF order.affiliate_id IS NOT NULL
        └─► INSERT commission_entries (status='pending')

shipment/box tracking → 'DELIVERED'   (#19: Freigabe-Trigger auf tracking_events, Modul 5)
  └─► UPDATE commission_entries
        SET status='approved', approved_at=now(), approved_by=system
        WHERE order_id = shipment.order_id AND status='pending'

commission_payouts.status → 'paid'
  └─► UPSERT movements (
        movement_type = 'SUPPLIER_PAYMENT',
        source_type   = 'commission_payout',
        source_id     = payout.id,
        amount        = payout.total_amount_chf,
        payment_method = payout.payment_method,
        contact_id    = affiliate.contact_id
      )
  └─► UPDATE commission_entries SET status='paid', movement_id=...
        WHERE payout_id = payout.id

Modul-zu-Modul-Abhängigkeiten

  • Modul 4 (Offerten): liest referral_codes für Code-Validierung und Kundenrabatt-Berechnung; bei Konversion entsteht commission_entries (pending, §4.5 A).
  • Modul 5 (Logistik/ERP): orders.affiliate_id wird aus Offerte übernommen; Tracking-DELIVERED löst die Provisions-Freigabe (approved) aus (#19, §4.5 B).
  • Modul 6 (Finanzen): Payout erzeugt movements-Eintrag; Kreditoren-View zeigt offene Payouts. Dashboard-KPI: „Ausstehende Provisionen (CHF)". (Rechnungsstatus PAID ist nicht mehr Provisions-Auslöser.)
  • Modul 1 (CRM): Kontaktansicht zeigt, ob Kontakt Affiliate ist (Badge).
  • Modul 8 (Plattform): Affiliate-Rolle (spätere Stufe 2) benötigt eigene RLS-Policies; Notifications (WhatsApp/E-Mail) bei neuer Provision oder Payout.

7. Validierungen & Edge-Cases

BereichRegelVerhalten bei Verletzung
Code-Einzigartigkeitreferral_codes.code UNIQUEInline-Fehler + Alternativvorschlag
Code-Gültigkeitvalid_from ≤ heute ≤ valid_until (wenn gesetzt)Fehler bei Offerten-Zuweisung
Nutzungslimituses_count < max_uses (wenn gesetzt)Fehler bei Auftrags-Erstellung
DoppelprovisionUNIQUE(order_id) auf commission_entriesDatenbank-Constraint verhindert Duplikat; App zeigt Warnung
Payout-RücksetzenNur approved → draft erlaubt; paid ist finalUI-Button deaktiviert; API gibt 409 zurück
Provision bei StornoRechnung storniert → Entry auf cancelledTrigger bei Invoice-Storno; manuelle Überprüfung empfohlen
Affiliate-LöschungON DELETE RESTRICT auf commission_entries, referral_codesLöschen gesperrt solange Entries/Codes existieren; stattdessen status = 'terminated'
Kontakt-Affiliate-DopplungUNIQUE(contact_id) auf affiliatesFehlermeldung „Kontakt ist bereits Affiliate"
Provisions-Betrag = 0Commission-Value 0 erlaubtEntry trotzdem angelegt, UI-Hinweis
Ungültiges Payout-KontoKein IBAN/Payout-MethodeWarnung bei Payout-Erstellung, kein Blockieren des Entwurfs
Affiliate paused/terminatedCode-Zuweisung möglich aber warnendGelbe Warnung im Offerten-Screen, kein Hard-Block (Auftrag ist Realität)

8. Compliance- und Sicherheitshinweise

Datenschutz (DSG / revDSG)

  • Personendaten von Affiliates (Name, Adresse, IBAN, Telefon) unterliegen dem DSG; Aufbewahrungsfrist nach Vertragsende beachten.
  • IBAN / Kontodetails sollten mit Supabase Vault oder Column-Level-Encryption gespeichert werden (🔲 zu bestätigen, je nach Vercel/Supabase-Tier).
  • Audit-Log (audit_log): alle Änderungen an affiliates, referral_codes, commission_entries, commission_payouts werden revisionssicher geloggt (unveränderlich nach Eintrag).
  • Recht auf Auskunft / Löschung: da Löschung durch RESTRICT gesperrt, muss ein Anonymisierungs-Workflow vorgesehen werden (Name → [GELÖSCHT], IBAN → NULL) bei Anfrage nach DSG Art. 32 ff.

Buchhaltung / Revisionssicherheit (OR 957 / GeBüV)

  • commission_payouts mit status = 'paid' sind unveränderlich; der verknüpfte movements-Eintrag darf nicht gelöscht werden.
  • Korrekturen erfolgen ausschliesslich durch Storno-Buchung (neuer gegensätzlicher movements-Eintrag), nie durch Überschreiben.
  • Audit-Log wird als Journal geführt: INSERT-only, kein UPDATE/DELETE.
  • Exportpflicht: Provisions-Abrechnungen müssen als PDF exportierbar sein (Treuhänder-Export).

Zugriffskontrolle

  • Affiliate-Provisions- und Payout-Daten sind streng auf admin-Rolle beschränkt für Schreibzugriffe.
  • Affiliates in eigener Rolle sehen ausschliesslich ihre eigenen Einträge (RLS via affiliate_id Join auf contact_id = auth.uid()-Mapping).
  • Provisions-Beträge sind KEIN Public-Feld; ReadOnly-Rolle hat SELECT, aber keine Aggregationen über alle Affiliates (🔲 zu entscheiden: granulare RLS oder separates API-Layer).

9. Offene Punkte

#ThemaFrage
✅ 1Provisions-Konditionen (entschieden 2026-06-28 #18)Prozentsatz je Affiliate/Sublieferant, individuell konfigurierbar (commission_type='percentage', commission_value = %-Satz je Affiliate auf affiliates, pro Code via referral_codes überschreibbar). Kein globaler Einheitssatz. Default-Satz = 5 % (best practice, §10), je Affiliate/Code übersteuerbar. 🔲 Staffeln (mehr % ab X Sendungen) bleiben spätere Option.
✅ 2Provisions-Trigger-Zeitpunkt (entschieden 2026-06-28 #19)Entstehung (pending) bei Offerte-Konversion; Freigabe/Fälligkeit (approved) automatisch per DB-Trigger beim Tracking-DELIVERED der Sendung. Bindung an invoices.status='PAID' aufgehoben. 🔲 offen nur: Teil-Sendungen je Auftrag (anteilige vs. Gesamt-Freigabe).
🔲 3IBAN-VerschlüsselungSupabase Vault aktivieren? Oder reicht RLS? Abhängig von Supabase-Plan und Sicherheitsanforderungen.
🔲 4Affiliate-Mini-Portal (Stufe 2)Separate Subdomain (affiliates.caja.dominicanoexpress)? Oder Tab in der Haupt-App mit eingeschränkter Sidebar? Eigene Auth-Einladungs-E-Mail (Magic-Link)?
🔲 5Payout-ZyklusMonatlich fix oder on-demand durch Admin? Automatische Benachrichtigung an Affiliate per WhatsApp/E-Mail bei Payout?
🔲 6Mehrere Codes pro SendungKann eine Offerte / ein Auftrag mehrere Codes haben (Kombination Rabatt + Provisions-Code)? Oder maximal ein Code?
🔲 7MWST auf ProvisionAffiliates als Selbstständige: Muss auf die Provision MWST abgerechnet werden? Wie wird dies in commission_entries abgebildet?
🔲 8Code-FormatGibt es Vorgaben für Format/Länge der Codes (z.B. nur Grossbuchstaben + Zahlen, max. 12 Zeichen)? Automatische Code-Generierung oder manuell?
🔲 9Granulare RLS für ReadOnlyDarf die ReadOnly-Rolle Provisions-Aggregate über alle Affiliates sehen (Dashboard-KPIs), oder nur eigene Daten?
🔲 10Affiliate-Lead-KonvertierungWer (Admin / Mariela / Markus) ist berechtigt, einen Kontakt aus dem Website-Lead-Intake (type = 'Programa Afiliación') in einen Affiliate zu konvertieren?

10. Provisionssatz-Default — Best Practice & Begründung

Entscheid 2026-06-28 (Default gesetzt): affiliates.default_commission_value = **5.00** (= 5 %), commission_type='percentage'. Der Satz ist ein Default (best practice) und je Affiliate übersteuerbar (affiliates.default_commission_value) bzw. pro Code (referral_codes.commission_value, NULL = Affiliate-Default). Berechnungsbasis = Netto-Sendungswert (§4.5).

10.1 Empfehlung

PunktEntscheid
Default-Satz5 % auf den Netto-Sendungswert je vermittelter Sendung
KonfigurierbarkeitDefault je Affiliate übersteuerbar; pro Referral-Code feiner überschreibbar (#18)
Artpercentage (Standard); fixed nur Ausnahme

10.2 Begründung

  • Branchenüblicher Korridor: Empfehlungs-/Vermittlungsprovisionen liegen breit bei 5–15 %; physische Versand-/Logistikwaren typisch am unteren Ende (5–15 %), klassische Geschäfts-/Tippgeber-Vermittlung (Versicherung 3–5 %, Makler 2–3 %, Handwerk/Immobilien bis ~10 %) eher 3–10 %. Reine Empfehlungsprogramme (z. B. PT Digital, Hostinger) nennen ~20 %, das gilt aber für margenstarke digitale Dienste.
  • Logistik hat schmale Margen: Grenzüberschreitende Beförderung CH→DR ist ein physisches, margenarmes Geschäft (Fracht, Handling, Zoll). Ein zweistelliger Satz würde die Marge je Sendung zu stark belasten. 5 % ist tragbar, attraktiv genug als Anreiz für Community-Vermittler und liegt am unteren, nachhaltigen Rand des üblichen Korridors.
  • Kombinierbar mit Kundenrabatt: Der Code gewährt zusätzlich einen sichtbaren Kundenrabatt (mindert quotes.grand_total_chf, §1). Provision und Rabatt belasten zusammen die Sendung → konservativer Provisions-Default schützt die Marge, ohne den Anreiz zu verlieren.
  • Pragmatisch & sicher: Ein runder, niedriger Default reduziert Fehlkonfiguration; höhere Sätze für Schlüssel-Partner sind bewusst je Affiliate/Code zu setzen statt global. 🔲 Staffeln (mehr % ab X Sendungen) bleiben spätere Option (§9 #1).

Quellen (Best-Practice-Recherche 2026-06-28):