feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331) - expense_reimbursement line type: kostnadsersättning outside gross, tax, avgifter and the AGI. The engine adds tax-free reimbursements (utlägg, skattefritt traktamente, skattefri milersättning) to the net payout only. - booking debits the claim's liability account (2820) on top of gross, never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K instead of being treated as a nollkörning - salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade, one payslip line per claim); settle_expense_claims_via_salary_run marks the claims paid with an expense_payout_batches row pointing at the salary verifikat, no second verifikat, idempotent on retry; wired into bookLoadedRun and the v1 book route with a pre-check before posting - create_expense_payout_batch refuses claims scheduled on a payslip (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft - "Lägg till utlägg" on the employee row of a draft run; the payslip page labels and removes the lines - pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line - salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited in the unmerged 20260906210300): the database refuses to delete a claim a payslip line still references, whichever path issues the DELETE - deleteExpenseClaim removes the draft line first (before the storno) and keeps refusing with ON_PAYSLIP once the run has left draft - pg-real: delete refused with 23503 on a booked and on a draft run; the app order (line, then claim) succeeds - unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for an expense_reimbursement line (FK011 derives from sre.gross_salary; only benefit_* types are read from line items) Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
0069a3f59a
commit
4fce2d7b94
@@ -0,0 +1,565 @@
|
||||
-- Utlägg repaid with the salary ("Betala ut via lön", issue #2331).
|
||||
--
|
||||
-- 1. salary_line_items gains the item type 'expense_reimbursement'
|
||||
-- (kostnadsersättning: utlägg). Tax-free, outside bruttolön, no
|
||||
-- arbetsgivaravgifter, not in the AGI gross (FK011). Its default account is
|
||||
-- the claim's liability account (2820 for an employee): the cost and the
|
||||
-- moms were booked when the claim was registered, so the salary verifikat
|
||||
-- only relieves the liability, never a 7xxx cost.
|
||||
-- 2. salary_line_items.source_expense_claim_id links a payslip line to the
|
||||
-- claim it repays, so the booking flips exactly those claims. ON DELETE
|
||||
-- RESTRICT: a claim that a payslip line still references cannot be deleted
|
||||
-- by anyone (PostgREST, a script, a future service), so a booked run's line
|
||||
-- can never vanish from under its posted verifikat. The app path
|
||||
-- (deleteExpenseClaim) removes the line first on a draft run and refuses
|
||||
-- once the run has left draft. The partial unique index keeps a claim on
|
||||
-- at most one payslip line at a time.
|
||||
-- 3. settle_expense_claims_via_salary_run is the payroll-side twin of
|
||||
-- create_expense_payout_batch: same batch table, same status flip, same
|
||||
-- row locking, but no verifikat of its own. The batch points at the booked
|
||||
-- run's salary verifikat, which already carries 2820 D and 1930 K.
|
||||
-- 4. create_expense_payout_batch refuses a claim that sits on a payslip line
|
||||
-- (ON_PAYSLIP): its repayment belongs to that salary run, and a bank
|
||||
-- transfer on top would pay the person twice.
|
||||
--
|
||||
-- pg-test: covered-by tests/pg/utlagg-via-lon.pg.test.ts
|
||||
-- and tests/pg/expense-payout-batch-rpc.pg.test.ts (ON_PAYSLIP)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. Item type. Re-added NOT VALID like 20260813143000; the VALIDATE runs in
|
||||
-- the next migration so the scan does not hold the stronger DDL lock.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE public.salary_line_items
|
||||
DROP CONSTRAINT salary_line_items_item_type_check;
|
||||
|
||||
ALTER TABLE public.salary_line_items
|
||||
ADD CONSTRAINT salary_line_items_item_type_check
|
||||
CHECK (item_type IN (
|
||||
'monthly_salary', 'hourly_salary',
|
||||
'overtime', 'overtime_50', 'overtime_100',
|
||||
'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday',
|
||||
'bonus', 'commission',
|
||||
'gross_deduction_pension', 'gross_deduction_other',
|
||||
'benefit_car', 'benefit_housing', 'benefit_meals',
|
||||
'benefit_wellness', 'benefit_bike', 'benefit_other',
|
||||
'sick_karens', 'sick_day2_14', 'sick_day15_plus',
|
||||
'vab', 'parental_leave', 'unpaid_leave',
|
||||
'vacation', 'semesterersattning',
|
||||
'traktamente_taxfree', 'traktamente_taxable',
|
||||
'mileage_taxfree', 'mileage_taxable',
|
||||
'expense_reimbursement',
|
||||
'net_deduction_advance', 'net_deduction_union',
|
||||
'net_deduction_benefit_payment', 'net_deduction_other',
|
||||
'oresavrundning',
|
||||
'correction', 'other'
|
||||
)) NOT VALID;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. Claim link. Tenant-scoped by construction (the dimensions pattern): the
|
||||
-- composite FK binds the claim to the line's company, so a member of one
|
||||
-- company can never hang another company's claim on a payslip.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'expense_claims_id_company_id_key'
|
||||
AND conrelid = 'public.expense_claims'::regclass
|
||||
) THEN
|
||||
ALTER TABLE public.expense_claims
|
||||
ADD CONSTRAINT expense_claims_id_company_id_key UNIQUE (id, company_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE public.salary_line_items
|
||||
ADD COLUMN source_expense_claim_id uuid;
|
||||
|
||||
ALTER TABLE public.salary_line_items
|
||||
ADD CONSTRAINT salary_line_items_source_expense_claim_fkey
|
||||
FOREIGN KEY (source_expense_claim_id, company_id)
|
||||
REFERENCES public.expense_claims(id, company_id) ON DELETE RESTRICT;
|
||||
|
||||
CREATE UNIQUE INDEX salary_line_items_source_expense_claim_uniq
|
||||
ON public.salary_line_items (source_expense_claim_id)
|
||||
WHERE source_expense_claim_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN public.salary_line_items.source_expense_claim_id IS
|
||||
'The registered expense claim (utlägg) this expense_reimbursement line repays. Set by "Lägg till öppna utlägg"; the salary booking marks the claim paid with a payout batch that points at the salary verifikat.';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. Settle the claims a booked run repays.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.settle_expense_claims_via_salary_run(
|
||||
p_company_id uuid,
|
||||
p_salary_run_id uuid,
|
||||
p_user_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_caller uuid;
|
||||
v_run record;
|
||||
v_line record;
|
||||
v_group record;
|
||||
v_period text;
|
||||
v_batch_id uuid;
|
||||
v_batches jsonb := '[]'::jsonb;
|
||||
v_settled integer := 0;
|
||||
v_already integer := 0;
|
||||
v_marked integer;
|
||||
v_total numeric(15,2) := 0;
|
||||
BEGIN
|
||||
-- Actor resolution mirrors create_expense_payout_batch: 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).
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
-- The run is locked for the duration so two callers (a retry, the MCP path
|
||||
-- and the dashboard at once) serialize on it.
|
||||
SELECT sr.id, sr.status, sr.salary_entry_id, sr.payment_date, sr.period_year, sr.period_month
|
||||
INTO v_run
|
||||
FROM public.salary_runs sr
|
||||
WHERE sr.id = p_salary_run_id
|
||||
AND sr.company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
IF v_run.id IS NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'SALARY_RUN_NOT_FOUND');
|
||||
END IF;
|
||||
IF v_run.status <> 'booked' OR v_run.salary_entry_id IS NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'SALARY_RUN_NOT_BOOKED',
|
||||
'details', jsonb_build_object('status', v_run.status));
|
||||
END IF;
|
||||
-- The batch points at the salary verifikat; it must be a posted entry of
|
||||
-- this company that the run itself claims as its salary entry.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.journal_entries je
|
||||
WHERE je.id = v_run.salary_entry_id
|
||||
AND je.company_id = p_company_id
|
||||
AND je.status = 'posted'
|
||||
AND je.source_type = 'salary_payment'
|
||||
AND je.source_id = p_salary_run_id
|
||||
) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'SALARY_ENTRY_NOT_POSTED');
|
||||
END IF;
|
||||
|
||||
v_period := v_run.period_year::text || '-' || lpad(v_run.period_month::text, 2, '0');
|
||||
|
||||
-- Lock the claims behind this run's lines and validate every one before
|
||||
-- writing anything. A claim already settled by THIS run (retry after a
|
||||
-- partial failure) is fine; one paid any other way is a refusal, since the
|
||||
-- salary verifikat already debited 2820 for it.
|
||||
FOR v_line IN
|
||||
SELECT sli.amount AS line_amount,
|
||||
sre.employee_id AS line_employee_id,
|
||||
ec.id AS claim_id,
|
||||
ec.status,
|
||||
ec.employee_id AS claim_employee_id,
|
||||
ec.amount_sek,
|
||||
ec.payout_batch_id
|
||||
FROM public.salary_line_items sli
|
||||
JOIN public.salary_run_employees sre ON sre.id = sli.salary_run_employee_id
|
||||
JOIN public.expense_claims ec
|
||||
ON ec.id = sli.source_expense_claim_id
|
||||
AND ec.company_id = sli.company_id
|
||||
WHERE sre.salary_run_id = p_salary_run_id
|
||||
AND sli.company_id = p_company_id
|
||||
AND sli.source_expense_claim_id IS NOT NULL
|
||||
ORDER BY ec.id
|
||||
FOR UPDATE OF ec
|
||||
LOOP
|
||||
IF v_line.status = 'paid' THEN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.expense_payout_batches b
|
||||
WHERE b.id = v_line.payout_batch_id
|
||||
AND b.company_id = p_company_id
|
||||
AND b.journal_entry_id = v_run.salary_entry_id
|
||||
) THEN
|
||||
v_already := v_already + 1;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'CLAIM_NOT_OPEN',
|
||||
'details', jsonb_build_object('claim_id', v_line.claim_id));
|
||||
END IF;
|
||||
IF v_line.claim_employee_id IS DISTINCT FROM v_line.line_employee_id THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'CLAIM_EMPLOYEE_MISMATCH',
|
||||
'details', jsonb_build_object('claim_id', v_line.claim_id));
|
||||
END IF;
|
||||
IF round(v_line.line_amount, 2) <> v_line.amount_sek THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'CLAIM_AMOUNT_MISMATCH',
|
||||
'details', jsonb_build_object(
|
||||
'claim_id', v_line.claim_id,
|
||||
'line_amount', v_line.line_amount,
|
||||
'claim_amount', v_line.amount_sek));
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- One batch per person (the batch table's unit: one claimant, one
|
||||
-- liability account), pointing at the salary verifikat. The cash side is
|
||||
-- the salary entry's 1930 net-pay credit.
|
||||
FOR v_group IN
|
||||
SELECT sre.employee_id,
|
||||
max(ec.claimant_name) AS claimant_name,
|
||||
ec.liability_account,
|
||||
sum(ec.amount_sek) AS total_sek,
|
||||
array_agg(ec.id ORDER BY ec.id) AS claim_ids,
|
||||
count(*)::integer AS claim_count
|
||||
FROM public.salary_line_items sli
|
||||
JOIN public.salary_run_employees sre ON sre.id = sli.salary_run_employee_id
|
||||
JOIN public.expense_claims ec
|
||||
ON ec.id = sli.source_expense_claim_id
|
||||
AND ec.company_id = sli.company_id
|
||||
WHERE sre.salary_run_id = p_salary_run_id
|
||||
AND sli.company_id = p_company_id
|
||||
AND sli.source_expense_claim_id IS NOT NULL
|
||||
AND ec.status = 'registered'
|
||||
GROUP BY sre.employee_id, ec.liability_account
|
||||
ORDER BY sre.employee_id, ec.liability_account
|
||||
LOOP
|
||||
v_batch_id := gen_random_uuid();
|
||||
INSERT INTO public.expense_payout_batches
|
||||
(id, company_id, user_id, employee_id, claimant_name, payout_date,
|
||||
cash_account, liability_account, total_sek, journal_entry_id, notes)
|
||||
VALUES
|
||||
(v_batch_id, p_company_id, v_caller, v_group.employee_id, v_group.claimant_name,
|
||||
v_run.payment_date, '1930', v_group.liability_account, v_group.total_sek,
|
||||
v_run.salary_entry_id, 'Utbetalt via lön ' || v_period);
|
||||
|
||||
UPDATE public.expense_claims
|
||||
SET status = 'paid', payout_batch_id = v_batch_id
|
||||
WHERE id = ANY(v_group.claim_ids)
|
||||
AND company_id = p_company_id
|
||||
AND status = 'registered';
|
||||
GET DIAGNOSTICS v_marked = ROW_COUNT;
|
||||
IF v_marked <> v_group.claim_count THEN
|
||||
-- Cannot happen while the rows are locked above; the exception rolls
|
||||
-- every batch of this call back together.
|
||||
RAISE EXCEPTION 'settle_expense_claims_via_salary_run: marked % of % claims paid',
|
||||
v_marked, v_group.claim_count;
|
||||
END IF;
|
||||
|
||||
v_settled := v_settled + v_marked;
|
||||
v_total := v_total + v_group.total_sek;
|
||||
v_batches := v_batches || jsonb_build_object(
|
||||
'batch_id', v_batch_id,
|
||||
'employee_id', v_group.employee_id,
|
||||
'total_sek', v_group.total_sek,
|
||||
'claim_count', v_group.claim_count);
|
||||
END LOOP;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'salary_run_id', p_salary_run_id,
|
||||
'journal_entry_id', v_run.salary_entry_id,
|
||||
'batches', v_batches,
|
||||
'claim_count', v_settled,
|
||||
'already_settled', v_already,
|
||||
'total_sek', v_total
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.settle_expense_claims_via_salary_run(uuid, uuid, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.settle_expense_claims_via_salary_run(uuid, uuid, uuid) TO authenticated, service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.settle_expense_claims_via_salary_run(uuid, uuid, uuid) IS
|
||||
'Marks the expense claims linked to a booked salary run''s payslip lines as paid: one expense_payout_batches row per person pointing at the salary verifikat, no verifikat of its own. Idempotent for a retry.';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. The bank-side payout refuses a claim scheduled on a payslip.
|
||||
-- Body identical to 20260905183000 except the ON_PAYSLIP check inside the
|
||||
-- claim loop; same signature, so CREATE OR REPLACE and no DROP.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
p_transaction_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;
|
||||
v_tx record;
|
||||
v_tx_updated integer;
|
||||
v_debit text;
|
||||
v_payslip record;
|
||||
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;
|
||||
-- A claim on a payslip line is repaid by that salary run (#2331): a bank
|
||||
-- transfer on top would pay the person twice. Remove the line from the
|
||||
-- draft payslip first if the bank path is the intended one.
|
||||
SELECT sr.id AS salary_run_id, sr.period_year, sr.period_month, sr.status
|
||||
INTO v_payslip
|
||||
FROM public.salary_line_items sli
|
||||
JOIN public.salary_run_employees sre ON sre.id = sli.salary_run_employee_id
|
||||
JOIN public.salary_runs sr ON sr.id = sre.salary_run_id
|
||||
WHERE sli.source_expense_claim_id = v_claim.id
|
||||
AND sli.company_id = p_company_id
|
||||
LIMIT 1;
|
||||
IF v_payslip.salary_run_id IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'ON_PAYSLIP',
|
||||
'details', jsonb_build_object(
|
||||
'claim_id', v_claim.id,
|
||||
'salary_run_id', v_payslip.salary_run_id,
|
||||
'salary_run_status', v_payslip.status,
|
||||
'period', v_payslip.period_year::text || '-' || lpad(v_payslip.period_month::text, 2, '0')));
|
||||
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;
|
||||
|
||||
-- Bank-line mode: the transfer that repays these claims. Locked with the
|
||||
-- claims so a concurrent categorisation of the same row waits and then
|
||||
-- sees it booked. The amount must equal the claims exactly (öre): a partial
|
||||
-- transfer is a different payout, chosen by a different set of claims.
|
||||
IF p_transaction_id IS NOT NULL THEN
|
||||
SELECT t.id, t.amount, t.currency, t.date
|
||||
INTO v_tx
|
||||
FROM public.transactions t
|
||||
WHERE t.id = p_transaction_id
|
||||
AND t.company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
IF v_tx.id IS NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'TX_NOT_FOUND');
|
||||
END IF;
|
||||
IF public.is_transaction_booked(v_tx.id) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'TX_ALREADY_BOOKED');
|
||||
END IF;
|
||||
IF upper(COALESCE(v_tx.currency, 'SEK')) <> 'SEK' THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'TX_CURRENCY',
|
||||
'details', jsonb_build_object('currency', v_tx.currency));
|
||||
END IF;
|
||||
IF v_tx.amount >= 0 OR round(-v_tx.amount, 2) <> v_total THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'TX_AMOUNT_MISMATCH',
|
||||
'details', jsonb_build_object('transaction_amount', v_tx.amount, 'claims_total', v_total));
|
||||
END IF;
|
||||
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;
|
||||
v_debit := CASE WHEN v_liability = '2018' THEN '2013' ELSE v_liability END;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.chart_of_accounts a
|
||||
WHERE a.company_id = p_company_id
|
||||
AND a.account_number = v_debit
|
||||
AND COALESCE(a.is_active, true)
|
||||
) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'code', 'ACCOUNT_NOT_IN_CHART',
|
||||
'details', jsonb_build_object('account', v_debit));
|
||||
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_debit, 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;
|
||||
|
||||
IF p_transaction_id IS NOT NULL THEN
|
||||
-- Same stamp as the bulk-book RPCs: the 1:1 pointer plus is_business, so
|
||||
-- every "unbooked" predicate (inbox, worklist, badges) drops the row.
|
||||
UPDATE public.transactions
|
||||
SET journal_entry_id = v_je_id,
|
||||
is_business = TRUE,
|
||||
reconciliation_method = 'manual',
|
||||
updated_at = now()
|
||||
WHERE id = p_transaction_id
|
||||
AND company_id = p_company_id
|
||||
AND journal_entry_id IS NULL;
|
||||
GET DIAGNOSTICS v_tx_updated = ROW_COUNT;
|
||||
IF v_tx_updated <> 1 THEN
|
||||
RAISE EXCEPTION 'create_expense_payout_batch: transaction % could not be linked', p_transaction_id;
|
||||
END IF;
|
||||
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),
|
||||
'transaction_id', p_transaction_id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid, uuid) IS
|
||||
'Books one reimbursement transfer for N registered expense claims atomically: locks the claims (and the bank transaction when given), posts liability -> cash via commit_journal_entry, marks the claims paid and links the transaction. Refuses claims scheduled on a payslip line (ON_PAYSLIP).';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Validate the item_type CHECK re-added NOT VALID in 20260906210300.
|
||||
-- Kept separate to avoid a full-table scan under the stronger DDL lock
|
||||
-- (same split as 20260813143000 / 20260813143001).
|
||||
|
||||
ALTER TABLE public.salary_line_items
|
||||
VALIDATE CONSTRAINT salary_line_items_item_type_check;
|
||||
Reference in New Issue
Block a user