Files
accounted/supabase/migrations/20260906135730_complete_invoice_rows_rpc.sql
39d409d257 fix(invoices): write migrated invoice rows through one locking RPC so two writers cannot double them (#2313) (#2340)
* fix(invoices): write migrated invoice rows through one locking RPC so two writers cannot double them

The row-completion pass (#2291) and the migration wizard both wrote
invoice_items for migrated sales invoices with check-then-insert across
separate statements and nothing serializing them per invoice; the pass
also wrote the header VAT split in a third statement, so "rows landed,
header did not" was reachable and never revisited.

Adds complete_invoice_rows (SECURITY DEFINER, FOR UPDATE on the invoice
scoped to the company, inserts only when the invoice still has no rows,
optional header split in the same transaction, returns wrote) and routes
both writers through it: the pass one call per invoice (wrote = false is
skipped, not completed), the wizard one call per invoice in small
concurrent groups. Unknown row keys and partial headers are refused
rather than dropped. Grants: revoked from PUBLIC and anon, kept for
authenticated (membership gate in the body) and service_role.

pg test proves the invariant (first call writes, second returns wrote =
false with rows and header unchanged), the rollback of rows on a failing
header, every refusal, the grants and two-connection serialization.

Closes #2313

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* fix(invoices): complete_invoice_rows requires the row's tax facts instead of defaulting vat_rate to 25

Review finding on #2340: COALESCE(r.vat_rate, 25) let a row without a
rate land with a fabricated 25 % (ML 17 kap 24 § p.9). Both writers
always send vat_rate, line_total, vat_amount and description, so the
defaults were never needed and only hid a bug. The RPC now refuses a
row missing any of the four (absent or JSON null) with
MISSING_REQUIRED naming the column; sort_order, quantity, unit and
line_type keep their table defaults since none states a tax fact.
The rate's value is deliberately not restricted to the Swedish set:
0 (omvänd skattskyldighet, export) and foreign rates (OSS) are
legitimate on a migrated row, and the pg test pins both as accepted.

Migration edited in place: unshipped, preview branches only.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:55:42 +02:00

191 lines
7.7 KiB
PL/PgSQL

-- complete_invoice_rows: write a migrated invoice's rows at most once.
--
-- Two writers put invoice_items under MIGRATED sales invoices: the migration
-- wizard (rows inserted milliseconds after the invoice header) and the hourly
-- row-completion pass (#2291, extensions/general/arcim-migration/lib/
-- complete-invoice-lines.ts) that fetches the rows the wizard's hydration
-- budget did not reach. Both were check-then-insert across two statements
-- with nothing serializing them per invoice: invoice_items carries only a
-- non-unique index on invoice_id, so two writers landing on the same invoice
-- at once both succeeded and doubled its rows (#2313, recorded as an accepted
-- residual on #2291). The pass also wrote the header VAT split in a third
-- statement, so "rows landed, header did not" was a reachable state that the
-- next run could not revisit (the invoice now had rows).
--
-- This RPC is the one write path for both. It locks the invoice row
-- (FOR UPDATE, scoped to the company), inserts the rows only when the invoice
-- still has none, and applies the optional header split in the same
-- transaction. A concurrent caller for the same invoice queues on the lock
-- and, once the first commits, reads the rows and returns wrote = false. The
-- invariant is "an invoice's rows are written at most once by the completion
-- writers"; it needs no unique index and therefore no clean-up of the legacy
-- rows that carry duplicate sort_order values within one invoice.
--
-- Column set: exactly what mapSalesInvoiceLine emits. An unknown key is
-- refused (UNKNOWN_COLUMN) rather than dropped, so a mapper that starts
-- emitting a column this function does not carry fails loudly instead of
-- silently losing it. The facts a sales row must state (description,
-- line_total, vat_rate, vat_amount; ML 17 kap 24 §) are required
-- (MISSING_REQUIRED) rather than defaulted: the table's DEFAULT 25 on
-- vat_rate would put a fabricated 25 % on a row whose source said nothing,
-- and both writers always send all four, so a missing one is a bug to
-- surface, not a gap to fill. Only sort_order, quantity, unit and line_type
-- take their table defaults; none of them states a tax fact. The rate is
-- not restricted to the Swedish set: 0 (omvänd skattskyldighet, export) and
-- foreign rates (OSS, unionsordningen) are legitimate on a migrated row.
--
-- Actor resolution mirrors the sibling definer RPCs: service_role callers
-- (the cron on createServiceClientNoCookies, auth.uid() NULL) are trusted,
-- the same trust a direct service-role insert already carries; every other
-- caller is pinned to auth.uid() and must hold a write role (owner, admin or
-- member) in p_company_id, the same gate as invoice_items_insert. A caller
-- with no JWT at all is refused.
--
-- pg-test: tests/pg/complete-invoice-rows-rpc.pg.test.ts
CREATE OR REPLACE FUNCTION public.complete_invoice_rows(
p_company_id uuid,
p_invoice_id uuid,
p_rows jsonb,
p_header jsonb DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_caller uuid;
v_locked uuid;
v_bad_key text;
v_missing text;
v_inserted integer := 0;
v_header_updated boolean := false;
BEGIN
IF COALESCE(auth.role(), '') <> 'service_role' THEN
v_caller := auth.uid();
IF v_caller IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'FORBIDDEN');
END IF;
-- SECURITY DEFINER bypasses RLS, so the write-role gate is explicit.
IF NOT EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = v_caller
AND cm.role IN ('owner', 'admin', 'member')
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'FORBIDDEN');
END IF;
END IF;
IF p_rows IS NULL OR jsonb_typeof(p_rows) <> 'array' OR jsonb_array_length(p_rows) = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'NO_ROWS');
END IF;
IF EXISTS (SELECT 1 FROM jsonb_array_elements(p_rows) AS e WHERE jsonb_typeof(e) <> 'object') THEN
RETURN jsonb_build_object('ok', false, 'code', 'INVALID_ROWS');
END IF;
SELECT k INTO v_bad_key
FROM jsonb_array_elements(p_rows) AS e, jsonb_object_keys(e) AS k
WHERE k NOT IN (
'sort_order', 'description', 'quantity', 'unit', 'unit_price',
'line_total', 'vat_rate', 'vat_amount', 'line_type'
)
LIMIT 1;
IF v_bad_key IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'UNKNOWN_COLUMN',
'details', jsonb_build_object('column', v_bad_key));
END IF;
-- Absent or JSON null: either would otherwise fall through to a default.
SELECT k INTO v_missing
FROM jsonb_array_elements(p_rows) AS e,
unnest(ARRAY['description', 'line_total', 'vat_rate', 'vat_amount']) AS k
WHERE NOT (e ? k) OR jsonb_typeof(e -> k) = 'null'
LIMIT 1;
IF v_missing IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'MISSING_REQUIRED',
'details', jsonb_build_object('column', v_missing));
END IF;
IF p_header IS NOT NULL THEN
-- All six or nothing: a partial header would null the columns it omits.
IF jsonb_typeof(p_header) <> 'object' OR NOT (p_header ?& ARRAY[
'subtotal', 'subtotal_sek', 'vat_amount', 'vat_amount_sek', 'vat_rate', 'vat_treatment'
]) THEN
RETURN jsonb_build_object('ok', false, 'code', 'INVALID_HEADER');
END IF;
END IF;
-- The per-invoice serialization point. A concurrent writer for the same
-- invoice waits here and sees the committed rows below.
SELECT i.id INTO v_locked
FROM public.invoices i
WHERE i.id = p_invoice_id
AND i.company_id = p_company_id
FOR UPDATE;
IF v_locked IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'INVOICE_NOT_FOUND');
END IF;
IF EXISTS (SELECT 1 FROM public.invoice_items ii WHERE ii.invoice_id = p_invoice_id) THEN
RETURN jsonb_build_object('ok', true, 'wrote', false, 'rows', 0, 'header_updated', false);
END IF;
INSERT INTO public.invoice_items
(invoice_id, sort_order, description, quantity, unit, unit_price,
line_total, vat_rate, vat_amount, line_type)
SELECT
p_invoice_id,
COALESCE(r.sort_order, 0),
r.description,
COALESCE(r.quantity, 1),
COALESCE(r.unit, 'st'),
COALESCE(r.unit_price, 0),
r.line_total,
r.vat_rate,
r.vat_amount,
COALESCE(r.line_type, 'product')
FROM jsonb_to_recordset(p_rows) AS r(
sort_order integer,
description text,
quantity numeric,
unit text,
unit_price numeric,
line_total numeric,
vat_rate numeric,
vat_amount numeric,
line_type text
);
GET DIAGNOSTICS v_inserted = ROW_COUNT;
IF p_header IS NOT NULL THEN
UPDATE public.invoices
SET subtotal = (p_header ->> 'subtotal')::numeric,
subtotal_sek = (p_header ->> 'subtotal_sek')::numeric,
vat_amount = (p_header ->> 'vat_amount')::numeric,
vat_amount_sek = (p_header ->> 'vat_amount_sek')::numeric,
vat_rate = (p_header ->> 'vat_rate')::numeric,
vat_treatment = p_header ->> 'vat_treatment'
WHERE id = p_invoice_id
AND company_id = p_company_id;
v_header_updated := true;
END IF;
RETURN jsonb_build_object(
'ok', true,
'wrote', true,
'rows', v_inserted,
'header_updated', v_header_updated
);
END;
$$;
REVOKE ALL ON FUNCTION public.complete_invoice_rows(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.complete_invoice_rows(uuid, uuid, jsonb, jsonb) TO authenticated, service_role;
COMMENT ON FUNCTION public.complete_invoice_rows(uuid, uuid, jsonb, jsonb) IS
'Writes a migrated invoice''s rows (and optionally its header VAT split) at most once: locks the invoice, inserts only when it has no rows, returns wrote = false otherwise. The one write path for the migration wizard and the row-completion pass.';
NOTIFY pgrst, 'reload schema';