Customer report (Aisen & Adison AB, 2026-09-03): Fortnox years 2024-2026
imported first, then the first year 2022/2023 backfilled. Two bugs surfaced.
1. The backfilled year was saved as "Räkenskapsår 2027": CreatePeriodDialog
seeds the next forward year and kept that name when the user re-dated the
form. The name now follows the typed dates until the user edits the name
(fiscalYearName exported from suggest-fiscal-period).
2. Nollställ of the backfilled year was refused with next_year_dependency
because 2024 carried an opening-balance verifikat. Any IB in the next year
counted as reliance, so a backfilled year could never be reset, while a
next year WITHOUT an IB (whose balansrapport really rolls from this year)
was allowed. Migration 20260904163000 redefines fiscal_year_reset_snapshot:
the block fires only when the next year is locked, closed or has its own
closing entry; a bokslut-generated IB is still refused via this year's
closing_entry_id (year_end_state). The snapshot returns next_period
{id, name, has_opening_balances} and the dialog states that the following
year's IB stays as it is.
pg-real: reset-fiscal-year.pg.test.ts pins the narrowed guard (closed next
year, next year with closing entry, next year with its own IB survives the
reset untouched).
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
271 lines
11 KiB
PL/PgSQL
271 lines
11 KiB
PL/PgSQL
-- 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.';
|