fix(sie-import): reject overlap-but-not-contain fiscal periods (#421)

* fix(sie-import): reject overlap-but-not-contain fiscal periods

ensureFiscalPeriod previously fell back silently to any partially-overlapping
fiscal_period when no period fully contained the SIE file's #RAR range. That
stamped every imported voucher with a fiscal_period_id whose date window
didn't cover the voucher's own entry_date — breaking the SIE invariant that
#VER dates fall inside #RAR and BFL 5 kap. (verifikationsnummer i obruten
serie per räkenskapsår). Reproduced in production: a customer with a broken
fiscal year (Mar–Feb) had their previous-year SIE collapse into a calendar
2026 period, mixing 116 prior-year vouchers into the current period's
voucher sequence.

Now: fully-contained → reuse; partial overlap → reject with a clear Swedish
message pointing at the period dates that would need to be fixed; no overlap
→ create a new period as before.

Adds a defense-in-depth pre-check in executeImport that rejects with a
Swedish error if any individual #VER date falls outside the resolved fiscal
period — catches multi-year SIE files (which gnubok does not yet support)
without producing corrupted journal entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sie-import): fail closed on period-fetch error in voucher-date guard

Greptile correctly flagged that the voucher-date guard wrapped the period
fetch in `if (resolvedPeriod)` without checking the .single() error. A
transient network error or RLS failure returned null for data, the guard
body skipped, and importVouchers ran with no date validation — the same
data-corruption path the guard exists to close.

Now: surface the fetch error to the user (Swedish) and abort the import.

Also switches the date comparison from millisecond timestamps to YYYY-MM-DD
string compare. SIE per spec is date-only and our parser normalizes to
midnight, but string compare matches the underlying DATE columns exactly and
removes a latent off-by-one risk on the period's last day if a future
parser change ever attached a time component to v.date.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-08 17:17:36 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 81e9dd224e
commit 11e07b34c3
2 changed files with 86 additions and 4 deletions
+31
View File
@@ -421,6 +421,37 @@ describe('ensureFiscalPeriod validation', () => {
expect(id).toBe('existing-period-id')
})
it('rejects when an existing period overlaps the range but does not fully contain it', async () => {
// Regression: previously fell through to the overlapping period silently,
// which stamped every imported voucher with a fiscal_period_id whose
// window did not cover the voucher's own entry_date — breaking the SIE
// invariant and BFL 5 kap. (verifikationsnummer per räkenskapsår).
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null }, // containing check — no match
{
data: [
{
id: 'calendar-2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
name: 'Räkenskapsår 2026',
},
],
error: null,
},
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-03-01', // Capelix-style broken FY MarchFeb
'2026-02-28',
),
).rejects.toThrow(/överlappar men matchar inte/)
})
})
describe('linkOpeningBalanceEntryToPeriod', () => {
+55 -4
View File
@@ -236,11 +236,15 @@ export async function ensureFiscalPeriod(
return containing.id
}
// Check for any overlapping period (DB exclusion constraint would reject
// a new insert that overlaps). Use the overlapping period instead.
// If an existing period overlaps the requested range but does not fully
// contain it, we MUST refuse — silently reusing it would stamp every
// imported voucher with a fiscal_period_id whose date window doesn't match
// the voucher's own date. That breaks the SIE invariant that #VER dates fall
// inside #RAR, breaks BFL 5 kap. (verifikationsnummer per räkenskapsår),
// and produces wrong-shaped trial balances per period.
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id')
.select('id, period_start, period_end, name')
.eq('company_id', companyId)
.lte('period_start', endDate)
.gte('period_end', startDate)
@@ -248,7 +252,12 @@ export async function ensureFiscalPeriod(
.limit(1)
if (overlapping && overlapping.length > 0) {
return overlapping[0].id
const existing = overlapping[0]
throw new Error(
`SIE-filens räkenskapsår (${startDate} ${endDate}) överlappar men matchar inte ett befintligt räkenskapsår i gnubok ` +
`(${existing.name}: ${existing.period_start} ${existing.period_end}). ` +
`Justera räkenskapsåret i Inställningar → Räkenskap så att det matchar SIE-filen exakt, eller importera en SIE-fil som täcker exakt samma period.`
)
}
// Pre-validate against the DB-side enforce_period_start_day trigger so the
@@ -1730,6 +1739,48 @@ export async function executeSIEImport(
// Import transactions (SIE4 only)
if (options.importTransactions && parsed.vouchers.length > 0 && result.fiscalPeriodId) {
// Reject vouchers whose date falls outside the resolved fiscal period.
// Without this guard, a SIE file whose #VER dates extend beyond #RAR (or
// a fiscal period whose shape doesn't match the file's #RAR) would
// produce journal entries stamped to a period that doesn't cover their
// own entry_date — breaking the SIE invariant and BFL 5 kap.
//
// Fail closed if the period fetch errors: a silent skip would leave the
// exact data-corruption path this guard exists to close.
const { data: resolvedPeriod, error: resolvedPeriodError } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', result.fiscalPeriodId)
.single()
if (resolvedPeriodError || !resolvedPeriod) {
result.errors.push(
`Kunde inte verifiera räkenskapsårets datumintervall innan import: ${resolvedPeriodError?.message ?? 'räkenskapsåret hittades inte'}. Försök igen.`
)
return result
}
// Date-only string comparison — sidesteps any latent off-by-one if the
// SIE parser ever attaches a time component to v.date. SIE per spec is
// YYYYMMDD and our parser normalizes to midnight, but a string compare
// matches the underlying DATE columns exactly and is cheap.
const periodStart = resolvedPeriod.period_start as string
const periodEnd = resolvedPeriod.period_end as string
const outOfRange = parsed.vouchers.filter((v) => {
const d = formatDate(v.date)
return d < periodStart || d > periodEnd
})
if (outOfRange.length > 0) {
const sample = outOfRange.slice(0, 3).map(v => `${v.series}${v.number} (${formatDate(v.date)})`).join(', ')
result.errors.push(
`${outOfRange.length} verifikation${outOfRange.length === 1 ? '' : 'er'} har datum utanför räkenskapsåret ` +
`${periodStart} ${periodEnd}. Exempel: ${sample}${outOfRange.length > 3 ? '…' : ''}. ` +
`Importera varje räkenskapsår som en egen SIE-fil — flera år i samma fil stöds inte.`
)
return result
}
// Detect partial-year export: if voucher dates don't span the full fiscal year,
// the migration adjustment will produce incorrect large deltas for the missing period.
if (parsed.vouchers.length > 0 && fiscalYearStart && fiscalYearEnd) {