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) {