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.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
-- Expense claims (utlägg): first-class module for out-of-pocket purchases.
|
||||
--
|
||||
-- An expense claim is a receipt someone paid privately: registering it books
|
||||
-- cost + ingående moms against an owner/employee liability account (2893
|
||||
-- skulder till närstående for owners, 2820 kortfristiga skulder till
|
||||
-- anställda for employees, 2018 egen insättning for enskild firma). A payout
|
||||
-- batch reimburses N registered claims in one bank transfer and books
|
||||
-- liability against the cash account.
|
||||
--
|
||||
-- Claims are registered directly as posted verifikat (status 'registered'):
|
||||
-- there is no draft state here, unbooked receipts live in the document inbox
|
||||
-- until they are registered.
|
||||
|
||||
-- Tenant-scoped uniqueness on employees so the expense tables can bind
|
||||
-- employee_id to the row's company (parties_substrate uses the same shape
|
||||
-- for parties). Added idempotently: it does not exist on main, and #2044
|
||||
-- adds the same key, so whichever merges second must not collide.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'employees_id_company_id_key'
|
||||
AND conrelid = 'public.employees'::regclass
|
||||
) THEN
|
||||
ALTER TABLE public.employees ADD CONSTRAINT employees_id_company_id_key UNIQUE (id, company_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE public.expense_payout_batches (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
|
||||
|
||||
-- Who is reimbursed. employee_id may be null for the owner; claimant_name
|
||||
-- is denormalized so history stays readable if the employee row goes away.
|
||||
-- The composite FK below binds it to this row's company (SET NULL on the
|
||||
-- employee column only: company_id is NOT NULL and must survive a delete).
|
||||
employee_id uuid,
|
||||
claimant_name text NOT NULL,
|
||||
|
||||
payout_date date NOT NULL,
|
||||
cash_account text NOT NULL CHECK (cash_account ~ '^19[0-9]{2}$'),
|
||||
liability_account text NOT NULL CHECK (liability_account IN ('2893', '2820', '2018', '2890')),
|
||||
total_sek numeric(15,2) NOT NULL CHECK (total_sek > 0),
|
||||
journal_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL,
|
||||
notes text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
FOREIGN KEY (employee_id, company_id)
|
||||
REFERENCES public.employees(id, company_id) ON DELETE SET NULL (employee_id)
|
||||
);
|
||||
|
||||
-- Target for the tenant-scoped FK from expense_claims.payout_batch_id.
|
||||
ALTER TABLE public.expense_payout_batches
|
||||
ADD CONSTRAINT expense_payout_batches_id_company_id_key UNIQUE (id, company_id);
|
||||
|
||||
COMMENT ON TABLE public.expense_payout_batches IS
|
||||
'One reimbursement transfer covering N registered expense claims; books liability -> cash.';
|
||||
|
||||
CREATE TABLE public.expense_claims (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
|
||||
|
||||
-- employee_id is company-scoped via the composite FK below.
|
||||
employee_id uuid,
|
||||
claimant_name text NOT NULL,
|
||||
|
||||
description text NOT NULL,
|
||||
expense_date date NOT NULL,
|
||||
|
||||
-- SEK is the booking truth; the original currency is provenance. The claim
|
||||
-- total is gross (incl VAT): vat_sek is the deductible part booked on 2641.
|
||||
amount_sek numeric(15,2) NOT NULL CHECK (amount_sek > 0),
|
||||
vat_sek numeric(15,2) NOT NULL DEFAULT 0
|
||||
CHECK (vat_sek >= 0 AND vat_sek < amount_sek),
|
||||
currency text NOT NULL DEFAULT 'SEK',
|
||||
amount_in_currency numeric(15,2),
|
||||
exchange_rate numeric(14,6),
|
||||
|
||||
expense_account text NOT NULL CHECK (expense_account ~ '^[0-9]{4}$'),
|
||||
liability_account text NOT NULL DEFAULT '2893'
|
||||
CHECK (liability_account IN ('2893', '2820', '2018', '2890')),
|
||||
|
||||
document_id uuid REFERENCES public.document_attachments(id) ON DELETE SET NULL,
|
||||
status text NOT NULL DEFAULT 'registered' CHECK (status IN ('registered', 'paid')),
|
||||
journal_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL,
|
||||
-- Same-company by construction: the composite FK below binds the batch to
|
||||
-- this row's company, so a member of two companies cannot mark a claim in
|
||||
-- one as paid by a batch belonging to the other.
|
||||
payout_batch_id uuid,
|
||||
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- A paid claim must reference the batch that paid it.
|
||||
CHECK (status <> 'paid' OR payout_batch_id IS NOT NULL),
|
||||
|
||||
FOREIGN KEY (payout_batch_id, company_id)
|
||||
REFERENCES public.expense_payout_batches(id, company_id) ON DELETE SET NULL,
|
||||
|
||||
FOREIGN KEY (employee_id, company_id)
|
||||
REFERENCES public.employees(id, company_id) ON DELETE SET NULL (employee_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.expense_claims IS
|
||||
'Out-of-pocket purchase (utlägg): booked as cost + moms against an owner/employee liability on registration.';
|
||||
|
||||
ALTER TABLE public.expense_claims ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.expense_payout_batches ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "expense_claims_select" ON public.expense_claims
|
||||
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "expense_claims_insert" ON public.expense_claims
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
CREATE POLICY "expense_claims_update" ON public.expense_claims
|
||||
FOR UPDATE USING (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
CREATE POLICY "expense_claims_delete" ON public.expense_claims
|
||||
FOR DELETE USING (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "expense_payout_batches_select" ON public.expense_payout_batches
|
||||
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "expense_payout_batches_insert" ON public.expense_payout_batches
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
CREATE POLICY "expense_payout_batches_update" ON public.expense_payout_batches
|
||||
FOR UPDATE USING (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
CREATE POLICY "expense_payout_batches_delete" ON public.expense_payout_batches
|
||||
FOR DELETE USING (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_expense_claims_company ON public.expense_claims (company_id, expense_date DESC);
|
||||
CREATE INDEX idx_expense_claims_employee ON public.expense_claims (employee_id);
|
||||
CREATE INDEX idx_expense_claims_status ON public.expense_claims (company_id, status);
|
||||
CREATE INDEX idx_expense_claims_batch ON public.expense_claims (payout_batch_id)
|
||||
WHERE payout_batch_id IS NOT NULL;
|
||||
CREATE INDEX idx_expense_payout_batches_company
|
||||
ON public.expense_payout_batches (company_id, payout_date DESC);
|
||||
|
||||
CREATE TRIGGER set_updated_at_expense_claims
|
||||
BEFORE UPDATE ON public.expense_claims
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
CREATE TRIGGER set_updated_at_expense_payout_batches
|
||||
BEFORE UPDATE ON public.expense_payout_batches
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER audit_expense_claims
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.expense_claims
|
||||
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
||||
CREATE TRIGGER audit_expense_payout_batches
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.expense_payout_batches
|
||||
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
||||
|
||||
-- New journal entry source types so utlägg verifikat are traceable to their
|
||||
-- module rows. Rule (see 20260811073416): DB allowlist + the TS union +
|
||||
-- JournalEntrySourceTypeSchema change together.
|
||||
ALTER TABLE public.journal_entries
|
||||
DROP CONSTRAINT IF EXISTS journal_entries_source_type_check;
|
||||
|
||||
ALTER TABLE public.journal_entries
|
||||
ADD CONSTRAINT journal_entries_source_type_check
|
||||
CHECK (source_type IN (
|
||||
'manual', 'bank_transaction', 'invoice_created',
|
||||
'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment',
|
||||
'opening_balance', 'year_end',
|
||||
'storno', 'correction', 'import', 'system',
|
||||
'inbox_item',
|
||||
'supplier_invoice_registered', 'supplier_invoice_paid',
|
||||
'supplier_invoice_cash_payment', 'supplier_credit_note',
|
||||
'currency_revaluation',
|
||||
'supplier_invoice_privately_paid',
|
||||
'reminder_fee',
|
||||
'accrual',
|
||||
'result_appropriation',
|
||||
'rot_rut_payout',
|
||||
'vat_settlement',
|
||||
'stripe_payout',
|
||||
'webshop_order',
|
||||
'expense_claim',
|
||||
'expense_payout'
|
||||
)) NOT VALID;
|
||||
|
||||
ALTER TABLE public.journal_entries
|
||||
VALIDATE CONSTRAINT journal_entries_source_type_check;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,230 @@
|
||||
-- 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';
|
||||
Reference in New Issue
Block a user