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:
co-authored by
Claude Opus 4.7
parent
81e9dd224e
commit
11e07b34c3
@@ -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 March–Feb
|
||||
'2026-02-28',
|
||||
),
|
||||
).rejects.toThrow(/överlappar men matchar inte/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkOpeningBalanceEntryToPeriod', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user