f776e375c5
* 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>
178 lines
9.2 KiB
PL/PgSQL
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';
|