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
@@ -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']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('*')
|
||||
|
||||
Reference in New Issue
Block a user