Finanzen (movements-Ledger + Auto-Posting)

1. Zweck & Scope

Dieses Modul ist das finanzielle Herz von Caja und die Single Source of Truth für jeden Geldfluss der Dominicano Express GmbH. Es ersetzt das Excel-Blatt Erfassung_Digitar (Version 2, DIGITAR-Prinzip) durch eine movements-Tabelle, in die jede operative Erfassung genau einmal landet — manuell oder automatisch erzeugt durch operative Events (Offerte angenommen, Auftrag, Rechnung, Zahlung, Depot, Provision). Alle abgeleiteten Grössen (Kasse-Eingang/-Ausgang, Bank, offene Schuld, Status-Ampel, Monatskey, laufender Saldo) sind nicht gespeichert, sondern als GENERATED-Spalten bzw. Views berechnet — exakt nach der bewährten Excel-Routing-Logik, aber relational sauber (Zahlungen sind explizit an ihre Rechnung/ihr Depot verknüpft statt per Ref+Partei-String-Match). Buchhaltung = Cash-Basis + Offene Posten, keine doppelte Buchführung; das Modul speist die formale Buchhaltung (Treuhänder-Export, MWST-Kennzahlen, Monats-/Jahresreport), ersetzt aber nicht den rechtsgültigen Jahresabschluss. MWST ist seit dem Finanz-Entscheid vom 2026-06-28 scharf (app_settings.vat_enabled=true): Das System ist MwSt-fähig (Methode inclusive für allfällige steuerbare Inland-Leistungen, eingefrorener Satz, §3.8). Die Kern-Versandleistung (grenzüberschreitende Beförderung CH → DR / Ausfuhr) ist jedoch MwSt-BEFREIT (echte Befreiung Art. 23 MWSTG → 0 %, vat_code='EXEMPT', Entscheid 2026-06-28 Teil 3): kein MwSt-Aufschlag, nichts herausgerechnet, vat_amount_chf=0. Der Normalsatz (8.1 %) greift nur für allfällige steuerbare Inland-Zusatzleistungen. Einfuhrsteuer/eVV bleibt schema-ready (🔲). Das Ledger ist mehrwährungsfähig (CHF + DOP) mit Berichtswährung CHF (§3.9), DOP-Zahlungen laufen über Revolut Business (§10). Revisionssicherheit nach OR 957 / GeBüV: Monatsabschluss sperrt die Periode, jede Nachbuchung ist nur auditiert möglich, Bewegungen aus Quellen werden nie gelöscht — nur der Ursprung wird korrigiert (Upsert).


2. Domänenmodell

Kern ist movements (das Ledger). Jede Zeile trägt ihre fachlichen Eingabewerte (Datum, Referenz, Partei, Vorgangsart, Zahlweise, Gesamtbetrag, Bezahlt/Angezahlt, Notiz, Fälligkeit), die Währung (currency, Default CHF) + Kurs (fx_rate zu CHF), den MWST-Satz (eingefroren als vat_rate_percent + vat_amount_chf) plus Herkunft (source_type + source_id) für Idempotenz. Alles Rechnerische ist abgeleitet.

Entitäten & Beziehungen:

  • movements — n:1 zu contacts (Partei), movement_types (Vorgangsart-Code), payment_methods (Zahlweise-Code), vat_rates (MWST-Satz, optional), monthly_closings (über period_key). Optionaler Self-Link transfer_group_id paart die zwei Beine eines internen Transfers (z. B. CASH_TO_BANK).
  • movement_types — Lookup mit stabilem code (EN) + DE/ES/EN-Labels + Verhaltens-Flags (is_internal_transfer, is_receivable, is_payable, is_deposit, cash_sign).
  • payment_methods — Lookup mit stabilem code + DE/ES/EN-Labels + channel ∈ {cash, bank, open} (entscheidet Kasse/Bank — harte Spec-Regel statt Textmatch).
  • payment_linksexplizite m:n-Verknüpfung einer Zahlung (movement vom Typ INVOICE_PAYMENT / SUPPLIER_PAYMENT) auf ihre Ziel-Rechnung/-Schuld (movement vom Typ INVOICE / RECEIVABLE / SUPPLIER_DEBT / DEPOSIT_CASH). Ersetzt den Excel-Ref+Partei-Match durch eine prüfbare Kante. (Migrations-Fallback: parent_ref + parent_contact_id.)
  • vat_rates — effektiv-datierte MWST-Sätze (CH-Sätze), seit 2026-06-28 aktiv (active=true); enthält neben dem Normalsatz (8.1 %, STD) den Code EXEMPT (0 %, echte Ausfuhr-Befreiung Art. 23 MWSTG) — Default für die Versandleistung. Der zum Buchungszeitpunkt gültige Satz wird je Bewegung als vat_rate_percent eingefroren (bei EXEMPT: 0).
  • monthly_closings — eine Zeile je period_key (MM/YYYY), hält status (open|review|closed), Sperr-Zeitpunkt, Anfangs-/Endsaldo-Snapshot.
  • audit_log (geteilt mit Modul Plattform) — unveränderbares Journal jeder Mutation an movements und monthly_closings.

ER-Skizze (mermaid):

Diagramm wird geladen …

Auto-Posting-Quellen (operative Module → genau ein movements-Eintrag je Quelle):

Event (Herkunftsmodul)source_typeerzeugt movement_type
Rechnung gestellt (Modul 4/5/6)invoice_issuedINVOICE (einmalige Forderungsbuchung, F-06)
Kundenzahlung (Modul 6)debtor_paymentINVOICE_PAYMENT
Depot-Order angelegt (Modul 3/5)deposit_orderDEPOSIT_CASH (Soll)
Depot-Zahlung (Modul 5/6)deposit_paymentDEPOSIT_CASH (Abono)
Depot-Erstattung/-Verfall (Modul 5)deposit_refundEXPENSE (Rückzahlung) / INCOME (Verfall)
Affiliate-Payout bezahlt (Modul 7)commission_payoutSUPPLIER_PAYMENT
Manuelle Erfassung (inkl. Lieferantenrechnung/-zahlung, F-02)manualbeliebig (EXPENSE/SUPPLIER_PAYMENT/SUPPLIER_DEBT)
Excel-Migrationimportbeliebig

F-06 — keine Offerten-Vormerkung: Die frühere Zeile quote_accepted → RECEIVABLE entfällt. Die Forderung wird genau einmal bei Rechnungsstellung (invoice_issued → INVOICE) gebucht; eine zusätzliche RECEIVABLE bei Offerten-Annahme würde dieselbe Forderung in v_debitoren/v_dashboard doppelt zählen. F-02 — Kreditoren manuell: Lieferantenrechnungen/-zahlungen haben keine Quell-Tabelle und werden als manuelle movements (source_type='manual', EXPENSE/SUPPLIER_PAYMENT/SUPPLIER_DEBT) erfasst. 🔲 strukturierte Kreditoren-Entität als spätere Option.


3. Supabase-Schema

Konvention: alle CHF-Beträge numeric(12,2); Berichtswährung = CHF (jede Bewegung führt zusätzlich currency + fx_rate für Originalwährung, §3.9). Stabile Codes sind TEXT-PKs der Lookups (nie Fachbegriff in Logik verdrahten — immer FK auf code). Zeitstempel timestamptz. Soft-Delete via voided_at (kein hartes DELETE für quellen-erzeugte Zeilen).

3.1 Lookups (stabile Codes + DE/ES/EN-Labels)

movement_types — Quelle: Glossar + Erfassung_Digitar-Dropdown. Kanonische DDL: Dok 30 §3.1 (movement_types, K-06 — Plattform-Heimat, Finanzen-Superset) — hier nur der fachliche Auszug. Stabiler code (EN, nie übersetzen) + DE/ES/EN-Labels + Verhaltens-Flags is_internal_transfer, is_receivable (treibt Debitoren), is_payable (treibt Kreditoren), is_deposit (Anzahlung), cash_sign (+1 Eingang / −1 Ausgang / 0 neutral, nur bei CASH relevant). Fachliche Seed-Werte (Codes + Flags):

code (stabil)label_delabel_eslabel_entransferrecvpaydepositcash_sign
DEPOSIT_CASHAnzahlung Kasse (bar)Depósito cajaCash deposit+1
INVOICERechnungFacturaInvoice+1
INVOICE_PAYMENTRechnungszahlungAbono facturaInvoice payment+1
RECEIVABLEForderung (Kunde)DeudaReceivable0
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

payment_methodschannel ist die harte Cash/Bank-Entscheidung. Kanonische DDL: Dok 30 §3.2 (payment_methods, K-06) — hier nur der fachliche Auszug. Stabiler code + DE/ES/EN-Labels + channel ∈ {cash, bank, open} (entscheidet Kasse/Bank — Spec-Regel statt Textmatch). Fachliche Seeds:

codelabel_delabel_eslabel_enchannel
CASHBarEfectivoCashcash
BANKBankBancoBankbank
TWINTTwintTwintTwintbank
CARDKarteTarjetaCardbank
OPENOffenAbiertoOpenopen

Status-Codes (abgeleitet, nicht gespeichert — als Enum-Domäne dokumentiert):

codelabel_delabel_eslabel_enAmpel
PAIDBezahltPagadoPaid🟢
PARTIALTeilweise bezahltParcialPartially paid🟠
OPENOffenAbiertoOpen🔴
DEPOSIT_NO_PAYMENTAnzahlung ohne ZahlungDepósito sin abonoDeposit without payment🟡
INTERNAL_TRANSFERInterne UmbuchungTransferencia internaInternal transfer

vat_rates (CH, aktiv seit 2026-06-28) — Kanonische DDL: Dok 30 §3.2 (vat_rates); hier nur fachlich. Effektiv-datierte CH-Sätze (code, Labels, rate_pct, valid_from/valid_to, active); der je Bewegung gültige Satz wird als vat_rate_percent eingefroren (§3.8).

  • Seeds (CH ab 2024): STD 8.1 %, RED 2.6 %, SPECIAL 3.8 % (Beherbergung), EXEMPT 0 % (echte Befreiung Art. 23 MWSTG — Default für die Versandleistung CH→DR/Ausfuhr), ZERO 0 % (eVV-Vorsteuer-relevant/Import).
  • Entscheid 2026-06-28 Teil 3: Fracht/Versand = EXEMPT (0 %); STD 8.1 % nur für steuerbare Inland-Zusatzleistungen.

3.2 Kerntabelle movements

Kanonische DDL: Dok 30 §4.1 (movements, Owner; K-05, K-18/19) — Single Source of Truth für Spalten, Constraints, Indizes. Hier nur der fachliche Auszug (Feldbedeutung + Caja-spezifische Regeln); keine Schema-Definition reproduzieren.

Eingabewerte (entsprechen Erfassung_Digitar A..G, O, P): entry_date (A), reference (B), contact_idcontacts (C, Partei), movement_type_codemovement_types (D), payment_method_codepayment_methods (E), total_chf (F, Gesamtbetrag CHF), paid_chf (G, Bezahlt/Angezahlt CHF), note (O), due_date (P).

Mehrwährung (§3.9, Berichtswährung CHF): currencycurrencies (Default CHF), fx_rate (Kurs Original→CHF, CHF=1), total_orig/paid_orig (Originalbeträge). Es gilt total_chf = round(total_orig * fx_rate, 2); CHF bleibt die gebuchte/berichtete Grösse.

MWST (scharf seit 2026-06-28, §3.8):

  • total_chf — bei EXEMPT (Versand/Ausfuhr) der volle befreite Betrag; bei steuerbarer Leistung BRUTTO inkl. MWST (Inclusive).
  • vat_codevat_ratesDefault EXEMPT für die Versandleistung (Art. 23 MWSTG); STD nur für steuerbare Inland-Zusatzleistungen; NULL für MWST-neutrale Transfers/Altzeilen.
  • vat_rate_percenteingefrorener Satz zum Buchungszeitpunkt; entkoppelt von späteren vat_rates-Änderungen (bei EXEMPT: 0).
  • vat_amount_chf — bei EXEMPT/0 immer 0; bei steuerbarer Leistung aus dem BRUTTO herausgerechnet (total_chf - round(total_chf/(1+vat_rate_percent/100),2)). F-18: im Schreibpfad (Server-Action / post_movement) persistiert, nicht in einer View, keine generated column (Satz ist eingefroren).

Herkunft / Idempotenz: source_type (manual/import/invoice_issued/debtor_payment/deposit_order/deposit_payment/deposit_refund/commission_payout, plus revolut §10 — kanonische CHECK-Liste in Dok 30 §4.1) + source_id (Quell-Datensatz). F-02: kein creditor_invoice/creditor_payment (Kreditoren manuell). F-05: commission_payout (kanonisch, Dok 30 K-05), nicht affiliate_payout. transfer_group_id paart die zwei Beine eines internen Transfers. Unique (source_type, source_id) = genau eine Buchung pro operativer Quelle (Upsert-Target).

Revisionssicherheit: period_key (generated MM/YYYY aus entry_date), voided_at/voided_by/void_reason (Storno statt DELETE) + created/updated_*. Constraint: Beträge nie negativ.

Hinweis: source_id ist bei manual NULL — das Unique greift dann nicht (Postgres behandelt NULL als verschieden), was korrekt ist (mehrere manuelle Buchungen erlaubt; quellen-erzeugte sind eindeutig).

Kanonische DDL: Dok 30 §4.2 (payment_links) — hier nur fachlich.

Explizite Kante einer Zahlung auf ihre Ziel-Forderung: payment_idmovements (die Zahlung, INVOICE_PAYMENT / SUPPLIER_PAYMENT; on delete cascade), target_idmovements (Rechnung/Schuld, INVOICE / RECEIVABLE / SUPPLIER_DEBT / DEPOSIT_CASH; on delete restrict), amount_chf (> 0). Unique (payment_id, target_id). Ersetzt den Excel-Ref+Partei-Match durch eine prüfbare Verknüpfung (Migrations-Fallback s. §3.5).

3.4 Periodenabschluss monthly_closings

Kanonische DDL: Dok 30 §4.3 (monthly_closings) — hier nur fachlich.

Eine Zeile je period_key (MM/YYYY, PK): status (open/review/closed), Snapshot von opening_cash/closing_cash/opening_bank/closing_bank bei Abschluss, closed_at/closed_by, reopened_at/reopened_by (Reopen audit-pflichtig), notes. status='closed' sperrt die Periode (Insert/Update via RLS verriegelt, §3.7/§8).

3.5 Abgeleitete Werte — GENERATED-Spalten vs. View

Die paritätsstabile, zeilenlokale Klassifikation (Kanal, Status, cash_sign) ist als Trigger-gepflegte Spalten oder rein in einer View lösbar. Weil cash_in/cash_out/open_debt von anderen Zeilen abhängen (SUMIFS über gleiche Ref+Partei) bzw. von der Zahlweise-Tabelle, sind sie nicht als GENERATED ALWAYS (immutable) abbildbar → sie leben in der View v_movements (Single Point der Routing-Logik). Nur das rein zeilenlokale period_key ist eine echte generated column (s. o.).

v_movements — bildet Erfassung_Digitar!H..M 1:1 ab, aber mit channel aus payment_methods statt Textmatch:

create view v_movements as
with base as (
  select
    m.*,
    pm.channel                                  as method_channel,   -- cash | bank | open
    mt.is_internal_transfer,
    mt.is_receivable, mt.is_payable, mt.is_deposit
  from movements m
  join payment_methods pm on pm.code = m.payment_method_code
  join movement_types  mt on mt.code = m.movement_type_code
  where m.voided_at is null
)
select
  b.*,
  -- H Kasse Eingang: BANK_TO_CASH ODER (CASH & type in {DEPOSIT_CASH,INVOICE,INVOICE_PAYMENT,INCOME})
  case when b.movement_type_code = 'BANK_TO_CASH'
        or (b.method_channel = 'cash'
            and b.movement_type_code in ('DEPOSIT_CASH','INVOICE','INVOICE_PAYMENT','INCOME'))
       then b.paid_chf else 0 end                                   as cash_in_chf,
  -- I Kasse Ausgang: CASH_TO_BANK ODER (CASH & type in {EXPENSE,SUPPLIER_PAYMENT,SUPPLIER_DEBT})
  case when b.movement_type_code = 'CASH_TO_BANK'
        or (b.method_channel = 'cash'
            and b.movement_type_code in ('EXPENSE','SUPPLIER_PAYMENT','SUPPLIER_DEBT'))
       then b.paid_chf else 0 end                                   as cash_out_chf,
  -- J Bank/Twint/Karte: método in {BANK,TWINT,CARD} ODER interner Transfer
  case when b.method_channel = 'bank' or b.is_internal_transfer
       then b.paid_chf else 0 end                                   as bank_chf,
  -- K Offene Schuld: 0 bei internem Transfer; bei {DEPOSIT_CASH,INVOICE,RECEIVABLE}
  --   = max(0, total - Σ paid[gleiche Ref+Partei]); sonst 0
  case
    when b.is_internal_transfer then 0
    when b.movement_type_code in ('DEPOSIT_CASH','INVOICE','RECEIVABLE') then
      greatest(0, b.total_chf - coalesce((
        select sum(s.paid_chf) from movements s
        where s.voided_at is null
          and s.reference is not distinct from b.reference
          and s.contact_id is not distinct from b.contact_id
      ),0))
    else 0
  end                                                               as open_debt_chf,
  -- M Status-Ampel
  case
    when b.is_internal_transfer then 'INTERNAL_TRANSFER'
    when b.movement_type_code = 'DEPOSIT_CASH' and coalesce(b.paid_chf,0) = 0
      then 'DEPOSIT_NO_PAYMENT'
    when b.movement_type_code in ('DEPOSIT_CASH','INVOICE','RECEIVABLE','SUPPLIER_DEBT') then
      case
        when greatest(0, b.total_chf - coalesce((
               select sum(s.paid_chf) from movements s
               where s.voided_at is null
                 and s.reference is not distinct from b.reference
                 and s.contact_id is not distinct from b.contact_id),0)) = 0
             then 'PAID'
        when b.paid_chf > 0 then 'PARTIAL'
        else 'OPEN'
      end
    else 'PAID'
  end                                                               as status_code
from base b;

Normalisierter Pfad (Ziel): Sobald payment_links gepflegt sind, ersetzt die Restschuld-Berechnung den Ref+Partei-Subselect durch target.total_chf - Σ payment_links.amount_chf. Die View bietet beide Pfade; Ref+Partei bleibt Migrations-Fallback für Altdaten ohne Links.

3.6 Geschäftliche Views

-- laufender Kassensaldo (Erfassung_Digitar!L) — Saldo NICHT gespeichert
create view v_kasse as
select v.*,
  sum(v.cash_in_chf - v.cash_out_chf)
    over (order by v.entry_date, v.created_at, v.id
          rows between unbounded preceding and current row)        as running_cash_chf
from v_movements v
where v.cash_in_chf <> 0 or v.cash_out_chf <> 0;

-- Debitoren = offene Kundenforderungen (INVOICE/RECEIVABLE/DEPOSIT_CASH mit Restschuld)
create view v_debitoren as
select v.id, v.entry_date, v.reference, v.contact_id, v.movement_type_code,
       v.total_chf, v.paid_chf, v.open_debt_chf, v.status_code, v.due_date, v.note
from v_movements v
where v.movement_type_code in ('INVOICE','RECEIVABLE','DEPOSIT_CASH')
  and v.open_debt_chf > 0.05;

-- Kreditoren = offene Lieferantenschulden (+ Prioritäts-Sortierung)
create view v_kreditoren as
select v.id, v.entry_date, v.reference, v.contact_id, v.movement_type_code,
       v.total_chf, v.paid_chf, v.open_debt_chf, v.status_code, v.due_date, v.note,
       case
         when v.status_code = 'PAID'                           then '4 Bezahlt'
         when v.due_date is not null and v.due_date <  current_date           then '2 Überfällig'
         when v.due_date is not null and v.due_date <= current_date + 7       then '3 Bald fällig'
         else '5 Offen'
       end as priority   -- '1 Dringend' wird via manuellem Flag/Notiz gesetzt
from v_movements v
where v.movement_type_code in ('EXPENSE','SUPPLIER_PAYMENT','SUPPLIER_DEBT')
  and v.open_debt_chf > 0.05;

-- Anzahlungen (Depot) — Historie + Status
create view v_anzahlungen as
select v.id, v.entry_date, v.reference, v.contact_id,
       v.total_chf as deposit_chf, v.paid_chf, v.open_debt_chf as pending_chf, v.status_code, v.note
from v_movements v
where v.movement_type_code = 'DEPOSIT_CASH';

-- Dashboard-KPIs (Übersicht_Resumen A..H), optional per Monat gefiltert in der App
create view v_dashboard as
select
  sum(cash_in_chf)                                          as cash_in,
  sum(cash_out_chf)                                         as cash_out,
  sum(cash_in_chf) - sum(cash_out_chf)                      as cash_net,
  sum(case when bank_chf<>0 and movement_type_code in
        ('DEPOSIT_CASH','INVOICE','INVOICE_PAYMENT','INCOME','BANK_TO_CASH','BANK_TO_TWINT')
       then bank_chf else 0 end)                            as bank_in,
  sum(case when bank_chf<>0 and movement_type_code in
        ('EXPENSE','SUPPLIER_PAYMENT','SUPPLIER_DEBT','CASH_TO_BANK','TWINT_TO_BANK')
       then bank_chf else 0 end)                            as bank_out,
  (select coalesce(sum(open_debt_chf),0) from v_debitoren)  as debtors_open,
  (select coalesce(sum(open_debt_chf),0) from v_kreditoren) as creditors_open,
  (select coalesce(sum(paid_chf),0)  from v_anzahlungen)    as deposits_paid,
  (select coalesce(sum(pending_chf),0) from v_anzahlungen)  as deposits_open
from v_movements;

-- Monatskontrolle (CONTROL_MENSUAL): pro period_key Summen + Abschluss-Status
create view v_monatskontrolle as
select
  v.period_key,
  sum(v.cash_in_chf)                                   as cash_in,
  sum(v.cash_out_chf)                                  as cash_out,
  sum(v.cash_in_chf - v.cash_out_chf)                  as cash_net,
  sum(v.bank_chf)                                      as bank_total,
  coalesce(sum(v.open_debt_chf) filter
    (where v.movement_type_code in ('INVOICE','RECEIVABLE','DEPOSIT_CASH')),0) as debtors_open,
  coalesce(sum(v.open_debt_chf) filter
    (where v.movement_type_code in ('EXPENSE','SUPPLIER_PAYMENT','SUPPLIER_DEBT')),0) as creditors_open,
  coalesce(c.status,'open')                            as closing_status
from v_movements v
left join monthly_closings c on c.period_key = v.period_key
group by v.period_key, c.status;

3.7 RLS-Skizze (wer darf was)

Rollen-Codes (kanonisch, Dok 30 K-17 / Modul 17, F-09): ADMIN (Marcel), BUCHHALTUNG (Mariela), OPERATIONS (Markus), FAHRER (Arkys), AFFILIATE (extern), READONLY (Treuhänder/Gast). Die Personennamen sind nur umgangssprachliche Referenz. RLS prüft über has_role('CODE') (nicht über einen auth_role()-Personennamen-Helper).

TabelleSELECTINSERTUPDATEDELETE
movementsADMIN, BUCHHALTUNG, OPERATIONS, READONLY (alle); FAHRER: nur eigene Quellen (Depot/Logistik); AFFILIATE: keineADMIN, BUCHHALTUNG (manuell); Auto-Posting via service_role aus Edge-FunctionsADMIN, BUCHHALTUNG — nur wenn period_key nicht closed und source_type='manual'; quellen-erzeugte Zeilen read-only (nur Ursprung editieren)niemand (hartes DELETE blockiert); nur void via RPC durch ADMIN/BUCHHALTUNG
payment_linkswie movements SELECTADMIN, BUCHHALTUNG, service_roleADMIN, BUCHHALTUNGcascade über payment-void
movement_types, payment_methods, vat_ratesalle authentifizierten (Labels fürs UI)ADMINADMINADMIN
monthly_closingsADMIN, BUCHHALTUNG, OPERATIONS, READONLYADMIN, BUCHHALTUNGADMIN, BUCHHALTUNG (Abschluss/Reopen)niemand

Beispiel-Policies:

alter table movements enable row level security;

-- Lesen: Finanz-/Leitungsrollen alles; FAHRER nur eigene Logistik-Quellen
create policy mov_select on movements for select using (
  has_role('ADMIN') or has_role('BUCHHALTUNG') or has_role('OPERATIONS') or has_role('READONLY')
  or (has_role('FAHRER') and source_type in ('deposit_order','deposit_payment'))
);

-- Manuelles Update nur in offener Periode und nur manuelle Quelle
create policy mov_update on movements for update using (
  (has_role('ADMIN') or has_role('BUCHHALTUNG'))
  and source_type = 'manual'
  and not exists (select 1 from monthly_closings c
                  where c.period_key = movements.period_key and c.status = 'closed')
);

-- Kein hartes Löschen
create policy mov_no_delete on movements for delete using (false);

3.8 MWST scharf — Versand/Ausfuhr befreit (EXEMPT/0 %); inclusive nur für steuerbare Leistungen

Entscheid 2026-06-28 (Teil 3): MWST ist aktiv (app_settings.vat_enabled=true), das System bleibt MwSt-fähig (Methode vat_method='inclusive' für steuerbare Leistungen). Die Fracht-/Versandleistung ist aber MwSt-BEFREIT. Damit gilt:

  • Versandleistung = echte Befreiung (Art. 23 MWSTG → 0 %). Die grenzüberschreitende Beförderung CH → DR (Ausfuhr) ist von der Steuer befreit: vat_code='EXEMPT', vat_rate_percent=0, vat_amount_chf=0. Es wird kein MwSt aufgeschlagen und nichts aus dem Betrag herausgerechnet — total_chf ist der volle (befreite) Entgeltbetrag. Ausfuhrnachweis (Frachtdokument) wird als Beleg archiviert (Compliance §3.6).

  • Inclusive nur für steuerbare Inland-Leistungen. Nur bei einer allf. steuerbaren Leistung (z. B. rein innerschweizerisch, ohne Ausfuhrbezug) gilt der Normalsatz 8.1 % (STD) nach der Inclusive-Methode: total_chf ist dann der Bruttobetrag, aus dem die MWST herausgerechnet wird:

    -- nur wenn vat_code steuerbar ist (z.B. STD/RED/SPECIAL); bei EXEMPT/ZERO entfällt das, vat_amount_chf=0
    netto_chf      = round(total_chf / (1 + vat_rate_percent/100), 2)
    vat_amount_chf = total_chf - netto_chf
    

    Beispiel Normalsatz 8.1 %: Brutto 100.00 CHF → Netto 92.51 CHF → MWST 7.49 CHF. Für die Versandleistung (EXEMPT) gilt das nicht: 100.00 CHF → MWST 0.00 CHF.

  • Eingefrorener Satz je Bewegung. Beim Schreiben wird der zum entry_date gültige vat_rates.rate_pct als vat_rate_percent auf der Zeile eingefroren (Spalte in Dok 30 §4 ergänzt) und vat_amount_chf daraus berechnet (bei EXEMPT: 0). Spätere Satzänderungen berühren bestehende Buchungen nicht — der historische Satz/Status bleibt rechtssicher erhalten. Das ist bewusst keine generated column und keine View-Berechnung (der Satz darf nicht „mitwandern").

  • Schweizer Sätze / Codes (vat_rates): Normalsatz 8.1 % (STD), reduziert 2.6 % (RED), Sondersatz Beherbergung 3.8 % (SPECIAL), echte Befreiung 0 % (EXEMPT) — Default für Versand/Ausfuhr, Vorsteuer-/Import-relevant 0 % (ZERO). Die Satz-Zuordnung je Produkt/Leistung macht das Produktmodul (Modul 3); für die Fracht ist sie mit diesem Entscheid auf EXEMPT fixiert.

  • Idempotenz: Bei Upsert-Buchungen (Auto-Posting) wird derselbe eingefrorene Satz/Status erneut geschrieben → gleicher vat_amount_chf (für Versand: 0), kein Drift.

  • Rundung (Hinweis): Preise werden immer auf ganze CHF aufgerundet (ceil, Produktmodul/Modul 3). Bei der befreiten Versandleistung ist der ceil-Betrag zugleich der gebuchte Betrag (keine MwSt-Herausrechnung); bei steuerbaren Leistungen wird die MWST aus dem aufgerundeten Brutto herausgerechnet.

  • MWST-neutral: Interne Transfers (CASH_TO_BANK etc.) und reine Umschichtungen tragen vat_code=NULL/vat_amount_chf=0. Einfuhrsteuer/eVV (Import) bleibt als eigener vat_code (ZERO/Vorsteuer) schema-ready 🔲.

3.9 Mehrwährung CHF + DOP (Berichtswährung CHF)

Das Ledger ist mehrwährungsfähig. Berichts- und Abschlusswährung ist immer CHF; Fremdwährungen werden zum Buchungskurs umgerechnet und in CHF gebucht.

  • currencies-Lookup (stabile ISO-Codes) — Kanonische DDL: Dok 30 §3.4 (currencies); hier nur fachlich. Stabiler code (CHF/DOP/EUR) + DE/ES/EN-Labels + symbol + is_report (genau eine = CHF) + active. Seeds: CHF (is_report=true), DOP (Dominikanische Pesos), EUR (optional).

  • Pro Bewegung: currency (Default CHF) + fx_rate (Kurs Originalwährung→CHF zum Buchungszeitpunkt) + total_orig/paid_orig (Originalbetrag). Die CHF-Spalten bleiben die gebuchte und berichtete Grösse: total_chf = round(total_orig * fx_rate, 2). Bei currency='CHF' ist fx_rate=1 und total_orig optional (= total_chf). (Die Spalten currency/fx_rate werden in Dok 30 §4 movements und §3.4 price_list_items ergänzt — siehe dort.)

  • DOP-Bewegungen (v. a. Auslagen/Zahlungen an Lieferanten/Fahrer in der DR) tragen Originalbetrag (DOP) + Kurs + CHF-Gegenwert. Der Kurs kommt bei Revolut-Importen direkt aus Revolut (§10), bei manueller Erfassung als Eingabe/Tageskurs.

  • Reporting/Abschluss (v_kasse, v_dashboard, v_monatskontrolle, Treuhänder-Export) rechnen ausschliesslich in CHF — die Views aggregieren *_chf. Originalwährung + Kurs sind zur Nachvollziehbarkeit (Beleg, Kursdifferenz) mitgeführt, aber nie aggregiert.

  • Kursdifferenzen: Differenzen zwischen Buchungs- und Zahlungskurs (Realisationsdifferenz) werden vorerst als separate movements (INCOME/EXPENSE, Notiz „Kursdifferenz") erfasst 🔲 — formale Behandlung mit Treuhänder zu bestätigen.


4. Kern-Workflows

A · Manuelle Bewegung erfassen (Erfassung_Digitar-Ersatz)

  1. Nutzer (Mariela/Marcel) wählt Vorgang + Zahlweise aus Bottom-Sheet-Selects, gibt Datum, Ref, Partei, Gesamtbetrag, Bezahlt, Notiz.
  2. Insert in movements (source_type='manual'). period_key, channel, cash_in/out, open_debt, status ergeben sich aus v_movements.
  3. Toast bestätigt; Kasse-/Dashboard-Kacheln revalidieren.
  4. Edge-Case: Periode bereits closed → Insert in dieser Periode blockiert (Policy), UI zeigt „Periode gesperrt — als Nachbuchung in offener Periode erfassen?".
  5. Edge-Case: total < paid bei INVOICE → Warnung „Überzahlung"; nur mit allow_overpayment speicherbar.

B · Auto-Posting Depot-Zahlung (idempotent)

  1. Logistik/Finanz erfasst Depot-Zahlung (Modul 5/6) → Event mit payment.id.
  2. Edge-Function post_movement macht Upsert auf movements mit source_type='deposit_payment', source_id=payment.id: entry_date=zahldatum, contact_id, payment_method_code, paid_chf=betrag, note="Pago depósito automático - envío DD.MM.YY" (DEPOSIT_CASH, Abono).
  3. Kanal folgt payment_methods.channel → Kasse oder Bank.
  4. F-14 — Soll/Abono-Verknüpfung: Die Abono-Bewegung wird via payment_links(payment_id=Abono, target_id=Depot-Soll-movement, amount=amount_chf) an die DEPOSIT_CASH-Soll-Bewegung des Depot-Auftrags (AP-3) gehängt. Der Soll wird über deposit_payments.deposit_order_id → deposit_orders.movement_id eindeutig gefunden — nicht per reference-Match. So finden sich Soll und Abono in v_anzahlungen sicher (analog INVOICE/INVOICE_PAYMENT, Workflow C). deposit_orders.statusPARTIAL/PAID.
  5. Korrektur: Betrag im Ursprung geändert → erneutes Event → Upsert überschreibt dieselbe Zeile (kein Duplikat, dank Unique (source_type,source_id)).
  6. Storno: Depot-Zahlung gelöscht/storniert → void-RPC setzt voided_at auf der gekoppelten movement (bleibt im Audit).

C · Kundenzahlung gegen Rechnung (explizite Verknüpfung)

  1. Mariela öffnet offene INVOICE, klickt „Zahlung erfassen".
  2. Insert movements (INVOICE_PAYMENT, debtor_payment) + payment_links(payment_id, target_id=factura, amount).
  3. v_debitoren rechnet Restschuld neu; Status PARTIAL/PAID.
  4. invoices.status-Nachführung (F-04): Ein Trigger auf payment_links/movements summiert die der INVOICE-Bewegung zugeordneten Zahlungen und schreibt invoices.status zurück: Σ ≥ invoices.total_chfPAID, 0 < Σ < total_chfPARTIAL, sonst OPEN. Dies ist eine bewusste Ausnahme von „Status nur aus Views" (der Restschuld-Betrag bleibt View-abgeleitet). Der Rechnungsstatus dient dem Mahnwesen/Debitoren; er ist kein Auslöser der Affiliate-Provision mehr — die Provision entsteht bei Offerte-Konversion (pending) und wird fällig/freigegeben beim Tracking-DELIVERED der Sendung (Geschäftsentscheid 2026-06-28 #19; Modul 16 §4.5).
  5. Edge-Case: Splitzahlung auf mehrere Rechnungen → mehrere payment_links-Zeilen, Summe ≤ paid_chf.
  6. Edge-Case: Altdaten ohne Link → Fallback Ref+Partei-Match (View deckt beides ab).

D · Interner Transfer (Kasse↔Bank↔Twint)

  1. Nutzer wählt z. B. CASH_TO_BANK, Betrag, Datum.
  2. System erzeugt ein movement mit transfer_group_id (optional zweites Bein für die Gegenseite). Status = INTERNAL_TRANSFER, open_debt=0.
  3. Aus Debitoren/Kreditoren/Erfolgsrechnung ausgeschlossen (nur Kasse↔Bank-Umschichtung).
  4. Edge-Case: Transfer darf Kassenbestand nicht negativ machen → Soft-Warnung (kein harter Block; Bargeldbestand kann real abweichen).

E · Monatsabschluss (revisionssicher)

  1. Marcel/Mariela wählt Monat → v_monatskontrolle zeigt Summen + offene Posten.
  2. Status review, solange offene Debitoren/Kreditoren/Anzahlungen > 0 (analog Excel Revisar pendientes).
  3. Bei closed: Snapshot von opening/closing cash+bank in monthly_closings, closed_at/by gesetzt → Periode gesperrt (Insert/Update via Policy verriegelt).
  4. Nachbuchung: nur via „Reopen" (audit-pflichtig, reopened_at/by) oder Buchung in offener Folgeperiode mit Korrektur-Referenz.
  5. Export: Treuhänder-CSV/Excel + PDF-Report der Periode.

F · MWST-Buchung (scharf seit 2026-06-28; Versand befreit, inclusive nur für steuerbare Leistungen)

  1. app_settings.vat_enabled=true, vat_method='inclusive', vat_rates.active=true. Das Erfassungs-Formular verlangt vat_code (Default je Leistung/Produkt aus Modul 3) — für die Versandleistung ist der Default EXEMPT (Art. 23 MWSTG, 0 %).
  2. Versand (EXEMPT): vat_rate_percent=0, vat_amount_chf=0 — keine Herausrechnung; total_chf ist der volle befreite Betrag. Steuerbare Inland-Leistung (inclusive): total_chf ist der Bruttobetrag; der zum entry_date gültige Satz (z. B. STD 8.1 %) wird als vat_rate_percent eingefroren; F-18: vat_amount_chf wird im Schreibpfad (Server-Action bzw. post_movement() für quellen-erzeugte Buchungen) per total_chf - round(total_chf/(1+vat_rate_percent/100),2) berechnet und auf der Zeile gespeichert — nicht in einer View, keine generated column (Satz ist eingefroren). Für Upsert-Buchungen idempotent (gleicher Wert bei Re-Upsert).
  3. Interne Transfers / MWST-neutrale Vorgänge tragen vat_code=NULL, vat_amount_chf=0.
  4. Edge-Case Satzwechsel: Eine künftige CH-Satzänderung wird über vat_rates.valid_from/valid_to neu effektiv-datiert; Altbuchungen behalten ihren eingefrorenen vat_rate_percent (kein Rückwirken). Die Export-Befreiung (EXEMPT) ist davon unberührt.
  5. Edge-Case Fremdwährung: Bei DOP-Buchungen wird zuerst auf CHF umgerechnet (total_chf = total_orig * fx_rate); bei steuerbarer Leistung dann die MWST aus dem CHF-Brutto herausgerechnet (§3.9 + §3.8), bei EXEMPT bleibt vat_amount_chf=0.

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

Grundsatz: Tabelle ab Desktop, Karten am Handy (kein Horizontal-Scroll), Bottom-Sheet-Selects für Vorgang/Zahlweise, inputmode="decimal" für CHF, ≥44px-Targets, Swipe für Schnellaktionen, Empty-States/Skeletons. Sidebar ab lg, Bottom-Tab-Bar + Drawer am Handy. Geld-Beträge rechtsbündig, monospaced.

5.1 Finanz-Dashboard — KPI-Kacheln aus v_dashboard: Kasse-Saldo, Bank/Twint-Saldo, Debitoren offen, Kreditoren offen, Anzahlungen offen, Cashflow netto. Monatsfilter (Bottom-Sheet, Default ALL).

  • Handy: 2-spaltiges Kachel-Grid, vertikal gestapelt, Sparkline pro Kachel; Monatsfilter als Chip → Bottom-Sheet.
  • Tablet/Desktop: 3×2-Kachelraster + Monatsübersicht-Tabelle (v_monatskontrolle) darunter; Recharts-Balken Eingang/Ausgang je Monat.

5.2 Kasse / Cashflow (v_kasse) — chronologisches Journal mit laufendem Saldo.

  • Handy: Karten-Liste (Datum + Partei oben; Eingang grün / Ausgang rot rechts; Saldo als Sub-Zeile; Methode/Status als Badges). Swipe-left = Storno-Dialog, Swipe-right = Duplizieren. Filter-Chips (Monat, Kanal cash/bank) sticky oben.
  • Tablet/Desktop: TanStack-Table (Datum, Partei, Vorgang, Zahlweise, Kanal, Eingang, Ausgang, laufender Saldo, Status, Notiz), sticky Header, Spaltenfilter, CSV-Export.

5.3 Schnellerfassung (Quick-Entry) — Floating-Action-Button „+" öffnet Bottom-Sheet-Formular.

  • Reihenfolge thumb-optimiert: Vorgang (Bottom-Sheet-Select mit Codes+Labels) → Zahlweise → Betrag (inputmode=decimal, grosses Numpad) → Datum (Default heute) → Partei (Suche, mit Anlegen-Inline) → Notiz.
  • Kanal-Vorschau-Badge (Kasse/Bank) erscheint live, sobald Zahlweise gewählt. „Speichern & Neu" für Serienerfassung.

5.4 Debitoren / Kreditoren (v_debitoren / v_kreditoren) — offene Posten mit Ampel.

  • Handy: Karten nach Priorität gruppiert (Kreditoren: Dringend → Überfällig → Bald fällig → Offen → Bezahlt). Restschuld prominent. Swipe-right = „Zahlung erfassen" → Bottom-Sheet.
  • Tablet/Desktop: Tabelle mit Status-Badge, Fälligkeit, Restschuld; Zeilen-Action „Zahlung", die payment_links setzt.

5.5 Anzahlungen / Depot (v_anzahlungen) — Box-Depot-Status.

  • Handy: Karten je Depot (Kunde, Box-Typ×Menge, Depot total / bezahlt / offen, Ampel). Camera-First: „Box-QR scannen" springt vom Depot-Eintrag zur Box (Modul 5).
  • Desktop: Tabelle + Inline-Zahlung.

5.6 Monatsabschluss (v_monatskontrolle + monthly_closings)

  • Handy: Monats-Akkordeon; offene-Posten-Checkliste; grosser Button „Periode abschliessen" (disabled bei offenen Posten, mit Begründungs-Hinweis).
  • Desktop: 12-Monats-Tabelle (Eingang/Ausgang/Netto/Saldo/Debitoren/Kreditoren/Status), Abschluss- + Export-Buttons je Zeile.

5.7 Treuhänder-Export — Dialog: Zeitraum, Format (CSV/Excel/PDF), MWST-Kennzahlen (wenn aktiv), „nur abgeschlossene Perioden". Generiert Datei via Edge-Function → Supabase Storage → Download-Link.


6. Integrationen & Verbindungen zu anderen Modulen

  • Geteilte Entitäten: movements (dieses Modul = Owner), movement_types, payment_methods, vat_rates, monthly_closings, audit_log; FK auf contacts (Modul CRM).
  • Auto-Posting-Eingänge (alle idempotent via source_type/source_id, Upsert):
    • Offerten (4): Bei Rechnungsstellung → INVOICE (invoice_issued); die Offerten-Annahme bucht keine RECEIVABLE (F-06, keine Doppelzählung).
    • Logistik/ERP (5): Rechnung → INVOICE; Depot-Order → DEPOSIT_CASH (Soll); Box-Lifecycle berührt Finanzen nur über Depot.
    • Produkte & Preise (3): liefern Beträge/Depot-Sätze für die Soll-Buchungen (effektiv-datierte Preislisten); vat_code ggf. je Produkt.
    • Affiliate (7): Payout bezahlt → SUPPLIER_PAYMENT (commission_payout, F-05), Partei = Affiliate-Kontakt.
    • Kreditoren (F-02): Lieferantenrechnung/-zahlung sind manuelle movements (source_type='manual'), kein Auto-Posting.
    • Revolut Business (§10): importierte Revolut-Transaktionen (v. a. DOP-Auslagen/-Zahlungen) → movements (source_type='revolut', idempotent über Revolut-transaction_id), Kurs/fx_rate aus Revolut.
    • Zahlungen (überall): Kunden-/Depot-Zahlung → INVOICE_PAYMENT/Depot-Abono + payment_links.
  • Ausgänge: v_dashboard/v_monatskontrolle speisen das Plattform-Dashboard; Audit jeder Mutation → audit_log (Modul Plattform).
  • Auto-Posting-Kontrakt (Gewinn ggü. Excel): operative Events erzeugen die Buchung mit Quelle; upsert + Unique (source_type,source_id) = eine Buchung pro Event, Korrektur überschreibt statt zu duplizieren; Quellen-Buchungen sind im Ledger read-only (nur der Ursprung wird editiert).

7. Validierungen & Edge-Cases

  • Beträge ≥ 0 (DB-Check); CHF auf 2 Nachkommastellen gerundet. Bruttopreise kommen vom Produktmodul auf ganze CHF aufgerundet (ceil, §3.8).
  • Mehrwährung: bei currency<>'CHF' muss fx_rate>0 und total_orig gesetzt sein; total_chf = round(total_orig*fx_rate,2) (Konsistenz-Check). Bei currency='CHF' gilt fx_rate=1. Aggregiert wird nur in CHF (§3.9).
  • MWST: Versandleistung befreit (vat_code='EXEMPT', vat_rate_percent=0, vat_amount_chf=0, keine Herausrechnung; Art. 23 MWSTG). Nur bei steuerbarer Leistung (inclusive): vat_amount_chf = total_chf - round(total_chf/(1+vat_rate_percent/100),2); vat_rate_percent wird beim Schreiben aus dem zum entry_date gültigen vat_rates-Satz eingefroren und danach nicht nachgezogen (§3.8). MWST-neutrale Zeilen (Transfers): vat_code=NULL, vat_amount_chf=0.
  • Revolut-Idempotenz: importierte Transaktion ist eindeutig über (source_type='revolut', source_id=transaction_id); Re-Import überschreibt statt zu duplizieren (§10.2).
  • Cash-Seite eindeutig: eine Zeile erzeugt nicht gleichzeitig cash_in und cash_out (durch disjunkte Typ-Mengen garantiert).
  • channel='open' (OPEN): Zeile ist nicht zahlungswirksam (cash_in=cash_out=bank=0), erzeugt aber open_debt bei INVOICE/RECEIVABLE/DEPOSIT_CASH bzw. Kreditor bei EXPENSE/SUPPLIER_PAYMENT/SUPPLIER_DEBT mit paid=0 → erscheint korrekt als OPEN.
  • Restschuld-Match: primär über payment_links; Fallback reference + contact_id (is not distinct from behandelt NULL sauber). Risiko bei leerer Ref/Partei → UI erzwingt Ref bei INVOICE/RECEIVABLE.
  • Überzahlung: Σ Zahlungen > totalopen_debt=0 (kein Negativ), Warn-Badge „Überzahlung"; nur mit allow_overpayment speicherbar.
  • Interner Transfer: immer open_debt=0, Status INTERNAL_TRANSFER, aus Deb./Kred./GuV ausgeschlossen; bei transfer_group müssen beide Beine denselben Betrag tragen.
  • Idempotenz: Doppel-Event derselben Quelle → Upsert, kein Duplikat. Manuelle Zeile (source_id=NULL) ist absichtlich nicht unique-geschützt.
  • Periode gesperrt: Insert/Update in closed-Periode blockiert; Datumswechsel einer Zeile in eine gesperrte Periode ebenfalls blockiert.
  • Storno statt Delete: quellen-erzeugte Buchung nie hart löschen; void setzt voided_at, View filtert sie aus, Audit behält sie.
  • Monatskey-Robustheit: period_key aus entry_date generiert (MM/YYYY); kein Free-Text-Parsing nötig (Excel parste Strings — hier echte date).

8. Compliance-/Sicherheits-Hinweise

  • OR 957 / GeBüV (Revisionssicherheit): movements ist ein unveränderbares Journal — kein hartes DELETE, Korrektur nur via Storno (voided_at) + Neubuchung; audit_log protokolliert wer/wann/alt→neu für jede Mutation an movements und monthly_closings. Abgeschlossene Perioden sind gesperrt; Reopen ist audit-pflichtig. Aufbewahrung 10 Jahre (Storage-Belege + Ledger).
  • Cash-Basis-Abgrenzung: Caja ist operatives Vorsystem (Einnahmen/Ausgaben + Offene Posten), kein rechtsgültiger Abschluss — der Treuhänder-Export ist die offizielle Schnittstelle; das verhindert falsche Gewissheit über „fertige Buchhaltung".
  • MWST/eVV: MWST ist scharf (seit 2026-06-28, §3.8). Die Kern-Versandleistung ist echt befreit (vat_code='EXEMPT', 0 %, Art. 23 MWSTG — Ausfuhr CH→DR; kein Aufschlag, keine Herausrechnung); nur steuerbare Inland-Leistungen laufen inclusive/brutto mit eingefrorenem Satz (vat_rate_percent) und herausgerechnetem vat_amount_chf. So erscheint der Hauptumsatz korrekt als 0 %/befreit → rechtssichere, historisch stabile Steuerkennzahlen für den Treuhänder-Export. Einfuhrsteuer/eVV als eigener movement_type/vat_code (ZERO/Vorsteuer) bleibt vorgesehen 🔲.
  • Mehrwährung/Revolut: Fremdwährung (DOP via Revolut, §3.9/§10) wird zum Buchungskurs in CHF gebucht; Originalbetrag + Kurs sind je Bewegung als Beleg mitgeführt (Nachvollziehbarkeit, GeBüV). Revolut-API-Secrets nur server-seitig (Vault), Import idempotent + revisionssicher (read-only, Storno statt Delete).
  • revDSG/DSG: movements referenziert contacts (Personendaten); Zugriff strikt per RLS (AFFILIATE/READONLY sehen keine fremden Finanzdaten; FAHRER nur eigene Logistik-Quellen). Exporte enthalten nur das fachlich Nötige.
  • Least Privilege: Auto-Posting läuft über service_role in Edge-Functions, nicht über Client-Keys; Clients können quellen-erzeugte Buchungen nicht fälschen oder löschen.

9. Offene Punkte

  • MWST Versand befreit (entschieden 2026-06-28, Teil 3): Die Versandleistung ist MwSt-BEFREIT (echte Befreiung Art. 23 MWSTG → 0 %, vat_code='EXEMPT'; kein Aufschlag, keine Herausrechnung, vat_amount_chf=0). Das System bleibt MwSt-fähig: steuerbare Inland-Zusatzleistungen laufen inclusive/brutto mit eingefrorenem Satz (STD 8.1 %) — §3.8. Verbleibend 🔲: ob/welche konkreten Inland-Zusatzleistungen steuerbar anfallen — Satz-Zuordnung je Leistung macht Modul 3 (Fracht = EXEMPT fixiert).
  • 🔲 Einfuhrsteuer/eVV-Modellierung: eigener movement_type (z. B. IMPORT_TAX) und/oder vat_code=ZERO mit Vorsteuer-Abzug — finale Abbildung mit Treuhänder klären.
  • 🔲 „1 Dringend"-Flag (Kreditoren): im DIGITAR-Modell nicht als Spalte vorhanden; Caja braucht ein explizites urgent-Feld/Notiz-Konvention — Quelle/Trigger bestätigen.
  • Treuhänder-Exportformat (Default gesetzt 2026-06-28, §11): bexio-kompatibles Buchungs-CSV (UTF-8, Semikolon, doppelte Buchung: Datum, Soll-Konto, Haben-Konto, Betrag CHF, MWST-Code, Text, Referenz) auf Basis Kontenrahmen KMU (Käfer). Verbreitetster CH-KMU-Standard, vom Treuhänder per einmaligem Spalten-Mapping einlesbar; Banana/Sage/Abacus lesen dasselbe generische CSV. Final bestätigbar (welche genauen MWST-Kennzahlen der Treuhänder verlangt) — Detail-Spec §11.
  • 🔲 Anfangssalden: Excel hält Saldo inicial caja/banco als Konstante (B2/D2). In Caja als monthly_closings-Opening der ersten Periode oder als Settings-Wert? Migrationswert aus Live-Excel übernehmen.
  • Mehrwährung (entschieden 2026-06-28): CHF + DOP, Berichtswährung CHF; movements führt currency+fx_rate+total_orig (§3.9, Spalten via Dok 30 §4). DOP-Zahlungen über Revolut Business (§10). Verbleibend 🔲: USD-Bedarf, Kursquelle für manuelle Buchungen, formale Behandlung von Kursdifferenzen mit Treuhänder.
  • 🔲 INCOME im Dropdown: Glossar/Brief führen INCOME, das Live-Erfassung_Digitar-Dropdown listet es nicht explizit — als gültigen Vorgang aufnehmen (empfohlen) bestätigen.
  • 🔲 Revolut-Integration (§10): welche Konten/Währungen angebunden werden, API-Tier/Verfügbarkeit, Webhook vs. Polling, MVP per CSV vs. direkt API — siehe §10.

10. Revolut-Business-Integration (DOP-Zahlungen + Multi-Währungs-Konto)

Entscheid 2026-06-28: DOP-Zahlungen (v. a. an Lieferanten, Fahrer und Auslagen in der DR) sowie das Halten von Fremdwährung laufen über ein Revolut Business-Konto. Revolut wird als Zahlungs-/Konto-Quelle in Caja integriert: Revolut-Transaktionen werden importiert und als movements ins Ledger verbucht (Auto-Posting/Abgleich), sodass die Fremdwährungs-Realität der DR sauber in der CHF-Berichtswährung (§3.9) ankommt.

10.1 Zweck & Scope

  • Konten-/Zahlungsquelle für DOP (und ggf. CHF/EUR) — Revolut Business hält Multi-Währungs-Salden und führt FX selbst durch.
  • Single Source bleibt das Caja-Ledger: Revolut ist die Bank, movements ist die Buchhaltung. Jede relevante Revolut-Transaktion wird genau einmal als movement gebucht (idempotent), nicht doppelt.
  • Kein Kunden-Inkasso über Revolut im MVP — Fokus ist die Auszahlungs-/Auslagen-Seite in der DR und die Multi-Währungs-Kasse. (CH-Kundenzahlungen laufen weiter über Bank/TWINT/Bar/Karte.)

10.2 Anbindung — Revolut Business API

Revolut bietet eine Business-API für Konten, Transaktionen, FX und Zahlungen. Genutzt werden:

BereichRevolut-APIVerwendung in Caja
AccountsKonten + Salden je WährungKonto-Übersicht, Soll-Abgleich der Salden
TransactionsTransaktionsliste (inkl. transaction_id, Betrag, Währung, Counterparty, FX-Rate, Status, Datum)Import → movements (Auto-Posting)
Foreign Exchangeausgeführte FX-Trades + Kursefx_rate je Bewegung übernehmen; Kursdifferenzen erkennen
Payments (optional, später)Zahlung an Counterparty auslösenAuslagen/Lieferantenzahlung aus Caja heraus anstossen 🔲
  • Datenfluss (Import → Ledger): Revolut-Transaktion → Import (Webhook oder Polling, §10.3) → Mapping auf movementsUpsert mit source_type='revolut', source_id=<Revolut transaction_id>. Damit greift derselbe Idempotenz-Vertrag wie beim übrigen Auto-Posting (unique (source_type, source_id), §3.2): erneuter Import derselben Transaktion überschreibt dieselbe Zeile, kein Duplikat.
  • Währung & Kurs: currency = Revolut-Transaktionswährung (z. B. DOP), total_orig/paid_orig = Originalbetrag, fx_rate = aus Revolut übernommener Kurs zu CHF, total_chf = round(total_orig * fx_rate, 2) (§3.9). Bei reinen CHF-Transaktionen fx_rate=1.
  • Typ-Mapping: Abfluss (Zahlung/Auslage) → EXPENSE/SUPPLIER_PAYMENT; Zufluss → INCOME; konto-interner FX-/Umbuchungsvorgang → interner Transfer (MWST-neutral, §3.8). Counterparty → Match auf contacts (sonst manuell zuzuordnen).
  • MWST: Auf importierten DR-Auslagen wird die MWST nach derselben Inclusive-Regel behandelt (§3.8); DR-Auslagen sind in der Regel ohne CH-MWST (vat_code entsprechend, ggf. ZERO/Vorsteuer-Import) — konkrete Zuordnung mit Treuhänder klären 🔲.

Hinweis zum eingefrorenen Posting-Schreibpfad: Der Revolut-Import läuft über die Edge Function revolut-sync mit service_role (kein Client-Key), genau wie das übrige Auto-Posting (Modul 20 §8/§9). source_type='revolut' wird in Dok 30 §4 in die movements.source_type-CHECK-Liste aufgenommen.

10.3 Import-Mechanismus (MVP-Fallback → API)

  1. MVP-Fallback (sofort, robust): CSV-/Statement-Import. Revolut-Kontoauszug (CSV) wird hochgeladen; der Parser legt movements per Upsert an (Idempotenz über die Revolut-transaction_id-Spalte des Exports, ersatzweise Hash aus Datum+Betrag+Counterparty). So ist der Mehrwährungs-Abgleich ohne API-Freischaltung lauffähig.
  2. Ausbau: Revolut Business API. revolut-sync (Edge Function, Cron) zieht periodisch neue Transaktionen (Polling) oder empfängt sie per Webhook; dieselbe Upsert-Logik. Webhook bevorzugt (näher an Echtzeit, weniger Quota), Polling als Fallback.
  3. Beide Wege schreiben dieselben movements über denselben Idempotenz-Schlüssel → ein späterer Wechsel CSV→API ist transparent (kein Datenbruch).

10.4 Reconciliation (Revolut ↔ Caja)

  • Banksaldo-Abgleich: Revolut-Kontosaldo je Währung (aus Accounts-API/CSV) wird gegen den aus movements abgeleiteten Saldo derselben Quelle gestellt → Differenz-Report. Eine eigene Reconciliation-View (v_revolut_abgleich 🔲) markiert offene/abweichende Posten.
  • Status: importierte, aber noch nicht zugeordnete Transaktionen (z. B. unbekannte Counterparty) landen in einer Abgleich-Queue zur manuellen Zuordnung (analog Kreditoren-Workflow).
  • Storno/Korrektur: stornierte Revolut-Transaktionen → void der gekoppelten movement (bleibt im Audit, §8); kein hartes Löschen.

10.5 Sicherheit & Betrieb

  • Secrets: Revolut-API-Credentials (Client-ID/Key bzw. OAuth-Token) liegen ausschliesslich in Env/Vault (Vercel-/Supabase-Secrets), nie im Repo und nie im Client-Bundle. Zugriff nur aus service_role-Edge-Functions (Modul 20 §6.2/§10.2).
  • Least Privilege: API-Scope auf read (Accounts/Transactions/FX) im MVP; payment-initiation (write) ist ein separater, später zu aktivierender Scope (🔲, Vier-Augen-Freigabe empfohlen).
  • Revisionssicherheit: Revolut-erzeugte movements sind im Ledger read-only (nur Storno via RPC), Periodensperre gilt (§3.7/§8); jede Mutation → audit_log.

10.6 Offene Punkte (Revolut)

  • 🔲 Konten/Währungen: welche Revolut-Konten (DOP, CHF, EUR?) angebunden werden und welche als „Kasse DR" gelten.
  • 🔲 API-Tier/Verfügbarkeit: Revolut-Business-Plan + API-Zugang/Quota; Webhook-Verfügbarkeit im gewählten Tier.
  • 🔲 Payment-Initiation: ob Auslagen/Zahlungen aus Caja heraus ausgelöst werden (write-Scope) oder nur Import (read) — MVP = nur Import.
  • 🔲 Counterparty-Mapping: automatische Zuordnung Revolut-Counterparty → contacts (Heuristik/Alias-Tabelle).
  • 🔲 Kursdifferenzen: formale Verbuchung der Realisationsdifferenz (§3.9) mit Treuhänder bestätigen.

11. Treuhänder-Export — Format-Default (Best Practice CH-KMU)

Entscheid 2026-06-28 (Default gesetzt, final bestätigbar): Der Treuhänder-Export erfolgt als bexio-kompatibles Buchungs-CSV auf Basis des Kontenrahmens KMU (Käfer). Caja ist Cash-Basis-Vorsystem (§1) und ersetzt nicht die formale Buchhaltung — der Export ist die Schnittstelle, über die der Treuhänder die Bewegungen in seine Software übernimmt.

11.1 Empfehlung & Begründung

KriteriumEntscheid
Ziel-Tool / Formatbexio-kompatibles Buchungs-CSV (generisches Soll/Haben-Journal-CSV), Kontenrahmen KMU/Käfer
Warum bexio als ReferenzMit Abstand verbreitetste Cloud-Buchhaltung für CH-KMU/GmbH; Treuhänder erhalten i. d. R. kostenlosen Mandanten-Zugang; CSV-Import ordnet beliebige Spalten einmalig zu und merkt sich das Mapping → kein starres Schema nötig.
Warum CSV (nicht proprietär)Ein generisches Soll/Haben-CSV ist tool-neutral: Banana (günstigste Volllösung, ~CHF 69–149/J., KMU-Kontenrahmen inkl.), Sage, Abacus und Run my Accounts lesen denselben Buchungs-CSV-Aufbau (DATEV-/CSV-Import-Standard). Wir binden uns damit nicht an ein einzelnes Tool — der Treuhänder wählt seins.
Pragmatik (kleine GmbH)Kein API-Lock-in, kein kostenpflichtiger Connector im MVP: eine Edge-Function erzeugt die CSV (§5.7) → Storage → Download. Direkter bexio-API-Push (manuelle Buchungen) ist noch eingeschränkt und bleibt spätere Option 🔲.
KontenrahmenKMU/Käfer (Schweizer Standard, 8 Kontenklassen) — von allen genannten Tools unterstützt; CH-MWST-Sätze 8.1 / 2.6 / 3.8 % sind dort Standard.

Kurz: bexio-kompatibles KMU-CSV = verbreitet, günstig, vom Treuhänder ohne Spezial-Setup einlesbar und gleichzeitig zu Banana/Sage/Abacus/Run-my-Accounts kompatibel. Der Treuhänder muss final nur das Spalten-Mapping einmal bestätigen und die genau gewünschten MWST-Kennzahlen nennen.

11.2 Exportformat (CSV-Spalten — doppelte Buchung)

UTF-8 (BOM), Trennzeichen Semikolon ;, Datum TT.MM.JJJJ, Beträge mit Punkt als Dezimaltrenner, 2 Nachkommastellen, CHF (Berichtswährung, §3.9). Eine Zeile = eine Soll/Haben-Buchung. Spalten-Reihenfolge fix; Zusatzspalten am Ende.

SpalteInhaltQuelle in Caja
DatumBuchungsdatum TT.MM.JJJJmovements.entry_date
SollSoll-Konto (KMU-Nr.)aus movement_type + payment_method.channel abgeleitetes Konto-Mapping (§11.3)
HabenHaben-Konto (KMU-Nr.)analog (Gegenkonto)
BetragBetrag in CHF (brutto bzw. befreit)movements.total_chf / paid_chf
MWST-CodeSteuer-Code (z. B. UN81 Normalsatz / EXEMPT/leer bei befreit)movements.vat_code → Treuhänder-Steuercode-Mapping
MWST-Betragherausgerechnete MWST (0 bei Versand/EXEMPT)movements.vat_amount_chf
TextBuchungstextmovements.reference + contact + note
ReferenzBeleg-/Quell-Referenzmovements.reference / source_type:source_id
WährungOriginalwährung (Info)movements.currency
Kursfx_rate (Info, CHF=1)movements.fx_rate
  • Nur abgeschlossene Perioden exportierbar (Dialog §5.7) → revisionssicher (§8), keine offenen/driftenden Zeilen im Treuhänder-File.
  • MWST-Logik bleibt erhalten: Versandleistung befreit (EXEMPT, 0 %, Art. 23 MWSTG, §3.8) → MWST-Betrag=0; steuerbare Inland-Leistung mit eingefrorenem vat_rate_percent → herausgerechneter MWST-Betrag. Der historisch eingefrorene Satz garantiert stabile Kennzahlen.
  • PDF-Begleitexport (Perioden-Report + Provisions-Abrechnungen, Modul 7 §8) ergänzt das CSV für die Belegablage.

11.3 Konto-Mapping (🔲 mit Treuhänder final)

Das fachliche Mapping movement_type/channel → KMU-Konto (z. B. Kasse 1000, Bank 1020, Debitoren 1100, Kreditoren 2000, Dienstleistungsertrag 3400/3xxx, MWST-Konten 22xx) wird als Lookup-Tabelle (account_mapping 🔲) gepflegt und vom Treuhänder einmalig bestätigt. Bis dahin liefert der Export Konto-Vorschläge nach Käfer-Standard; das Mapping ist datengetrieben und ohne Code-Änderung anpassbar.

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