-- Atomic payout of expense claims (utlägg). -- -- One RPC locks the selected claims, books the liability -> cash verifikat -- through commit_journal_entry and marks the claims paid, all in a single -- transaction. It replaces the application-side sequence (select claims, -- insert batch, post entry, link, mark paid) whose only guard was a storno -- compensation on the last step. That sequence had no row lock and no status -- predicate on the final update, so two concurrent submits (a double click, -- a retried request) each booked a payout verifikat for the same claims: -- three identical concurrent requests produced three posted transfers for -- one set of claims in the 2026-09-05 end-to-end run. -- -- Rules mirrored from the service: one batch = one claimant (employee_id or -- the normalized free-text name) against one liability account; every claim -- must still be 'registered'; the payout date must fall in an open, unlocked -- fiscal year; both accounts must exist in the chart (the picker only offers -- accounts that do). Period locks and the company lock date are additionally -- enforced by the journal_entries triggers, which roll the whole call back. -- -- Actor resolution mirrors bulk_book_transactions (20260824170000): p_user_id -- is honored only for service_role callers (API-key / MCP paths run on the -- cookieless service client where auth.uid() is NULL); every other caller is -- pinned to auth.uid() and must be an owner/admin/member of the company. -- -- pg-test: covered-by tests/pg/expense-payout-batch-rpc.pg.test.ts CREATE OR REPLACE FUNCTION public.create_expense_payout_batch( p_company_id uuid, p_claim_ids uuid[], p_payout_date date, p_cash_account text, p_notes text DEFAULT NULL, p_user_id uuid DEFAULT NULL ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $$ DECLARE v_caller uuid; v_ids uuid[]; v_claim record; v_count integer := 0; v_first boolean := true; v_employee_id uuid; v_claimant_name text; v_claimant_key text; v_liability text; v_total numeric(15,2) := 0; v_period_id uuid; v_period_locked_at timestamptz; v_series text := 'A'; v_series_raw text; v_batch_id uuid := gen_random_uuid(); v_je_id uuid := gen_random_uuid(); v_voucher_number integer; v_desc text; v_marked integer; BEGIN 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', 'FORBIDDEN'); END IF; -- Same gate as the expense tables' write policies (owner/admin/member); -- SECURITY DEFINER bypasses RLS, so the check has to be 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; SELECT ARRAY(SELECT DISTINCT unnest(p_claim_ids)) INTO v_ids; IF v_ids IS NULL OR cardinality(v_ids) = 0 THEN RETURN jsonb_build_object('ok', false, 'code', 'NO_CLAIMS'); END IF; IF p_cash_account IS NULL OR p_cash_account !~ '^19[0-9]{2}$' THEN RETURN jsonb_build_object('ok', false, 'code', 'INVALID_CASH_ACCOUNT'); END IF; -- Lock the claims. A concurrent caller for any of the same rows queues on -- this lock and, once this transaction commits, reads them as 'paid'. FOR v_claim IN SELECT ec.id, ec.status, ec.employee_id, ec.claimant_name, ec.liability_account, ec.amount_sek FROM public.expense_claims ec WHERE ec.id = ANY(v_ids) AND ec.company_id = p_company_id ORDER BY ec.id FOR UPDATE LOOP v_count := v_count + 1; IF v_claim.status <> 'registered' THEN RETURN jsonb_build_object('ok', false, 'code', 'ALREADY_PAID', 'details', jsonb_build_object('claim_id', v_claim.id)); END IF; IF v_first THEN v_employee_id := v_claim.employee_id; v_claimant_name := v_claim.claimant_name; v_claimant_key := COALESCE(v_claim.employee_id::text, 'name:' || lower(btrim(v_claim.claimant_name))); v_liability := v_claim.liability_account; v_first := false; ELSE IF COALESCE(v_claim.employee_id::text, 'name:' || lower(btrim(v_claim.claimant_name))) <> v_claimant_key THEN RETURN jsonb_build_object('ok', false, 'code', 'MIXED_CLAIMANTS'); END IF; IF v_claim.liability_account <> v_liability THEN RETURN jsonb_build_object('ok', false, 'code', 'MIXED_LIABILITY'); END IF; END IF; v_total := v_total + v_claim.amount_sek; END LOOP; IF v_count <> cardinality(v_ids) THEN RETURN jsonb_build_object('ok', false, 'code', 'CLAIMS_NOT_FOUND'); END IF; -- Open fiscal year covering the payout date (mirrors engine.findFiscalPeriod). SELECT fp.id, fp.locked_at INTO v_period_id, v_period_locked_at FROM public.fiscal_periods fp WHERE fp.company_id = p_company_id AND fp.period_start <= p_payout_date AND fp.period_end >= p_payout_date AND fp.is_closed = false ORDER BY fp.period_start DESC LIMIT 1; IF v_period_id IS NULL THEN RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_PERIOD_NOT_FOUND'); END IF; IF v_period_locked_at IS NOT NULL THEN RETURN jsonb_build_object('ok', false, 'code', 'PERIOD_LOCKED', 'details', jsonb_build_object('fiscal_period_id', v_period_id)); END IF; IF NOT EXISTS ( SELECT 1 FROM public.chart_of_accounts a WHERE a.company_id = p_company_id AND a.account_number = p_cash_account AND COALESCE(a.is_active, true) ) THEN RETURN jsonb_build_object('ok', false, 'code', 'ACCOUNT_NOT_IN_CHART', 'details', jsonb_build_object('account', p_cash_account)); END IF; IF NOT EXISTS ( SELECT 1 FROM public.chart_of_accounts a WHERE a.company_id = p_company_id AND a.account_number = v_liability AND COALESCE(a.is_active, true) ) THEN RETURN jsonb_build_object('ok', false, 'code', 'ACCOUNT_NOT_IN_CHART', 'details', jsonb_build_object('account', v_liability)); END IF; -- Voucher series: the per-source-type default from company_settings, 'A' -- otherwise (mirrors resolveDefaultSeriesForSource). SELECT cs.default_voucher_series_per_source_type ->> 'expense_payout' INTO v_series_raw FROM public.company_settings cs WHERE cs.company_id = p_company_id; IF v_series_raw ~ '^[A-Z]$' THEN v_series := v_series_raw; END IF; v_desc := 'Utbetalning utlägg: ' || v_claimant_name || ' (' || v_count || ' st)'; INSERT INTO public.expense_payout_batches (id, company_id, user_id, employee_id, claimant_name, payout_date, cash_account, liability_account, total_sek, notes) VALUES (v_batch_id, p_company_id, v_caller, v_employee_id, v_claimant_name, p_payout_date, p_cash_account, v_liability, v_total, p_notes); INSERT INTO public.journal_entries (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, entry_date, description, source_type, source_id, status) VALUES (v_je_id, v_caller, p_company_id, v_period_id, 0, v_series, p_payout_date, v_desc, 'expense_payout', v_batch_id, 'draft'); INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, currency, sort_order, line_description) VALUES (v_je_id, v_liability, v_total, 0, 'SEK', 0, v_desc), (v_je_id, p_cash_account, 0, v_total, 'SEK', 1, v_desc); SELECT voucher_number INTO v_voucher_number FROM public.commit_journal_entry(p_company_id, v_je_id); UPDATE public.expense_payout_batches SET journal_entry_id = v_je_id WHERE id = v_batch_id AND company_id = p_company_id; UPDATE public.expense_claims SET status = 'paid', payout_batch_id = v_batch_id WHERE id = ANY(v_ids) AND company_id = p_company_id AND status = 'registered'; GET DIAGNOSTICS v_marked = ROW_COUNT; IF v_marked <> cardinality(v_ids) THEN -- Cannot happen while the rows are locked above; if it ever does, the -- exception rolls back the batch and the verifikat together. RAISE EXCEPTION 'create_expense_payout_batch: marked % of % claims paid', v_marked, cardinality(v_ids); END IF; RETURN jsonb_build_object( 'ok', true, 'batch_id', v_batch_id, 'journal_entry_id', v_je_id, 'voucher_number', v_voucher_number, 'total_sek', v_total, 'claim_count', cardinality(v_ids) ); END; $$; REVOKE ALL ON FUNCTION public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid) TO authenticated, service_role; COMMENT ON FUNCTION public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid) IS 'Books one reimbursement transfer for N registered expense claims atomically: locks the claims, posts liability -> cash via commit_journal_entry, marks them paid.'; NOTIFY pgrst, 'reload schema';