diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index d4593e2d..aae6784e 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -5624,16 +5624,19 @@ async function commitSubmitAgi( async function commitMatchBatchAllocate( supabase: SupabaseClient, + userId: string, companyId: string, params: Record ): Promise { // 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) diff --git a/supabase/migrations/20260817150000_match_batch_allocate_service_actor.sql b/supabase/migrations/20260817150000_match_batch_allocate_service_actor.sql new file mode 100644 index 00000000..b106bcff --- /dev/null +++ b/supabase/migrations/20260817150000_match_batch_allocate_service_actor.sql @@ -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'; diff --git a/tests/pg/match-batch-allocate-service-actor.pg.test.ts b/tests/pg/match-batch-allocate-service-actor.pg.test.ts new file mode 100644 index 00000000..88271fce --- /dev/null +++ b/tests/pg/match-batch-allocate-service-actor.pg.test.ts @@ -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 { + 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 { + 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 { + 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') + }) +})