Files
accounted/supabase/migrations/20260810160748_supplier_payment_batches.sql
Jakob Wennberg f776e375c5 feat(payments): supplier payment batch schema + pain.001 domain lib (betalfil 1/3) (#1500)
* feat(payments): supplier payment batch schema + pain.001 domain lib

Betalfil for leverantorsfakturor, part 1 of 3. New tables
supplier_payment_batches + supplier_payment_batch_items (RLS, immutable
item snapshots, FK RESTRICT on invoices), payee/reference resolution,
eligibility rules shared by preview and create, and a supplier-dialect
pain.001.001.03 generator (SESBA 9900 BGNR / 9960 BBAN / clearing BBAN,
SCOR for Luhn-valid OCR, Ustrd fallback, no SvcLvl/CtgyPurp).
Deterministic regeneration: msg_id derives from the batch id, CreDtTm
from created_at, so re-downloads are byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(payments): use lib/money helpers instead of raw ore rounding

The naive-ore-round ratchet flags new Math.round(x*100)/100 sites;
roundOre/sumOre/ORE_TOLERANCE are the sanctioned forms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payments): classify batch tables in full-archive contract + fixture

The no-phantom-columns contract requires every company-scoped table to
be triaged in full-archive-export; the batch rows are underlag for the
payments they initiated, so they dump with the archive. makeSupplier
gains the clearing/account columns the Supplier type now carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payments): harden batch integrity per review

Composite (id, company_id) FKs so items can never cross-link a batch and
an invoice from different companies; BEFORE UPDATE trigger keeps batches
immutable outside lifecycle + download metadata and one-way on
created -> cancelled; active-batch lookup now fails closed (an error no
longer reads as no active batches, which would have silently disabled
the duplicate-batch guard); today derives from Europe/Stockholm, not
UTC; pain.001 control sums add the amounts as rendered so CtrlSum always
equals sum(InstdAmt); event-bus reset in test hooks; Danske LB date
claim in DECISIONS verified against the primary page (the bot's 12 May
date is the alias-initiation date, not LB retirement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payments): bind cancellation metadata to the cancel transition

cancelled_at/cancelled_by may only be written by created -> cancelled;
cancelled_by may still become NULL so the FK's ON DELETE SET NULL keeps
working when the cancelling user's account is deleted (proven in pg).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 19:53:39 +02:00

178 lines
9.2 KiB
PL/PgSQL

-- Supplier payment batches (betalfil for leverantorsfakturor).
--
-- A batch is a snapshot of payment instructions handed to the user's bank as a
-- pain.001 file. The file regenerates deterministically from these rows alone
-- (msg_id stored at creation, CreDtTm derived from created_at), so a
-- re-download is byte-identical and bank-side duplicate detection works; a new
-- batch gets a new msg_id. This closes, for supplier payments, the
-- no-regeneration-guard hazard documented for the salary payment files
-- (DECISIONS.md 2026-07-26).
--
-- Generating a file moves no money and books nothing: settlement stays in the
-- existing mark-paid / bank-match flows. Batch settlement progress is derived
-- at read time by joining items to live supplier_invoices; it is never stored.
--
-- format allows 'bg_lb' at the DB level so a future LB or pain.001.001.09
-- addition needs no migration; the v1 API accepts only 'pain001'.
CREATE TABLE public.supplier_payment_batches (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
format text NOT NULL CHECK (format IN ('pain001', 'bg_lb')),
status text NOT NULL DEFAULT 'created' CHECK (status IN ('created', 'cancelled')),
currency text NOT NULL DEFAULT 'SEK',
total_amount numeric NOT NULL CHECK (total_amount > 0),
item_count integer NOT NULL CHECK (item_count > 0),
-- pain.001 MsgId, derived from the batch id at creation (Max35Text). Stored
-- so regeneration never recomputes it differently.
msg_id text NOT NULL,
-- Debtor snapshot at creation: {name, org_number, iban, bic}. Later changes
-- to company_settings never mutate an existing batch.
debtor_snapshot jsonb NOT NULL,
file_generated_at timestamptz,
download_count integer NOT NULL DEFAULT 0,
cancelled_at timestamptz,
cancelled_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Composite-FK target so items can enforce company agreement with their
-- batch. Trivially unique (id is the PK).
CONSTRAINT uq_supplier_payment_batches_id_company UNIQUE (id, company_id)
);
-- Composite-FK target on the invoice side, same reasoning as above.
CREATE UNIQUE INDEX uq_supplier_invoices_id_company
ON public.supplier_invoices (id, company_id);
CREATE TABLE public.supplier_payment_batch_items (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
batch_id uuid NOT NULL,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
-- RESTRICT: a batch item documents a payment instruction possibly already
-- handed to a bank; the invoice behind it must not vanish. The supplier
-- invoice DELETE route pre-checks and returns a friendly error.
supplier_invoice_id uuid NOT NULL,
amount numeric NOT NULL CHECK (amount > 0),
payment_date date NOT NULL,
-- Payee and reference snapshot at creation; supplier edits after batch
-- creation never change what the generated file says.
payee_type text NOT NULL CHECK (payee_type IN ('bankgiro', 'plusgiro', 'bank_account')),
payee_bankgiro text,
payee_plusgiro text,
payee_clearing text,
payee_account text,
payee_name text NOT NULL,
reference_type text NOT NULL CHECK (reference_type IN ('ocr', 'invoice_number')),
reference text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_supplier_payment_batch_invoice UNIQUE (batch_id, supplier_invoice_id),
-- Composite FKs: company_id must agree with BOTH parents, so a user who
-- belongs to two companies can never cross-link a batch in one company to
-- an invoice in another (plain per-column FKs would allow it).
CONSTRAINT fk_supplier_payment_batch_items_batch
FOREIGN KEY (batch_id, company_id)
REFERENCES public.supplier_payment_batches (id, company_id) ON DELETE CASCADE,
CONSTRAINT fk_supplier_payment_batch_items_invoice
FOREIGN KEY (supplier_invoice_id, company_id)
REFERENCES public.supplier_invoices (id, company_id) ON DELETE RESTRICT,
CONSTRAINT supplier_payment_batch_items_payee_fields_match CHECK (
(payee_type = 'bankgiro' AND payee_bankgiro IS NOT NULL)
OR (payee_type = 'plusgiro' AND payee_plusgiro IS NOT NULL)
OR (payee_type = 'bank_account' AND payee_clearing IS NOT NULL AND payee_account IS NOT NULL)
)
);
ALTER TABLE public.supplier_payment_batches ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.supplier_payment_batch_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company supplier_payment_batches"
ON public.supplier_payment_batches FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company supplier_payment_batches"
ON public.supplier_payment_batches FOR INSERT
WITH CHECK (company_id IN (SELECT user_company_ids()));
CREATE POLICY "update own-company supplier_payment_batches"
ON public.supplier_payment_batches FOR UPDATE
USING (company_id IN (SELECT user_company_ids()));
-- No DELETE policy on batches: a batch documents a payment instruction that
-- may already sit at the bank. Lifecycle ends at status 'cancelled'.
CREATE POLICY "view own-company supplier_payment_batch_items"
ON public.supplier_payment_batch_items FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company supplier_payment_batch_items"
ON public.supplier_payment_batch_items FOR INSERT
WITH CHECK (company_id IN (SELECT user_company_ids()));
-- No UPDATE or DELETE policies on items: rows are immutable snapshots. The
-- only removal path is the batch CASCADE.
CREATE INDEX idx_supplier_payment_batches_company_created
ON public.supplier_payment_batches (company_id, created_at DESC);
CREATE INDEX idx_supplier_payment_batches_company_status
ON public.supplier_payment_batches (company_id, status);
CREATE INDEX idx_supplier_payment_batch_items_batch_id
ON public.supplier_payment_batch_items (batch_id);
CREATE INDEX idx_supplier_payment_batch_items_supplier_invoice_id
ON public.supplier_payment_batch_items (supplier_invoice_id);
CREATE INDEX idx_supplier_payment_batch_items_company_id
ON public.supplier_payment_batch_items (company_id);
-- The RLS UPDATE policy cannot compare OLD and NEW, so column-level
-- immutability is a trigger: a batch is a snapshot a bank file regenerates
-- from, and rewriting msg_id/amounts/debtor after creation would break the
-- byte-identical re-download contract. Only lifecycle (created -> cancelled,
-- with its who/when) and download metadata may change.
CREATE OR REPLACE FUNCTION public.enforce_supplier_payment_batch_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.id IS DISTINCT FROM OLD.id
OR NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.format IS DISTINCT FROM OLD.format
OR NEW.currency IS DISTINCT FROM OLD.currency
OR NEW.total_amount IS DISTINCT FROM OLD.total_amount
OR NEW.item_count IS DISTINCT FROM OLD.item_count
OR NEW.msg_id IS DISTINCT FROM OLD.msg_id
OR NEW.debtor_snapshot IS DISTINCT FROM OLD.debtor_snapshot
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
RAISE EXCEPTION 'supplier_payment_batches are immutable snapshots: only lifecycle and download metadata may change';
END IF;
IF NEW.status IS DISTINCT FROM OLD.status
AND NOT (OLD.status = 'created' AND NEW.status = 'cancelled') THEN
RAISE EXCEPTION 'supplier_payment_batches: the only status transition is created -> cancelled';
END IF;
-- Cancellation metadata is audit data: it may only be written as part of
-- the created -> cancelled transition. Exception: cancelled_by may become
-- NULL at any time, because the FK's ON DELETE SET NULL fires this same
-- trigger when the cancelling user's account is deleted.
IF NOT (OLD.status = 'created' AND NEW.status = 'cancelled') THEN
IF NEW.cancelled_at IS DISTINCT FROM OLD.cancelled_at THEN
RAISE EXCEPTION 'supplier_payment_batches: cancelled_at may only be set by the created -> cancelled transition';
END IF;
IF NEW.cancelled_by IS DISTINCT FROM OLD.cancelled_by AND NEW.cancelled_by IS NOT NULL THEN
RAISE EXCEPTION 'supplier_payment_batches: cancelled_by may only be set by the created -> cancelled transition';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER enforce_supplier_payment_batch_immutability
BEFORE UPDATE ON public.supplier_payment_batches
FOR EACH ROW EXECUTE FUNCTION public.enforce_supplier_payment_batch_immutability();
CREATE TRIGGER set_updated_at_supplier_payment_batches
BEFORE UPDATE ON public.supplier_payment_batches
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_supplier_payment_batches
AFTER INSERT OR UPDATE OR DELETE ON public.supplier_payment_batches
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
CREATE TRIGGER audit_supplier_payment_batch_items
AFTER INSERT OR UPDATE OR DELETE ON public.supplier_payment_batch_items
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
NOTIFY pgrst, 'reload schema';