From e35714518f6c5f53a833cc4f5c10f1fe4559b1d4 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 25 Aug 2026 10:25:58 +0200 Subject: [PATCH] fix(year-end): never seed opening balances into a non-adjacent period (#1849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback seq 249297: run_year_end on Räkenskapsår 2024/2025 seeded the closing balances into an existing 2026/2027 period and left no period at all for 2025/2026. Root cause: SIE import wires previous_period_id to the NEAREST later period regardless of gap (the company had an onboarding-seeded 2026/2027 when 2024/2025 was imported), and findNextPeriod trusted the chain unchecked. - findNextPeriod: a chained period is only the next period when it starts the day after the current one; otherwise log and fall through to the date lookup so year-end creates the contiguous period. - createNextPeriod: relink a successor that was chained across the gap onto the newly created period, healing the chain. - SIE import: wire predecessor and successor links only when date-adjacent. A gap stays unlinked until the missing year exists. Prod scan 2026-08-24: 40 non-adjacent links across 39 companies (mostly an old historical year chained to an onboarding-seeded current year). The read-side guard neutralizes all of them for year-end; the data repair is a separate, founder-approved step. The reporting company (23dc3c97) self-repaired the same evening via the fiscal-periods gap-fill route; the stray IB entry is reversed. Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../__tests__/period-service.test.ts | 100 ++++++++++++++++++ lib/core/bookkeeping/period-service.ts | 54 +++++++++- lib/import/__tests__/sie-import.test.ts | 68 +++++++++++- lib/import/sie-import.ts | 32 +++++- 5 files changed, 245 insertions(+), 10 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 3b829c5d..f0bad4ab 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1199,3 +1199,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] Bokslut checklist (Reko bilagor, PR 3) keeps the item catalogue in code and only the per-period state in bokslut_checklist_items: steps the system can judge (drafts, voucher gaps, trial balance, sign-offs through balansdagen, reskontra tie-outs) are computed live every time and a stored row only overrides them, so the checklist never claims a state the ledger contradicts; manual steps (inventering, osäkra fordringar, dispositioner) are what the konsult ticks. Mutable on purpose (a late verifikat reopens a step), no DELETE policy. The missing-fiscal-year check is a pure helper reused by the readiness warnings and the SIE import result; the non-adjacent previous_period_id fix is #1849 and is not duplicated here. [2026-08-24] Bokslutsbilagor pärm (Reko bilagor, PR 4) is generated from the sign-off rows, the trial balance through balansdagen and the attachment rows, never by recomputing each account's live status: the bilaga documents what was attested (numbers as they stood at sign-off, who, when, note) plus the files with their SHA-256, which is what a kvalitetskontroll reads. Whole period only (a bilaga is per balansdag), PDF-only export, written into every period folder of the full archive as JSON + PDF; an archive run has no acting user, so the checklist's readiness-derived items are left as stored there. [2026-08-25] A period klarmarkerad as closed in a previous system (closed_externally) no longer trips the trial balance's "closed without closing_entry_id" guard for statutory pre-closing balances: its closing verifikat never existed in these books, so the booked balances are the pre-closing balances and there is nothing to strip. The guard stays for periods our own engine closed, where a missing link is a real inconsistency. Found by Väla Redovisning: Klarmarkera + Årsredovisning = 500. +[2026-08-24] fiscal_periods.previous_period_id is adjacency-only: findNextPeriod ignores a chained period that does not start the day after the current one, and SIE import only wires predecessor/successor links between date-adjacent periods (before: nearest period across any gap). A non-adjacent link is what sent a company's opening balances two years forward (feedback seq 249297); 40 such links exist on prod across 39 companies and are neutralized by the read-side guard, not repaired in this change. A gap in the chain means a missing räkenskapsår (BFL 3 kap), which reports should show as missing rather than bridge silently. diff --git a/lib/core/bookkeeping/__tests__/period-service.test.ts b/lib/core/bookkeeping/__tests__/period-service.test.ts index 0abd6e62..02d16bc7 100644 --- a/lib/core/bookkeeping/__tests__/period-service.test.ts +++ b/lib/core/bookkeeping/__tests__/period-service.test.ts @@ -946,4 +946,104 @@ describe('findNextPeriod', () => { const result = await findNextPeriod(supabase as never, 'company-1', 'missing') expect(result).toBeNull() }) + + // Feedback seq 249297: an SIE import had wired 2026/2027.previous_period_id + // to 2024/2025 across a missing 2025/2026, and run_year_end seeded the + // closing balances of 2024/2025 into 2026/2027 because the chained row was + // returned unchecked. A non-adjacent link must be ignored so the caller + // creates the chronologically correct next period instead. + it('ignores a chained period that is not date-adjacent and falls back to the date lookup', async () => { + const current = makeFiscalPeriod({ + id: 'fp-2024-25', + period_start: '2024-05-01', + period_end: '2025-04-30', + }) + const twoYearsOut = makeFiscalPeriod({ + id: 'fp-2026-27', + period_start: '2026-05-01', + period_end: '2027-04-30', + previous_period_id: 'fp-2024-25', + }) + + results = [ + { data: current, error: null }, // fetch current + { data: twoYearsOut, error: null }, // chained lookup hits the wrong period + { data: null, error: null }, // date lookup: 2025-05-01 does not exist + ] + + const supabase = makeClient() + const result = await findNextPeriod(supabase as never, 'company-1', 'fp-2024-25') + expect(result).toBeNull() + }) + + it('prefers the date-adjacent period over a mis-chained one', async () => { + const current = makeFiscalPeriod({ + id: 'fp-2024-25', + period_start: '2024-05-01', + period_end: '2025-04-30', + }) + const twoYearsOut = makeFiscalPeriod({ + id: 'fp-2026-27', + period_start: '2026-05-01', + period_end: '2027-04-30', + previous_period_id: 'fp-2024-25', + }) + const adjacent = makeFiscalPeriod({ + id: 'fp-2025-26', + period_start: '2025-05-01', + period_end: '2026-04-30', + previous_period_id: null, + }) + + results = [ + { data: current, error: null }, + { data: twoYearsOut, error: null }, + { data: adjacent, error: null }, + ] + + const supabase = makeClient() + const result = await findNextPeriod(supabase as never, 'company-1', 'fp-2024-25') + expect(result?.id).toBe('fp-2025-26') + }) +}) + +describe('createNextPeriod chain healing', () => { + it('relinks a successor that was chained across the gap onto the new period', async () => { + const current = makeFiscalPeriod({ + id: 'fp-2024-25', + period_start: '2024-05-01', + period_end: '2025-04-30', + }) + const created = makeFiscalPeriod({ + id: 'fp-2025-26', + period_start: '2025-05-01', + period_end: '2026-04-30', + previous_period_id: 'fp-2024-25', + }) + + results = [ + { data: current, error: null }, // fetch current + { data: [], error: null }, // overlap check + { data: created, error: null }, // insert + { data: [{ id: 'fp-2026-27' }], error: null }, // mis-chained successor starting 2026-05-01 + { data: null, error: null }, // relink update + ] + + const client = makeClient() + const builders: Array> = [] + const from = client.from + client.from = vi.fn().mockImplementation(() => { + const b = from() + builders.push(b) + return b + }) + + const result = await createNextPeriod(client as never, 'company-1', 'user-1', 'fp-2024-25') + expect(result.id).toBe('fp-2025-26') + + const relink = builders.find((b) => (b.update as ReturnType).mock.calls.length > 0) + expect(relink).toBeDefined() + expect((relink!.update as ReturnType).mock.calls[0][0]).toEqual({ previous_period_id: 'fp-2025-26' }) + expect((relink!.in as ReturnType).mock.calls[0]).toEqual(['id', ['fp-2026-27']]) + }) }) diff --git a/lib/core/bookkeeping/period-service.ts b/lib/core/bookkeeping/period-service.ts index 3baa3d6d..3cc7a22d 100644 --- a/lib/core/bookkeeping/period-service.ts +++ b/lib/core/bookkeeping/period-service.ts @@ -648,6 +648,35 @@ export async function createNextPeriod( throw new Error(`Failed to create next period: ${insertError?.message}`) } + // Heal a chain wired across the gap this period fills: a later period that + // claims the CURRENT period as predecessor but actually starts the day + // after the new one now follows the new period (findNextPeriod explains + // how such links arise). The new period's own row cannot match: it starts + // the day after current, not the day after itself. Best-effort: the period + // is created either way, and findNextPeriod no longer trusts a + // non-adjacent link. + const { data: mischained } = await supabase + .from('fiscal_periods') + .select('id') + .eq('company_id', companyId) + .eq('previous_period_id', currentPeriodId) + .eq('period_start', addDaysUTC(nextEndStr, 1)) + if (mischained && mischained.length > 0) { + const successorIds = mischained.map((row: { id: string }) => row.id) + const { error: relinkError } = await supabase + .from('fiscal_periods') + .update({ previous_period_id: newPeriod.id }) + .in('id', successorIds) + .eq('company_id', companyId) + if (relinkError) { + log.error('failed to relink successor onto the newly created period', relinkError, { + companyId, + newPeriodId: newPeriod.id, + successorIds, + }) + } + } + return newPeriod as FiscalPeriod } @@ -685,10 +714,6 @@ export async function findNextPeriod( .eq('previous_period_id', currentPeriodId) .maybeSingle() - if (chained) { - return chained as FiscalPeriod - } - // UTC-only arithmetic: anchor the date string at UTC midnight, then // advance via setUTCDate. Using Date(string) + setDate/getDate causes an // off-by-one on servers in TZ+ when the day after period_end crosses a @@ -696,6 +721,27 @@ export async function findNextPeriod( // and toISOString() converts back through the shifted offset. const expectedStartStr = addDaysUTC(current.period_end, 1) + // The chain is only trusted when it is date-adjacent. SIE import used to + // point previous_period_id at the NEAREST later period regardless of the + // gap (40 such rows on prod as of 2026-08-24), and year-end then seeded a + // whole missing year's opening balances into a period two years out + // because this returned the chained row unchecked (feedback seq 249297). + // A non-adjacent link means the true next period is missing or unlinked: + // fall through to the date lookup and let the caller create it. + if (chained) { + const chainedPeriod = chained as FiscalPeriod + if (chainedPeriod.period_start === expectedStartStr) { + return chainedPeriod + } + log.warn('fiscal period chain is not date-adjacent: ignoring previous_period_id link', { + companyId, + currentPeriodId, + chainedPeriodId: chainedPeriod.id, + expectedStart: expectedStartStr, + chainedStart: chainedPeriod.period_start, + }) + } + const { data: byDate } = await supabase .from('fiscal_periods') .select('*') diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index 49a0f7d2..f02d3890 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -572,7 +572,7 @@ describe('ensureFiscalPeriod validation', () => { { data: [], error: null }, { data: [], error: null }, { data: { id: 'fp-2025' }, error: null }, - { data: [{ id: 'fp-2026' }], error: null }, + { data: [{ id: 'fp-2026', period_start: '2026-01-01' }], error: null }, { data: null, error: null }, ]) @@ -589,6 +589,72 @@ describe('ensureFiscalPeriod validation', () => { ]) }) + // previous_period_id means "the räkenskapsår immediately before". Linking + // the NEAREST period across a gap of missing years made year-end seed a + // company's opening balances two years forward (feedback seq 249297): the + // chain must only ever be wired between date-adjacent periods. + it('does not relink a successor that is not date-adjacent (gap of missing years)', async () => { + const { supabase, enqueueMany, findCalls } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, + { data: [], error: null }, + { data: [], error: null }, + { data: { id: 'fp-2024-25' }, error: null }, + // Onboarding-seeded 2026/2027 exists; 2025/2026 is missing. + { data: [{ id: 'fp-2026-27', period_start: '2026-05-01' }], error: null }, + ]) + + const id = await ensureFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2024-05-01', + '2025-04-30', + ) + + expect(id).toBe('fp-2024-25') + expect(findCalls('fiscal_periods', 'update')).toEqual([]) + }) + + it('links the predecessor only when it ends the day before the new period starts', async () => { + const { supabase, enqueueMany, findCalls } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, + { data: [], error: null }, + { data: [{ id: 'fp-2022', period_end: '2022-12-31' }], error: null }, // nearest, but 2023-2024 missing + { data: { id: 'fp-2025' }, error: null }, + { data: [], error: null }, + ]) + + await ensureFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2025-01-01', + '2025-12-31', + ) + + const [insertPayload] = findCalls('fiscal_periods', 'insert')[0] as [{ previous_period_id: string | null }] + expect(insertPayload.previous_period_id).toBeNull() + + const adjacent = createQueuedMockSupabase() + adjacent.enqueueMany([ + { data: null, error: null }, + { data: [], error: null }, + { data: [{ id: 'fp-2024', period_end: '2024-12-31' }], error: null }, + { data: { id: 'fp-2025' }, error: null }, + { data: [], error: null }, + ]) + + await ensureFiscalPeriod( + adjacent.supabase as unknown as Supabase, + 'company-id', + '2025-01-01', + '2025-12-31', + ) + + const [adjacentPayload] = adjacent.findCalls('fiscal_periods', 'insert')[0] as [{ previous_period_id: string | null }] + expect(adjacentPayload.previous_period_id).toBe('fp-2024') + }) + // BFL 3 kap. caps any räkenskapsår at 18 months (12 is the norm; 18 is the // ceiling for a förlängt/omlagt year). #RAR used to be validated for start // and end DAY only, so a 24-month räkenskapsår from a foreign system diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index c7871f86..4c9fbd05 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -615,14 +615,24 @@ export async function ensureFiscalPeriod( // route: point this period at its closest predecessor, then relink the // immediate successor (if any) to follow this one, so multi-year SIE files // chain correctly regardless of the order #RAR years are processed in. + // + // Adjacency is required on both sides. previous_period_id means "the + // räkenskapsår immediately before", and year-end seeds opening balances + // into whatever follows the chain: linking the NEAREST period across a gap + // of missing years once sent a company's IB two years forward (feedback + // seq 249297). A gap stays unlinked until the missing year is imported. const { data: predecessors } = await supabase .from('fiscal_periods') - .select('id') + .select('id, period_end') .eq('company_id', companyId) .lt('period_end', startDate) .order('period_end', { ascending: false }) .limit(1) - const previousPeriodId = predecessors && predecessors.length > 0 ? predecessors[0].id : null + const nearestPredecessor = predecessors && predecessors.length > 0 ? predecessors[0] : null + const previousPeriodId = + nearestPredecessor && nearestPredecessor.period_end === shiftIsoDate(startDate, -1) + ? nearestPredecessor.id + : null const { data: newPeriod, error } = await supabase .from('fiscal_periods') @@ -643,16 +653,21 @@ export async function ensureFiscalPeriod( } // Relink the immediate successor (e.g. when an earlier year is imported after - // a later one) so the chain holds in both directions. + // a later one) so the chain holds in both directions. Only a successor that + // starts the day after this period qualifies (see above). const { data: successors } = await supabase .from('fiscal_periods') - .select('id') + .select('id, period_start') .eq('company_id', companyId) .gt('period_start', endDate) .neq('id', newPeriod.id) .order('period_start', { ascending: true }) .limit(1) - if (successors && successors.length > 0) { + if ( + successors && + successors.length > 0 && + successors[0].period_start === shiftIsoDate(endDate, 1) + ) { await supabase .from('fiscal_periods') .update({ previous_period_id: newPeriod.id }) @@ -663,6 +678,13 @@ export async function ensureFiscalPeriod( return newPeriod.id } +/** Shift a YYYY-MM-DD string by `days` in pure UTC (no local DST drift). */ +function shiftIsoDate(isoDate: string, days: number): string { + const d = new Date(isoDate + 'T00:00:00Z') + d.setUTCDate(d.getUTCDate() + days) + return d.toISOString().slice(0, 10) +} + /** * Compute IB imbalance and validate it before creating the opening balance entry. *