Files
accounted/supabase/migrations/20260827100000_create_supplier_payment_batch_rpc.sql
T
533df34369 fix(payments): make supplier payment batch creation atomic via create_supplier_payment_batch RPC (#1989)
createSupplierPaymentBatch wrote the batch header and its items as two
separate PostgREST inserts, and the active-batch recheck ran app-side
before either. Two concurrent creates selecting the same invoice could
both pass that check and both land an active batch without
confirm_already_batched, and an item-insert failure after the header
landed could leave an empty 'created' batch behind when the best-effort
cancel also failed.

The new SECURITY DEFINER RPC is now the single write path: it locks the
selected invoices FOR UPDATE in id order, re-checks payability, amounts
and active batches inside the transaction, and inserts header + items
together so a constraint violation rolls both back. TypeScript keeps the
shared eligibility evaluation and the msg_id minting (branding lives in
TS); the service result union is unchanged so the route and UI are
untouched.

Closes #1503


Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 22:24:36 +02:00

227 lines
10 KiB
PL/PgSQL

-- create_supplier_payment_batch: the single write path for supplier payment
-- batch (betalfil) creation. Closes #1503.
--
-- Before this RPC, createSupplierPaymentBatch wrote the batch header and its
-- items as two separate PostgREST inserts, and the active-batch recheck ran
-- app-side before either. Two failure modes followed:
--
-- 1. Race: two concurrent creates selecting the same invoice could both pass
-- the app-side active-batch check and both land an active batch without
-- confirm_already_batched, so the same invoice could be handed to the
-- bank twice.
-- 2. Empty header: if the item insert failed after the header landed, the
-- best-effort cancel of the header could itself fail, leaving an empty
-- 'created' batch behind (inert, because the file route refuses it, but
-- wrong in history).
--
-- This function is the authority. TypeScript keeps the eligibility, amount
-- and date evaluation (shared with the preview so nothing is created that the
-- preview would not show) and the minting of the batch id + pain.001 MsgId
-- (MsgId derives from the branded app name, a TS-only white-label concept).
-- The RPC then, inside ONE transaction:
--
-- a. locks the selected supplier_invoices FOR UPDATE, in id order so two
-- overlapping concurrent creates queue instead of deadlocking;
-- b. re-checks the invoices under the lock (still present, still payable,
-- amount still within remaining), reusing the service's result codes;
-- c. re-checks active batches AFTER the lock unless the caller confirmed
-- already-batched invoices: under READ COMMITTED a creator that waited on
-- the lock sees the winner's committed items here, which is the recheck
-- the app-side pre-check cannot provide;
-- d. inserts header + items. Totals are computed from the items so header
-- and rows can never disagree, and a constraint violation on either
-- table (payee_fields_match, uq_supplier_payment_batch_invoice, the
-- composite company FKs, the CHECKs) aborts the whole call: the header
-- can no longer outlive its items.
--
-- Domain refusals return jsonb {ok:false, code, details} in the
-- match_batch_allocate shape: no write precedes them, so nothing needs
-- rolling back and the service maps them onto its existing result union.
-- The tenant guard RAISEs 42501 (detach_underlag_duplicate shape): JWT callers
-- must be members of p_company_id and always act as auth.uid(); p_user_id is
-- honored only for service-role callers, which authenticate the user
-- application-side. The guard parses request.jwt.claims directly rather than
-- calling auth.role(): the CI auth shim leaves auth.role() NULL under a
-- claims-only session.
--
-- The write_audit_log triggers on both tables fire as before; the RLS insert
-- policies are bypassed by SECURITY DEFINER, which is why the membership guard
-- above is mandatory.
CREATE OR REPLACE FUNCTION public.create_supplier_payment_batch(
p_company_id uuid,
p_batch_id uuid,
p_format text,
p_msg_id text,
p_debtor_snapshot jsonb,
p_items jsonb,
p_confirm_already_batched boolean DEFAULT false,
p_user_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_actor uuid := COALESCE(p_user_id, auth.uid());
v_caller_role text;
v_ids uuid[];
v_missing jsonb;
v_not_payable jsonb;
v_excessive jsonb;
v_already jsonb;
v_total numeric;
v_count integer;
v_batch public.supplier_payment_batches%ROWTYPE;
BEGIN
-- 1. Actor + tenant guard.
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF NOT public.caller_is_company_member(p_company_id) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
-- A JWT caller can never act as someone else: p_user_id is only for
-- service-role paths, which authenticate the user application-side.
v_actor := auth.uid();
END IF;
IF v_actor IS NULL THEN
RAISE EXCEPTION 'unauthorized: no actor' USING ERRCODE = '42501';
END IF;
SELECT cm.role INTO v_caller_role
FROM public.company_members cm
WHERE cm.company_id = p_company_id AND cm.user_id = v_actor;
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin', 'member') THEN
RAISE EXCEPTION 'unauthorized: caller has no write role in company %', p_company_id
USING ERRCODE = '42501';
END IF;
-- 2. Payload shape.
IF p_batch_id IS NULL OR p_msg_id IS NULL OR p_debtor_snapshot IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'create_failed', 'details', 'missing header fields');
END IF;
IF jsonb_typeof(p_items) IS DISTINCT FROM 'array' OR jsonb_array_length(p_items) = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'create_failed', 'details', 'no items');
END IF;
IF p_format NOT IN ('pain001', 'bg_lb') THEN
RETURN jsonb_build_object('ok', false, 'code', 'create_failed', 'details', 'unsupported format');
END IF;
SELECT array_agg((x ->> 'supplier_invoice_id')::uuid)
INTO v_ids
FROM jsonb_array_elements(p_items) AS x;
-- 3. Lock the invoices in id order. Two concurrent creates with overlapping
-- invoice sets queue on the first shared row instead of deadlocking, and
-- the loser proceeds only after the winner has committed or rolled back.
PERFORM si.id
FROM public.supplier_invoices si
WHERE si.company_id = p_company_id AND si.id = ANY(v_ids)
ORDER BY si.id
FOR UPDATE;
-- 4. Defense-in-depth rechecks under the lock. TypeScript already evaluated
-- every invoice; these close the "changed meanwhile" window. The payable
-- status list must match PAYABLE_SUPPLIER_INVOICE_STATUSES in
-- lib/payments/batch-eligibility.ts.
SELECT jsonb_agg(jsonb_build_object('id', x.id, 'reason', 'not_found'))
INTO v_missing
FROM unnest(v_ids) AS x(id)
WHERE NOT EXISTS (
SELECT 1 FROM public.supplier_invoices si
WHERE si.id = x.id AND si.company_id = p_company_id
);
IF v_missing IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'ineligible', 'details', v_missing);
END IF;
SELECT jsonb_agg(jsonb_build_object(
'id', si.id,
'reason', CASE WHEN si.is_credit_note THEN 'credit_note' ELSE 'not_payable' END))
INTO v_not_payable
FROM public.supplier_invoices si
WHERE si.company_id = p_company_id
AND si.id = ANY(v_ids)
AND (si.status NOT IN ('registered', 'approved', 'partially_paid', 'overdue')
OR si.is_credit_note);
IF v_not_payable IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'ineligible', 'details', v_not_payable);
END IF;
SELECT jsonb_agg(jsonb_build_object('id', si.id))
INTO v_excessive
FROM jsonb_array_elements(p_items) AS x
JOIN public.supplier_invoices si
ON si.id = (x ->> 'supplier_invoice_id')::uuid AND si.company_id = p_company_id
WHERE (x ->> 'amount')::numeric > si.remaining_amount + 0.005;
IF v_excessive IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'amount_exceeds_remaining', 'details', v_excessive);
END IF;
-- 5. The recheck #1503 asks for: active batches, inside the transaction,
-- after the lock. A creator that waited on step 3 sees the winner's
-- committed items here (READ COMMITTED: each statement in a VOLATILE
-- function takes a fresh snapshot).
IF NOT COALESCE(p_confirm_already_batched, false) THEN
SELECT jsonb_agg(jsonb_build_object('id', i.supplier_invoice_id, 'batch_id', b.id))
INTO v_already
FROM public.supplier_payment_batch_items i
JOIN public.supplier_payment_batches b
ON b.id = i.batch_id AND b.company_id = i.company_id
WHERE i.company_id = p_company_id
AND b.status = 'created'
AND i.supplier_invoice_id = ANY(v_ids);
IF v_already IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'already_batched', 'details', v_already);
END IF;
END IF;
-- 6. Header + items in this one transaction. Totals come from the items so
-- header and rows can never disagree; the table CHECKs (amount > 0,
-- total_amount > 0, item_count > 0, payee_fields_match) and the
-- uniqueness / composite company FKs still fire and abort the whole call.
SELECT round(sum((x ->> 'amount')::numeric), 2), count(*)
INTO v_total, v_count
FROM jsonb_array_elements(p_items) AS x;
INSERT INTO public.supplier_payment_batches
(id, company_id, user_id, format, status, currency, total_amount, item_count, msg_id, debtor_snapshot)
VALUES
(p_batch_id, p_company_id, v_actor, p_format, 'created', 'SEK', v_total, v_count, p_msg_id, p_debtor_snapshot)
RETURNING * INTO v_batch;
INSERT INTO public.supplier_payment_batch_items
(batch_id, company_id, supplier_invoice_id, amount, payment_date, payee_type,
payee_bankgiro, payee_plusgiro, payee_clearing, payee_account, payee_name, payee_city,
reference_type, reference)
SELECT p_batch_id, p_company_id, r.supplier_invoice_id, r.amount, r.payment_date, r.payee_type,
r.payee_bankgiro, r.payee_plusgiro, r.payee_clearing, r.payee_account, r.payee_name, r.payee_city,
r.reference_type, r.reference
FROM jsonb_to_recordset(p_items) AS r(
supplier_invoice_id uuid,
amount numeric,
payment_date date,
payee_type text,
payee_bankgiro text,
payee_plusgiro text,
payee_clearing text,
payee_account text,
payee_name text,
payee_city text,
reference_type text,
reference text
);
RETURN jsonb_build_object('ok', true, 'batch', to_jsonb(v_batch));
END;
$function$;
REVOKE ALL ON FUNCTION public.create_supplier_payment_batch(uuid, uuid, text, text, jsonb, jsonb, boolean, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.create_supplier_payment_batch(uuid, uuid, text, text, jsonb, jsonb, boolean, uuid) TO authenticated, service_role;
NOTIFY pgrst, 'reload schema';