fix(year-end): never seed opening balances into a non-adjacent period (#1849)

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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-25 10:25:58 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 3dce94d39e
commit e35714518f
5 changed files with 245 additions and 10 deletions
+1
View File
@@ -1199,3 +1199,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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<Record<string, unknown>> = []
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<typeof vi.fn>).mock.calls.length > 0)
expect(relink).toBeDefined()
expect((relink!.update as ReturnType<typeof vi.fn>).mock.calls[0][0]).toEqual({ previous_period_id: 'fp-2025-26' })
expect((relink!.in as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual(['id', ['fp-2026-27']])
})
})
+50 -4
View File
@@ -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('*')
+67 -1
View File
@@ -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
+27 -5
View File
@@ -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.
*