From 9618bab27355ae611a8f5a0d72bf82c0060fee49 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 4 Sep 2026 18:49:23 +0200 Subject: [PATCH] =?UTF-8?q?fix(bookkeeping):=20a=20following=20year's=20ow?= =?UTF-8?q?n=20IB=20no=20longer=20blocks=20nollst=C3=A4ll,=20and=20a=20re-?= =?UTF-8?q?dated=20r=C3=A4kenskaps=C3=A5r=20gets=20the=20right=20name=20(#?= =?UTF-8?q?2286)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DECISIONS.md | 1 + components/bookkeeping/CreatePeriodDialog.tsx | 29 +- components/settings/FiscalYearResetDialog.tsx | 5 + .../__tests__/suggest-fiscal-period.test.ts | 17 ++ lib/bookkeeping/suggest-fiscal-period.ts | 12 +- .../__tests__/fiscal-year-reset.test.ts | 2 + lib/core/bookkeeping/fiscal-year-reset.ts | 1 + messages/en.json | 1 + messages/sv.json | 1 + ...fiscal_year_reset_next_year_dependency.sql | 270 ++++++++++++++++++ tests/pg/reset-fiscal-year.pg.test.ts | 98 ++++++- types/index.ts | 9 + 12 files changed, 434 insertions(+), 12 deletions(-) create mode 100644 supabase/migrations/20260904163000_fiscal_year_reset_next_year_dependency.sql diff --git a/DECISIONS.md b/DECISIONS.md index 4cba0094..5d0546c5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1582,3 +1582,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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). diff --git a/components/bookkeeping/CreatePeriodDialog.tsx b/components/bookkeeping/CreatePeriodDialog.tsx index a4ec7235..339bd3bd 100644 --- a/components/bookkeeping/CreatePeriodDialog.tsx +++ b/components/bookkeeping/CreatePeriodDialog.tsx @@ -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
- setName(e.target.value)} className="mt-1" /> + { + setName(e.target.value) + setNameEdited(true) + }} + className="mt-1" + />
- setPeriodStart(e.target.value)} className="mt-1" /> + updateDates(e.target.value, periodEnd)} className="mt-1" />
- setPeriodEnd(e.target.value)} className="mt-1" /> + updateDates(periodStart, e.target.value)} className="mt-1" />
diff --git a/components/settings/FiscalYearResetDialog.tsx b/components/settings/FiscalYearResetDialog.tsx index 7f84bd46..f7e15202 100644 --- a/components/settings/FiscalYearResetDialog.tsx +++ b/components/settings/FiscalYearResetDialog.tsx @@ -242,6 +242,11 @@ export function FiscalYearResetDialog({

{t('fy_reset_documents_note')}

+ {eligibility.next_period?.has_opening_balances ? ( +

+ {t('fy_reset_next_year_ib_note', { name: eligibility.next_period.name })} +

+ ) : null} {eligibility.eligible ? ( diff --git a/lib/bookkeeping/__tests__/suggest-fiscal-period.test.ts b/lib/bookkeeping/__tests__/suggest-fiscal-period.test.ts index 8c17a3a6..f200aed3 100644 --- a/lib/bookkeeping/__tests__/suggest-fiscal-period.test.ts +++ b/lib/bookkeeping/__tests__/suggest-fiscal-period.test.ts @@ -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({ diff --git a/lib/bookkeeping/suggest-fiscal-period.ts b/lib/bookkeeping/suggest-fiscal-period.ts index b8ce8b09..234fa272 100644 --- a/lib/bookkeeping/suggest-fiscal-period.ts +++ b/lib/bookkeeping/suggest-fiscal-period.ts @@ -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: diff --git a/lib/core/bookkeeping/__tests__/fiscal-year-reset.test.ts b/lib/core/bookkeeping/__tests__/fiscal-year-reset.test.ts index b82f4e2f..957d3137 100644 --- a/lib/core/bookkeeping/__tests__/fiscal-year-reset.test.ts +++ b/lib/core/bookkeeping/__tests__/fiscal-year-reset.test.ts @@ -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', { diff --git a/lib/core/bookkeeping/fiscal-year-reset.ts b/lib/core/bookkeeping/fiscal-year-reset.ts index 2128fede..5b886efd 100644 --- a/lib/core/bookkeeping/fiscal-year-reset.ts +++ b/lib/core/bookkeeping/fiscal-year-reset.ts @@ -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, }, } } diff --git a/messages/en.json b/messages/en.json index 8b3c15bb..62a901f7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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 {name} to confirm", "fy_reset_submit": "Reset fiscal year", "fy_reset_resetting": "Resetting...", diff --git a/messages/sv.json b/messages/sv.json index f986e939..6562abe3 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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 {name} för att bekräfta", "fy_reset_submit": "Nollställ räkenskapsåret", "fy_reset_resetting": "Nollställer...", diff --git a/supabase/migrations/20260904163000_fiscal_year_reset_next_year_dependency.sql b/supabase/migrations/20260904163000_fiscal_year_reset_next_year_dependency.sql new file mode 100644 index 00000000..84e330d9 --- /dev/null +++ b/supabase/migrations/20260904163000_fiscal_year_reset_next_year_dependency.sql @@ -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.'; diff --git a/tests/pg/reset-fiscal-year.pg.test.ts b/tests/pg/reset-fiscal-year.pg.test.ts index f8409b60..b03513c3 100644 --- a/tests/pg/reset-fiscal-year.pg.test.ts +++ b/tests/pg/reset-fiscal-year.pg.test.ts @@ -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) diff --git a/types/index.ts b/types/index.ts index 69acedb6..b8afdf9d 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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