4fce2d7b94
* 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>
566 lines
23 KiB
PL/PgSQL
566 lines
23 KiB
PL/PgSQL
-- 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';
|