Kanonisches Supabase-Schema (konsolidiert)

Dokument 30 — die EINE Wahrheitsquelle des Datenmodells. Diese Spec führt die in den neun Modul-Specs (10–18) verstreuten Schema-Fragmente zu einem widerspruchsfreien Supabase/Postgres-Schema zusammen. Wo zwei Module dieselbe Entität unterschiedlich definiert haben (contacts, recipients, movements, payment_methods, box_products, zones, quotes, orders, invoices, deposits, referral_codes, audit_log, i18n-Lookups), gilt ab hier diese Datei. Die Modul-Specs bleiben für Fachlogik/Workflows/UI massgeblich; bei Schema-Abweichungen gewinnt Dokument 30.

Stack: Supabase (Postgres 15+) · RLS · Storage · Auth Magic-Link. Konventionen: PK id uuid default gen_random_uuid() (Ausnahmen: Lookups = text-Code-PK, audit_log = bigint identity); Geld numeric(12,2) in CHF; Zeit timestamptz; Audit-Spalten created_at/updated_at (Trigger) + created_by/updated_by. Stabile Fachcodes sind TEXT (nie Postgres-enum für fachlich erweiterbare Mengen, nie übersetzt, nie in Logik hartkodiert).

Geld-/Satz-Domänen (data-01, entschieden 2026-06-28): Kanonisch gelten zwei Domänen — create domain chf as numeric(12,2) (alle Geldbeträge in CHF) und create domain pct as numeric(5,2) (Prozentsätze: MWST, Provision, Rabatt). Neue Spalten nutzen diese Domänen; bestehende numeric(12,2)-Geldspalten sind wertgleich und werden bei der Migration auf chf umgestellt. Ausnahme: Wechselkurse/Faktoren (fx_rate u. ä.) bleiben bewusst feiner (numeric(18,8)), da kein Geld-/Prozentwert.


0. Konsolidierungs-Entscheide (Konflikt-Auflösung)

Diese Tabelle dokumentiert jede aufgelöste Überschneidung. Sie ist normativ.

#Konflikt (Quelle)Entscheid (kanonisch)
K-01contacts: CRM nutzt contact_roles-Bridge; Affiliate setzt contacts.type='affiliate'Rollen ausschliesslich über contact_roles. Kein type-Freitextfeld. Affiliate-Sein = Zeile in contact_roles(role='affiliate') + 1:1-Satz in affiliates.
K-02contacts-Spalten: CRM-Basis vs. OCR-ALTER (cedula_number, kyc_status, birth_date, nationality, address_dr)Eine contacts-Tabelle mit allen Feldern (§2.1). OCR-Felder sind reguläre Spalten, kein nachträgliches ALTER. id_number/cedula_number doppeln sich → cedula_number (UNIQUE) + passport_number sind die KYC-Felder; das generische id_type/id_number entfällt zugunsten typisierter Felder.
K-03contacts.phone: CRM phone_primary; OCR/Offerten/Logistik referenzieren mal phone, mal phone_primaryKanonisch phone_primary / phone_secondary. phone existiert nicht. WhatsApp-Matching nutzt whatsapp_number (fällt auf phone_primary zurück).
K-04recipients: zweimal definiert (CRM §3.7 = province/municipality/neighborhood/street/address_reference; Logistik §3.2 = full_name/address_line/zone_id/city/province)Eine recipients-Tabelle (§2.3), Superset: getrennte first_name/last_name plus generierter full_name; DR-Adresse als province/municipality/neighborhood/street/address_reference; plus zone_id FK (Preis-/Logistik-Relevanz); KYC cedula_number/cedula_scan_path. city/province (Logistik) = Aliasse von municipality/province.
K-05movements.source_type: Finanzen-CHECK-Liste vs. Logistik (deposit_refund) vs. Affiliate (commission_payout)Union aller Quellen im CHECK (§4.1): manual, import, quote_accepted, invoice_issued, debtor_payment, deposit_order, deposit_payment, deposit_refund, commission_payout. F-02: creditor_invoice/creditor_payment entfernt — Kreditoren (Lieferantenrechnungen/-zahlungen) werden manuell als movements (source_type='manual', Typen EXPENSE/SUPPLIER_PAYMENT/SUPPLIER_DEBT) erfasst. 🔲 strukturierte Kreditoren-Entität (supplier_invoices/supplier_payments) als spätere Option.
K-06payment_methods/movement_types: in Finanzen und Plattform definiert (Finanzen: is_receivable/is_payable/is_deposit/cash_sign; Plattform: affects_debt)Schema-Heimat = Plattform-Modul, Spalten-Superset von Finanzen (§3.1/§3.2). movement_types trägt alle Flags (is_internal_transfer, is_receivable, is_payable, is_deposit, cash_sign, affects_debt als generierte Bequemlichkeit).
K-07box_products: Produkte nutzt label_de/es/en; Offerten referenziert name_de/es/enKanonisch label_de/es/en (Glossar-Konvention für alle Lookups). name_* ist nur Offerten-Jargon → im product_snapshot-JSONB der Quote-Zeile heisst der Key label_*. default_deposit_chf (Logistik) wird als Spalte ergänzt.
K-08Zielzonen: Produkte = zones; Logistik referenziert delivery_zonesKanonisch zones (eine Tabelle, §3.4). delivery_zones ist nur ein Logistik-Alias → FKs heissen zone_id REFERENCES zones(id).
K-09orders: Logistik order_no/customer_id; Offerten order_number/contact_id; Affiliate-ALTER affiliate_idEine orders-Tabelle (§2.6): order_no (kanonisch, nicht order_number), customer_id (kanonisch, FK→contacts, nicht contact_id), quote_id, referral_code_id, affiliate_id (denormalisiert) — alles nativ, kein ALTER.
K-10quotes: Offerten-Vollschema + Affiliate-ALTER (referral_code_id, discount_from_code_*)Eine quotes-Tabelle (§2.5) mit Affiliate-Feldern nativ. Preisbasis-FK heisst price_list_item_id (vereinheitlicht, s. K-12).
K-11invoices: von keinem Modul vollständig definiert (nur referenziert)Hier kanonisch definiert (§2.7): Kopf + abgeleiteter Saldo-Bezug; speist movements.INVOICE via Auto-Posting.
K-12Preis-Snapshot-FK: Produkte price_list_items + price_list_item_id; Offerten price_list_entries + price_list_entry_idKanonisch price_list_items / price_list_item_id. price_list_entries/entry_id (Offerten-Jargon) wird überall ersetzt.
K-13Depot: Logistik/Finanzen deposit_orders/deposit_payments/deposit_refunds; Brief-Begriff „deposits"Drei Tabellen deposit_orders (Soll/Pfand-Kopf, 1:1 zu boxes), deposit_payments (Eingänge), deposit_refunds (Rückzahlung/Verfall, 1:1 zu deposit_order). „deposits" = Sammelbegriff, keine eigene Tabelle.
K-14Provision: Affiliate commission_entries/commission_payouts; Offerten nennt affiliate_commissionsKanonisch commission_entries + commission_payouts. affiliate_commissions (Offerten-Jargon) = commission_entries.
K-15audit_log: Plattform-Vollschema (bigint identity, actor_email, Rules) vs. lose ReferenzenPlattform-Definition gewinnt (§6.1). Append-only via DB-Rules; nur Trigger schreibt.
K-16i18n: i18n_labels (CRM/Affiliate/Design) vs. locale_strings (Plattform) vs. Inline-label_*Hybrid (Plattform-Entscheid): Lookups tragen Inline-label_de/es/en; freie UI-Texte in locale_strings. i18n_labels wird NICHT gebaut — Inserts, die dorthin zeigten (Affiliate §3.7, Design §3.1), gehen nach locale_strings.
K-17Rollen-Codes: Finanzen (marcel/mariela/markus/arkys) vs. generisch (admin/ops/office/driver) vs. Plattform-CodesKanonisch Plattform-Codes (§5): ADMIN, BUCHHALTUNG, OPERATIONS, FAHRER, AFFILIATE, READONLY. Personen-Namen sind nur Referenz. RLS prüft via has_role('CODE').
K-18Auth-Spiegel: contacts vs. app_usersGetrennt. app_users (1:1 zu auth.users) = Login-Identität (Team + Affiliate). contacts = Geschäfts-Partei (Kunde/Lead/Lieferant/Empfänger/Affiliate-Kontakt). Brücke: affiliate_users(user_id→app_users, affiliate_id→affiliates); tracking_events.actor_id/created_by-Spalten referenzieren app_users (handelnder Login), nicht contacts.
K-19created_by/actor_id Ziel: Logistik schreibt references contacts(id); Finanzen/Plattform auth.users(id)Kanonisch app_users(id) für alle Bearbeiter-/Actor-Spalten (= auth.users.id, aber über app_users für RLS-Joins). Logistiks created_by references contacts(id) wird auf app_users(id) korrigiert.
K-20Tracking-/Status-Mengen: Postgres-enum (CRM/Offerten/Affiliate) vs. TEXT-Lookup (Logistik/Finanzen/Plattform)TEXT-Lookup-Tabellen für alle fachlich-erweiterbaren Mengen (Migrations-/i18n-Flexibilität). Echte Postgres-enum nur für geschlossene, technische Mengen ohne i18n-Bedarf (discount_kind, commission_kind). Status-Enums aus CRM/Offerten/Affiliate werden zu Lookup-Tabellen.
K-21discount_type/commission_type doppelt als enum (Offerten + Affiliate, identisch)Je ein enum: discount_kind ('PERCENT','ABSOLUTE'), commission_kind ('PERCENT','ABSOLUTE'). Werte vereinheitlicht auf PERCENT/ABSOLUTE (Offerten nutzte das; Affiliate percentage/fixed → gemappt).

1. Tabellenübersicht (kanonischer Bestand)

48 Tabellen in 8 fachlichen Gruppen + 2 echte Enums. Lookups kursiv.

GruppeTabellen
Plattform/Identitätapp_users, user_roles, affiliate_users, roles, locale_strings, audit_log, app_settings, monthly_closings, storage_objects, notifications
CRMcontacts, contact_roles, recipients, contact_duplicates, contact_merge_log
KYC/WhatsAppkyc_scans, kyc_field_corrections, whatsapp_inbound, kyc_document_types
Produkte & Preisebox_products, zones, price_lists, price_list_items, product_depot_rates
Offertenquotes, quote_lines
Logistikorders, shipments, containers, boxes, tracking_events, tracking_statuses, tracking_phases, box_conditions, shipment_statuses, container_statuses, order_statuses
Depotdeposit_orders, deposit_payments, deposit_refunds, deposit_refund_kinds
Finanzenmovements, payment_links, invoices, movement_types, payment_methods, vat_rates, currencies
Affiliateaffiliates, referral_codes, commission_entries, commission_payouts
Echte Enumsdiscount_kind, commission_kind

F-12 — Schreibweisen-Konvention (projektweit, normativ): Codes in Lookup-Tabellen (*_statuses, tracking_statuses, movement_types, payment_methods, roles, …) sind UPPERCASE (Glossar-Fachcode-Prinzip), referenziert per FK auf code — z. B. quote_statuses, invoice_statuses, order_statuses, shipment_statuses, container_statuses mit DRAFT/SENT/OPEN/PARTIAL/PAID/.... Inline-status-Spalten mit check (...)-Enum-Werten (kein eigener Lookup) sind lowercase — das betrifft genau affiliates.status (active/paused/terminated), commission_entries.status (pending/approved/paid/cancelled), commission_payouts.status (draft/approved/paid), monthly_closings.status (open/review/closed), deposit_*-kind/resolution-Werte. Diese Trennung ist bewusst (klare Regel statt Zufall): wer auf ein *_status-Lookup schreibt → UPPERCASE; wer auf einen Inline-CHECK-status schreibt → lowercase. Modul-Docs mit lowercase quote_status/invoice_status-Codes sind Tippfehler und folgen dem Lookup (UPPERCASE).


2. Geschäfts-Kernentitäten

2.1 contacts — Golden Record aller Geschäfts-Parteien

create type contact_status as enum
  ('active','inactive','lead','blocked','merged');  -- geschlossen, ohne i18n-Bedarf
create type kyc_status     as enum ('NONE','PENDING','VERIFIED','REJECTED');

create table contacts (
  id                   uuid primary key default gen_random_uuid(),
  -- Name / Firma
  first_name           text not null,
  last_name            text not null,
  display_name         text generated always as (first_name || ' ' || last_name) stored,
  company_name         text,
  alias                text,                       -- Spitz-/Rufname (IST People.Alias); fuzzy-durchsuchbar (BR-P16)
  -- Kontaktdaten
  phone_primary        text,                       -- E.164
  phone_secondary      text,
  email                text,
  whatsapp_number      text,                       -- fällt auf phone_primary zurück
  preferred_lang       text not null default 'es' check (preferred_lang in ('de','es','en')),
  -- Status / Klassifikation
  status               contact_status not null default 'lead',
  lead_source          text check (lead_source is null or lead_source in     -- F-15: validiert (lead_source_type-Werte), nicht freier Text
                          ('website_form','whatsapp','manual','import','referral')),
  lead_source_ref      text,                       -- referral_code / Formular-Session
  needs_verification   boolean not null default false,
  tags                 text[],
  notes                text,
  -- KYC (befüllt aus kyc_scans; K-02)
  cedula_number        text unique,                -- DR-Cédula, stärkster Dedup-Key
  passport_number      text,
  id_doc_type          text check (id_doc_type is null or id_doc_type in
                          ('CEDULA','PASSPORT','RNC','OTHER')),  -- Absender-Dokumenttyp fürs Aduana-Manifest (G4/BR-48; IST TipoDocumentoRemitente)
  kyc_status           kyc_status not null default 'NONE',
  kyc_scan_id          uuid references kyc_scans(id) on delete set null,
  kyc_verified_at      timestamptz,
  consent_onboarding_at  timestamptz,                -- Entscheid 2026-06-28: Einwilligung (OCR + Auslandtransfer Ausweisdaten) gem. Onboarding-Vertrag; ohne diese kein KYC-Processing (Modul 2/45)
  consent_onboarding_ref text,                       -- Referenz/Version des Onboarding-Vertrags
  birth_date           date,
  nationality          char(2),                    -- ISO 3166-1 alpha-2
  id_scan_storage_path text,                       -- Storage-Pfad Ausweis (privat)
  -- Golden Record / Merge
  golden_record_id     uuid references contacts(id),
  is_golden_record     boolean not null default true,
  -- Audit
  created_at           timestamptz not null default now(),
  updated_at           timestamptz not null default now(),
  created_by           uuid references app_users(id),   -- K-19
  updated_by           uuid references app_users(id),
  constraint chk_phone_primary_e164
    check (phone_primary is null or phone_primary ~ '^\+[1-9]\d{6,14}$'),
  constraint chk_phone_secondary_e164                                       -- F-16: gleiches E.164-Format wie phone_primary
    check (phone_secondary is null or phone_secondary ~ '^\+[1-9]\d{6,14}$'),
  constraint chk_whatsapp_number_e164                                       -- F-16: WhatsApp-Nummer ebenfalls E.164
    check (whatsapp_number is null or whatsapp_number ~ '^\+[1-9]\d{6,14}$'),
  constraint chk_cedula_format
    check (cedula_number is null or cedula_number ~ '^\d{3}-\d{7}-\d{1}$'),
  constraint chk_golden_not_self
    check (golden_record_id is null or golden_record_id <> id)
);
create index idx_contacts_phone        on contacts (phone_primary);
create index idx_contacts_email        on contacts (lower(email));
create index idx_contacts_last_name    on contacts (lower(last_name));
create index idx_contacts_status       on contacts (status);
create index idx_contacts_golden       on contacts (golden_record_id) where golden_record_id is not null;
create index idx_contacts_trgm_name    on contacts using gin (lower(last_name) gin_trgm_ops);  -- Fuzzy-Dedup (pg_trgm)
create index idx_contacts_trgm_alias   on contacts using gin (lower(alias) gin_trgm_ops);      -- Alias-Fuzzy/Suche (BR-P16)

Telefon-Uniqueness: bewusst kein harter UNIQUE auf phone_primary (Familien teilen Nummern) → Soft-Warning via Dedup. 🔲 zu bestätigen (CRM-Offenpunkt). cedula_number ist hingegen hart UNIQUE (eindeutig pro Person, KYC).

E-Mail-/Telefon-Eindeutigkeit (#20, entschieden 2026-06-28): Eindeutigkeit angestrebt mit Dubletten-Warnung + Merge-Vorschlag (kein stilles Duplikat) — umgesetzt via Dedup-Workflow (§2.4) statt hartem DB-UNIQUE (erlaubt bestätigte Ausnahmen, z.B. geteilte Familien-Nummer). cedula_number bleibt harter Unique-Key.

Telefon-/Alias-Mapping (IST → Caja): IST hatte Phone/PhoneExtension + drei Mobilnummern (MobilePhone/2/3). Caja konsolidiert auf phone_primary + phone_secondary + whatsapp_number; eine selten genutzte dritte Nummer wird bei der Migration auf phone_secondary (falls frei) bzw. notes abgebildet, PhoneExtension entfällt (DR-Logistik). alias (IST People.Alias) ist übernommen und Teil von Suche + Dedup-Match-Set.

2.2 contact_roles — Mehrfachrollen (K-01)

create table contact_roles (
  id          uuid primary key default gen_random_uuid(),
  contact_id  uuid not null references contacts(id) on delete cascade,
  role        text not null check (role in
                ('customer','lead','supplier','affiliate','recipient_dr')),
  active      boolean not null default true,
  since       date,
  notes       text,
  created_at  timestamptz not null default now(),
  created_by  uuid references app_users(id),
  unique (contact_id, role)
);
create index idx_contact_roles_contact on contact_roles (contact_id);
create index idx_contact_roles_role    on contact_roles (role) where active;

i18n der Rollen-Labels: locale_strings (namespace='contact_role'). 🔲 Konsistenzregel (CRM §7): recipient_dr und supplier nie am selben Kontakt (CH-Partei ≠ DR-Empfänger).

2.3 recipients — Empfänger in der DR (K-04, eine Tabelle)

create table recipients (
  id               uuid primary key default gen_random_uuid(),
  contact_id       uuid not null references contacts(id) on delete restrict,  -- Absender CH
  first_name       text not null,
  last_name        text not null,
  full_name        text generated always as (first_name || ' ' || last_name) stored,
  alias            text,                    -- Rufname des Empfängers (Zustell-Identifikation; BR-P16)
  phone_primary    text,
  phone_secondary  text,
  email            text,
  preferred_lang   text not null default 'es' check (preferred_lang in ('de','es','en')),  -- F-07: Sprache für SHIPMENT_STATUS-Notification an Empfänger-DR (Default ES)
  -- KYC (aus Modul 2)
  cedula_number    text,
  cedula_scan_path text,
  id_doc_type      text,                    -- 'CEDULA'|'PASSPORT'
  -- DR-Adresse (province/municipality kanonisch; city/province = Aliasse)
  province         text,
  municipality     text,
  neighborhood     text,
  street           text,
  address_reference text,                   -- Landmark/Wegbeschreibung
  zone_id          uuid references zones(id),   -- Preis-/Logistik-Zielzone (K-08)
  promoted_contact_id uuid references contacts(id),  -- BR-P15: gesetzt, wenn dieser Empfänger zu eigenständigem Kunden befördert wurde (CRM-Abschnitt "Empfänger → Kunde befördern")
  notes            text,
  active           boolean not null default true,
  created_at       timestamptz not null default now(),
  updated_at       timestamptz not null default now(),
  created_by       uuid references app_users(id)
);
create index idx_recipients_contact   on recipients (contact_id);
create index idx_recipients_cedula    on recipients (cedula_number) where cedula_number is not null;
create index idx_recipients_zone      on recipients (zone_id);

2.4 contact_duplicates & contact_merge_log (CRM)

create table contact_duplicates (
  id            uuid primary key default gen_random_uuid(),
  contact_a_id  uuid not null references contacts(id) on delete cascade,
  contact_b_id  uuid not null references contacts(id) on delete cascade,
  score         numeric(4,3) not null check (score between 0 and 1),
  match_fields  text[],
  resolution    text not null default 'unresolved'
                  check (resolution in ('unresolved','merged','not_duplicate')),
  resolved_by   uuid references app_users(id),
  resolved_at   timestamptz,
  notes         text,
  created_at    timestamptz not null default now(),
  unique (contact_a_id, contact_b_id),
  check (contact_a_id < contact_b_id)        -- kanonische Ordnung, keine Spiegelpaare
);
create index idx_dupl_open  on contact_duplicates (resolution) where resolution = 'unresolved';
create index idx_dupl_score on contact_duplicates (score desc);

-- Unveränderbares Merge-Journal (OR 957). Nur via service_role-Funktion beschrieben.
create table contact_merge_log (
  id              uuid primary key default gen_random_uuid(),
  source_id       uuid not null,             -- aufgehobener Kontakt
  target_id       uuid not null,             -- überlebender Golden Record
  merged_by       uuid not null references app_users(id),
  merged_at       timestamptz not null default now(),
  snapshot_source jsonb not null,
  snapshot_target jsonb not null,
  field_decisions jsonb,
  notes           text
);
create index idx_merge_source on contact_merge_log (source_id);
create index idx_merge_target on contact_merge_log (target_id);

2.5 quotes & quote_lines (K-10, K-12, K-21)

create type discount_kind   as enum ('PERCENT','ABSOLUTE');   -- K-21

create table quotes (
  id                    uuid primary key default gen_random_uuid(),
  quote_number          text not null unique,            -- 'QUO-2026-0042'
  contact_id            uuid not null references contacts(id) on delete restrict,
  recipient_name        text,                            -- F-24: Freitext-Empfänger für die Offerte/PDF (früh, vor KYC; rein kosmetisch, kein FK)
  recipient_address     text,
  issue_date            date not null default current_date,
  valid_until           date not null,
  -- Gesamtrabatt
  total_discount_kind   discount_kind,
  total_discount_value  numeric(10,2) check (total_discount_value >= 0),
  -- Affiliate-/Rabattcode (K-10, nativ)
  referral_code_id      uuid references referral_codes(id) on delete set null,
  referral_code_text    text,                            -- Snapshot
  affiliate_id          uuid references affiliates(id) on delete set null,  -- denormalisiert (Affiliate-RLS)
  discount_from_code_chf   numeric(10,2),
  discount_from_code_kind  discount_kind,
  discount_from_code_value numeric(10,4),
  -- kalkulierte Beträge (Snapshot)
  subtotal_chf          numeric(12,2) not null default 0,
  discount_total_chf    numeric(12,2) not null default 0,
  grand_total_chf       numeric(12,2) not null default 0,
  -- MWST (schema-ready, initial NULL)
  vat_code              text references vat_rates(code),
  vat_rate_percent      numeric(5,2),
  vat_amount_chf        numeric(12,2),
  -- Notizen / Status / Versand
  internal_notes        text,
  customer_notes        text,
  status                text not null default 'DRAFT'
                          references quote_statuses(code),
  sent_at               timestamptz,
  sent_via              text[],
  pdf_storage_path      text,
  order_id              uuid references orders(id) on delete set null,  -- gesetzt bei CONVERTED
  created_by            uuid not null references app_users(id),
  updated_by            uuid references app_users(id),
  created_at            timestamptz not null default now(),
  updated_at            timestamptz not null default now()
);
create index idx_quotes_contact   on quotes (contact_id);
create index idx_quotes_status    on quotes (status);
create index idx_quotes_issue     on quotes (issue_date desc);
create index idx_quotes_referral  on quotes (referral_code_id) where referral_code_id is not null;
create index idx_quotes_affiliate on quotes (affiliate_id) where affiliate_id is not null;
create sequence quote_number_seq start 1;   -- Entscheid 2026-06-28: jährlicher Reset → Nummer = QUO-<jahr>-<lfd>; Zähler pro Jahr (Sequenz-Reset zum Jahreswechsel via Cron, oder max(lfd)+1 je Jahr). Eindeutigkeit über das Jahr-Präfix.

create table quote_lines (
  id                   uuid primary key default gen_random_uuid(),
  quote_id             uuid not null references quotes(id) on delete cascade,
  position             smallint not null check (position > 0),
  product_id           uuid not null references box_products(id) on delete restrict,
  product_snapshot     jsonb not null,                  -- {code,label_de,label_es,label_en}
  price_list_item_id   uuid references price_list_items(id) on delete set null,  -- K-12
  unit_price_chf       numeric(10,2) not null check (unit_price_chf >= 0),
  zone_id              uuid references zones(id),
  quantity             numeric(8,2) not null check (quantity > 0),
  unit                 text not null default 'Stk',
  line_discount_kind   discount_kind,
  line_discount_value  numeric(10,2) check (line_discount_value >= 0),
  line_subtotal_chf    numeric(12,2) not null default 0,
  line_discount_chf    numeric(12,2) not null default 0,
  line_total_chf       numeric(12,2) not null default 0,
  line_note            text,
  unique (quote_id, position)
);
create index idx_quote_lines_quote on quote_lines (quote_id);

F-24 — Empfänger-Übergang Offerte → Sendung. Auf der quotes ist der Empfänger Freitext (recipient_name/recipient_address, oft vor KYC, nur fürs PDF) und hat keinen FK auf recipients. Der strukturierte DR-Empfänger entsteht erst bei der Sendungsbildung als recipients-Zeile (shipments.recipient_id, Modul 14 §4.3) — dort wird der Empfänger eigenständig/KYC-konform erfasst; der Offerten-Freitext kann als Vorschlag vorbelegt werden, wird aber nicht automatisch zur recipients-Zeile. 🔲 Optionale quotes.recipient_id → recipients (Vorschlag bei der Sendungsbildung) bleibt späte Ausbaustufe.

2.6 orders (K-09)

create table orders (
  id               uuid primary key default gen_random_uuid(),
  order_no         text not null unique,            -- 'A-2026-0042' (kanonisch, nicht order_number)
  quote_id         uuid references quotes(id),
  customer_id      uuid not null references contacts(id),   -- kanonisch (nicht contact_id)
  referral_code_id uuid references referral_codes(id) on delete set null,
  affiliate_id     uuid references affiliates(id) on delete set null,  -- denormalisiert
  status           text not null default 'OPEN' references order_statuses(code),
  total_chf        numeric(12,2),                   -- read-only Spiegel der akzeptierten Offerte
  order_date       date not null default current_date,
  note             text,
  created_at       timestamptz not null default now(),
  updated_at       timestamptz not null default now(),
  created_by       uuid references app_users(id),
  constraint orders_quote_uniq unique (quote_id)    -- 1 Offerte ⇒ max 1 Auftrag (Duplicate-Guard)
);
create index idx_orders_customer on orders (customer_id);
create index idx_orders_status   on orders (status);
create index idx_orders_quote    on orders (quote_id);

F-01 — keine eigene order_lines/order_items-Tabelle. Auftragspositionen werden nicht dupliziert. Die fachlichen Positionen leben im (eingefrorenen) quote_lines der konvertierten Offerte (orders.quote_idquotesquote_lines); die physische Materialisierung erfolgt direkt in boxes (je Position N Boxen, mit Produkt-Snapshot auf der Box via box_product_id). orders.total_chf ist nur ein read-only-Spiegel der akzeptierten Offerte. invoices ist kopflos (nur total_chf). 🔲 invoice_lines (Rechnungspositionen) erst nötig, wenn MWST je Position/Satz aktiviert wird — dann analog quote_lines zu definieren.

2.7 invoices (K-11 — hier kanonisch definiert)

create table invoices (
  id              uuid primary key default gen_random_uuid(),
  invoice_number  text not null unique,            -- 'F-2026-0042'
  order_id        uuid references orders(id) on delete restrict,
  contact_id      uuid not null references contacts(id) on delete restrict,
  issue_date      date not null default current_date,
  due_date        date,
  total_chf       numeric(12,2) not null default 0, -- = quotes.grand_total_chf bei Konversion
  vat_code        text references vat_rates(code),
  vat_rate_percent numeric(5,3),                     -- data-02: angewandter Satz eingefroren (kein Drift, wenn vat_rates später ändert)
  vat_amount_chf  numeric(12,2),
  status          text not null default 'OPEN' references invoice_statuses(code),
  -- Buchungsbrücke: die INVOICE-Bewegung im Ledger (Auto-Posting, §7)
  movement_id     uuid references movements(id) on delete set null,
  pdf_storage_path text,
  bill_to_snapshot jsonb,  -- G1/E4 (BR-30): Rechnungsempfänger-Stand (Name/Adresse/Cédula) bei Ausstellung eingefroren — revisions-/zollkritisch, überlebt Kontakt-Merge
  note            text,
  created_at      timestamptz not null default now(),
  updated_at      timestamptz not null default now(),
  created_by      uuid references app_users(id),
  constraint invoices_order_uniq unique (order_id)   -- 1 Auftrag ⇒ 1 Rechnung
);
create index idx_invoices_contact on invoices (contact_id);
create index idx_invoices_order   on invoices (order_id);
create index idx_invoices_status  on invoices (status);

create table invoice_statuses (   -- Lookup
  code text primary key, sort_order smallint not null,
  label_de text not null, label_es text not null, label_en text not null
);
-- OPEN=Offen/Abierta/Open, PARTIAL=Teilbezahlt/Parcial/Partial,
-- PAID=Bezahlt/Pagada/Paid, CANCELLED=Storniert/Cancelada/Cancelled

Restschuld-Betrag wird nicht auf invoices gespeichert, sondern aus dem Ledger abgeleitet (v_debitoren, über payment_links bzw. INVOICE-movement). So bleibt es konsistent mit der Single-Source-of-Truth-Regel des Finanzmoduls.

F-04 — invoices.status wird per Trigger nachgeführt (bewusste Ausnahme von „Status nur aus Views"). Ein Trigger auf payment_links/movements rechnet je INVOICE-Bewegung die Summe der zugeordneten Zahlungen (Σ payment_links.amount_chf auf das movement_id der Rechnung; Fallback Σ INVOICE_PAYMENT mit gleicher reference + contact_id):

  • Σ Zahlungen ≥ invoices.total_chfstatus = 'PAID'
  • 0 < Σ Zahlungen < total_chfstatus = 'PARTIAL'
  • Σ Zahlungen = 0 → bleibt OPEN

Dieser Übergang → 'PAID' ist der einzige definierte Auslöser des Affiliate-Provisions-Triggers (§8 / Modul 16 §4.5: bei invoices.status → 'PAID' und orders.affiliate_id IS NOT NULLINSERT commission_entries(status='pending')). 🔲 Trigger-Zeitpunkt (Voll- vs. Teilzahlung vs. Sendung DELIVERED) bleibt Geschäftsentscheid (§14); Default hier: Vollzahlung (PAID).


3. Produkte, Preise, Zonen & Lookups

3.1 movement_types (K-06 — Plattform-Heimat, Finanzen-Superset)

create table movement_types (
  code                 text primary key,         -- stabil, ES-basiert, NIE übersetzen
  label_de             text not null,
  label_es             text not null,
  label_en             text not null,
  is_internal_transfer boolean not null default false,
  is_receivable        boolean not null default false,  -- treibt Debitoren
  is_payable           boolean not null default false,  -- treibt Kreditoren
  is_deposit           boolean not null default false,
  affects_debt         boolean not null default false,  -- treibt open_debt (= is_receivable bei DEPOSIT_CASH/INVOICE/RECEIVABLE)
  cash_sign            smallint not null default 0,      -- +1 Eingang / -1 Ausgang / 0 (nur bei channel=cash relevant)
  sort_order           smallint not null default 100,
  active               boolean not null default true
);
codelabel_delabel_eslabel_entransferrecvpaydepositaffects_debtcash_sign
DEPOSIT_CASHAnzahlung KasseDepósito cajaCash deposit+1
INVOICERechnungFacturaInvoice+1
INVOICE_PAYMENTRechnungszahlungAbono facturaInvoice payment+1
RECEIVABLEForderungDeudaReceivable0
INCOMEEinnahmeIngresoIncome+1
EXPENSEAusgabeGastoExpense−1
SUPPLIER_PAYMENTLieferantenzahlungPago proveedorSupplier payment−1
SUPPLIER_DEBTLieferantenschuldDeuda proveedorSupplier debt−1
CASH_TO_BANKKasse → BankCaja → BancoCash → Bank−1
BANK_TO_CASHBank → KasseBanco → CajaBank → Cash+1
BANK_TO_TWINTBank → TwintBanco → TwintBank → Twint0
TWINT_TO_BANKTwint → BankTwint → BancoTwint → Bank0

3.2 payment_methods (K-06) & vat_rates

create table payment_methods (
  code       text primary key,
  label_de   text not null, label_es text not null, label_en text not null,
  channel    text not null check (channel in ('cash','bank','open')),  -- harte Cash/Bank-Regel
  sort_order smallint not null default 100,
  active     boolean not null default true
);
-- CASH(cash), BANK(bank), TWINT(bank), CARD(bank), OPEN(open)

create table vat_rates (
  code       text primary key,           -- STD, RED, SPECIAL, EXEMPT, ZERO
  label_de   text not null, label_es text not null, label_en text not null,
  rate_pct   numeric(5,3) not null,       -- 8.100, 2.600, 3.800, 0.000
  valid_from date not null,
  valid_to   date,
  active     boolean not null default false   -- schema-ready, default inaktiv
);

3.3 box_products (K-07)

create table box_products (
  id                  uuid primary key default gen_random_uuid(),
  code                text not null unique,        -- 'BOX_MEDIANA','BARRIL_120',… stabil
  category            text not null check (category in ('box','barrel','bin','service')),
  length_cm           numeric(6,1),
  width_cm            numeric(6,1),
  height_cm           numeric(6,1),
  volume_liters       numeric(7,1),
  max_weight_kg       numeric(6,1),
  label_de            text not null,               -- K-07: label_*, nicht name_*
  label_es            text not null,
  label_en            text not null,
  description_de      text, description_es text, description_en text,
  image_path          text,                              -- Entscheid 2026-06-28: Katalog-/Anzeigebild je Box-Typ (extern bereitgestellt; IST hatte Assets_*.png)
  default_deposit_chf numeric(12,2) not null default 0,  -- K-07 (Logistik braucht es)
  is_returnable       boolean not null default false,    -- F-22: EINZIGE Quelle für „returnable" (Fass/Bin=true, Karton=false); boxes.is_returnable wird hieraus abgeleitet
  active              boolean not null default true,
  sort_order          smallint not null default 0,
  created_at          timestamptz not null default now(),
  updated_at          timestamptz not null default now()
);
create index idx_box_products_category on box_products (category);
create index idx_box_products_active   on box_products (active);

Seed: BOX_MEDIANA/JUMBO/MAXI/MEGA (box), BARRIL_120/150/220 (barrel, returnable), ZAFACON_150/240 (bin, returnable), SRV_ABHOLUNG (service), OTRO (box, Freimaß — Maße/Volumen kommen pro Box aus boxes.length_cm/width_cm/height_cm, nicht aus dem Produkt). 🔲 Dimensionen, Depot-Sätze, is_returnable je Produkt vom Ops-Team bestätigen.

G7 / Entscheid E7 (Hybrid-Tarif): Standard-Box-Typen werden über Festpreis-Preislisten (price_list_items, Produkt × Zone × Datum) bepreist. Das Produkt OTRO (Übergröße/Freimaß) trägt keine festen Produktmaße; stattdessen werden B/H/L pro Box (boxes.length_cm/width_cm/height_cm) erfasst und über einen Volumen-Tarif bepreist — fortgeführte IST-Logik ⌈ (B×H×L) / volume_tariff_unit × Tarif ⌉ (IST-Divisor 100; Tarifeinheit + Rundung als app_settings.volume_tariff_unit bzw. Preis-Resolver, 🔲 DR-Zonen-Differenzierung). Details im Preis-Resolver (Modul 3 §4).

3.4 zones, price_lists, price_list_items, product_depot_rates (K-08, K-12)

create table zones (
  id           uuid primary key default gen_random_uuid(),
  code         text not null unique,        -- 'SDQ','STI','PROV','CH_DEPOT'
  label_de     text not null, label_es text not null, label_en text not null,
  country_code char(2) not null default 'DO',
  active       boolean not null default true,
  sort_order   smallint not null default 0,
  created_at   timestamptz not null default now()
);

create table price_lists (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  description text,
  valid_from  date not null,                -- inklusiv
  valid_until date,                         -- NULL=unbegrenzt
  is_default  boolean not null default false,
  created_by  uuid references app_users(id),
  created_at  timestamptz not null default now(),
  updated_at  timestamptz not null default now(),
  constraint pl_valid_range check (valid_until is null or valid_until >= valid_from)
);
create unique index idx_price_lists_one_default on price_lists (is_default) where is_default;
create index idx_price_lists_valid_from on price_lists (valid_from);

create table currencies (              -- Entscheid 2026-06-28: Mehrwährung (CHF Berichtswährung, DOP via Revolut)
  code     char(3) primary key,        -- 'CHF','DOP','EUR','USD'
  label_de text not null, label_es text not null, label_en text not null,
  active   boolean not null default true
);  -- Seed: CHF, DOP (+ EUR/USD optional)

create table price_list_items (        -- K-12: kanonischer Name
  id             uuid primary key default gen_random_uuid(),
  price_list_id  uuid not null references price_lists(id) on delete restrict,
  product_id     uuid not null references box_products(id) on delete restrict,
  zone_id        uuid references zones(id) on delete restrict,  -- NULL = zonenunabhängig (Fallback)
  unit_price_chf numeric(10,2) not null check (unit_price_chf >= 0),
  currency       char(3) not null default 'CHF' references currencies(code),  -- i.d.R. CHF (Verkaufspreise); Mehrwährung v.a. im Ledger (movements)
  notes          text,
  created_at     timestamptz not null default now(),
  updated_at     timestamptz not null default now(),
  constraint pli_unique_product_zone unique (price_list_id, product_id, zone_id)
);
create index idx_pli_list on price_list_items (price_list_id);
create index idx_pli_prod on price_list_items (product_id);
create index idx_pli_zone on price_list_items (zone_id);

create table product_depot_rates (
  id            uuid primary key default gen_random_uuid(),
  price_list_id uuid not null references price_lists(id) on delete restrict,
  product_id    uuid not null references box_products(id) on delete restrict,
  depot_chf     numeric(10,2) not null check (depot_chf >= 0),
  currency      char(3) not null default 'CHF',
  notes         text,
  created_at    timestamptz not null default now(),
  constraint pdr_unique_product_per_list unique (price_list_id, product_id)
);
create index idx_pdr_list on product_depot_rates (price_list_id);

3.5 Logistik-Lookups (TEXT-Codes + i18n)

create table tracking_phases (
  code text primary key, label_de text not null, label_es text not null, label_en text not null,
  sort_order smallint not null
); -- CH, OCEAN, RD

create table tracking_statuses (
  code        text primary key,            -- die 10 Schritte, stabil
  sort_order  smallint not null unique,    -- 1..10
  phase       text not null references tracking_phases(code),
  label_de    text not null, label_es text not null, label_en text not null,
  is_terminal boolean not null default false,
  active      boolean not null default true
);
-- 1 EMPTY_DELIVERED(CH) · 2 PACKED(CH) · 3 PICKED_UP(CH) · 4 CH_DEPOT(CH)
-- 5 CONTAINER_LOADED(OCEAN) · 6 SHIPPED(OCEAN) · 7 IN_TRANSIT(OCEAN) · 8 DR_CUSTOMS(OCEAN)
-- 9 DR_DEPOT(RD) · 10 DELIVERED(RD, is_terminal)   -- DR_DEPOT: Entscheid 2026-06-28 (eigener DR-Lager-Schritt zw. Zoll und Zustellung); sort_order 9, DELIVERED→10

create table box_conditions (    -- NEW, GOOD, WORN, DAMAGED, LOST
  code text primary key, label_de text not null, label_es text not null, label_en text not null
);
create table shipment_statuses (  -- DRAFT,PACKING,READY,IN_CONTAINER,SHIPPED,ARRIVED,DELIVERED,CLOSED
  code text primary key, sort_order smallint not null,
  label_de text not null, label_es text not null, label_en text not null
);
create table container_statuses ( -- PLANNED,OPEN,CLOSED,DEPARTED,ARRIVED,CLEARED,COMPLETED
  code text primary key, sort_order smallint not null,
  label_de text not null, label_es text not null, label_en text not null
);
create table order_statuses (     -- OPEN,IN_PROGRESS,SHIPPED,DELIVERED,CLOSED,CANCELLED
  code text primary key, sort_order smallint not null,
  label_de text not null, label_es text not null, label_en text not null
);
create table quote_statuses (     -- DRAFT,SENT,ACCEPTED,REJECTED,EXPIRED,CONVERTED
  code text primary key, sort_order smallint not null,
  label_de text not null, label_es text not null, label_en text not null
);

3.6 KYC-Lookup

create table kyc_document_types (    -- CEDULA, PASSPORT, OTHER
  code text primary key,
  label_de text not null, label_es text not null, label_en text not null,
  description text
);

4. Finanz-Ledger

4.1 movements (Owner; K-05, K-18/19 angepasst)

create table movements (
  id                  uuid primary key default gen_random_uuid(),
  -- Eingabewerte (Erfassung_Digitar A..G, O, P)
  entry_date          date not null,
  reference           text,
  contact_id          uuid references contacts(id),
  movement_type_code  text not null references movement_types(code),
  payment_method_code text not null references payment_methods(code),
  total_chf           numeric(12,2) not null default 0,    -- Berichtswährung CHF (Gegenwert)
  paid_chf            numeric(12,2) not null default 0,
  currency            text not null default 'CHF' references currencies(code),  -- Entscheid 2026-06-28: Mehrwährung CHF/DOP (DOP-Zahlungen via Revolut Business)
  fx_rate             numeric(12,6) not null default 1,     -- Kurs Original→CHF zum Buchungszeitpunkt (aus Revolut/Bank)
  total_orig          numeric(14,2),                        -- Originalbetrag in `currency` (bei CHF = total_chf)
  note                text,
  due_date            date,
  -- MWST (aktivierbar)
  vat_code            text references vat_rates(code),
  vat_rate_percent    numeric(5,3),                     -- data-02: angewandter Satz eingefroren (kein Drift, wenn vat_rates später ändert)
  vat_amount_chf      numeric(12,2),
  -- Herkunft / Idempotenz (K-05: vollständige Quellen-Union)
  -- F-02: Kreditoren bleiben manuell → kein 'creditor_invoice'/'creditor_payment'
  source_type         text not null default 'manual' check (source_type in
                        ('manual','import','revolut','quote_accepted','invoice_issued','debtor_payment',
                         'deposit_order','deposit_payment','deposit_refund','commission_payout')),   -- 'revolut': Import Revolut-Business-Transaktion (idempotent über Revolut-transaction_id in source_id)
  source_id           uuid,
  transfer_group_id   uuid,                          -- data-05: interne Transfers; Invariante per Constraint-Trigger erzwingen — je Gruppe genau 2 nicht-stornierte Beine mit identischem paid_chf (sonst unbalancierte Kasse/Bank)
  -- Revisionssicherheit
  period_key          text generated always as (to_char(entry_date,'MM/YYYY')) stored,
  voided_at           timestamptz,
  voided_by           uuid references app_users(id),
  void_reason         text,
  created_at          timestamptz not null default now(),
  created_by          uuid references app_users(id),
  updated_at          timestamptz not null default now(),
  updated_by          uuid references app_users(id),
  constraint movements_source_uniq unique (source_type, source_id),  -- 1 Buchung je Quelle
  constraint movements_amounts_nonneg check (total_chf >= 0 and paid_chf >= 0)
);
create index movements_entry_date_idx on movements (entry_date);
create index movements_period_idx     on movements (period_key);
create index movements_contact_idx    on movements (contact_id);
create index movements_type_idx       on movements (movement_type_code);
create index movements_source_idx     on movements (source_type, source_id);
create index movements_ref_party_idx  on movements (reference, contact_id);          -- Migrations-Match
create index movements_live_idx       on movements (entry_date) where voided_at is null;

source_id ist bei manual NULL → Unique greift nicht (mehrere manuelle Buchungen erlaubt); quellen-erzeugte sind eindeutig. Hartes DELETE verboten (RLS), Korrektur nur via voided_at.

F-18 — vat_amount_chf wird in der schreibenden Schicht berechnet und auf der Quell-Zeile gespeichert, nicht in einer View. movements.vat_amount_chf (ebenso invoices.vat_amount_chf/quotes.vat_amount_chf) ist eine persistierte Spalte. Berechnung beim INSERT/Upsert der Quell-Zeile: Server-Action bzw. post_movement() liest vat_rates.rate_pct zum vat_code und schreibt vat_amount_chf mit. Sie ist keine der abgeleiteten View-Spalten (§4.4) — diese bleiben read-only-Ableitungen aus den Eingabewerten. Da rate_pct in einer Fremdtabelle liegt, kommt eine generated column nicht in Frage; der Schreibpfad (Action/Trigger) ist die einzige Stelle. Idempotenz bleibt gewahrt: der Upsert auf (source_type, source_id) überschreibt denselben berechneten Wert.

create table payment_links (
  id         uuid primary key default gen_random_uuid(),
  payment_id uuid not null references movements(id) on delete restrict,  -- INVOICE_PAYMENT / SUPPLIER_PAYMENT (data-04: restrict statt cascade — konsistent mit No-Delete-Ledger; Storno via voided_at)
  target_id  uuid not null references movements(id) on delete restrict,  -- INVOICE / RECEIVABLE / SUPPLIER_DEBT / DEPOSIT_CASH
  amount_chf numeric(12,2) not null check (amount_chf > 0),
  created_at timestamptz not null default now(),
  constraint payment_links_uniq unique (payment_id, target_id)
);
create index payment_links_target_idx on payment_links (target_id);

4.3 monthly_closings (Periodensperre)

create table monthly_closings (
  period_key   text primary key,             -- MM/YYYY
  start_date   date not null,
  end_date     date not null,
  status       text not null default 'open' check (status in ('open','review','closed')),
  opening_cash numeric(12,2), closing_cash numeric(12,2),
  opening_bank numeric(12,2), closing_bank numeric(12,2),
  closed_at    timestamptz, closed_by uuid references app_users(id),
  reopened_at  timestamptz, reopened_by uuid references app_users(id),
  notes        text
);

4.4 Abgeleitete Werte (Views — nicht gespeichert)

Die Routing-Logik (Erfassung_Digitar!H..M) lebt in v_movements (Kanal aus payment_methods.channel, nie Textmatch). Restschuld primär via payment_links, Fallback reference + contact_id. Darauf aufbauend: v_kasse (laufender Saldo), v_debitoren, v_kreditoren, v_anzahlungen, v_dashboard, v_monatskontrolle. Vollständige View-DDL: Modul 15 §3.5–3.6 (unverändert übernommen; einzige Anpassung: app_users statt auth.users in Audit-Spalten).


5. Depot-Lifecycle

create table deposit_orders (
  id                uuid primary key default gen_random_uuid(),
  order_id          uuid references orders(id),
  box_id            uuid references boxes(id),           -- 1 returnable Box ⇒ ≤1 offener Depot-Auftrag
  contact_id        uuid not null references contacts(id),
  product_id        uuid references box_products(id),
  depot_rate_id     uuid references product_depot_rates(id),  -- eingefrorener Satz
  quantity          integer not null default 1,
  deposit_total_chf numeric(12,2) not null default 0,
  status            text not null default 'OPEN'
                      check (status in ('OPEN','PARTIAL','PAID','CLOSED')),
  movement_id       uuid references movements(id) on delete set null,  -- F-14: die DEPOSIT_CASH-Soll-Bewegung (AP-3); deposit_payments verlinken ihre Abono via payment_links hierauf
  note              text,
  created_at        timestamptz not null default now(),
  updated_at        timestamptz not null default now(),
  created_by        uuid references app_users(id)
);
create index idx_deposit_orders_box     on deposit_orders (box_id);
create index idx_deposit_orders_contact on deposit_orders (contact_id);

create table deposit_payments (
  id                uuid primary key default gen_random_uuid(),
  deposit_order_id  uuid not null references deposit_orders(id),
  amount_chf        numeric(12,2) not null check (amount_chf > 0),
  payment_method_id text references payment_methods(code),   -- F-03: text (passt zu payment_methods.code text-PK + deposit_refunds)
  pay_date          date not null default current_date,
  note              text,
  created_at        timestamptz not null default now(),
  created_by        uuid references app_users(id)
);
create index idx_deposit_payments_order on deposit_payments (deposit_order_id);

create table deposit_refunds (          -- Logistik-Neubau
  id                uuid primary key default gen_random_uuid(),
  deposit_order_id  uuid not null references deposit_orders(id),
  box_id            uuid references boxes(id),
  kind              text not null references deposit_refund_kinds(code),  -- RETURNED|FORFEITED|PARTIAL
  refund_date       date not null default current_date,
  amount_chf        numeric(12,2) not null default 0,    -- 0 bei Verfall
  payment_method_id text references payment_methods(code),
  reason            text,
  created_at        timestamptz not null default now(),
  updated_at        timestamptz not null default now(),
  created_by        uuid references app_users(id),
  unique (deposit_order_id)             -- genau ein Abschluss je Depot
);

create table deposit_refund_kinds (     -- RETURNED, FORFEITED, PARTIAL
  code text primary key, label_de text not null, label_es text not null, label_en text not null
);

Depot-Deckelung: deposit_refunds.amount_chf ≤ Σ deposit_payments.amount_chf derselben deposit_order (Trigger/App).

Depot-Entscheide (2026-06-28): Box↔Depot-Kardinalität 1:1 (box_id) — 1 Fass = 1 Pfand, kein Mengen-Depot. Verfallsfrist: 1 Monat nach Zustellung (DELIVERED) → wird das Fass nicht zurückgegeben, schlägt der Refund auf FORFEITED um (amount_chf=0). Verfall ist finanziell neutralkeine Ertragsbuchung (kein INCOME), nur Depot-Abschluss (s. AP-6).


6. Plattform: Identität, i18n, Audit, Storage, Notifications

6.1 Identität & Audit

create table roles (
  code text primary key,                  -- ADMIN,BUCHHALTUNG,OPERATIONS,FAHRER,AFFILIATE,READONLY
  label_de text not null, label_es text not null, label_en text not null,
  rank int not null,
  is_internal boolean not null default true   -- false nur für AFFILIATE
);

create table app_users (
  id               uuid primary key,       -- = auth.users.id (kein default)
  email            text not null unique,
  display_name     text,
  preferred_locale text not null default 'es' check (preferred_locale in ('de','es','en')),
  theme            text not null default 'system' check (theme in ('light','dark','system')),  -- F-10: einzige Theme-Quelle (user_preferences entfällt)
  is_active        boolean not null default true,
  phone_e164       text,
  last_seen_at     timestamptz,
  created_at       timestamptz not null default now(),
  updated_at       timestamptz not null default now()
);

create table user_roles (
  user_id    uuid not null references app_users(id) on delete cascade,
  role_code  text not null references roles(code),
  granted_by uuid references app_users(id),
  granted_at timestamptz not null default now(),
  primary key (user_id, role_code)
);

create table affiliate_users (            -- Brücke Login ↔ Affiliate (K-18)
  user_id      uuid primary key references app_users(id) on delete cascade,
  affiliate_id uuid not null unique references affiliates(id)
);

create table audit_log (                  -- append-only (K-15)
  id            bigint primary key generated always as identity,
  at            timestamptz not null default now(),
  actor_user_id uuid references app_users(id),
  actor_email   text,                      -- denormalisiert (bleibt lesbar)
  action        text not null check (action in
                  ('insert','update','delete','login','export','close_period','reopen_period')),
  table_name    text not null,
  row_pk        text,
  old_data      jsonb, new_data jsonb, diff jsonb,
  period_key    text,
  context       jsonb
);
create index idx_audit_table on audit_log (table_name, row_pk);
create index idx_audit_actor on audit_log (actor_user_id, at);
create index idx_audit_period on audit_log (period_key);
-- Unveränderbarkeit: revoke update,delete + Rules audit_log_no_update/no_delete (Modul 17 §3.8)

create table app_settings (               -- G-Settings: globale Konfig-Flags (ersetzt IST GlobalSettings; RLS: ADMIN write, internal read)
  key        text primary key,            -- 'vat_enabled','vat_method','default_currency','volume_tariff_unit','opening_balances_done','gs1_sscc_enabled','gs1_company_prefix','gs1_extension_digit'
  value      jsonb not null,
  updated_by uuid references app_users(id),
  updated_at timestamptz not null default now()
);

6.2 i18n: locale_strings (K-16 — i18n_labels entfällt)

create table locale_strings (
  namespace  text not null,    -- 'ui.common','email_template','contact_role','tracking_status',…
  key        text not null,    -- stabiler Schlüssel (oft = Fachcode)
  locale     text not null check (locale in ('de','es','en')),
  value      text not null,
  updated_at timestamptz not null default now(),
  primary key (namespace, key, locale)
);
create index idx_locale_ns on locale_strings (namespace, locale);

Label-Strategie (Hybrid): Lookups mit häufigen Joins (roles, movement_types, payment_methods, alle *_statuses, box_products, zones, tracking_*) tragen Inline-label_de/es/en. Freie UI-Texte (Buttons, Toasts, E-Mail-Templates, Rollen-/Status-Erklärtexte) liegen in locale_strings. Beide aus GLOSSAR-ERP_DE-ES-EN.csv geseedet. Die in Affiliate §3.7 / Design §3.1 gezeigten i18n_labels-Inserts gehen 1:1 in locale_strings (namespace = entity).

6.3 Storage & Notifications

create table storage_objects (
  id           uuid primary key default gen_random_uuid(),
  bucket       text not null check (bucket in ('kyc','documents','receipts','public-assets')),
  storage_path text not null unique,
  doc_class    text not null check (doc_class in
                 ('id_scan','quote_pdf','invoice_pdf','receipt','delivery_proof',
                  'signature','customs_report','shipment_list','other')),  -- G6/G2: Unterschrift, Zoll-Beleg, Sendungsliste
  owner_type   text not null check (owner_type in
                 ('contact','recipient','quote','order','invoice','deposit','box','shipment','movement')),
  owner_id     uuid not null,            -- polymorph, kein harter FK
  mime_type    text, byte_size bigint,
  uploaded_by  uuid references app_users(id),
  retain_until date,                     -- Default = Ablage + 10 J für Belege/Rechnungen
  is_sensitive boolean not null default false,  -- true für KYC
  created_at   timestamptz not null default now()
);
create index idx_storage_owner on storage_objects (owner_type, owner_id);
create index idx_storage_bucket on storage_objects (bucket, doc_class);

create table notifications (             -- Outbox
  id                  uuid primary key default gen_random_uuid(),
  channel             text not null check (channel in ('email','whatsapp')),
  template_code       text not null,     -- QUOTE_SENT,PAYMENT_RECEIVED,SHIPMENT_STATUS,AFFILIATE_STATEMENT,AUTH_MAGIC_LINK
  locale              text not null check (locale in ('de','es','en')),
  to_email            text, to_phone_e164 text,
  subject             text, body text,
  owner_type          text check (owner_type is null or owner_type in     -- F-20: alle real referenzierten Owner-Typen (inkl. 'recipient' für SHIPMENT_STATUS an Empfänger-DR)
                        ('contact','recipient','quote','order','invoice','shipment','movement','commission_payout')),
  owner_id            uuid,
  status              text not null default 'queued' check (status in ('queued','sent','failed','skipped')),
  provider_message_id text, error text, attempts int not null default 0,
  dedupe_key          text unique,       -- Idempotenz, z.B. 'payment_received:<movement_id>'
  created_at          timestamptz not null default now(), sent_at timestamptz
);
create index idx_notif_queue on notifications (status, created_at);
create index idx_notif_owner on notifications (owner_type, owner_id);

6.4 KYC-/WhatsApp-Tabellen (Modul 2, app_users-Korrektur)

create table whatsapp_inbound (
  id            uuid primary key default gen_random_uuid(),
  received_at   timestamptz not null default now(),
  wa_message_id text not null unique,           -- Idempotenz gegen WA-Retry
  wa_from       text not null,                  -- E.164
  wa_account_id text not null,
  media_type    text, media_url text, storage_path text,
  body_text     text,
  status        text not null default 'RECEIVED' check (status in ('RECEIVED','PROCESSING','LINKED','IGNORED')),
  kyc_scan_id   uuid references kyc_scans(id),
  contact_id    uuid references contacts(id),
  error_message text,
  created_at    timestamptz not null default now(), updated_at timestamptz not null default now()
);
create index idx_wa_from on whatsapp_inbound (wa_from);
create index idx_wa_status on whatsapp_inbound (status);

create table kyc_scans (
  id               uuid primary key default gen_random_uuid(),
  contact_id       uuid references contacts(id),         -- NULL bis Merge
  wa_inbound_id    uuid references whatsapp_inbound(id),
  document_type    text not null default 'CEDULA' references kyc_document_types(code),
  raw_image_path   text not null,
  ocr_provider     text not null,                        -- ANTHROPIC_VISION|GOOGLE_VISION|AZURE_FORM|MANUAL
  ocr_confidence   numeric(4,3),
  ocr_raw_response jsonb,
  extracted_fields jsonb not null default '{}',
  review_status    text not null default 'PENDING' check (review_status in ('PENDING','APPROVED','REJECTED','MERGED')),
  reviewed_by      uuid references app_users(id),
  submitted_by     uuid references app_users(id),   -- sec-03: Einreicher (FAHRER bei source=CAMERA; NULL bei WHATSAPP) → trägt die "eigene Scans"-RLS
  reviewed_at      timestamptz, rejection_reason text, notes text,
  source           text not null default 'CAMERA',       -- CAMERA|WHATSAPP
  created_at       timestamptz not null default now(), updated_at timestamptz not null default now()
);
create index idx_kyc_contact   on kyc_scans (contact_id);
create index idx_kyc_doc_number on kyc_scans ((extracted_fields->>'doc_number'));
create index idx_kyc_review     on kyc_scans (review_status);
create unique index idx_kyc_approved_per_doc
  on kyc_scans (contact_id, document_type) where review_status = 'APPROVED';

create table kyc_field_corrections (   -- append-only Korrektur-Journal
  id              uuid primary key default gen_random_uuid(),
  kyc_scan_id     uuid not null references kyc_scans(id) on delete cascade,
  field_name      text not null, ocr_value text, corrected_value text not null,
  corrected_by    uuid not null references app_users(id),
  corrected_at    timestamptz not null default now()
);

Zirkulär-Referenz contacts.kyc_scan_id ↔ kyc_scans.contact_id: beide FKs sind nullable + on delete set null → in der Migration zuerst beide Tabellen ohne den jeweils anderen FK anlegen, dann FKs per alter table ergänzen. Reihenfolge in §9.


7. Logistik: Container, Sendungen, Boxen, Tracking

create table containers (
  id             uuid primary key default gen_random_uuid(),
  code           text not null unique,        -- '2026-07' / 'CNT-2026-07-A'
  name           text,
  status         text not null default 'PLANNED' references container_statuses(code),
  next_departure date,                         -- "próxima salida"
  departed_at    date, eta_dr date, arrived_at date, cleared_at date,
  capacity_boxes integer, capacity_cbm numeric(10,2),   -- Entscheid 2026-06-28: m³ (capacity_cbm) ist für die Auslastung führend; capacity_boxes nur ergänzend angezeigt
  shipping_line  text, bl_number text, note text,
  created_at     timestamptz not null default now(), updated_at timestamptz not null default now(),
  created_by     uuid references app_users(id)
);
create index idx_containers_status on containers (status);
create index idx_containers_dep    on containers (next_departure);

create table shipments (
  id           uuid primary key default gen_random_uuid(),
  shipment_no  text not null unique,
  order_id     uuid not null references orders(id),
  recipient_id uuid references recipients(id),
  container_id uuid references containers(id),
  status       text not null default 'DRAFT' references shipment_statuses(code),
  pickup_driver_id    uuid references app_users(id),   -- G5 (BR-33): Abhol-Fahrer
  transport_driver_id uuid references app_users(id),   -- G5: Versand-/Transport-Fahrer
  delivery_driver_id  uuid references app_users(id),   -- G5: Zustell-Fahrer (Basis der Fahrer-RLS BR-05)
  sender_snapshot    jsonb,    -- G1/E4 (BR-30): Absender-Stand (Name/Adresse/Cédula) beim Materialisieren eingefroren
  recipient_snapshot jsonb,    -- G1/E4: Empfänger-Stand eingefroren — überlebt Stammdaten-Edit + Kontakt-Merge (zoll-/revisionssicher)
  note         text,
  created_at   timestamptz not null default now(), updated_at timestamptz not null default now(),
  created_by   uuid references app_users(id)
);
create index idx_shipments_order     on shipments (order_id);
create index idx_shipments_container on shipments (container_id);
create index idx_shipments_recipient on shipments (recipient_id);
create index idx_shipments_pickup_drv   on shipments (pickup_driver_id);
create index idx_shipments_transport_drv on shipments (transport_driver_id);
create index idx_shipments_delivery_drv on shipments (delivery_driver_id);

create table boxes (
  id             uuid primary key default gen_random_uuid(),   -- = QR-Inhalt
  box_no         text not null unique,
  sscc           text unique check (sscc is null or sscc ~ '^\d{18}$'),  -- GS1 SSCC (18-stellig, Prüfziffer per Generator), optional/nullable; gesetzt sobald GS1 aktiv. UUID `id` bleibt interner PK + QR-Inhalt (§12).
  order_id       uuid not null references orders(id),
  shipment_id    uuid references shipments(id),
  container_id   uuid references containers(id),               -- denormalisiert
  box_product_id uuid not null references box_products(id),
  current_status text not null default 'EMPTY_DELIVERED' references tracking_statuses(code),
  condition      text not null default 'GOOD' references box_conditions(code),
  weight_kg      numeric(8,2),                  -- CH-Wiegung (beim Packen)
  weight_rd_kg   numeric(8,2),                  -- G3/BR-20: RD-Nachwiegung. Entscheid 2026-06-28: derzeit NICHT genutzt (keine RD-Nachwiegung); Feld bleibt optional/deaktiviert
  length_cm      numeric(6,1), width_cm numeric(6,1), height_cm numeric(6,1),  -- G7/E7: nur für OTRO/Freimaß befüllt (sonst aus box_products); Input für Volumen-Tarif
  content_note   text,
  sealed_at      timestamptz, delivered_at timestamptz,
  is_returnable  boolean not null,              -- F-22: abgeleitet — bei Box-Materialisierung aus box_products.is_returnable initialisiert (kein eigener Default, box_products ist die einzige Quelle)
  note           text,
  created_at     timestamptz not null default now(), updated_at timestamptz not null default now(),
  created_by     uuid references app_users(id)
);
create index idx_boxes_order     on boxes (order_id);
create index idx_boxes_shipment  on boxes (shipment_id);
create index idx_boxes_container on boxes (container_id);
create index idx_boxes_status    on boxes (current_status);
create unique index idx_boxes_box_no_lower on boxes (lower(box_no));

create table tracking_events (   -- append-only (kein UPDATE/DELETE)
  id              uuid primary key default gen_random_uuid(),
  box_id          uuid not null references boxes(id),
  status          text not null references tracking_statuses(code),
  occurred_at     timestamptz not null default now(),
  actor_id        uuid references app_users(id),     -- K-19: handelnder Login
  source          text not null default 'manual',    -- manual|qr_scan|bulk|import|system
  geo_lat         numeric(9,6), geo_lng numeric(9,6),
  photo_path      text,
  signer_name     text,                             -- G6 (BR-28): Name des Unterzeichners bei DELIVERED; Unterschrift-Bild als storage_objects.doc_class='signature'
  proof_method    text check (proof_method is null or proof_method in ('signature','deposit','self_declared')),  -- Entscheid 2026-06-28: Liefernachweis-Methode (Empfänger wählt; Modul 14 §10.3)
  note            text,
  client_event_id text,                              -- Offline-Idempotenz
  created_at      timestamptz not null default now()
);
create index idx_track_box on tracking_events (box_id, occurred_at);
create unique index idx_track_client_event on tracking_events (client_event_id) where client_event_id is not null;

Statuswechsel nur über fn_record_tracking_event() (SECURITY DEFINER, atomar Event+current_status, max-Schritt gewinnt, Idempotenz via client_event_id) — DDL unverändert aus Modul 14 §4.2.

F-08 — Empfänger/KYC-Gate läuft über shipments.recipient_id, nicht über boxes. boxes hat keine Spalte recipient_contact_id (eine ältere Modul-2/45-Formulierung referenzierte eine Phantom-Spalte). Die reale Kette ist boxes.shipment_id → shipments.recipient_id → recipients; KYC-relevant ist recipients.cedula_number (bzw. recipients.contact_id → contacts.kyc_status, falls ein Golden Record verknüpft ist). Das KYC-/Zoll-Gate („Box darf nur reisen, wenn Empfänger-Identität vollständig") prüft daher auf der recipients-Zeile der Sendung, nicht auf der Box. (recipients.contact_id ist nullable → bei fehlendem Golden Record greift recipients.cedula_number direkt.)

IST-Gap-Folds (aus Alt-ERP-Analyse, docs/legacy-ist/95-caja-gap-analyse.md): Die Felder shipments.{pickup,transport,delivery}_driver_id (G5/BR-33 — Basis der Fahrer-RLS BR-05), shipments.sender_snapshot/recipient_snapshot + invoices.bill_to_snapshot (G1/BR-30 — eingefrorener Personen-Stand, zoll-/revisionssicher, Entscheid E4), boxes.weight_rd_kg (G3/BR-20 — zweiter Wiegepunkt RD), boxes.{length,width,height}_cm (G7/E7 — Freimaß-Tarif für OTRO), tracking_events.signer_name + Storage-doc_class signature/customs_report/shipment_list (G6/BR-28, G2/BR-39) sowie app_settings (G-Settings — ersetzt IST GlobalSettings) schließen die in der Gap-Analyse bestätigten P0-Lücken. Zoll-/Aduana-Report-Workflow → Modul 14; Snapshot-Befüllung beim Materialisieren der Sendung/Rechnung (analog quote_lines.product_snapshot).


8. Affiliate

create type commission_kind as enum ('PERCENT','ABSOLUTE');   -- K-21

create table affiliates (
  id                      uuid primary key default gen_random_uuid(),
  contact_id              uuid not null unique references contacts(id) on delete restrict,
  status                  text not null default 'active' check (status in ('active','paused','terminated')),
  contract_start          date not null, contract_end date,
  default_commission_kind commission_kind not null default 'PERCENT',
  default_commission_value numeric(10,4) not null default 0,
  payout_method           text,                       -- payment_methods.code
  payout_iban             text,                       -- verschlüsselt via pgsodium/Supabase Vault (Entscheid 2026-06-28); Klartext nie an Nicht-ADMIN/BUCHHALTUNG, nie in Logs
  payout_notes            text, notes text,
  created_at              timestamptz not null default now(), updated_at timestamptz not null default now(),
  created_by              uuid references app_users(id)
);
create index idx_affiliates_status  on affiliates (status);
create index idx_affiliates_contact on affiliates (contact_id);

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 unique,
  description      text,
  -- Kunden-Rabatt
  discount_kind    discount_kind not null default 'PERCENT',
  discount_value   numeric(10,4) not null default 0,
  discount_max_chf numeric(10,2),
  -- Provisions-Override (NULL = affiliates.default_*)
  commission_kind  commission_kind,
  commission_value numeric(10,4),
  valid_from       date not null default current_date,
  valid_until      date,
  max_uses         integer, uses_count integer not null default 0,
  is_active        boolean not null default true,
  created_at       timestamptz not null default now(), updated_at timestamptz not null default now(),
  created_by       uuid references app_users(id)
);
create index idx_rc_affiliate on referral_codes (affiliate_id);
create index idx_rc_active     on referral_codes (is_active, valid_from, valid_until);

create table commission_payouts (
  id               uuid primary key default gen_random_uuid(),
  affiliate_id     uuid not null references affiliates(id) on delete restrict,
  period_from      date not null, period_to date not null,
  total_amount_chf numeric(10,2) not null,
  status           text not null default 'draft' check (status in ('draft','approved','paid')),
  approved_at timestamptz, approved_by uuid references app_users(id),
  paid_at timestamptz, paid_by uuid references app_users(id),
  payment_method   text, payment_reference text,
  movement_id      uuid references movements(id) on delete set null,   -- SUPPLIER_PAYMENT (Auto-Posting)
  notes text,
  created_at timestamptz not null default now(), updated_at timestamptz not null default now(),
  created_by uuid references app_users(id)
);
create index idx_payouts_affiliate on commission_payouts (affiliate_id);

create table commission_entries (        -- = "affiliate_commissions" (K-14)
  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,
  order_id              uuid not null references orders(id) on delete restrict,  -- data-03: NOT NULL → UNIQUE(order_id) greift hart (keine Doppel-/verwaiste Provision)
  invoice_id            uuid references invoices(id) on delete set null,
  base_amount_chf       numeric(10,2) not null,
  commission_kind       commission_kind not null,
  commission_value      numeric(10,4) not null,
  commission_amount_chf numeric(10,2) not null,
  status                text not null default 'pending' check (status in ('pending','approved','paid','cancelled')),
  approved_at timestamptz, approved_by uuid references app_users(id),
  payout_id             uuid references commission_payouts(id) on delete set null,
  movement_id           uuid references movements(id) on delete set null,
  notes text,
  created_at timestamptz not null default now(), updated_at timestamptz not null default now(),
  constraint commission_entries_order_uniq unique (order_id)   -- 1 Provision je Auftrag
);
create index idx_ce_affiliate on commission_entries (affiliate_id);
create index idx_ce_status    on commission_entries (status);
create index idx_ce_payout    on commission_entries (payout_id);

F-13 — Provisions-Lifecycle (Zeitpunkte eindeutig): (a) Entstehung INSERT status='pending' beim Übergang invoices.status → 'PAID' (Ledger-Trigger, §2.7 / Modul 16 §4.5 — konsistent mit F-04); (b) Freigabe → 'approved' durch ADMIN/OPERATIONS bei der Abrechnungsprüfung (Modul 16 §4.6; Sendung DELIVERED kann als fachliche Voraussetzung dienen, ist aber kein DB-Trigger); (c) Auszahlung → 'paid' beim Payout (AP-9). „Fällig wenn DELIVERED" (frühere Modul-5-Formulierung) bezieht sich auf die Freigabe-Voraussetzung, nicht auf die Entstehung. 🔲 Voll- vs. Teilzahlung als Auslöser bleibt Geschäftsentscheid (§14, Default Vollzahlung).


9. Migrations-Reihenfolge (FK-Topologie)

Wegen zyklischer Referenzen (contacts↔kyc_scans, quotes↔orders, affiliates↔contacts, movements↔invoices/payouts) in dieser Reihenfolge anlegen; zirkuläre FKs als nachgelagertes alter table ... add constraint.

  1. Extensions: pg_trgm, pgcrypto (für gen_random_uuid).
  2. Echte Enums: contact_status, kyc_status, discount_kind, commission_kind.
  3. Lookups ohne FKs: roles, movement_types, payment_methods, vat_rates, tracking_phases, box_conditions, *_statuses (shipment/container/order/quote/invoice), kyc_document_types, deposit_refund_kinds, locale_strings, tracking_statuses (FK→phases).
  4. Identität: app_usersuser_roles. (affiliate_users erst nach affiliates.)
  5. CRM: contacts (ohne kyc_scan_id-FK) → contact_roles, contact_duplicates, contact_merge_log.
  6. Produkte: box_products, zones, price_listsprice_list_items, product_depot_rates.
  7. KYC: whatsapp_inbound (ohne kyc_scan_id-FK) → kyc_scanskyc_field_corrections; dann alter: contacts.kyc_scan_id, whatsapp_inbound.kyc_scan_id.
  8. Affiliate-Stamm: affiliatesreferral_codes; dann alter: affiliate_users.
  9. Offerten/Aufträge: quotes (ohne order_id-FK) → ordersalter: quotes.order_id. (quotes.affiliate_id/referral_code_id schon vorhanden, da referenzierte Tabellen aus 8 existieren.)
  10. Rechnungen: invoices (ohne movement_id-FK).
  11. Logistik: containersshipmentsboxestracking_events.
  12. Depot: deposit_ordersdeposit_payments, deposit_refunds.
  13. Finanzen: movementspayment_links, monthly_closings; dann alter: invoices.movement_id.
  14. Provision: commission_payoutscommission_entries.
  15. Plattform-Rest: storage_objects, notifications, audit_log (+ Rules/Grants).
  16. Views: v_movements, v_kasse, v_debitoren, v_kreditoren, v_anzahlungen, v_dashboard, v_monatskontrolle, v_box_tracking, v_shipment_progress, v_container_load, v_open_box_deposits, v_recipients_masked.
  17. Funktionen/Trigger: has_role/is_internal/current_affiliate_id, handle_new_user, fn_audit (an revisionsrelevante Tabellen), fn_guard_closed_period, fn_record_tracking_event, post_movement (Auto-Posting), set_updated_at.
  18. Seeds: Lookups + locale_strings aus GLOSSAR-ERP_DE-ES-EN.csv.

10. ER-Diagramm (mermaid)

Diagramm wird geladen …

11. AUTO-POSTING-Engine (Events → movements, idempotent)

Kernprinzip: operative Events erzeugen Buchungen automatisch und genau einmal — der Kerngewinn gegenüber Excel. Jede Auto-Buchung läuft über service_role in einer Edge-/Server-Function post_movement(...) mit upsert auf (source_type, source_id). Korrektur am Ursprung ⇒ Upsert überschreibt dieselbe Zeile (kein Duplikat). Storno am Ursprung ⇒ void-RPC setzt voided_at (Zeile bleibt fürs Audit, View filtert sie aus). Quellen-erzeugte movements sind im Ledger read-only (nur der Ursprung wird editiert), hartes DELETE ist für niemanden erlaubt.

11.1 Event-→-Buchung-Matrix

#Auslösendes Event (Modul)source_typesource_idmovement_type_codeBetrag (total_chf / paid_chf)Kanal (payment_method_code)contact_idperiod_key aus
AP-1Rechnung gestellt — invoices angelegt (4→6)invoice_issuedinvoices.idINVOICEtotal=invoices.total_chf, paid=0OPEN (open)invoices.contact_idissue_date
AP-2Kundenzahlung erfasst (6)debtor_paymentpayment.idINVOICE_PAYMENTpaid=Betragreale ZahlweiseKundeZahldatum
AP-3Depot-Order angelegt — Soll (5)deposit_orderdeposit_orders.idDEPOSIT_CASHtotal=deposit_total_chf, paid=0OPENKundecreated_at/order_date
AP-4Depot-Zahlung erfasst (5/6)deposit_paymentdeposit_payments.idDEPOSIT_CASH (Abono)paid=amount_chfpayment_methods.channel der ZahlweiseKundepay_date
AP-5Box zurück → Depot erstattet RETURNED/PARTIAL (5)deposit_refunddeposit_refunds.idEXPENSEpaid=amount_chf (Ausgang)Rückzahl-ZahlweiseKunderefund_date
AP-6Depot verfallen FORFEITED (5)deposit_refunddeposit_refunds.idkeine Buchung (Entscheid 2026-06-28: finanziell neutral, kein INCOME)nur Depot-Abschluss, 0 CashKunderefund_date
AP-7F-02: entfällt — Lieferantenrechnung ist manuelle Buchung (source_type='manual', SUPPLIER_DEBT/EXPENSE), kein Auto-Posting (keine Quell-Tabelle)
AP-8F-02: entfällt — Lieferantenzahlung ist manuelle Buchung (source_type='manual', SUPPLIER_PAYMENT) + ggf. payment_links auf die SUPPLIER_DEBT
AP-9Affiliate-Payout bezahlt — commission_payouts.status→paid (7)commission_payoutcommission_payouts.idSUPPLIER_PAYMENTpaid=total_amount_chfpayout.payment_methodaffiliate.contact_idpaid_at
AP-10F-06: entfällt — Offerten-Annahme bucht keine RECEIVABLE. Die Konversion legt Auftrag + Rechnung an; die Forderung entsteht genau einmal bei Rechnungsstellung (AP-1, INVOICE). Doppelzählung RECEIVABLE+INVOICE damit ausgeschlossen.

Begleit-Updates der Engine (Folgekanten):

  • AP-1 setzt invoices.movement_id; AP-2 erzeugt zusätzlich payment_links(payment_id, target_id=INVOICE-movement, amount).
  • F-14: AP-3 setzt deposit_orders.movement_id (die Soll-Bewegung). AP-4 erzeugt zusätzlich payment_links(payment_id=DEPOSIT_CASH-Abono-movement, target_id=DEPOSIT_CASH-Soll-movement, amount=amount_chf) — die Soll-Bewegung (AP-3) wird über deposit_payments.deposit_order_id → deposit_orders → deposit_orders.movement_id (= AP-3-source_id-Buchung) eindeutig gefunden, nicht per reference-Match. Dadurch findet v_anzahlungen.pending_chf Soll und Abono sicher zusammen (analog AP-2/INVOICE). AP-4 setzt zudem deposit_orders.status (PARTIAL/PAID) via v_open_box_deposits.
  • AP-9 setzt commission_payouts.movement_id und commission_entries.movement_id + status='paid' für alle Entries des Payouts.
  • Jede Auto-Buchung schreibt zusätzlich audit_log (action='insert'|'update').

11.2 Idempotenz- & Integritäts-Garantien

  1. Unique (source_type, source_id) auf movements ist das Upsert-Target → ein Event = max. eine Buchung; Re-Delivery/Race überschreibt statt zu duplizieren.
  2. Kanal nie per Textmatch — immer payment_methods.channel. channel='open' ⇒ Zeile nicht zahlungswirksam (cash_in=cash_out=bank=0), erzeugt aber open_debt (Debitor/Kreditor).
  3. period_key aus dem Finanz-Datum (Zahl-/Refund-/Belegdatum), nie aus dem operativen Tracking-Datum (Excel-Regel „Fecha pago vs. Fecha envío").
  4. Periodensperre: fn_guard_closed_period() blockt Auto-Posting in closed-Perioden; Buchung fällt dann in die offene Folgeperiode bzw. erfordert ADMIN-Reopen.
  5. Storno-Kaskade: Ursprung storniert → void der gekoppelten movement (+ payment_links cascade über payment-void); nie hartes Delete.
  6. 🔲 offene Buchungs-Entscheide: AP-6 (verfallenes Depot als INCOME-Ertrag vs. neutral) und MWST brutto/netto (total_chf inkl./exkl.) — mit Treuhänder klären.

11.3 Pseudocode post_movement

// service_role; läuft serverseitig (Edge/Server Action), nie mit Client-Key
async function post_movement(ev: {
  source_type: SourceType; source_id: string;
  movement_type_code: string; payment_method_code: string;
  entry_date: string; total_chf?: number; paid_chf?: number;
  contact_id?: string; reference?: string; note?: string; due_date?: string;
}) {
  // 1) Periodensperre prüfen (fn_guard_closed_period greift zusätzlich als Trigger)
  // 2) UPSERT auf (source_type, source_id) — überschreibt bei Korrektur
  await db.from('movements').upsert(
    { ...ev, total_chf: ev.total_chf ?? 0, paid_chf: ev.paid_chf ?? 0 },
    { onConflict: 'source_type,source_id' }
  );
  // 3) Folgekanten (payment_links / invoices.movement_id / payout-Updates) je source_type
  // 4) audit_log wird durch fn_audit-Trigger automatisch geschrieben
}

12. Indizes (Gesamtüberblick)

Alle Indizes sind bei den Tabellen-DDL inline definiert. Zusammenfassung der performance-kritischen:

ZweckIndex
Fuzzy-Dedup Kontakteidx_contacts_trgm_name (GIN/pg_trgm), idx_contacts_phone, idx_contacts_email
KYC-Lookupcontacts.cedula_number (UNIQUE), idx_kyc_doc_number (JSONB-Expr)
Ledger-Routingmovements_period_idx, movements_live_idx (partial voided_at is null), movements_ref_party_idx
Auto-Posting-Idempotenzmovements_source_uniq (UNIQUE source_type,source_id), payment_links_uniq
Tracking-Hotpathidx_track_box (box_id, occurred_at), idx_track_client_event (UNIQUE partial), idx_boxes_status
Container-Boardidx_containers_status, idx_containers_dep, idx_boxes_container
Offerten/Aufträgeidx_quotes_status, idx_quotes_affiliate, orders_quote_uniq, invoices_order_uniq
Preiseidx_price_lists_one_default (UNIQUE partial), pli_unique_product_zone
Affiliateidx_rc_active, commission_entries_order_uniq, idx_ce_status
Audit-Queueidx_audit_table, idx_audit_period, idx_notif_queue (status,created_at)

13. Modulübergreifende RLS-Matrix (Rolle × Tabelle × CRUD)

Rollen-Codes (K-17): ADM=ADMIN (Marcel), BUC=BUCHHALTUNG (Mariela), OPS=OPERATIONS (Markus), FAH=FAHRER (Arkys), AFF=AFFILIATE (extern), RO=READONLY (Treuhänder/Gast). Annahme: enable row level security auf allen Tabellen, deny-by-default. service_role (Edge-/Server-Functions) umgeht RLS und macht alle Auto-Posting-/Notification-/OCR-Writes. Policies prüfen via has_role('CODE'), is_internal(), current_affiliate_id().

Legende: Create · Read · Update · Delete · kein Zugriff · R* eingeschränkt/maskiert · R(own)/U(own) nur eigene Zeilen · (svc) nur via service_role.

TabelleADMBUCOPSFAHAFFRO
contactsCRUDCRUCRUR* (id,name,phone,status)R(own)¹R* (kein cedula/scan)
contact_rolesCRUDRCRURR
recipientsCRUDRCRUR* (Lieferfelder)R* (maskiert)
contact_duplicatesCRUDRCRU
contact_merge_logRR
kyc_scansCRUDRCRUC+R(own)²
kyc_field_correctionsCRRCR
whatsapp_inboundRURURU
box_productsCRUDRCRU³RRR
zonesCRUDRCRU³RRR
price_listsCRUDRCRU³RRR
price_list_itemsCRUDRCRU³RRR
product_depot_ratesCRUDRCRU³RRR
quotesCRUD⁴RCRU⁴R(own)⁵
quote_linesCRUD⁴RCRU⁴R(own)⁵
ordersCRUDRRU (status)RR
invoicesCRUDCRURR
shipmentsCRUDRCRURU (status)R
boxesCRUDRCRURU (condition/status via fn)⁶R
tracking_eventsCRCRCRCRR
containersCRUDRCRURR
deposit_ordersCRUDCRCRRR
deposit_paymentsCRUDCRCRR
deposit_refundsCRUDCRCRR
movementsR+U⁸R+U⁸R⁹R(own)¹⁰R
payment_linksCRUCRURR
monthly_closingsCRU (close/reopen)CRU (open↔review)RR
affiliatesCRUDRRUR(own)R
referral_codesCRUDRRU* (is_active,desc,valid_until)R(own)R* (nur aktive)
commission_entriesCRUDRRU (status,notes)R(own)R
commission_payoutsCRUDRRU (notes)R(own)R
app_usersCRU (alle)R(internal)+RU(self)R(internal)+RU(self)RU(self)RU(self)R(internal)
user_rolesCRUDRRR
affiliate_usersCRUDRRR(own)
rolesCRURRRRR
locale_stringsCRUDRRRRR(+anon)
movement_types / payment_methods / vat_ratesCRURRRRR
**invoice_statuses / _statuses / _kinds / kyc_document_typesCRURRRRR
audit_logRRRRR(internal)
storage_objectsCRUDR+CU(receipts/docs)CU(kyc/docs)+RR*(delivery_proof)R(own owner)¹¹R(docs)
notificationsRRRR(own addressee)R

Fussnoten zur Matrix:

  1. contacts/AFF: ein Affiliate sieht keine fremden Kunden. R(own) greift nur, falls ein Affiliate-Login auf einen eigenen Kontaktsatz gemappt ist; Kundendaten anderer Parteien bleiben unsichtbar.
  2. kyc_scans/FAH: Fahrer darf einreichen (Kamera-Upload für zugewiesenen Auftrag) und nur eigene Scans lesen — keine fremden Ausweisdaten.
  3. Produkte/OPS: Schreiben (Preislisten-Pflege) ist im Brief OPERATIONS zugeordnet; finaler Schreibumfang 🔲 (CRM/Produkte nannte „admin/manager"). Default hier: OPS darf Katalog/Preise pflegen, ADM zusätzlich löschen.
  4. quotes/ACCEPTED+: U nur bei status IN ('DRAFT','SENT'). Übergänge ACCEPTED→CONVERTED + Order-/Invoice-Anlage nur via Server Action (service_role), nie per Client-RLS. Kein D für ACCEPTED/CONVERTED (Revisionssicherheit).
  5. quotes/AFF: nur Offerten mit eigenem referral_code_id/affiliate_id (= current_affiliate_id()); kein Einblick in Beträge fremder Offerten.
  6. boxes/FAH: current_status nicht per freiem UPDATE — nur über fn_record_tracking_event() (SECURITY DEFINER, schreibt Event + Status atomar). Direktes Status-UPDATE per Spalten-Policy/Trigger blockiert.
  7. tracking_events/ALLE: kein UPDATE, kein DELETE für irgendeine Rolle (OR 957). Insert-Policy with check (actor_id = auth.uid()). Korrektur = Storno-Event.
  8. movements/ADM+BUC: U nur wenn source_type='manual' und Periode nicht closed. Quellen-erzeugte Zeilen read-only.
  9. movements/OPS: lesend (operative Übersicht), kein Schreiben im Ledger.
  10. movements/FAH: nur eigene Logistik-Quellen (source_type IN ('deposit_order','deposit_payment')).
  11. storage_objects/AFF: nur Objekte, deren Owner-Offerte ihm gehört (Policy-Join auf quotes.affiliate_id = current_affiliate_id()); KYC-Bucket nie.
  12. FAHRER-Zeilenfilter (BR-05, baut auf G5 §7 auf): shipments/boxes/tracking_events sind für FAH zeilengefiltert — sichtbar/bearbeitbar nur, wenn der eingeloggte Fahrer einer der drei dispositiven Fahrer der Sendung ist: using (has_role('FAHRER') and auth.uid() in (pickup_driver_id, transport_driver_id, delivery_driver_id)) (für boxes/tracking_events via shipment_id-Join). Ohne diesen Filter wäre die RU-Zeile oben unbeschränkt; erst damit ist die IST-Regel „Fahrer sieht nur eigene Sendungen" erfüllt.

Querschnitts-Invarianten (gelten überall):

  • audit_log unveränderbar: revoke update,delete + Rules — auch für ADMIN und service_role.
  • Periodensperre: fn_guard_closed_period() an allen finanzrelevanten Tabellen (movements, invoices, deposit_*, payment_links); Mutation in closed-Periode nur nach ADMIN-Reopen.
  • service_role nur serverseitig — alle privilegierten Writes (Auto-Posting, Notifications, OCR, Merge) laufen in Edge-/Server-Functions; nie im Client.
  • AFFILIATE-Mandantentrennung ist hart: ein Affiliate sieht ausschliesslich eigene vermittelte Offerten/Sendungen/Provisionen/Dokumente — kein Kunden- oder Finanz-Gesamtzugriff.
  • FAHRER hat keinerlei Finanz-/Stammdaten-Schreibrechte ausser Tracking-Events + KYC-Upload, und sieht shipments/boxes/tracking_events nur zeilengefiltert auf ihm zugeordnete Sendungen (Fussnote 12, BR-05).

14. Offene Punkte (modulübergreifend konsolidiert)

🔲 Buchung verfallenes Depot (AP-6): INCOME-Ertrag vs. finanziell neutral — Treuhänder/MWST-Frage (Logistik + Finanzen). ✅ MWST (entschieden 2026-06-28): aktiv, Preise inkl./brutto — MWST wird herausgerechnet, Satz je Bewegung als vat_rate_percent eingefroren (Modul 6). Satz-Zuordnung je Leistung noch 🔲. 🔲 Schreibrechte Produkte/Preise: OPERATIONS schreibend (hier angenommen) vs. nur ADMIN/BUCHHALTUNG — Rollen-Feinschliff (Produkte vs. RLS-Brief). 🔲 Box↔Depot-Kardinalität: 1:1 (deposit_orders.box_id) vs. Mengen-Depot (N:M wie Excel DEPOT.cantidad) — Logistik. 🔲 Telefon-Uniqueness: Soft-Warning vs. harter UNIQUE auf contacts.phone_primary — CRM. 🔲 Provisions-Trigger-Zeitpunkt: Provision bei invoices.status='PAID' (Vollzahlung) vs. erste Teilzahlung vs. Sendung DELIVERED — Affiliate. ✅ Affiliate-IBAN-Verschlüsselung (entschieden 2026-06-28): affiliates.payout_iban via pgsodium/Supabase Vault (Column-Encryption); Entschlüsselung nur server-seitig für ADMIN/BUCHHALTUNG (Auszahlung), nie in Logs/Responses. 🔲 OCR-Provider & WhatsApp-Modus: Anbieter (ocr_provider-Werte) + Direct-Meta-API vs. BSP — KYC. ✅ DR-Lager-Status (entschieden 2026-06-28): DR_DEPOT als eigener Tracking-Schritt (sort_order 9, DELIVERED→10). ✅ Fahrer-FK-Ziel (E10b, entschieden 2026-06-28): alle Fahrer bedienen die App → FK shipments.{pickup,transport,delivery}_driver_idapp_users bleibt (jeder Fahrer hat einen Caja-Login). Keine contacts/drivers-Variante nötig. ✅ Mehrwährung (entschieden 2026-06-28): CHF (Berichtswährung) + DOP; movements.currency/fx_rate/total_orig + currencies-Lookup; DOP-Zahlungen via Revolut Business (Modul 6 §10). 🔲 Anfangssalden: Saldo inicial caja/banco als monthly_closings-Opening der ersten Periode vs. Settings-Wert — Finanzen. 🔲 2FA für Finanzrollen: zusätzlich TOTP für ADMIN/BUCHHALTUNG neben Magic-Link — Plattform. 🔲 Auth-User-Merge: Verhalten beim Mergen eines Kontakts mit app_users/affiliate_users-Login (Source-Account deaktivieren?) — CRM + Plattform.