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:
Jakob Wennberg
2026-09-04 18:49:23 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent db289e3bdc
commit 9618bab273
12 changed files with 434 additions and 12 deletions
+1
View File
@@ -1582,3 +1582,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-04] Fortnox/provider migration (#2211, #2238, fix from first principles): the three-year fiscal-year window (getAllowedFiscalYears, #718) became a DEFAULT selection with a year picker, not a fixed lift to every year: the window gates only the SIE fetch (documents, invoices, assets never were), the import is one request per year (300 s function, 290 s RPC) so its cost is bounded per year, but /preview and /sie-data fetch and parse one SIE export per selected year inside a single 300 s invocation (15 s timeout, 3 attempts, 30 s backoff) and no Fortnox export latency is measured in the repo; a selection keeps the default cost identical and an oversized selection fails in /sie-data before any ledger write. Explaining the cap and pointing at SIE (the first version of #2280) was replaced, not kept: the user's problem was a missing year with its documents, and the SIE path leaves document re-attachment as manual work, which is what the import exists to remove. Source years are keyed by start year in the selection (years=2022,2024), consistent with fileStatuses.fiscalYear; two fiscal years starting in the same calendar year already collide in the existing import and were not fixed here. Splitting /sie-data into per-year requests is a restructure (the mapping step needs the account union across files), deferred until large selections time out in practice. The selection per run is capped at MAX_SELECTED_FISCAL_YEARS = 6, derived from the /sie-data budget (300 s function; 48 s worst case per year = 3 x 15 s timeout + 1 s + 2 s backoff; 6 x 48 = 288 s), enforced in the route before the consent is resolved and mirrored in the picker via /preview rather than a client-side constant; unknown years are refused after the year listing and before the first export (Superagent P2 on #2280). Lifting the cap means per-year /sie-data requests, a restructure, not a bigger number.
[2026-09-04] Supplier-invoice list overflow (#2262) fixed per page, not in a shared list component: none exists (every list hand-writes the overflow-x-auto wrapper) and the three overflow reports had three different causes, so the column budget went into .claude/rules/design.md instead. Fakturadatum was dropped rather than Kvar or the action column (förfaller is the payer's date and the default order, the customer list has no invoice-date column either); the always-visible sort icon from #2091 was kept although it was the proximate regression.
[2026-09-04] Parties: the model reads a counterpart only on demand (picker, review list), never when the queue builds: a five-hundred-row queue would cost five hundred calls nobody asked for and a rebuild would repeat them; the reading is a 'model' fact and a search query, never a hard key. The review list ticks rows with exactly one active SCB match but writes nothing until a person approves: an exact legal name plus one active hit is high precision, auto-attaching would still be the system choosing. Exact legal-form names ("Visma Spcs AB", not "Visma") group keys and attach to existing parties: registered company names are unique in Sweden, so this is a key in all but form; the "name never merges" rule keeps applying to fuzzy and form-less names.
[2026-09-04] reset_fiscal_year's next_year_dependency no longer counts an opening-balance verifikat in the following year as reliance (migration 20260904163000 redefines fiscal_year_reset_snapshot; the block now fires only when the following year is locked, closed or has its own closing entry). Why: the old check (opening_balance_entry_id / opening_balances_set on the next period) fired for the dominant migration shape, import the first year with its own #IB and later backfill the year before it, so a backfilled year could never be reset (Aisen & Adison AB, 2026-09-03), while a next year WITHOUT an IB, whose balansrapport really rolls from this year's books, was allowed: the check was inverted relative to actual reliance. An IB in the next year is its own verifikat with its own underlag and survives the reset untouched; the one IB that IS derived from this year's books, the bokslut-generated one, is still refused via this year's closing_entry_id (year_end_state). The preview now returns next_period {name, has_opening_balances} and the dialog says the following year's IB stays as it is, instead of a blocker. Rejected alternative: stornoing the next year's IB inside the reset (it would destroy a correct migration boundary and re-create the #1022 dead end). Sibling fix in the same change: CreatePeriodDialog now derives the name from the dates the user types until the name is hand-edited, which is how a 2022-07-01..2023-12-31 year got saved as "Räkenskapsår 2027" (the seed suggestion is always the next forward year).
+25 -4
View File
@@ -16,7 +16,7 @@ import { Label } from '@/components/ui/label'
import { AttnLine } from '@/components/ui/attn-line'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { computeSuggestedPeriod } from '@/lib/bookkeeping/suggest-fiscal-period'
import { computeSuggestedPeriod, fiscalYearName } from '@/lib/bookkeeping/suggest-fiscal-period'
import { fiscalPeriodAdvisoryText } from '@/lib/bookkeeping/fiscal-period-warnings'
import type { FiscalPeriod } from '@/types'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
@@ -61,6 +61,11 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
const suggested = useMemo(() => computeSuggestedPeriod(entryDate, periods), [entryDate, periods])
const [name, setName] = useState(suggested.name)
// The name follows the dates until the user types a name of their own.
// Without this the seed suggestion (the next forward year, e.g.
// "Räkenskapsår 2027") survived the user re-dating the form to a backfilled
// first year and was saved verbatim.
const [nameEdited, setNameEdited] = useState(false)
const [periodStart, setPeriodStart] = useState(suggested.period_start)
const [periodEnd, setPeriodEnd] = useState(suggested.period_end)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -72,12 +77,21 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
const [lastSuggested, setLastSuggested] = useState(suggested)
if (suggested.name !== lastSuggested.name || suggested.period_start !== lastSuggested.period_start) {
setName(suggested.name)
setNameEdited(false)
setPeriodStart(suggested.period_start)
setPeriodEnd(suggested.period_end)
setLastSuggested(suggested)
setCreated(null)
}
// A date input yields '' while incomplete and a full YYYY-MM-DD otherwise:
// only derive a name once both ends are known.
const updateDates = (start: string, end: string) => {
setPeriodStart(start)
setPeriodEnd(end)
if (!nameEdited && start && end) setName(fiscalYearName(start, end))
}
// Close, and refetch in the parent if this session created a period. The
// refetch is deferred to here on the advisory path on purpose: it swaps the
// `periods` prop, which resets the form above, and that would pull the
@@ -175,16 +189,23 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
<div className="space-y-3">
<div>
<Label>Namn</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" />
<Input
value={name}
onChange={(e) => {
setName(e.target.value)
setNameEdited(true)
}}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Startdatum</Label>
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="mt-1" />
<Input type="date" value={periodStart} onChange={(e) => updateDates(e.target.value, periodEnd)} className="mt-1" />
</div>
<div>
<Label>Slutdatum</Label>
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="mt-1" />
<Input type="date" value={periodEnd} onChange={(e) => updateDates(periodStart, e.target.value)} className="mt-1" />
</div>
</div>
</div>
@@ -242,6 +242,11 @@ export function FiscalYearResetDialog({
<p className="mt-2 text-xs text-muted-foreground">
{t('fy_reset_documents_note')}
</p>
{eligibility.next_period?.has_opening_balances ? (
<p className="mt-2 text-xs text-muted-foreground">
{t('fy_reset_next_year_ib_note', { name: eligibility.next_period.name })}
</p>
) : null}
</div>
{eligibility.eligible ? (
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
computeSuggestedPeriod,
fiscalYearName,
suggestSeedDate,
resolveCurrentPeriodId,
} from '../suggest-fiscal-period'
@@ -10,6 +11,22 @@ type Range = { period_start: string; period_end: string }
const FY2024: Range = { period_start: '2024-01-01', period_end: '2024-12-31' }
const FY2026: Range = { period_start: '2026-01-01', period_end: '2026-12-31' }
describe('fiscalYearName', () => {
it('names a calendar year by its year', () => {
expect(fiscalYearName('2025-01-01', '2025-12-31')).toBe('Räkenskapsår 2025')
})
it('names a straddling year by both years', () => {
expect(fiscalYearName('2024-07-01', '2025-06-30')).toBe('Räkenskapsår 2024/2025')
})
it('names a backfilled first year that spans two calendar years by both years', () => {
// The customer case: the create dialog seeded "Räkenskapsår 2027" and the
// user re-dated the form to the company's first, extended year.
expect(fiscalYearName('2022-07-28', '2023-12-31')).toBe('Räkenskapsår 2022/2023')
})
})
describe('computeSuggestedPeriod', () => {
it('suggests a calendar year around the entry date when there are no periods', () => {
expect(computeSuggestedPeriod('2025-06-15', [])).toEqual({
+9 -3
View File
@@ -28,13 +28,19 @@ export interface SuggestedPeriod {
* A fiscal-year name: `Räkenskapsår 2025`, or `Räkenskapsår 2024/2025` when it
* straddles two calendar years. Swedish by default to match the app's existing
* fiscal-year naming (Swedish-first); the field stays editable in the dialog.
* Exported so the create dialog can keep the name in step with the dates the
* user actually types: a suggested "Räkenskapsår 2027" must not survive the
* user re-dating the year to 2022-07-01..2023-12-31. Callers pass full
* YYYY-MM-DD strings.
*/
function periodName(start: string, end: string): string {
const startYear = Number(start.slice(0, 4))
const endYear = Number(end.slice(0, 4))
export function fiscalYearName(start: string, end: string): string {
const startYear = start.slice(0, 4)
const endYear = end.slice(0, 4)
return startYear === endYear ? `Räkenskapsår ${startYear}` : `Räkenskapsår ${startYear}/${endYear}`
}
const periodName = fiscalYearName
/**
* Suggest a fiscal period for the create dialog, given the date the user is
* trying to book and the company's existing periods. Three cases:
@@ -35,6 +35,7 @@ const SNAPSHOT = {
period_end: '2026-12-31',
},
counts: { vouchers: 7, documents_to_detach: 2 },
next_period: { id: 'period-2', name: '2027', has_opening_balances: true },
}
describe('getFiscalYearResetEligibility', () => {
@@ -52,6 +53,7 @@ describe('getFiscalYearResetEligibility', () => {
blockers: [],
period: SNAPSHOT.period,
counts: SNAPSHOT.counts,
next_period: SNAPSHOT.next_period,
},
})
expect(supabase.rpc).toHaveBeenCalledWith('get_fiscal_year_reset_eligibility', {
@@ -56,6 +56,7 @@ export async function getFiscalYearResetEligibility(
blockers: result.blockers ?? [],
period: result.period!,
counts: result.counts ?? { vouchers: 0, documents_to_detach: 0 },
next_period: result.next_period ?? null,
},
}
}
+1
View File
@@ -1854,6 +1854,7 @@
"fy_reset_summary_vouchers": "Vouchers to delete",
"fy_reset_summary_documents": "Document links to remove",
"fy_reset_documents_note": "Documents stay in the archive (7-year retention); they are only detached from the vouchers.",
"fy_reset_next_year_ib_note": "Opening balances in {name} stay as they are. Reconcile them against this year once it has been booked again.",
"fy_reset_confirm_name": "Type <strong>{name}</strong> to confirm",
"fy_reset_submit": "Reset fiscal year",
"fy_reset_resetting": "Resetting...",
+1
View File
@@ -1854,6 +1854,7 @@
"fy_reset_summary_vouchers": "Verifikat som raderas",
"fy_reset_summary_documents": "Dokumentkopplingar som tas bort",
"fy_reset_documents_note": "Dokumenten och underlagen ligger kvar i arkivet (7 års arkiveringsplikt), de kopplas bara loss från verifikaten.",
"fy_reset_next_year_ib_note": "Ingående balanser i {name} ligger kvar oförändrade. Stäm av dem mot det här året när det är bokfört igen.",
"fy_reset_confirm_name": "Skriv <strong>{name}</strong> för att bekräfta",
"fy_reset_submit": "Nollställ räkenskapsåret",
"fy_reset_resetting": "Nollställer...",
@@ -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.';
+93 -5
View File
@@ -1,5 +1,6 @@
/**
* pg-real tests for the fiscal-year reset RPCs (migration 20260825150000).
* pg-real tests for the fiscal-year reset RPCs (migration 20260825150000,
* next-year dependency narrowed in 20260904163000).
*
* Pins: the actor gate (service-role p_user_id, owner/admin only), every
* eligibility guard (locked, closed, company lock date, year-end state,
@@ -29,6 +30,7 @@ type RpcResult = {
eligible?: boolean
blockers?: Array<{ code: string; count?: number; date?: string }>
counts?: { vouchers: number; documents_to_detach: number }
next_period?: { id: string; name: string; has_opening_balances: boolean } | null
deleted?: number
detached_documents?: number
period_name?: string
@@ -212,7 +214,7 @@ describe('reset_fiscal_year: eligibility guards', () => {
)
})
it('refuses when a later year depends on this year (IB generated)', async () => {
it('refuses when a later year is finalised on top of this one (closed)', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const nextId = await insertFiscalPeriod({
userId,
@@ -220,11 +222,10 @@ describe('reset_fiscal_year: eligibility guards', () => {
name: '2027',
periodStart: '2027-01-01',
periodEnd: '2027-12-31',
isClosed: true,
})
await getPool().query(
`UPDATE public.fiscal_periods
SET previous_period_id = $1, opening_balances_set = true
WHERE id = $2`,
`UPDATE public.fiscal_periods SET previous_period_id = $1 WHERE id = $2`,
[fiscalPeriodId, nextId],
)
@@ -235,6 +236,86 @@ describe('reset_fiscal_year: eligibility guards', () => {
)
})
it('refuses when a later year has its own closing entry', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const nextId = await insertFiscalPeriod({
userId,
companyId,
name: '2027',
periodStart: '2027-01-01',
periodEnd: '2027-12-31',
})
const nextClosingId = await insertPostedEntry({
companyId,
userId,
fiscalPeriodId: nextId,
sourceType: 'year_end',
entryDate: '2027-12-31',
})
await getPool().query(
`UPDATE public.fiscal_periods
SET previous_period_id = $1, closing_entry_id = $2
WHERE id = $3`,
[fiscalPeriodId, nextClosingId, nextId],
)
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
expect(result).toMatchObject({ ok: false, code: 'FISCAL_YEAR_RESET_INELIGIBLE' })
expect(result.blockers).toEqual(
expect.arrayContaining([expect.objectContaining({ code: 'next_year_dependency' })]),
)
})
it('does not treat the later year\'s own opening balances as a dependency', async () => {
// The backfill shape: the first imported year carries its own IB (from
// the SIE file's #IB, or resynced from the backfilled year's #UB). Its
// IB is a verifikat of its own and survives the reset untouched; the
// preview discloses it instead of refusing.
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertPostedEntry({ companyId, userId, fiscalPeriodId, sourceType: 'import' })
const nextId = await insertFiscalPeriod({
userId,
companyId,
name: '2027',
periodStart: '2027-01-01',
periodEnd: '2027-12-31',
})
const nextIbId = await insertPostedEntry({
companyId,
userId,
fiscalPeriodId: nextId,
sourceType: 'opening_balance',
entryDate: '2027-01-01',
})
await getPool().query(
`UPDATE public.fiscal_periods
SET previous_period_id = $1,
opening_balance_entry_id = $2,
opening_balances_set = true
WHERE id = $3`,
[fiscalPeriodId, nextIbId, nextId],
)
const eligibility = await callEligibility(companyId, fiscalPeriodId, userId)
expect(eligibility).toMatchObject({
ok: true,
eligible: true,
blockers: [],
next_period: { id: nextId, name: '2027', has_opening_balances: true },
})
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
expect(result.ok).toBe(true)
expect(result.deleted).toBe(1)
expect(await entryCount(companyId, nextId)).toBe(1)
const { rows } = await getPool().query<{ opening_balance_entry_id: string; opening_balances_set: boolean }>(
`SELECT opening_balance_entry_id, opening_balances_set FROM public.fiscal_periods WHERE id = $1`,
[nextId],
)
expect(rows[0]).toEqual({ opening_balance_entry_id: nextIbId, opening_balances_set: true })
})
it('allows a later year that carries no dependency yet', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertPostedEntry({ companyId, userId, fiscalPeriodId })
@@ -250,6 +331,13 @@ describe('reset_fiscal_year: eligibility guards', () => {
[fiscalPeriodId, nextId],
)
const eligibility = await callEligibility(companyId, fiscalPeriodId, userId)
expect(eligibility).toMatchObject({
ok: true,
eligible: true,
next_period: { id: nextId, name: '2027', has_opening_balances: false },
})
const result = await callReset(companyId, fiscalPeriodId, '2026', userId)
expect(result.ok).toBe(true)
expect(result.deleted).toBe(1)
+9
View File
@@ -145,6 +145,14 @@ export interface FiscalYearResetEligibility {
vouchers: number
documents_to_detach: number
}
// The following räkenskapsår, when one exists. Its own opening balances
// are never touched by the reset and are disclosed, not treated as a
// dependency (migration 20260904163000).
next_period: {
id: string
name: string
has_opening_balances: boolean
} | null
}
export interface FiscalYearResetRpcResult {
@@ -154,6 +162,7 @@ export interface FiscalYearResetRpcResult {
blockers?: FiscalYearResetBlocker[]
period?: FiscalYearResetEligibility['period']
counts?: FiscalYearResetEligibility['counts']
next_period?: FiscalYearResetEligibility['next_period']
deleted?: number
detached_documents?: number
period_name?: string