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
+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.
*