Files
accounted/supabase/migrations/20260904171000_expense_payout_batch_rpc.sql
T
Joakim Hansson 397a3b9bca feat(expenses): expense claims module (utlägg) (#2145)
Contributed by @joakimhew. Maintainer commits on top: migration re-versioned to 20260904170000 (main's 20260901210000 took the original version), payout batches booked atomically through the create_expense_payout_batch RPC, accounted-api skill regenerated, main merged. Closes #2143.
2026-09-05 13:36:51 +02:00

231 lines
8.8 KiB
PL/PgSQL

-- 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';