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
@@ -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']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('*')
|
||||
|
||||
@@ -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