From 11e07b34c325db986b8ded267dbcc420d3da9319 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 8 May 2026 17:17:36 +0200 Subject: [PATCH] fix(sie-import): reject overlap-but-not-contain fiscal periods (#421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- lib/import/__tests__/sie-import.test.ts | 31 +++++++++++++ lib/import/sie-import.ts | 59 +++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index 1bbc8c55..4b2a4a0a 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -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', () => { diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index b93956a9..7a2139cd 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -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) {