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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
3dce94d39e
commit
e35714518f
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user