Files
accounted/supabase/migrations/20260801221047_atomic_opening_balance_replacement.sql
T
Mattsson 18cdba3574 fix: make out-of-order SIE opening balances atomic (#1334)
* fix: preserve SIE IB on out-of-order imports

* fix: make SIE opening balance replacement atomic

* test: seed accounts for atomic IB pg coverage

* test: complete atomic IB pg fixtures

* fix(import): avoid IB resync across fiscal-year gaps

* test(import): mirror PostgREST date values in pg adapter

* fix(import): address opening balance review feedback
2026-08-02 20:39:29 +02:00

400 lines
11 KiB
PL/PgSQL

-- Replace a period opening balance as one atomic bookkeeping operation.
--
-- The replacement entry, storno, original-entry status change, and fiscal
-- period pointer swap must either all commit or all roll back. The period row
-- lock closes the lock-date race, while the expected old entry id provides a
-- compare-and-swap guard against concurrent corrections.
CREATE OR REPLACE FUNCTION public.commit_opening_balance_replacement(
p_company_id uuid,
p_period_id uuid,
p_expected_old_entry_id uuid,
p_user_id uuid,
p_entry_date date,
p_description text,
p_voucher_series text,
p_lines jsonb,
p_actor_type text DEFAULT NULL,
p_actor_label text DEFAULT NULL
)
RETURNS TABLE(
new_entry_id uuid,
storno_entry_id uuid,
new_voucher_number integer,
storno_voucher_number integer
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $function$
DECLARE
v_claims jsonb := COALESCE(
NULLIF(current_setting('request.jwt.claims', true), '')::jsonb,
'{}'::jsonb
);
v_jwt_role text := COALESCE(
NULLIF(v_claims ->> 'role', ''),
NULLIF(current_setting('request.jwt.claim.role', true), ''),
''
);
v_member_role text;
v_period public.fiscal_periods%ROWTYPE;
v_old_entry public.journal_entries%ROWTYPE;
v_lock_date date;
v_new_entry_id uuid := uuid_generate_v4();
v_storno_entry_id uuid := uuid_generate_v4();
v_new_voucher_number integer;
v_storno_voucher_number integer;
v_line_count integer;
v_updated_count integer;
v_total_debit numeric;
v_total_credit numeric;
BEGIN
IF v_jwt_role = 'authenticated' THEN
IF auth.uid() IS NULL OR p_user_id IS DISTINCT FROM auth.uid() THEN
RAISE EXCEPTION 'unauthorized: user attribution does not match caller'
USING ERRCODE = '42501';
END IF;
ELSIF v_jwt_role <> 'service_role' THEN
RAISE EXCEPTION 'unauthorized: authenticated or service role required'
USING ERRCODE = '42501';
END IF;
SELECT cm.role
INTO v_member_role
FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = p_user_id;
IF v_member_role IS NULL OR v_member_role = 'viewer' THEN
RAISE EXCEPTION 'unauthorized: caller cannot write to company %', p_company_id
USING ERRCODE = '42501';
END IF;
SELECT fp.*
INTO v_period
FROM public.fiscal_periods fp
WHERE fp.id = p_period_id
AND fp.company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Fiscal period not found: %', p_period_id;
END IF;
IF v_period.is_closed OR v_period.locked_at IS NOT NULL THEN
RAISE EXCEPTION 'Cannot replace opening balance in locked/closed fiscal period "%"',
v_period.name;
END IF;
IF v_period.opening_balances_set IS NOT TRUE
OR v_period.opening_balance_entry_id IS DISTINCT FROM p_expected_old_entry_id THEN
RAISE EXCEPTION 'Opening balance changed concurrently for fiscal period %', p_period_id
USING ERRCODE = '40001';
END IF;
IF p_entry_date IS DISTINCT FROM v_period.period_start THEN
RAISE EXCEPTION 'Replacement opening balance date must equal fiscal period start %',
v_period.period_start;
END IF;
SELECT cs.bookkeeping_locked_through
INTO v_lock_date
FROM public.company_settings cs
WHERE cs.company_id = p_company_id;
IF v_lock_date IS NOT NULL AND p_entry_date <= v_lock_date THEN
RAISE EXCEPTION 'Bookkeeping is locked through %', v_lock_date;
END IF;
SELECT je.*
INTO v_old_entry
FROM public.journal_entries je
WHERE je.id = p_expected_old_entry_id
AND je.company_id = p_company_id
AND je.fiscal_period_id = p_period_id
FOR UPDATE;
IF NOT FOUND
OR v_old_entry.status <> 'posted'
OR v_old_entry.source_type <> 'opening_balance' THEN
RAISE EXCEPTION 'Expected opening balance is not a posted entry in period %', p_period_id;
END IF;
IF jsonb_typeof(p_lines) <> 'array' OR jsonb_array_length(p_lines) = 0 THEN
RAISE EXCEPTION 'Replacement opening balance requires at least one line';
END IF;
SELECT
round(COALESCE(sum(line.debit_amount), 0), 2),
round(COALESCE(sum(line.credit_amount), 0), 2)
INTO v_total_debit, v_total_credit
FROM jsonb_to_recordset(p_lines) AS line(
account_number text,
account_id uuid,
debit_amount numeric,
credit_amount numeric,
currency text,
amount_in_currency numeric,
exchange_rate numeric,
line_description text,
tax_code text,
dimensions jsonb,
sort_order integer
);
IF v_total_debit <= 0 OR v_total_debit IS DISTINCT FROM v_total_credit THEN
RAISE EXCEPTION 'Replacement opening balance is not balanced (debit %, credit %)',
v_total_debit, v_total_credit;
END IF;
IF EXISTS (
SELECT 1
FROM jsonb_to_recordset(p_lines) AS line(
account_number text,
account_id uuid,
debit_amount numeric,
credit_amount numeric,
currency text,
amount_in_currency numeric,
exchange_rate numeric,
line_description text,
tax_code text,
dimensions jsonb,
sort_order integer
)
LEFT JOIN public.chart_of_accounts account
ON account.id = line.account_id
AND account.company_id = p_company_id
AND account.account_number = line.account_number
AND account.is_active = true
WHERE account.id IS NULL
OR line.account_number IS NULL
OR line.debit_amount IS NULL
OR line.credit_amount IS NULL
OR line.debit_amount < 0
OR line.credit_amount < 0
OR (line.debit_amount > 0 AND line.credit_amount > 0)
OR (line.debit_amount = 0 AND line.credit_amount = 0)
) THEN
RAISE EXCEPTION 'Replacement opening balance contains an invalid line';
END IF;
INSERT INTO public.journal_entries (
id,
user_id,
company_id,
fiscal_period_id,
voucher_number,
voucher_series,
entry_date,
description,
source_type,
status
) VALUES (
v_new_entry_id,
p_user_id,
p_company_id,
p_period_id,
0,
COALESCE(NULLIF(p_voucher_series, ''), 'A'),
p_entry_date,
p_description,
'opening_balance',
'draft'
);
INSERT INTO public.journal_entry_lines (
journal_entry_id,
account_number,
account_id,
debit_amount,
credit_amount,
currency,
amount_in_currency,
exchange_rate,
line_description,
tax_code,
dimensions,
sort_order
)
SELECT
v_new_entry_id,
line.account_number,
line.account_id,
round(line.debit_amount, 2),
round(line.credit_amount, 2),
COALESCE(NULLIF(line.currency, ''), 'SEK'),
CASE
WHEN line.amount_in_currency IS NULL THEN NULL
ELSE round(line.amount_in_currency, 2)
END,
line.exchange_rate,
line.line_description,
line.tax_code,
COALESCE(line.dimensions, '{}'::jsonb),
COALESCE(line.sort_order, 0)
FROM jsonb_to_recordset(p_lines) AS line(
account_number text,
account_id uuid,
debit_amount numeric,
credit_amount numeric,
currency text,
amount_in_currency numeric,
exchange_rate numeric,
line_description text,
tax_code text,
dimensions jsonb,
sort_order integer
);
GET DIAGNOSTICS v_line_count = ROW_COUNT;
IF v_line_count <> jsonb_array_length(p_lines) THEN
RAISE EXCEPTION 'Replacement opening balance line count changed during insert';
END IF;
INSERT INTO public.journal_entries (
id,
user_id,
company_id,
fiscal_period_id,
voucher_number,
voucher_series,
entry_date,
description,
source_type,
source_id,
reverses_id,
status
) VALUES (
v_storno_entry_id,
p_user_id,
p_company_id,
p_period_id,
0,
COALESCE(v_old_entry.voucher_series, 'A'),
p_entry_date,
'Makulering: ' || v_old_entry.description,
'storno',
v_old_entry.source_id,
v_old_entry.id,
'draft'
);
INSERT INTO public.journal_entry_lines (
journal_entry_id,
account_number,
account_id,
debit_amount,
credit_amount,
currency,
amount_in_currency,
exchange_rate,
line_description,
tax_code,
dimensions,
sort_order
)
SELECT
v_storno_entry_id,
line.account_number,
line.account_id,
line.credit_amount,
line.debit_amount,
line.currency,
CASE
WHEN line.amount_in_currency IS NULL OR line.amount_in_currency = 0 THEN NULL
ELSE -line.amount_in_currency
END,
line.exchange_rate,
'Reversal: ' || COALESCE(line.line_description, ''),
line.tax_code,
line.dimensions,
line.sort_order
FROM public.journal_entry_lines line
WHERE line.journal_entry_id = v_old_entry.id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Expected opening balance has no lines: %', v_old_entry.id;
END IF;
SELECT committed.voucher_number
INTO v_new_voucher_number
FROM public.commit_journal_entry(
p_company_id,
v_new_entry_id,
'sie_import',
NULL,
p_actor_type,
p_actor_label
) committed;
SELECT committed.voucher_number
INTO v_storno_voucher_number
FROM public.commit_journal_entry(
p_company_id,
v_storno_entry_id,
'sie_import',
NULL,
p_actor_type,
p_actor_label
) committed;
UPDATE public.journal_entries
SET status = 'reversed',
reversed_by_id = v_storno_entry_id
WHERE id = v_old_entry.id
AND company_id = p_company_id
AND status = 'posted';
GET DIAGNOSTICS v_updated_count = ROW_COUNT;
IF v_updated_count <> 1 THEN
RAISE EXCEPTION 'Opening balance changed concurrently: %', v_old_entry.id
USING ERRCODE = '40001';
END IF;
UPDATE public.fiscal_periods
SET opening_balances_set = false
WHERE id = p_period_id
AND company_id = p_company_id
AND opening_balance_entry_id = p_expected_old_entry_id
AND opening_balances_set = true;
GET DIAGNOSTICS v_updated_count = ROW_COUNT;
IF v_updated_count <> 1 THEN
RAISE EXCEPTION 'Opening balance pointer changed concurrently for fiscal period %', p_period_id
USING ERRCODE = '40001';
END IF;
UPDATE public.fiscal_periods
SET opening_balance_entry_id = v_new_entry_id,
opening_balances_set = true
WHERE id = p_period_id
AND company_id = p_company_id
AND opening_balance_entry_id = p_expected_old_entry_id
AND opening_balances_set = false;
GET DIAGNOSTICS v_updated_count = ROW_COUNT;
IF v_updated_count <> 1 THEN
RAISE EXCEPTION 'Opening balance pointer could not be replaced for fiscal period %', p_period_id
USING ERRCODE = '40001';
END IF;
RETURN QUERY SELECT
v_new_entry_id,
v_storno_entry_id,
v_new_voucher_number,
v_storno_voucher_number;
END;
$function$;
REVOKE ALL ON FUNCTION public.commit_opening_balance_replacement(
uuid, uuid, uuid, uuid, date, text, text, jsonb, text, text
) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.commit_opening_balance_replacement(
uuid, uuid, uuid, uuid, date, text, text, jsonb, text, text
) TO authenticated, service_role;
NOTIFY pgrst, 'reload schema';