-- Fiscal-year reset: a following year's own opening balances are not reliance. -- -- Customer report 2026-09-03 (Aisen & Adison AB): Fortnox years 2024-2026 -- imported first (2024 got its IB from the file's #IB), then the first year -- 2022/2023 was backfilled by SIE import, which relinked 2024 onto it and -- resynced 2024's IB. Resetting the backfilled year was then refused with -- next_year_dependency ("ett senare rakenskapsar bygger pa det har arets -- utgaende balanser"), although nothing in 2024 was derived from the books -- in this system: its IB is its own verifikat with the SIE file as underlag, -- and it survives the reset unchanged. The old check treated ANY IB in the -- next year (opening_balance_entry_id / opening_balances_set) as reliance, -- which made every backfilled year permanently un-resettable, while a next -- year WITHOUT an IB, whose balansrapport really does roll from this year's -- books, was allowed. Reliance is structural: the next year is locked, -- closed, or has its own closing entry (finalised on top of this year), or -- this year has a closing entry (year_end_state), which is the only way an -- IB in the next year is generated from this year's books. -- -- Same function, same guards otherwise. The snapshot now also returns -- next_period {id, name, has_opening_balances} so the preview can state -- that the following year's IB is left as it is. CREATE OR REPLACE FUNCTION public.fiscal_year_reset_snapshot( p_company_id uuid, p_period_id uuid ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ DECLARE v_period record; v_next record; v_blockers jsonb := '[]'::jsonb; v_lock_through date; v_arsred integer; v_vat integer; v_agi integer; v_vouchers integer; v_docs integer; v_start_ym text; v_end_ym text; v_xref integer; v_rotrut integer; BEGIN SELECT id, name, period_start, period_end, is_closed, locked_at, closing_entry_id, opening_balance_entry_id INTO v_period FROM public.fiscal_periods WHERE id = p_period_id AND company_id = p_company_id; IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_YEAR_RESET_NOT_FOUND'); END IF; IF v_period.is_closed THEN v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'period_closed')); END IF; IF v_period.locked_at IS NOT NULL THEN v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'period_locked')); END IF; SELECT bookkeeping_locked_through INTO v_lock_through FROM public.company_settings WHERE company_id = p_company_id; IF v_lock_through IS NOT NULL AND v_lock_through >= v_period.period_start THEN v_blockers := v_blockers || jsonb_build_array(jsonb_build_object( 'code', 'company_lock_date', 'date', to_char(v_lock_through, 'YYYY-MM-DD') )); END IF; IF v_period.closing_entry_id IS NOT NULL THEN v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'year_end_state')); END IF; SELECT (SELECT count(*) FROM public.arsredovisning_submissions WHERE company_id = p_company_id AND fiscal_period_id = p_period_id) + (SELECT count(*) FROM public.arsredovisning_signature_requests WHERE company_id = p_company_id AND fiscal_period_id = p_period_id) INTO v_arsred; IF v_arsred > 0 THEN v_blockers := v_blockers || jsonb_build_array(jsonb_build_object( 'code', 'arsredovisning_state', 'count', v_arsred )); END IF; -- Later-year dependency: chain lookup first, then date-based fallback -- (mirrors findNextPeriod / scripts/undo-year-end-closing.ts). The next -- year blocks the reset only when it has been FINALISED on top of this -- year: locked, closed, or carrying its own closing entry. An opening -- balance verifikat in the next year is not reliance: the dominant -- migration shape (import the first year with its own #IB, later backfill -- the year before it) always leaves an IB there, and that IB is its own -- verifikat with its own underlag, which the reset leaves untouched and -- reports back as next_period.has_opening_balances so the UI can say so. -- An IB generated by this year's bokslut is still refused, via -- closing_entry_id on THIS period (year_end_state above). SELECT id, name, is_closed, locked_at, closing_entry_id, opening_balance_entry_id INTO v_next FROM public.fiscal_periods WHERE company_id = p_company_id AND previous_period_id = p_period_id LIMIT 1; IF NOT FOUND THEN SELECT id, name, is_closed, locked_at, closing_entry_id, opening_balance_entry_id INTO v_next FROM public.fiscal_periods WHERE company_id = p_company_id AND period_start = v_period.period_end + 1 LIMIT 1; END IF; IF v_next.id IS NOT NULL AND ( v_next.is_closed OR v_next.locked_at IS NOT NULL OR v_next.closing_entry_id IS NOT NULL ) THEN v_blockers := v_blockers || jsonb_build_array(jsonb_build_object('code', 'next_year_dependency')); END IF; -- Cross-year rättelse/storno chains: an entry OUTSIDE the year whose -- correction_of_id / reverses_id / reversed_by_id points INTO the year. -- Deleting the target fires the FK's ON DELETE SET NULL as an UPDATE on -- the referrer; enforce_journal_entry_immutability refuses that on a -- posted referrer (the delete escape hatch covers only TG_OP = 'DELETE'), -- and on a draft it would silently sever the rättelse chain. Either way -- the year has been relied upon: refuse up front, so the preview and the -- execution agree (mirrors the delete_last_voucher reference check, -- 20260528120600). SELECT count(*) INTO v_xref FROM public.journal_entries outside WHERE outside.company_id = p_company_id AND outside.fiscal_period_id <> p_period_id AND EXISTS ( SELECT 1 FROM public.journal_entries inside WHERE inside.company_id = p_company_id AND inside.fiscal_period_id = p_period_id AND inside.id IN (outside.correction_of_id, outside.reverses_id, outside.reversed_by_id) ); IF v_xref > 0 THEN v_blockers := v_blockers || jsonb_build_array(jsonb_build_object( 'code', 'cross_year_reference', 'count', v_xref )); END IF; -- VAT declared evidence. Skatteverket declaration state cannot be observed -- reliably from this database (the final signature happens at SKV), so -- every local trace counts and unparsable workflow keys fail closed. Same -- conservative posture as company_migration_reset (20260818224000). v_start_ym := to_char(v_period.period_start, 'YYYYMM'); v_end_ym := to_char(v_period.period_end, 'YYYYMM'); SELECT (SELECT count(*) FROM public.journal_entries WHERE company_id = p_company_id AND fiscal_period_id = p_period_id AND source_type = 'vat_settlement' AND status IN ('posted', 'reversed')) + (SELECT count(*) FROM public.skatteverket_api_audit_log WHERE company_id = p_company_id AND outcome = 'ok' AND endpoint IN ('declaration/lock', 'declaration/submit') AND (redovisningsperiod IS NULL OR (redovisningsperiod >= v_start_ym AND redovisningsperiod <= v_end_ym))) + (SELECT count(*) FROM public.extension_data WHERE company_id = p_company_id AND extension_id = 'skatteverket' AND key LIKE 'submission\_%' ESCAPE '\' AND (substring(key FROM 12) !~ '^[0-9]{6}$' OR (substring(key FROM 12) >= v_start_ym AND substring(key FROM 12) <= v_end_ym))) INTO v_vat; IF v_vat > 0 THEN v_blockers := v_blockers || jsonb_build_array(jsonb_build_object( 'code', 'vat_declared', 'count', v_vat )); END IF; -- AGI declared evidence for months inside the year. SELECT count(*) INTO v_agi FROM public.agi_declarations WHERE company_id = p_company_id AND (submitted_at IS NOT NULL OR status IN ('submitted', 'accepted', 'rejected')) AND make_date(period_year, period_month, 1) BETWEEN date_trunc('month', v_period.period_start)::date AND v_period.period_end; IF v_agi > 0 THEN v_blockers := v_blockers || jsonb_build_array(jsonb_build_object( 'code', 'agi_declared', 'count', v_agi )); END IF; -- ROT/RUT reliance: a begäran om utbetalning that has reached Skatteverket -- (submitted, or decided: paid/partially_paid/rejected) is external -- reliance in the same category as VAT/AGI. Its links into the year are -- ON DELETE SET NULL, so without this guard the reset would silently erase -- the bokföring behind a filed and possibly decided myndighetsärende. -- 'generated' (file never uploaded) and 'cancelled' do not block. SELECT count(*) INTO v_rotrut FROM public.rot_rut_payout_requests r WHERE r.company_id = p_company_id AND r.status IN ('submitted', 'paid', 'partially_paid', 'rejected') AND ( EXISTS ( SELECT 1 FROM public.journal_entries je WHERE je.id = r.settlement_journal_entry_id AND je.fiscal_period_id = p_period_id ) OR EXISTS ( SELECT 1 FROM public.rot_rut_payout_request_items ri JOIN public.invoices inv ON inv.id = ri.invoice_id JOIN public.journal_entries je ON je.id = inv.journal_entry_id WHERE ri.request_id = r.id AND je.fiscal_period_id = p_period_id ) ); IF v_rotrut > 0 THEN v_blockers := v_blockers || jsonb_build_array(jsonb_build_object( 'code', 'rot_rut_state', 'count', v_rotrut )); END IF; SELECT count(*) INTO v_vouchers FROM public.journal_entries WHERE company_id = p_company_id AND fiscal_period_id = p_period_id; SELECT count(*) INTO v_docs FROM public.document_attachments da WHERE da.journal_entry_id IN ( SELECT je.id FROM public.journal_entries je WHERE je.company_id = p_company_id AND je.fiscal_period_id = p_period_id) OR da.journal_entry_line_id IN ( SELECT jel.id FROM public.journal_entry_lines jel JOIN public.journal_entries je ON je.id = jel.journal_entry_id WHERE je.company_id = p_company_id AND je.fiscal_period_id = p_period_id); RETURN jsonb_build_object( 'ok', true, 'eligible', jsonb_array_length(v_blockers) = 0, 'blockers', v_blockers, 'period', jsonb_build_object( 'id', v_period.id, 'name', v_period.name, 'period_start', to_char(v_period.period_start, 'YYYY-MM-DD'), 'period_end', to_char(v_period.period_end, 'YYYY-MM-DD') ), 'counts', jsonb_build_object( 'vouchers', v_vouchers, 'documents_to_detach', v_docs ), 'next_period', CASE WHEN v_next.id IS NULL THEN NULL ELSE jsonb_build_object( 'id', v_next.id, 'name', v_next.name, 'has_opening_balances', v_next.opening_balance_entry_id IS NOT NULL ) END ); END; $function$; REVOKE ALL ON FUNCTION public.fiscal_year_reset_snapshot(uuid, uuid) FROM PUBLIC, anon, authenticated; COMMENT ON FUNCTION public.fiscal_year_reset_snapshot(uuid, uuid) IS 'Internal fail-closed eligibility snapshot for reset_fiscal_year. Not client-callable. next_year_dependency fires only on a locked/closed/closed-by-entry following year; a following year''s own opening balances are reported in next_period, not treated as reliance.';