fix(bookkeeping): a following year's own IB no longer blocks nollställ, and a re-dated räkenskapsår gets the right name (#2286)
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>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
db289e3bdc
commit
9618bab273
@@ -0,0 +1,270 @@
|
||||
-- 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.';
|
||||
Reference in New Issue
Block a user