fix(matching): make match_batch_allocate commit from the MCP approval path (#1645)

The pending-operations commit path runs on the cookieless service client,
where auth.uid() is NULL, so every MCP-approved batch allocation returned
BATCH_UNAUTHORIZED since 20260601122000 dropped the p_user_id argument.
The web /pending path only worked because it carries a cookie session.

Re-add p_user_id gated exactly like undo_sie_import (20260727121000):
honored only when auth.role() = 'service_role', every other caller is
pinned to its own auth.uid(), so an authenticated PostgREST caller cannot
impersonate. The commit handler now passes the approving user through,
which also attributes the journal entry and payment rows to the human
who approved instead of failing outright. The 3-arg signature is dropped
(the new 4th arg has a DEFAULT, so the HTTP twin's 3-arg call still
resolves); grants re-asserted: no PUBLIC/anon, authenticated +
service_role only.

Reported via gnubok_feedback 2026-07-24 (codex/hermes) and 2026-08-06.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-17 22:20:52 +02:00
committed by GitHub
parent 1470596591
commit e70718eea3
3 changed files with 729 additions and 4 deletions
+8 -4
View File
@@ -5624,16 +5624,19 @@ async function commitSubmitAgi(
async function commitMatchBatchAllocate(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
// Trust boundary (compliance-swarm V8.2.1, A.8.2):
// Tenant isolation is enforced authoritatively inside the SQL RPC
// `match_batch_allocate` (supabase/migrations/20260601122000_*.sql):
// `match_batch_allocate` (supabase/migrations/20260817150000_*.sql):
// - `transactions` row fetched WHERE id = p_tx_id AND company_id = p_company_id
// - `invoices` and `supplier_invoices` rows fetched WHERE id = ? AND company_id = p_company_id
// - `auth.uid()` resolves the caller; membership checked against
// `company_members.company_id = p_company_id`
// - the actor resolves from auth.uid(), with p_user_id honored only for
// service_role callers (this commit path runs on the cookieless
// service client, where auth.uid() is NULL); membership checked
// against `company_members.company_id = p_company_id`
// The MCP execute() handler additionally pre-checks the same IDs to
// surface clean errors before staging. This commit handler is a thin
// pass-through by design: re-querying here would triple the same
@@ -5648,6 +5651,7 @@ async function commitMatchBatchAllocate(
p_tx_id: txId,
p_allocations: allocations,
p_company_id: companyId,
p_user_id: userId,
})
if (error) {
// Sanitised log (A.8.11, CC7.2): only error code + message, no
@@ -6118,7 +6122,7 @@ async function commitPendingOperationInner(
result = await commitVacationYearClose(supabase, userId, companyId, pendingOp.params)
break
case 'match_batch_allocate':
result = await commitMatchBatchAllocate(supabase, companyId, pendingOp.params)
result = await commitMatchBatchAllocate(supabase, userId, companyId, pendingOp.params)
break
case 'bulk_book_transactions':
result = await commitBulkBookTransactions(supabase, companyId, pendingOp.params)
@@ -0,0 +1,520 @@
-- match_batch_allocate: honor an explicit actor for service-role callers.
--
-- The pending-operations commit path runs on the cookieless service client
-- (createServiceClientNoCookies), where auth.uid() is NULL, so EVERY
-- MCP-approved batch allocation returned BATCH_UNAUTHORIZED since
-- 20260601122000 deliberately dropped the old p_user_id argument. The web
-- /pending path only worked because it happens to carry a cookie session.
-- Reported via gnubok_feedback 2026-07-24 (codex/hermes) and 2026-08-06.
--
-- Fix: re-add p_user_id, but gated exactly like undo_sie_import
-- (20260727121000): the parameter is honored only when
-- auth.role() = 'service_role'; every other caller resolves from its own
-- auth.uid(), so an authenticated PostgREST caller cannot impersonate.
-- v_caller also stamps journal_entries.user_id and the payment rows, so
-- service-path commits are now attributed to the approving human instead
-- of failing outright.
--
-- The body is byte-for-byte the 20260801204551 definition (the latest:
-- payment-date paid_at) plus the header parameter and the actor-resolution
-- block. The 3-arg signature is dropped to avoid PostgREST overload
-- ambiguity; grants are re-asserted below because DROP discards them.
--
-- pg-test: tests/pg/match-batch-allocate-service-actor.pg.test.ts
DROP FUNCTION IF EXISTS public.match_batch_allocate(uuid, jsonb, uuid);
CREATE OR REPLACE FUNCTION public.match_batch_allocate(
p_tx_id uuid,
p_allocations jsonb,
p_company_id uuid,
p_user_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_tx RECORD;
v_tx_abs numeric;
v_tx_date_short text;
v_allocation jsonb;
v_alloc_index int := 0;
v_kind text;
v_invoice_id uuid;
v_supplier_invoice_id uuid;
v_alloc_amount numeric;
v_total_allocated numeric := 0;
v_has_customer boolean := false;
v_has_supplier boolean := false;
v_seen_ids text[] := ARRAY[]::text[];
v_target_id text;
v_invoice RECORD;
v_si_invoice RECORD;
v_supplier_name text;
v_supplier_invoice_number text;
v_invoice_number text;
v_fiscal_period_id uuid;
v_period_is_closed boolean;
v_period_locked_at timestamptz;
v_journal_entry_id uuid := gen_random_uuid();
v_voucher_series text := 'A';
v_voucher_number int;
v_entry_description text;
v_source_type text;
v_line_sort_order int := 0;
v_new_paid numeric;
v_new_remaining numeric;
v_new_status text;
v_now timestamptz := now();
v_payment_id uuid;
v_results jsonb := '[]'::jsonb;
v_inv_remaining numeric;
v_inv_currency text;
v_inv_fx_rate numeric;
v_inv_total numeric;
v_booked_sek numeric;
v_fx_diff numeric;
v_paid_in_inv_currency numeric;
v_payment_rate numeric; -- round-3 (swedish-compliance traceability)
v_inv_number_short text;
v_caller uuid;
BEGIN
-- Actor resolution. p_user_id is an assertion by the caller, so it is
-- honored ONLY when the caller holds the service role (the cookieless
-- server client used by the pending-operations commit path, where
-- auth.uid() is NULL). Any other caller is pinned to its own auth.uid():
-- otherwise an authenticated PostgREST caller could pass another user's
-- UUID and walk through the membership gate below. Same shape as
-- undo_sie_import (20260727121000).
IF auth.role() = 'service_role' THEN
v_caller := COALESCE(p_user_id, auth.uid());
ELSE
v_caller := auth.uid();
END IF;
IF v_caller IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED');
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.company_members
WHERE user_id = v_caller AND company_id = p_company_id
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_UNAUTHORIZED');
END IF;
SELECT * INTO v_tx FROM public.transactions
WHERE id = p_tx_id AND company_id = p_company_id FOR UPDATE;
IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_NOT_FOUND'); END IF;
IF v_tx.journal_entry_id IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ALREADY_BOOKED',
'details', jsonb_build_object('journal_entry_id', v_tx.journal_entry_id));
END IF;
IF v_tx.amount = 0 THEN RETURN jsonb_build_object('ok', false, 'code', 'BATCH_TX_ZERO_AMOUNT'); END IF;
v_tx_abs := ABS(v_tx.amount);
v_tx_date_short := LEFT(v_tx.date::text, 10);
IF jsonb_typeof(p_allocations) IS DISTINCT FROM 'array' OR jsonb_array_length(p_allocations) = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_ALLOCATIONS');
END IF;
FOR v_allocation IN
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
LOOP
v_kind := v_allocation->>'kind';
v_alloc_amount := (v_allocation->>'amount')::numeric;
v_target_id := COALESCE(v_allocation->>'invoice_id', v_allocation->>'supplier_invoice_id');
IF v_alloc_amount IS NULL OR v_alloc_amount <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_AMOUNT',
'details', jsonb_build_object('index', v_alloc_index, 'amount', v_alloc_amount));
END IF;
IF v_target_id IS NOT NULL AND v_target_id = ANY(v_seen_ids) THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DUPLICATE_ALLOCATION',
'details', jsonb_build_object('id', v_target_id, 'index', v_alloc_index));
END IF;
IF v_target_id IS NOT NULL THEN v_seen_ids := array_append(v_seen_ids, v_target_id); END IF;
v_total_allocated := v_total_allocated + v_alloc_amount;
IF v_kind = 'customer_invoice' THEN
v_has_customer := true;
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
SELECT * INTO v_invoice FROM public.invoices
WHERE id = v_invoice_id AND company_id = p_company_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_FOUND',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id));
END IF;
IF v_invoice.status NOT IN ('sent', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVOICE_NOT_OPEN',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id, 'status', v_invoice.status));
END IF;
v_inv_remaining := COALESCE(v_invoice.remaining_amount, v_invoice.total);
v_inv_currency := v_invoice.currency;
v_inv_fx_rate := v_invoice.exchange_rate;
IF v_inv_currency = v_tx.currency THEN
IF v_alloc_amount > v_inv_remaining + 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
'requested', v_alloc_amount, 'remaining', v_inv_remaining));
END IF;
ELSE
IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 OR v_inv_fx_rate >= 100000 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_RATE_MISSING',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
'invoice_currency', v_inv_currency));
END IF;
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
IF ABS(v_alloc_amount - v_booked_sek) > v_booked_sek * 0.10 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_DEVIATION_TOO_LARGE',
'details', jsonb_build_object('index', v_alloc_index, 'invoice_id', v_invoice_id,
'allocation_amount', v_alloc_amount, 'expected_sek', v_booked_sek));
END IF;
END IF;
ELSIF v_kind = 'supplier_invoice' THEN
v_has_supplier := true;
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
SELECT * INTO v_si_invoice FROM public.supplier_invoices
WHERE id = v_supplier_invoice_id AND company_id = p_company_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_FOUND',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id));
END IF;
IF v_si_invoice.status NOT IN ('registered', 'approved', 'overdue', 'partially_paid') THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_SUPPLIER_INVOICE_NOT_OPEN',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id, 'status', v_si_invoice.status));
END IF;
v_inv_remaining := COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total);
v_inv_currency := v_si_invoice.currency;
v_inv_fx_rate := v_si_invoice.exchange_rate;
IF v_inv_currency = v_tx.currency THEN
IF v_alloc_amount > v_inv_remaining + 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_OVERSHOOT',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
'requested', v_alloc_amount, 'remaining', v_inv_remaining));
END IF;
ELSE
IF v_inv_fx_rate IS NULL OR v_inv_fx_rate <= 0 OR v_inv_fx_rate >= 100000 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_RATE_MISSING',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
'invoice_currency', v_inv_currency));
END IF;
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
IF ABS(v_alloc_amount - v_booked_sek) > v_booked_sek * 0.10 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_FX_DEVIATION_TOO_LARGE',
'details', jsonb_build_object('index', v_alloc_index, 'supplier_invoice_id', v_supplier_invoice_id,
'allocation_amount', v_alloc_amount, 'expected_sek', v_booked_sek));
END IF;
END IF;
ELSE
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_INVALID_KIND',
'details', jsonb_build_object('index', v_alloc_index, 'kind', v_kind));
END IF;
v_alloc_index := v_alloc_index + 1;
END LOOP;
IF v_has_customer AND v_has_supplier THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_MIXED_KINDS_UNSUPPORTED');
END IF;
IF v_total_allocated > v_tx_abs + 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_EXCEEDS_TX',
'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs));
END IF;
IF v_total_allocated < v_tx_abs - 0.005 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_AMOUNT_BELOW_TX',
'details', jsonb_build_object('allocated', v_total_allocated, 'tx_amount_abs', v_tx_abs));
END IF;
IF v_has_customer AND v_tx.amount <= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
'details', jsonb_build_object('expected', 'income', 'tx_amount', v_tx.amount));
END IF;
IF v_has_supplier AND v_tx.amount >= 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_DIRECTION_MISMATCH',
'details', jsonb_build_object('expected', 'expense', 'tx_amount', v_tx.amount));
END IF;
SELECT id, is_closed, locked_at INTO v_fiscal_period_id, v_period_is_closed, v_period_locked_at
FROM public.fiscal_periods
WHERE company_id = p_company_id AND v_tx.date BETWEEN period_start AND period_end
ORDER BY period_start DESC LIMIT 1;
IF v_fiscal_period_id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_NO_FISCAL_PERIOD',
'details', jsonb_build_object('tx_date', v_tx.date));
END IF;
IF v_period_is_closed OR v_period_locked_at IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'BATCH_PERIOD_LOCKED',
'details', jsonb_build_object('fiscal_period_id', v_fiscal_period_id,
'is_closed', v_period_is_closed, 'locked_at', v_period_locked_at));
END IF;
v_entry_description := CASE WHEN v_has_customer THEN 'Samlingsinbetalning ' || v_tx_date_short ELSE 'Samlingsbetalning ' || v_tx_date_short END;
v_source_type := CASE WHEN v_has_customer THEN 'invoice_paid' ELSE 'supplier_invoice_paid' END;
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES
(v_journal_entry_id, v_caller, p_company_id, v_fiscal_period_id, 0, v_voucher_series,
v_tx.date, v_entry_description, v_source_type, 'draft');
v_alloc_index := 0;
FOR v_allocation IN
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
LOOP
v_alloc_amount := (v_allocation->>'amount')::numeric;
IF v_has_customer THEN
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
SELECT invoice_number, currency, exchange_rate, remaining_amount, total
INTO v_invoice_number, v_inv_currency, v_inv_fx_rate, v_inv_remaining, v_inv_total
FROM public.invoices
WHERE id = v_invoice_id AND company_id = p_company_id;
v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
v_inv_number_short := LEFT(COALESCE(v_invoice_number, ''), 32);
IF v_inv_currency = v_tx.currency THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1510', 0, v_alloc_amount, v_tx.currency, v_line_sort_order,
'Faktura ' || v_inv_number_short);
v_line_sort_order := v_line_sort_order + 1;
ELSE
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
v_fx_diff := ROUND((v_booked_sek - v_alloc_amount) * 100) / 100;
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1510', 0, v_booked_sek, v_tx.currency, v_line_sort_order,
'Faktura ' || v_inv_number_short || ' (' || v_inv_currency || ')');
v_line_sort_order := v_line_sort_order + 1;
IF ABS(v_fx_diff) > 0.005 THEN
IF v_fx_diff > 0 THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '7960', v_fx_diff, 0, v_tx.currency, v_line_sort_order,
'Valutakursförlust ' || v_inv_number_short);
ELSE
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '3960', 0, ABS(v_fx_diff), v_tx.currency, v_line_sort_order,
'Valutakursvinst ' || v_inv_number_short);
END IF;
v_line_sort_order := v_line_sort_order + 1;
END IF;
END IF;
ELSE
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
SELECT si.supplier_invoice_number, s.name, si.currency, si.exchange_rate,
si.remaining_amount, si.total
INTO v_supplier_invoice_number, v_supplier_name, v_inv_currency, v_inv_fx_rate,
v_inv_remaining, v_inv_total
FROM public.supplier_invoices si LEFT JOIN public.suppliers s ON s.id = si.supplier_id
WHERE si.id = v_supplier_invoice_id AND si.company_id = p_company_id;
v_inv_remaining := COALESCE(v_inv_remaining, v_inv_total);
v_inv_number_short := LEFT(COALESCE(v_supplier_invoice_number, ''), 32);
IF v_inv_currency = v_tx.currency THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '2440', v_alloc_amount, 0, v_tx.currency, v_line_sort_order,
TRIM(BOTH ' - ' FROM COALESCE(v_supplier_name, '') || ' - ' || v_inv_number_short));
v_line_sort_order := v_line_sort_order + 1;
ELSE
v_booked_sek := ROUND(v_inv_remaining * v_inv_fx_rate * 100) / 100;
v_fx_diff := ROUND((v_booked_sek - v_alloc_amount) * 100) / 100;
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '2440', v_booked_sek, 0, v_tx.currency, v_line_sort_order,
TRIM(BOTH ' - ' FROM
COALESCE(v_supplier_name, '') || ' - ' || v_inv_number_short
|| ' (' || v_inv_currency || ')'));
v_line_sort_order := v_line_sort_order + 1;
IF ABS(v_fx_diff) > 0.005 THEN
IF v_fx_diff > 0 THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '3960', 0, v_fx_diff, v_tx.currency, v_line_sort_order,
'Valutakursvinst ' || v_inv_number_short);
ELSE
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '7960', ABS(v_fx_diff), 0, v_tx.currency, v_line_sort_order,
'Valutakursförlust ' || v_inv_number_short);
END IF;
v_line_sort_order := v_line_sort_order + 1;
END IF;
END IF;
END IF;
v_alloc_index := v_alloc_index + 1;
END LOOP;
IF v_has_customer THEN
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1930', v_tx_abs, 0, v_tx.currency, v_line_sort_order,
'Inbetalning ' || v_tx_date_short);
ELSE
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency,
sort_order, line_description)
VALUES
(v_journal_entry_id, '1930', 0, v_tx_abs, v_tx.currency, v_line_sort_order,
'Utbetalning ' || v_tx_date_short);
END IF;
SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_journal_entry_id);
v_alloc_index := 0;
FOR v_allocation IN
SELECT value FROM jsonb_array_elements(p_allocations) AS t(value)
ORDER BY COALESCE(value->>'invoice_id', value->>'supplier_invoice_id', '')
LOOP
v_alloc_amount := (v_allocation->>'amount')::numeric;
IF v_has_customer THEN
v_invoice_id := (v_allocation->>'invoice_id')::uuid;
SELECT * INTO v_invoice FROM public.invoices
WHERE id = v_invoice_id AND company_id = p_company_id;
IF v_invoice.currency = v_tx.currency THEN
v_paid_in_inv_currency := v_alloc_amount;
v_payment_rate := NULL; -- same-currency: no FX context
ELSE
v_paid_in_inv_currency := COALESCE(v_invoice.remaining_amount, v_invoice.total);
-- Round-3: effective payment-day rate. SEK_paid / foreign_remaining.
IF v_paid_in_inv_currency > 0 THEN
v_payment_rate := ROUND((v_alloc_amount / v_paid_in_inv_currency) * 1000000) / 1000000;
ELSE
v_payment_rate := NULL;
END IF;
END IF;
v_new_paid := ROUND((COALESCE(v_invoice.paid_amount, 0) + v_paid_in_inv_currency) * 100) / 100;
v_new_remaining := GREATEST(0,
ROUND((COALESCE(v_invoice.remaining_amount, v_invoice.total) - v_paid_in_inv_currency) * 100) / 100);
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
UPDATE public.invoices SET status = v_new_status,
paid_at = CASE WHEN v_new_status = 'paid' THEN
((v_tx.date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
ELSE paid_at END,
paid_amount = v_new_paid, remaining_amount = v_new_remaining, updated_at = v_now
WHERE id = v_invoice_id AND company_id = p_company_id;
INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, exchange_rate,
payment_exchange_rate, journal_entry_id, transaction_id)
VALUES
(v_caller, p_company_id, v_invoice_id, v_tx.date, v_paid_in_inv_currency, v_invoice.currency,
v_invoice.exchange_rate, v_payment_rate, v_journal_entry_id, p_tx_id)
RETURNING id INTO v_payment_id;
v_results := v_results || jsonb_build_array(jsonb_build_object(
'kind', 'customer_invoice', 'invoice_id', v_invoice_id, 'payment_id', v_payment_id,
'status', v_new_status, 'paid_amount', v_new_paid, 'remaining_amount', v_new_remaining,
'amount', v_alloc_amount,
'cross_currency', v_invoice.currency <> v_tx.currency));
ELSE
v_supplier_invoice_id := (v_allocation->>'supplier_invoice_id')::uuid;
SELECT * INTO v_si_invoice FROM public.supplier_invoices
WHERE id = v_supplier_invoice_id AND company_id = p_company_id;
IF v_si_invoice.currency = v_tx.currency THEN
v_paid_in_inv_currency := v_alloc_amount;
v_payment_rate := NULL;
ELSE
v_paid_in_inv_currency := COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total);
IF v_paid_in_inv_currency > 0 THEN
v_payment_rate := ROUND((v_alloc_amount / v_paid_in_inv_currency) * 1000000) / 1000000;
ELSE
v_payment_rate := NULL;
END IF;
END IF;
v_new_paid := ROUND((COALESCE(v_si_invoice.paid_amount, 0) + v_paid_in_inv_currency) * 100) / 100;
v_new_remaining := GREATEST(0,
ROUND((COALESCE(v_si_invoice.remaining_amount, v_si_invoice.total) - v_paid_in_inv_currency) * 100) / 100);
v_new_status := CASE WHEN v_new_remaining <= 0.005 THEN 'paid' ELSE 'partially_paid' END;
UPDATE public.supplier_invoices SET status = v_new_status,
paid_at = CASE WHEN v_new_status = 'paid' THEN
((v_tx.date::timestamp + interval '12 hours') AT TIME ZONE 'UTC')
ELSE paid_at END,
paid_amount = v_new_paid, remaining_amount = v_new_remaining,
payment_journal_entry_id = v_journal_entry_id, updated_at = v_now
WHERE id = v_supplier_invoice_id AND company_id = p_company_id;
INSERT INTO public.supplier_invoice_payments
(user_id, company_id, supplier_invoice_id, payment_date, amount, currency, exchange_rate,
payment_exchange_rate, journal_entry_id, transaction_id)
VALUES
(v_caller, p_company_id, v_supplier_invoice_id, v_tx.date, v_paid_in_inv_currency,
v_si_invoice.currency, v_si_invoice.exchange_rate, v_payment_rate, v_journal_entry_id, p_tx_id)
RETURNING id INTO v_payment_id;
v_results := v_results || jsonb_build_array(jsonb_build_object(
'kind', 'supplier_invoice', 'supplier_invoice_id', v_supplier_invoice_id,
'payment_id', v_payment_id, 'status', v_new_status, 'paid_amount', v_new_paid,
'remaining_amount', v_new_remaining, 'amount', v_alloc_amount,
'cross_currency', v_si_invoice.currency <> v_tx.currency));
END IF;
v_alloc_index := v_alloc_index + 1;
END LOOP;
UPDATE public.transactions SET journal_entry_id = v_journal_entry_id, is_business = TRUE,
invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_customer AND ABS(v_total_allocated - v_tx_abs) < 0.005
THEN (p_allocations->0->>'invoice_id')::uuid ELSE NULL END,
supplier_invoice_id = CASE WHEN jsonb_array_length(p_allocations) = 1 AND v_has_supplier AND ABS(v_total_allocated - v_tx_abs) < 0.005
THEN (p_allocations->0->>'supplier_invoice_id')::uuid ELSE NULL END,
potential_invoice_id = NULL, potential_supplier_invoice_id = NULL,
updated_at = v_now WHERE id = p_tx_id AND company_id = p_company_id;
RETURN jsonb_build_object('ok', true, 'journal_entry_id', v_journal_entry_id,
'voucher_series', v_voucher_series, 'voucher_number', v_voucher_number,
'tx_id', p_tx_id, 'allocations', v_results, 'total_allocated', v_total_allocated,
'leftover', 0);
END;
$$;
-- Least privilege, same discipline as undo_sie_import (20260727121000):
-- the fresh CREATE picks up Supabase default grants (PUBLIC + anon +
-- authenticated + service_role), so PUBLIC and anon are revoked explicitly
-- and the two legitimate callers are re-asserted.
REVOKE EXECUTE ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid, uuid) TO authenticated, service_role;
COMMENT ON FUNCTION public.match_batch_allocate(uuid, jsonb, uuid, uuid) IS
'Books one bank transaction against N invoices/supplier invoices in a single samlingsverifikat. p_user_id is honored only for service_role callers (the pending-operations commit path); every other caller resolves from its own auth.uid(). Not callable by anon.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,201 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
} from '@/tests/pg/fixtures'
import { getPool, runAsServiceRole, withUserContext } from '@/tests/pg/setup'
/**
* Covers 20260817150000_match_batch_allocate_service_actor:
* - service_role caller + p_user_id of a member: allocation commits and
* the journal entry / payment rows are attributed to that user. This is
* the pending-operations commit path (createServiceClientNoCookies),
* which before the migration ALWAYS got BATCH_UNAUTHORIZED because
* auth.uid() is NULL on the service client.
* - p_user_id is an assertion, honored ONLY under auth.role() =
* 'service_role': an authenticated non-member spoofing an owner's UUID
* and a caller with no JWT at all both stay BATCH_UNAUTHORIZED.
* - Grants: PUBLIC/anon revoked, authenticated + service_role kept; the
* old 3-arg signature is gone (the 4th arg has a DEFAULT, so 3-arg
* call sites still resolve).
*/
let arrivalSeq = 0
async function insertSupplier(params: { userId: string; companyId: string }): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.suppliers
(id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency)
VALUES ($1, $2, $3, 'Leverantör AB', 'swedish_business', 'SE', 30, 'SEK')`,
[id, params.userId, params.companyId],
)
return id
}
async function insertSupplierInvoice(params: {
userId: string
companyId: string
supplierId: string
total: number
}): Promise<string> {
const id = randomUUID()
const arrivalNumber = (Date.now() % 1_000_000) * 1000 + arrivalSeq++
await getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
invoice_date, due_date, received_date, status, currency,
subtotal, vat_amount, total, paid_amount, remaining_amount,
vat_treatment, reverse_charge, is_credit_note)
VALUES ($1, $2, $3, $4, $5, $6, '2026-06-01', '2026-07-01', '2026-06-01', 'approved', 'SEK',
$7, 0, $7, 0, $7, 'standard_25', false, false)`,
[id, params.userId, params.companyId, params.supplierId, arrivalNumber, `LF-${arrivalNumber}`, params.total],
)
return id
}
async function insertTransaction(params: {
userId: string
companyId: string
amount: number
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.transactions
(id, user_id, company_id, date, description, amount, currency, category)
VALUES ($1, $2, $3, '2026-06-05', 'Bank transfer', $4, 'SEK', 'uncategorized')`,
[id, params.userId, params.companyId, params.amount],
)
return id
}
async function seedTenant() {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
await insertFiscalPeriod({
userId,
companyId,
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
})
const supplierId = await insertSupplier({ userId, companyId })
const invoiceId = await insertSupplierInvoice({ userId, companyId, supplierId, total: 2000 })
const txId = await insertTransaction({ userId, companyId, amount: -2000 })
return { userId, companyId, invoiceId, txId }
}
interface RpcResult {
ok: boolean
code?: string
journal_entry_id?: string
}
const CALL = `SELECT match_batch_allocate($1, $2::jsonb, $3, $4) AS result`
describe('match_batch_allocate service actor', () => {
it('commits for a service_role caller with p_user_id of a member and attributes rows to that user', async () => {
const { userId, companyId, invoiceId, txId } = await seedTenant()
const allocations = [{ kind: 'supplier_invoice', supplier_invoice_id: invoiceId, amount: 2000 }]
const result = await runAsServiceRole(async (client) => {
const r = await client.query<{ result: RpcResult }>(CALL, [
txId,
JSON.stringify(allocations),
companyId,
userId,
])
return r.rows[0]!.result
})
expect(result.ok).toBe(true)
expect(result.journal_entry_id).toBeTruthy()
const je = await getPool().query<{ user_id: string }>(
`SELECT user_id FROM public.journal_entries WHERE id = $1`,
[result.journal_entry_id],
)
expect(je.rows[0]!.user_id).toBe(userId)
const payment = await getPool().query<{ user_id: string }>(
`SELECT user_id FROM public.supplier_invoice_payments WHERE journal_entry_id = $1`,
[result.journal_entry_id],
)
expect(payment.rows).toHaveLength(1)
expect(payment.rows[0]!.user_id).toBe(userId)
})
it('still rejects a service_role caller that passes no p_user_id', async () => {
const { companyId, invoiceId, txId } = await seedTenant()
const allocations = [{ kind: 'supplier_invoice', supplier_invoice_id: invoiceId, amount: 2000 }]
const result = await runAsServiceRole(async (client) => {
const r = await client.query<{ result: RpcResult }>(CALL, [
txId,
JSON.stringify(allocations),
companyId,
null,
])
return r.rows[0]!.result
})
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_UNAUTHORIZED')
})
it('ignores a spoofed p_user_id from an authenticated non-member', async () => {
const { userId, companyId, invoiceId, txId } = await seedTenant()
const stranger = await insertAuthUser()
const allocations = [{ kind: 'supplier_invoice', supplier_invoice_id: invoiceId, amount: 2000 }]
const result = await withUserContext(stranger, async (client) => {
const r = await client.query<{ result: RpcResult }>(CALL, [
txId,
JSON.stringify(allocations),
companyId,
userId,
])
return r.rows[0]!.result
})
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_UNAUTHORIZED')
})
it('ignores p_user_id when there is no JWT context at all', async () => {
const { userId, companyId, invoiceId, txId } = await seedTenant()
const allocations = [{ kind: 'supplier_invoice', supplier_invoice_id: invoiceId, amount: 2000 }]
const r = await getPool().query<{ result: RpcResult }>(CALL, [
txId,
JSON.stringify(allocations),
companyId,
userId,
])
expect(r.rows[0]!.result.ok).toBe(false)
expect(r.rows[0]!.result.code).toBe('BATCH_UNAUTHORIZED')
})
it('keeps least-privilege grants and drops the 3-arg overload', async () => {
const { rows } = await getPool().query<{
anon_can: boolean
authenticated_can: boolean
service_role_can: boolean
overloads: string
}>(
`SELECT has_function_privilege('anon', 'public.match_batch_allocate(uuid,jsonb,uuid,uuid)', 'EXECUTE') AS anon_can,
has_function_privilege('authenticated', 'public.match_batch_allocate(uuid,jsonb,uuid,uuid)', 'EXECUTE') AS authenticated_can,
has_function_privilege('service_role', 'public.match_batch_allocate(uuid,jsonb,uuid,uuid)', 'EXECUTE') AS service_role_can,
(SELECT count(*) FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.proname = 'match_batch_allocate')::text AS overloads`,
)
expect(rows[0]!.anon_can).toBe(false)
expect(rows[0]!.authenticated_can).toBe(true)
expect(rows[0]!.service_role_can).toBe(true)
expect(rows[0]!.overloads).toBe('1')
})
})