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); Geldnumeric(12,2)in CHF; Zeittimestamptz; Audit-Spaltencreated_at/updated_at(Trigger) +created_by/updated_by. Stabile Fachcodes sind TEXT (nie Postgres-enumfü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) undcreate domain pct as numeric(5,2)(Prozentsätze: MWST, Provision, Rabatt). Neue Spalten nutzen diese Domänen; bestehendenumeric(12,2)-Geldspalten sind wertgleich und werden bei der Migration aufchfumgestellt. Ausnahme: Wechselkurse/Faktoren (fx_rateu. ä.) 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-01 | contacts: 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-02 | contacts-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-03 | contacts.phone: CRM phone_primary; OCR/Offerten/Logistik referenzieren mal phone, mal phone_primary | Kanonisch phone_primary / phone_secondary. phone existiert nicht. WhatsApp-Matching nutzt whatsapp_number (fällt auf phone_primary zurück). |
| K-04 | recipients: 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-05 | movements.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-06 | payment_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-07 | box_products: Produkte nutzt label_de/es/en; Offerten referenziert name_de/es/en | Kanonisch 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-08 | Zielzonen: Produkte = zones; Logistik referenziert delivery_zones | Kanonisch zones (eine Tabelle, §3.4). delivery_zones ist nur ein Logistik-Alias → FKs heissen zone_id REFERENCES zones(id). |
| K-09 | orders: Logistik order_no/customer_id; Offerten order_number/contact_id; Affiliate-ALTER affiliate_id | Eine 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-10 | quotes: 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-11 | invoices: von keinem Modul vollständig definiert (nur referenziert) | Hier kanonisch definiert (§2.7): Kopf + abgeleiteter Saldo-Bezug; speist movements.INVOICE via Auto-Posting. |
| K-12 | Preis-Snapshot-FK: Produkte price_list_items + price_list_item_id; Offerten price_list_entries + price_list_entry_id | Kanonisch price_list_items / price_list_item_id. price_list_entries/entry_id (Offerten-Jargon) wird überall ersetzt. |
| K-13 | Depot: 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-14 | Provision: Affiliate commission_entries/commission_payouts; Offerten nennt affiliate_commissions | Kanonisch commission_entries + commission_payouts. affiliate_commissions (Offerten-Jargon) = commission_entries. |
| K-15 | audit_log: Plattform-Vollschema (bigint identity, actor_email, Rules) vs. lose Referenzen | Plattform-Definition gewinnt (§6.1). Append-only via DB-Rules; nur Trigger schreibt. |
| K-16 | i18n: 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-17 | Rollen-Codes: Finanzen (marcel/mariela/markus/arkys) vs. generisch (admin/ops/office/driver) vs. Plattform-Codes | Kanonisch Plattform-Codes (§5): ADMIN, BUCHHALTUNG, OPERATIONS, FAHRER, AFFILIATE, READONLY. Personen-Namen sind nur Referenz. RLS prüft via has_role('CODE'). |
| K-18 | Auth-Spiegel: contacts vs. app_users | Getrennt. 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-19 | created_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-20 | Tracking-/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-21 | discount_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.
| Gruppe | Tabellen |
|---|---|
| Plattform/Identität | app_users, user_roles, affiliate_users, roles, locale_strings, audit_log, app_settings, monthly_closings, storage_objects, notifications |
| CRM | contacts, contact_roles, recipients, contact_duplicates, contact_merge_log |
| KYC/WhatsApp | kyc_scans, kyc_field_corrections, whatsapp_inbound, kyc_document_types |
| Produkte & Preise | box_products, zones, price_lists, price_list_items, product_depot_rates |
| Offerten | quotes, quote_lines |
| Logistik | orders, shipments, containers, boxes, tracking_events, tracking_statuses, tracking_phases, box_conditions, shipment_statuses, container_statuses, order_statuses |
| Depot | deposit_orders, deposit_payments, deposit_refunds, deposit_refund_kinds |
| Finanzen | movements, payment_links, invoices, movement_types, payment_methods, vat_rates, currencies |
| Affiliate | affiliates, referral_codes, commission_entries, commission_payouts |
| Echte Enums | discount_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 aufcode— z. B.quote_statuses,invoice_statuses,order_statuses,shipment_statuses,container_statusesmitDRAFT/SENT/OPEN/PARTIAL/PAID/.... Inline-status-Spalten mitcheck (...)-Enum-Werten (kein eigener Lookup) sind lowercase — das betrifft genauaffiliates.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-statusschreibt → lowercase. Modul-Docs mit lowercasequote_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_numberist 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_numberbleibt harter Unique-Key.Telefon-/Alias-Mapping (IST → Caja): IST hatte
Phone/PhoneExtension+ drei Mobilnummern (MobilePhone/2/3). Caja konsolidiert aufphone_primary+phone_secondary+whatsapp_number; eine selten genutzte dritte Nummer wird bei der Migration aufphone_secondary(falls frei) bzw.notesabgebildet,PhoneExtensionentfällt (DR-Logistik).alias(ISTPeople.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
quotesist der Empfänger Freitext (recipient_name/recipient_address, oft vor KYC, nur fürs PDF) und hat keinen FK aufrecipients. Der strukturierte DR-Empfänger entsteht erst bei der Sendungsbildung alsrecipients-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 zurrecipients-Zeile. 🔲 Optionalequotes.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_linesder konvertierten Offerte (orders.quote_id→quotes→quote_lines); die physische Materialisierung erfolgt direkt inboxes(je Position N Boxen, mit Produkt-Snapshot auf der Box viabox_product_id).orders.total_chfist nur ein read-only-Spiegel der akzeptierten Offerte.invoicesist kopflos (nurtotal_chf). 🔲invoice_lines(Rechnungspositionen) erst nötig, wenn MWST je Position/Satz aktiviert wird — dann analogquote_lineszu 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
invoicesgespeichert, sondern aus dem Ledger abgeleitet (v_debitoren, überpayment_linksbzw.INVOICE-movement). So bleibt es konsistent mit der Single-Source-of-Truth-Regel des Finanzmoduls.F-04 —
invoices.statuswird per Trigger nachgeführt (bewusste Ausnahme von „Status nur aus Views"). Ein Trigger aufpayment_links/movementsrechnet jeINVOICE-Bewegung die Summe der zugeordneten Zahlungen (Σ payment_links.amount_chfauf dasmovement_idder Rechnung; FallbackΣ INVOICE_PAYMENTmit gleicherreference + contact_id):
Σ Zahlungen ≥ invoices.total_chf→status = 'PAID'0 < Σ Zahlungen < total_chf→status = 'PARTIAL'Σ Zahlungen = 0→ bleibtOPENDieser Übergang
→ 'PAID'ist der einzige definierte Auslöser des Affiliate-Provisions-Triggers (§8 / Modul 16 §4.5: beiinvoices.status → 'PAID'undorders.affiliate_id IS NOT NULL→INSERT commission_entries(status='pending')). 🔲 Trigger-Zeitpunkt (Voll- vs. Teilzahlung vs. SendungDELIVERED) 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
);
| code | label_de | label_es | label_en | transfer | recv | pay | deposit | affects_debt | cash_sign |
|---|---|---|---|---|---|---|---|---|---|
DEPOSIT_CASH | Anzahlung Kasse | Depósito caja | Cash deposit | – | ✓ | – | ✓ | ✓ | +1 |
INVOICE | Rechnung | Factura | Invoice | – | ✓ | – | – | ✓ | +1 |
INVOICE_PAYMENT | Rechnungszahlung | Abono factura | Invoice payment | – | – | – | – | – | +1 |
RECEIVABLE | Forderung | Deuda | Receivable | – | ✓ | – | – | ✓ | 0 |
INCOME | Einnahme | Ingreso | Income | – | – | – | – | – | +1 |
EXPENSE | Ausgabe | Gasto | Expense | – | – | ✓ | – | – | −1 |
SUPPLIER_PAYMENT | Lieferantenzahlung | Pago proveedor | Supplier payment | – | – | ✓ | – | – | −1 |
SUPPLIER_DEBT | Lieferantenschuld | Deuda proveedor | Supplier debt | – | – | ✓ | – | ✓ | −1 |
CASH_TO_BANK | Kasse → Bank | Caja → Banco | Cash → Bank | ✓ | – | – | – | – | −1 |
BANK_TO_CASH | Bank → Kasse | Banco → Caja | Bank → Cash | ✓ | – | – | – | – | +1 |
BANK_TO_TWINT | Bank → Twint | Banco → Twint | Bank → Twint | ✓ | – | – | – | – | 0 |
TWINT_TO_BANK | Twint → Bank | Twint → Banco | Twint → Bank | ✓ | – | – | – | – | 0 |
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 ProduktOTRO(Ü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 alsapp_settings.volume_tariff_unitbzw. 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_idist beimanualNULL → Unique greift nicht (mehrere manuelle Buchungen erlaubt); quellen-erzeugte sind eindeutig. Hartes DELETE verboten (RLS), Korrektur nur viavoided_at.F-18 —
vat_amount_chfwird in der schreibenden Schicht berechnet und auf der Quell-Zeile gespeichert, nicht in einer View.movements.vat_amount_chf(ebensoinvoices.vat_amount_chf/quotes.vat_amount_chf) ist eine persistierte Spalte. Berechnung beim INSERT/Upsert der Quell-Zeile: Server-Action bzw.post_movement()liestvat_rates.rate_pctzumvat_codeund schreibtvat_amount_chfmit. Sie ist keine der abgeleiteten View-Spalten (§4.4) — diese bleiben read-only-Ableitungen aus den Eingabewerten. Darate_pctin einer Fremdtabelle liegt, kommt einegenerated columnnicht in Frage; der Schreibpfad (Action/Trigger) ist die einzige Stelle. Idempotenz bleibt gewahrt: der Upsert auf(source_type, source_id)überschreibt denselben berechneten Wert.
4.2 payment_links (explizite Zahlung→Rechnung-Kante)
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_chfderselbendeposit_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 aufFORFEITEDum (amount_chf=0). Verfall ist finanziell neutral — keine Ertragsbuchung (keinINCOME), 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 inlocale_strings. Beide ausGLOSSAR-ERP_DE-ES-EN.csvgeseedet. Die in Affiliate §3.7 / Design §3.1 gezeigteni18n_labels-Inserts gehen 1:1 inlocale_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 sindnullable+on delete set null→ in der Migration zuerst beide Tabellen ohne den jeweils anderen FK anlegen, dann FKs peralter tableergä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 viaclient_event_id) — DDL unverändert aus Modul 14 §4.2.F-08 — Empfänger/KYC-Gate läuft über
shipments.recipient_id, nicht überboxes.boxeshat keine Spalterecipient_contact_id(eine ältere Modul-2/45-Formulierung referenzierte eine Phantom-Spalte). Die reale Kette istboxes.shipment_id → shipments.recipient_id → recipients; KYC-relevant istrecipients.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 derrecipients-Zeile der Sendung, nicht auf der Box. (recipients.contact_idist nullable → bei fehlendem Golden Record greiftrecipients.cedula_numberdirekt.)IST-Gap-Folds (aus Alt-ERP-Analyse,
docs/legacy-ist/95-caja-gap-analyse.md): Die Feldershipments.{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ürOTRO),tracking_events.signer_name+ Storage-doc_classsignature/customs_report/shipment_list(G6/BR-28, G2/BR-39) sowieapp_settings(G-Settings — ersetzt ISTGlobalSettings) 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 (analogquote_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 Überganginvoices.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; SendungDELIVEREDkann als fachliche Voraussetzung dienen, ist aber kein DB-Trigger); (c) Auszahlung→ 'paid'beim Payout (AP-9). „Fällig wennDELIVERED" (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.
- Extensions:
pg_trgm,pgcrypto(fürgen_random_uuid). - Echte Enums:
contact_status,kyc_status,discount_kind,commission_kind. - 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). - Identität:
app_users→user_roles. (affiliate_userserst nachaffiliates.) - CRM:
contacts(ohnekyc_scan_id-FK) →contact_roles,contact_duplicates,contact_merge_log. - Produkte:
box_products,zones,price_lists→price_list_items,product_depot_rates. - KYC:
whatsapp_inbound(ohnekyc_scan_id-FK) →kyc_scans→kyc_field_corrections; dannalter:contacts.kyc_scan_id,whatsapp_inbound.kyc_scan_id. - Affiliate-Stamm:
affiliates→referral_codes; dannalter:affiliate_users. - Offerten/Aufträge:
quotes(ohneorder_id-FK) →orders→alter:quotes.order_id. (quotes.affiliate_id/referral_code_idschon vorhanden, da referenzierte Tabellen aus 8 existieren.) - Rechnungen:
invoices(ohnemovement_id-FK). - Logistik:
containers→shipments→boxes→tracking_events. - Depot:
deposit_orders→deposit_payments,deposit_refunds. - Finanzen:
movements→payment_links,monthly_closings; dannalter:invoices.movement_id. - Provision:
commission_payouts→commission_entries. - Plattform-Rest:
storage_objects,notifications,audit_log(+ Rules/Grants). - 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. - 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. - Seeds: Lookups +
locale_stringsausGLOSSAR-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_type | source_id | movement_type_code | Betrag (total_chf / paid_chf) | Kanal (payment_method_code) | contact_id | period_key aus |
|---|---|---|---|---|---|---|---|---|
| AP-1 | Rechnung gestellt — invoices angelegt (4→6) | invoice_issued | invoices.id | INVOICE | total=invoices.total_chf, paid=0 | OPEN (open) | invoices.contact_id | issue_date |
| AP-2 | Kundenzahlung erfasst (6) | debtor_payment | payment.id | INVOICE_PAYMENT | paid=Betrag | reale Zahlweise | Kunde | Zahldatum |
| AP-3 | Depot-Order angelegt — Soll (5) | deposit_order | deposit_orders.id | DEPOSIT_CASH | total=deposit_total_chf, paid=0 | OPEN | Kunde | created_at/order_date |
| AP-4 | Depot-Zahlung erfasst (5/6) | deposit_payment | deposit_payments.id | DEPOSIT_CASH (Abono) | paid=amount_chf | payment_methods.channel der Zahlweise | Kunde | pay_date |
| AP-5 | Box zurück → Depot erstattet RETURNED/PARTIAL (5) | deposit_refund | deposit_refunds.id | EXPENSE | paid=amount_chf (Ausgang) | Rückzahl-Zahlweise | Kunde | refund_date |
| AP-6 | Depot verfallen FORFEITED (5) | deposit_refund | deposit_refunds.id | keine Buchung (Entscheid 2026-06-28: finanziell neutral, kein INCOME) | nur Depot-Abschluss, 0 Cash | — | Kunde | refund_date |
F-02: entfällt — Lieferantenrechnung ist manuelle Buchung (source_type='manual', SUPPLIER_DEBT/EXPENSE), kein Auto-Posting (keine Quell-Tabelle) | — | — | — | — | — | — | — | |
F-02: entfällt — Lieferantenzahlung ist manuelle Buchung (source_type='manual', SUPPLIER_PAYMENT) + ggf. payment_links auf die SUPPLIER_DEBT | — | — | — | — | — | — | — | |
| AP-9 | Affiliate-Payout bezahlt — commission_payouts.status→paid (7) | commission_payout | commission_payouts.id | SUPPLIER_PAYMENT | paid=total_amount_chf | payout.payment_method | affiliate.contact_id | paid_at |
F-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ätzlichpayment_links(payment_id, target_id=INVOICE-movement, amount). - F-14: AP-3 setzt
deposit_orders.movement_id(die Soll-Bewegung). AP-4 erzeugt zusätzlichpayment_links(payment_id=DEPOSIT_CASH-Abono-movement, target_id=DEPOSIT_CASH-Soll-movement, amount=amount_chf)— die Soll-Bewegung (AP-3) wird überdeposit_payments.deposit_order_id → deposit_orders → deposit_orders.movement_id(= AP-3-source_id-Buchung) eindeutig gefunden, nicht perreference-Match. Dadurch findetv_anzahlungen.pending_chfSoll und Abono sicher zusammen (analog AP-2/INVOICE). AP-4 setzt zudemdeposit_orders.status(PARTIAL/PAID) viav_open_box_deposits. - AP-9 setzt
commission_payouts.movement_idundcommission_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
- Unique
(source_type, source_id)aufmovementsist das Upsert-Target → ein Event = max. eine Buchung; Re-Delivery/Race überschreibt statt zu duplizieren. - Kanal nie per Textmatch — immer
payment_methods.channel.channel='open'⇒ Zeile nicht zahlungswirksam (cash_in=cash_out=bank=0), erzeugt aberopen_debt(Debitor/Kreditor). period_keyaus dem Finanz-Datum (Zahl-/Refund-/Belegdatum), nie aus dem operativen Tracking-Datum (Excel-Regel „Fecha pago vs. Fecha envío").- Periodensperre:
fn_guard_closed_period()blockt Auto-Posting inclosed-Perioden; Buchung fällt dann in die offene Folgeperiode bzw. erfordert ADMIN-Reopen. - Storno-Kaskade: Ursprung storniert →
voidder gekoppeltenmovement(+payment_linkscascade über payment-void); nie hartes Delete. - 🔲 offene Buchungs-Entscheide: AP-6 (verfallenes Depot als
INCOME-Ertrag vs. neutral) und MWST brutto/netto (total_chfinkl./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:
| Zweck | Index |
|---|---|
| Fuzzy-Dedup Kontakte | idx_contacts_trgm_name (GIN/pg_trgm), idx_contacts_phone, idx_contacts_email |
| KYC-Lookup | contacts.cedula_number (UNIQUE), idx_kyc_doc_number (JSONB-Expr) |
| Ledger-Routing | movements_period_idx, movements_live_idx (partial voided_at is null), movements_ref_party_idx |
| Auto-Posting-Idempotenz | movements_source_uniq (UNIQUE source_type,source_id), payment_links_uniq |
| Tracking-Hotpath | idx_track_box (box_id, occurred_at), idx_track_client_event (UNIQUE partial), idx_boxes_status |
| Container-Board | idx_containers_status, idx_containers_dep, idx_boxes_container |
| Offerten/Aufträge | idx_quotes_status, idx_quotes_affiliate, orders_quote_uniq, invoices_order_uniq |
| Preise | idx_price_lists_one_default (UNIQUE partial), pli_unique_product_zone |
| Affiliate | idx_rc_active, commission_entries_order_uniq, idx_ce_status |
| Audit-Queue | idx_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.
| Tabelle | ADM | BUC | OPS | FAH | AFF | RO |
|---|---|---|---|---|---|---|
| contacts | CRUD | CRU | CRU | R* (id,name,phone,status) | R(own)¹ | R* (kein cedula/scan) |
| contact_roles | CRUD | R | CRU | R | – | R |
| recipients | CRUD | R | CRU | R* (Lieferfelder) | – | R* (maskiert) |
| contact_duplicates | CRUD | R | CRU | – | – | – |
| contact_merge_log | R | R | – | – | – | – |
| kyc_scans | CRUD | R | CRU | C+R(own)² | – | – |
| kyc_field_corrections | CR | R | CR | – | – | – |
| whatsapp_inbound | RU | RU | RU | – | – | – |
| box_products | CRUD | R | CRU³ | R | R | R |
| zones | CRUD | R | CRU³ | R | R | R |
| price_lists | CRUD | R | CRU³ | R | R | R |
| price_list_items | CRUD | R | CRU³ | R | R | R |
| product_depot_rates | CRUD | R | CRU³ | R | R | R |
| quotes | CRUD⁴ | R | CRU⁴ | – | R(own)⁵ | – |
| quote_lines | CRUD⁴ | R | CRU⁴ | – | R(own)⁵ | – |
| orders | CRUD | R | RU (status) | R | – | R |
| invoices | CRUD | CRU | R | – | – | R |
| shipments | CRUD | R | CRU | RU (status) | – | R |
| boxes | CRUD | R | CRU | RU (condition/status via fn)⁶ | – | R |
| tracking_events | CR | CR | CR | CR⁷ | – | R |
| containers | CRUD | R | CRU | R | – | R |
| deposit_orders | CRUD | CR | CR | R | – | R |
| deposit_payments | CRUD | CR | CR | – | – | R |
| deposit_refunds | CRUD | CR | CR | – | – | R |
| movements | R+U⁸ | R+U⁸ | R⁹ | R(own)¹⁰ | – | R |
| payment_links | CRU | CRU | R | – | – | R |
| monthly_closings | CRU (close/reopen) | CRU (open↔review) | R | – | – | R |
| affiliates | CRUD | R | RU | – | R(own) | R |
| referral_codes | CRUD | R | RU* (is_active,desc,valid_until) | – | R(own) | R* (nur aktive) |
| commission_entries | CRUD | R | RU (status,notes) | – | R(own) | R |
| commission_payouts | CRUD | R | RU (notes) | – | R(own) | R |
| app_users | CRU (alle) | R(internal)+RU(self) | R(internal)+RU(self) | RU(self) | RU(self) | R(internal) |
| user_roles | CRUD | R | R | – | – | R |
| affiliate_users | CRUD | R | R | – | R(own) | – |
| roles | CRU | R | R | R | R | R |
| locale_strings | CRUD | R | R | R | R | R(+anon) |
| movement_types / payment_methods / vat_rates | CRU | R | R | R | R | R |
| **invoice_statuses / _statuses / _kinds / kyc_document_types | CRU | R | R | R | R | R |
| audit_log | R | R | R | R | – | R(internal) |
| storage_objects | CRUD | R+CU(receipts/docs) | CU(kyc/docs)+R | R*(delivery_proof) | R(own owner)¹¹ | R(docs) |
| notifications | R | R | R | – | R(own addressee) | R |
Fussnoten zur Matrix:
- 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. - kyc_scans/FAH: Fahrer darf einreichen (Kamera-Upload für zugewiesenen Auftrag) und nur eigene Scans lesen — keine fremden Ausweisdaten.
- Produkte/OPS: Schreiben (Preislisten-Pflege) ist im Brief
OPERATIONSzugeordnet; finaler Schreibumfang 🔲 (CRM/Produkte nannte „admin/manager"). Default hier: OPS darf Katalog/Preise pflegen, ADM zusätzlich löschen. - quotes/ACCEPTED+:
Unur beistatus IN ('DRAFT','SENT'). ÜbergängeACCEPTED→CONVERTED+ Order-/Invoice-Anlage nur via Server Action (service_role), nie per Client-RLS. KeinDfürACCEPTED/CONVERTED(Revisionssicherheit). - quotes/AFF: nur Offerten mit eigenem
referral_code_id/affiliate_id(=current_affiliate_id()); kein Einblick in Beträge fremder Offerten. - boxes/FAH:
current_statusnicht per freiem UPDATE — nur überfn_record_tracking_event()(SECURITY DEFINER, schreibt Event + Status atomar). Direktes Status-UPDATE per Spalten-Policy/Trigger blockiert. - tracking_events/ALLE: kein UPDATE, kein DELETE für irgendeine Rolle (OR 957). Insert-Policy
with check (actor_id = auth.uid()). Korrektur = Storno-Event. - movements/ADM+BUC:
Unur wennsource_type='manual'und Periode nichtclosed. Quellen-erzeugte Zeilen read-only. - movements/OPS: lesend (operative Übersicht), kein Schreiben im Ledger.
- movements/FAH: nur eigene Logistik-Quellen (
source_type IN ('deposit_order','deposit_payment')). - storage_objects/AFF: nur Objekte, deren Owner-Offerte ihm gehört (Policy-Join auf
quotes.affiliate_id = current_affiliate_id()); KYC-Bucket nie. - FAHRER-Zeilenfilter (BR-05, baut auf G5 §7 auf):
shipments/boxes/tracking_eventssind fürFAHzeilengefiltert — 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ürboxes/tracking_eventsviashipment_id-Join). Ohne diesen Filter wäre dieRU-Zeile oben unbeschränkt; erst damit ist die IST-Regel „Fahrer sieht nur eigene Sendungen" erfüllt.
Querschnitts-Invarianten (gelten überall):
audit_logunverä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 inclosed-Periode nur nach ADMIN-Reopen. service_rolenur 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.FAHRERhat keinerlei Finanz-/Stammdaten-Schreibrechte ausser Tracking-Events + KYC-Upload, und siehtshipments/boxes/tracking_eventsnur 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_id → app_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.