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_valueist der Prozentsatz je Affiliate/Sublieferant (Default aufaffiliates, pro Code überreferral_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-DELIVEREDder vermittelten Sendung — nicht mehr beiinvoices.status → 'PAID'. Auszahlung danach wie gehabt (Payout →movements).- Code-Rabatt = Kundenrabatt: Der Code-Rabatt mindert
quotes.grand_total_chfund wird auf dem Offerten-PDF ausgewiesen (sichtbarer Preisnachlass für den Kunden), nicht nur als interne Provisionsbasis.
2. Domänenmodell
Entitäten
| Entität | Rolle im Fluss |
|---|---|
contacts | Stammdatensatz des Affiliates (Name, Telefon, E-Mail, Adresse); Affiliate ist ein Kontakt-Typ |
affiliates | Affiliate-spezifische Ergänzung: Vertragsdaten, Status, Payout-Methode, Provision-Default |
referral_codes | Ein oder mehrere Codes pro Affiliate; trägt Rabatt- und Provisions-Regel, Gültigkeitszeitraum, Limit |
quotes | Offerte, in der ein Code eingetragen wird → Rabatt wird berechnet |
orders | Auftrag, der aus einer akzeptierten Offerte entsteht; referenziert referral_code_id |
commission_entries | Provisionseintrag; entsteht pending bei Offerte-Konversion (Auftragsannahme) und wird approved beim Tracking-DELIVERED der Sendung (#19) |
commission_payouts | Gebündelte Auszahlung an einen Affiliate (mehrere commission_entries) |
movements | Payout-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 + DELETEBUCHHALTUNG(Mariela): SELECT + INSERT + UPDATE + DELETEOPERATIONS(Markus): SELECT + UPDATE (kein DELETE)FAHRER(Arkys): kein ZugriffAFFILIATE(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 + DELETEOPERATIONS: SELECT + UPDATE (is_active, description, valid_until)FAHRER: kein ZugriffAFFILIATE: 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 + DELETEOPERATIONS: SELECT + UPDATE (status, notes)FAHRER: kein ZugriffAFFILIATE: 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 + DELETEOPERATIONS: SELECT + UPDATE (notes)FAHRER: kein ZugriffAFFILIATE: SELECT eigene PayoutsREADONLY: 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
- Nutzer öffnet „Affiliates → Neu".
- Kontakt suchen oder neu anlegen (Feld
contact_id); bestehender Kontakt wird als Affiliate markiert. - Vertragsdaten eingeben:
contract_start, optionalescontract_end, Provisions-Default (Typ + Wert), Payout-Methode. - Speichern → INSERT in
affiliates;contacts.typewird aufaffiliategesetzt (oder zusätzliche Rolle incontact_roles). - 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
- Im Affiliate-Datensatz „Code hinzufügen".
- Code-String eingeben (z.B.
MARIA2026); System prüft Einzigartigkeit sofort (Echtzeit-Validierung überreferral_codes_code_unique). - Rabatt-Regel für Kunden: Typ (% oder Fix), Wert, optionale CHF-Obergrenze.
- Provisions-Regel: leer lassen = Affiliate-Default übernehmen; oder explizit überschreiben.
- Gültigkeitszeitraum und Nutzungslimit festlegen.
- 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
- Sachbearbeiter erstellt Offerte (Modul 4); Feld „Referral-Code" (optional).
- 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)
- Code existiert und
- Wenn gültig: Rabatt berechnen und in
discount_from_code_chfspeichern; Snapshot der Regel (discount_from_code_type,discount_from_code_value) in der Offerte speichern. - Zeilenrabatt oder Gesamtrabatt je nach Typ auf Offerten-Summe anwenden.
- 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)
- Offerte wird akzeptiert (Status
accepted) → Auftrag entsteht (Modul 5). referral_code_idundaffiliate_idwerden 1:1 aus der Offerte in den Auftrag übernommen.referral_codes.uses_countwird via Trigger um 1 erhöht.- 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:
base_amount_chf= Netto-Betrag der Sendung (Auftragswert ohne MWST, abgeleitet ausquotes.grand_total_chfherausgerechnet).- Provisions-Regel ermitteln: nimm
referral_codes.commission_type/valuefalls gesetzt, sonstaffiliates.default_commission_type/value(#18: %-Satz je Affiliate). commission_amount_chfberechnen: beipercentage→base_amount_chf × commission_value / 100; beifixed→commission_value.- INSERT in
commission_entriesmitstatus = 'pending'. - 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 AuftragsDELIVEREDsind (🔲 bei Teil-Sendungen: anteilige vs. Gesamt-Freigabe — Geschäftsentscheid, §9). - Eine manuelle Freigabe durch
BUCHHALTUNG/ADMINbleibt als Override möglich (§4.6), ist aber im Normalfall durch denDELIVERED-Trigger automatisiert.
F-13 — Lifecycle eindeutig (#19, konsistent mit Dok 30 §8): Entstehung =
pendingbei Offerte-Konversion (Schritt A). Freigabe/Fälligkeit (→ approved) = automatisch per DB-Trigger beim Tracking-DELIVEREDder 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 aninvoices.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)
- Admin öffnet „Affiliates → Abrechnungen → Neu".
- Affiliate und Abrechnungszeitraum wählen.
- System listet alle
commission_entriesmit Statusapprovedim Zeitraum (regulär perDELIVERED-Trigger freigegeben, §4.5 Schritt B; manuelle Freigaben als Override eingeschlossen). - Summe wird berechnet → INSERT
commission_payoutsmitstatus = 'draft'. - Commission-Entries werden mit
payout_idverknüpft. - Admin prüft, bestätigt →
status = 'approved'. - Bei Auszahlung:
status = 'paid'; Buchung insmovements-Ledger alsSUPPLIER_PAYMENTmitpayment_methoddes Affiliates →movement_idwird gesetzt;commission_entries.movement_idebenfalls.
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.
| Formfaktor | Pattern |
|---|---|
| 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.
| Formfaktor | Pattern |
|---|---|
| Handy | Tabs (Profil / Codes / Provisionen / Abrechnungen) als Bottom-Sheet-Drawer. Jeder Tab scrollbar. Aktionen als Fixed-Button am unteren Rand (z.B. „Code erstellen"). |
| Tablet | 2-Spalten: links Stammdaten + Codes, rechts Provisions-Tabelle + KPI-Kacheln (Gesamtprovision YTD, offene Provision, bezahlte Provision). |
| Desktop | Wie 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.
| Formfaktor | Pattern |
|---|---|
| Handy | Karten-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/Desktop | Tabelle + 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.
| Formfaktor | Pattern |
|---|---|
| Handy | Karten: Auftragsnummer, Kunde, Betrag, Provision, Status-Badge. Swipe-rechts → Freigeben (wenn pending). Bottom-Sheet mit Details und Aktionen. |
| Tablet/Desktop | TanStack-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.
| Formfaktor | Pattern |
|---|---|
| Handy | Step-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/Desktop | Einseitiger 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.
| Formfaktor | Pattern |
|---|---|
| Handy | Einzeiliges 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/Desktop | Seitenpanel 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ät | Verbindung |
|---|---|
contacts | Affiliate 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. |
quotes | Code-Feld + berechneter Rabatt; Affiliate-Info readonly. |
orders | referral_code_id + affiliate_id denormalisiert; uses_count-Trigger; Entstehung des commission_entries (pending) bei Offerte→Auftrag-Konversion (§4.5 A, #19). |
tracking_events / shipments | Freigabe-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). |
invoices | Reine Forderungs-/Zahlungsabbildung; kein Auslöser mehr für die Provision (frühere PAID-Bindung mit #19 aufgehoben). |
movements | Payout → 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_log | Alle 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_codesfür Code-Validierung und Kundenrabatt-Berechnung; bei Konversion entstehtcommission_entries(pending, §4.5 A). - Modul 5 (Logistik/ERP):
orders.affiliate_idwird aus Offerte übernommen; Tracking-DELIVEREDlö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)". (RechnungsstatusPAIDist 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
| Bereich | Regel | Verhalten bei Verletzung |
|---|---|---|
| Code-Einzigartigkeit | referral_codes.code UNIQUE | Inline-Fehler + Alternativvorschlag |
| Code-Gültigkeit | valid_from ≤ heute ≤ valid_until (wenn gesetzt) | Fehler bei Offerten-Zuweisung |
| Nutzungslimit | uses_count < max_uses (wenn gesetzt) | Fehler bei Auftrags-Erstellung |
| Doppelprovision | UNIQUE(order_id) auf commission_entries | Datenbank-Constraint verhindert Duplikat; App zeigt Warnung |
| Payout-Rücksetzen | Nur approved → draft erlaubt; paid ist final | UI-Button deaktiviert; API gibt 409 zurück |
| Provision bei Storno | Rechnung storniert → Entry auf cancelled | Trigger bei Invoice-Storno; manuelle Überprüfung empfohlen |
| Affiliate-Löschung | ON DELETE RESTRICT auf commission_entries, referral_codes | Löschen gesperrt solange Entries/Codes existieren; stattdessen status = 'terminated' |
| Kontakt-Affiliate-Dopplung | UNIQUE(contact_id) auf affiliates | Fehlermeldung „Kontakt ist bereits Affiliate" |
| Provisions-Betrag = 0 | Commission-Value 0 erlaubt | Entry trotzdem angelegt, UI-Hinweis |
| Ungültiges Payout-Konto | Kein IBAN/Payout-Methode | Warnung bei Payout-Erstellung, kein Blockieren des Entwurfs |
Affiliate paused/terminated | Code-Zuweisung möglich aber warnend | Gelbe 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 anaffiliates,referral_codes,commission_entries,commission_payoutswerden 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_payoutsmitstatus = 'paid'sind unveränderlich; der verknüpftemovements-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_idJoin aufcontact_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
| # | Thema | Frage |
|---|---|---|
| ✅ 1 | Provisions-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. |
| ✅ 2 | Provisions-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). |
| 🔲 3 | IBAN-Verschlüsselung | Supabase Vault aktivieren? Oder reicht RLS? Abhängig von Supabase-Plan und Sicherheitsanforderungen. |
| 🔲 4 | Affiliate-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)? |
| 🔲 5 | Payout-Zyklus | Monatlich fix oder on-demand durch Admin? Automatische Benachrichtigung an Affiliate per WhatsApp/E-Mail bei Payout? |
| 🔲 6 | Mehrere Codes pro Sendung | Kann eine Offerte / ein Auftrag mehrere Codes haben (Kombination Rabatt + Provisions-Code)? Oder maximal ein Code? |
| 🔲 7 | MWST auf Provision | Affiliates als Selbstständige: Muss auf die Provision MWST abgerechnet werden? Wie wird dies in commission_entries abgebildet? |
| 🔲 8 | Code-Format | Gibt es Vorgaben für Format/Länge der Codes (z.B. nur Grossbuchstaben + Zahlen, max. 12 Zeichen)? Automatische Code-Generierung oder manuell? |
| 🔲 9 | Granulare RLS für ReadOnly | Darf die ReadOnly-Rolle Provisions-Aggregate über alle Affiliates sehen (Dashboard-KPIs), oder nur eigene Daten? |
| 🔲 10 | Affiliate-Lead-Konvertierung | Wer (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
| Punkt | Entscheid |
|---|---|
| Default-Satz | 5 % auf den Netto-Sendungswert je vermittelter Sendung |
| Konfigurierbarkeit | Default je Affiliate übersteuerbar; pro Referral-Code feiner überschreibbar (#18) |
| Art | percentage (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):
- 100partnerprogramme — branchenübliche Affiliate-Provisionen (Versandwaren 5–15 %)
- rankwatcher — Affiliate-Provision: Vergütungsmodelle & übliche Sätze
- Gründer.de (CH) — Neukundengewinnung durch Vermittlungsprovision
- iqual — Affiliate Marketing / Vermittlungsprovision (Schweiz)
- bexio Blog — Affiliate Marketing in der Schweiz