/** * Full-flow regression suite for issue #675. * * Some systems export SIE files without current-year #IB 0 records: the * opening balances exist only implicitly via the SIE continuity invariant * IB(year 0) = UB(year -1). executeSIEImport must derive the IB from the * file's #UB -1 records, create a real opening-balance entry whose voucher * text documents the derivation, and warn the user. * * The make-or-break line is the gate in executeSIEImport: it must open on * the EFFECTIVE opening balances (getEffectiveOpeningBalances), not on raw * parsed.openingBalances: the raw set is empty for these files. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { executeSIEImport } from '../sie-import' import { createJournalEntry, replaceOpeningBalanceEntry } from '@/lib/bookkeeping/engine' import { findUntransferredResults } from '@/lib/reports/imbalance-diagnosis' import type { ParsedSIEFile, AccountMapping } from '../types' import type { SupabaseClient } from '@supabase/supabase-js' vi.mock('@/lib/bookkeeping/engine', () => ({ createJournalEntry: vi.fn(async () => ({ id: 'ob-entry-1' })), replaceOpeningBalanceEntry: vi.fn(), })) vi.mock('@/lib/reports/imbalance-diagnosis', () => ({ findUntransferredResults: vi.fn(async () => []), })) // --- Helpers --- type QueuedResult = { data?: unknown; error?: unknown; count?: number | null } /** * Table-routing supabase mock: each table has its own FIFO of results * (consumed per .from(table) call), falling back to { data: null, error: * null } when the queue is empty. Order-independent across tables, so the * mock doesn't break when an unrelated query is added elsewhere in the flow. */ function buildRoutingSupabase(tableQueues: Record) { const queues = new Map( Object.entries(tableQueues).map(([k, v]) => [k, [...v]]) ) const makeChain = (result: { data: unknown; error: unknown; count: number | null }): unknown => { const handler: ProxyHandler = { get(_target, prop) { if (prop === 'then') { return (resolve: (v: unknown) => void) => resolve(result) } return (..._args: unknown[]) => makeChain(result) }, } return new Proxy({}, handler) } const supabase = { from: (table: string) => { const next = queues.get(table)?.shift() ?? {} return makeChain({ data: next.data ?? null, error: next.error ?? null, count: next.count ?? null, }) }, rpc: async () => ({ data: null, error: null }), storage: { from: () => ({ upload: async () => ({ error: null }) }), }, } return supabase as unknown as SupabaseClient } function makeParsedFile(overrides?: Partial): ParsedSIEFile { return { header: { sieType: 4, flagga: 0, program: 'TestProg', programVersion: '1.0', generatedDate: '2024-01-01', format: 'PC8', companyName: 'Continuity AB', orgNumber: '5566778899', address: null, fiscalYears: [ { yearIndex: 0, start: '2024-01-01', end: '2024-12-31' }, { yearIndex: -1, start: '2023-01-01', end: '2023-12-31' }, ], currency: 'SEK', kontoPlanType: null, }, accounts: [ { number: '1930', name: 'Företagskonto' }, { number: '2010', name: 'Eget kapital' }, ], // Issue #675 shape: no #IB 0 at all: only prior-year IB/UB and current UB. openingBalances: [{ yearIndex: -1, account: '1930', amount: 9483.08 }], closingBalances: [ { yearIndex: -1, account: '1930', amount: 37400.78 }, { yearIndex: -1, account: '2010', amount: -37400.78 }, { yearIndex: 0, account: '1930', amount: 160406.0 }, { yearIndex: 0, account: '2010', amount: -160406.0 }, ], resultBalances: [], dimensions: [], dimensionValues: [], vouchers: [], issues: [], stats: { totalAccounts: 2, totalVouchers: 0, totalTransactionLines: 0, fiscalYearStart: '2024-01-01', fiscalYearEnd: '2024-12-31', }, ...overrides, } } function makeMapping(source: string, target: string): AccountMapping { return { sourceAccount: source, sourceName: `Account ${source}`, targetAccount: target, targetName: `Target ${target}`, confidence: 1, matchType: 'exact', isOverride: false, } } function standardQueues(): Record { return { sie_imports: [ { data: null }, // checkDuplicateImport: no duplicate {}, // cleanupStaleImportRecords delete { data: { id: 'imp-1' } }, // createPendingImportRecord insert { data: null }, // checkDuplicatePeriodImport: no duplicate // finalizeImportRecord updates ride on defaults ], chart_of_accounts: [ { // syncMappedAccounts paged fetch: both accounts already exist data: [ { account_number: '1930', account_name: 'Företagskonto' }, { account_number: '2010', account_name: 'Eget kapital' }, ], }, ], fiscal_periods: [ { data: { id: 'fp-1' } }, // find existing fiscal period { data: { opening_balances_set: false, opening_balance_entry_id: null } }, // IB-block check // link update + resync next-period lookup ride on defaults (null) ], journal_entries: [ { count: 0 }, // companyHasPriorActivity: first-ever import ], } } const standardOptions = { filename: 'continuity.se', fileContent: '#dummy', createFiscalPeriod: false, importOpeningBalances: true, importTransactions: true, updateAccountNames: false, } const standardMappings = [makeMapping('1930', '1930'), makeMapping('2010', '2010')] // --- Tests --- describe('executeSIEImport: derived IB from #UB -1 (issue #675)', () => { beforeEach(() => { vi.clearAllMocks() }) it('creates the opening-balance entry from #UB -1 when #IB 0 is missing', async () => { const supabase = buildRoutingSupabase(standardQueues()) const result = await executeSIEImport( supabase, 'company-1', 'user-1', makeParsedFile(), standardMappings, standardOptions, ) expect(result.errors).toEqual([]) expect(result.success).toBe(true) expect(result.openingBalanceEntryId).toBe('ob-entry-1') expect(result.journalEntriesCreated).toBe(1) expect(result.warnings.join(' ')).toMatch(/kontinuitetsprincipen/) expect(createJournalEntry).toHaveBeenCalledTimes(1) const input = vi.mocked(createJournalEntry).mock.calls[0][3] expect(input.source_type).toBe('opening_balance') expect(input.fiscal_period_id).toBe('fp-1') expect(input.entry_date).toBe('2024-01-01') expect(input.description).toBe( 'Ingående balanser från SIE-import (härledda från föregående års utgående balans)' ) expect(input.lines).toEqual([ { account_number: '1930', debit_amount: 37400.78, credit_amount: 0, line_description: 'IB 1930' }, { account_number: '2010', debit_amount: 0, credit_amount: 37400.78, line_description: 'IB 2010' }, ]) }) it('uses the plain description and no continuity warning for explicit #IB 0', async () => { const supabase = buildRoutingSupabase(standardQueues()) const parsed = makeParsedFile({ openingBalances: [ { yearIndex: 0, account: '1930', amount: 37400.78 }, { yearIndex: 0, account: '2010', amount: -37400.78 }, ], }) const result = await executeSIEImport( supabase, 'company-1', 'user-1', parsed, standardMappings, standardOptions, ) expect(result.success).toBe(true) expect(result.warnings.join(' ')).not.toMatch(/kontinuitetsprincipen/) const input = vi.mocked(createJournalEntry).mock.calls[0][3] expect(input.description).toBe('Ingående balanser från SIE-import') }) it('respects the continuation guard: no derived IB when the company has prior activity', async () => { const queues = standardQueues() queues.journal_entries = [{ count: 5 }] // posted entries exist const supabase = buildRoutingSupabase(queues) const result = await executeSIEImport( supabase, 'company-1', 'user-1', makeParsedFile(), standardMappings, standardOptions, ) expect(createJournalEntry).not.toHaveBeenCalled() expect(result.openingBalanceEntryId).toBeNull() expect(result.warnings.join(' ')).toMatch(/hoppades över eftersom bolaget redan har bokförda verifikationer/) // Zero entries created → the finalizer safety net downgrades the run so // the file slot stays free for a retry (existing behavior). expect(result.success).toBe(false) expect(result.errors.join(' ')).toMatch(/0 verifikationer/) }) it('creates no IB entry when the file has neither #IB 0 nor #UB -1', async () => { const supabase = buildRoutingSupabase(standardQueues()) const parsed = makeParsedFile({ openingBalances: [], closingBalances: [ { yearIndex: 0, account: '1930', amount: 160406.0 }, { yearIndex: 0, account: '2010', amount: -160406.0 }, ], }) const result = await executeSIEImport( supabase, 'company-1', 'user-1', parsed, standardMappings, standardOptions, ) expect(createJournalEntry).not.toHaveBeenCalled() expect(result.openingBalanceEntryId).toBeNull() }) it('keeps an earlier-year IB and resyncs the already-imported successor', async () => { const queues = standardQueues() queues.fiscal_periods = [ { data: { id: 'fp-2025' } }, { data: { opening_balances_set: false, opening_balance_entry_id: null } }, {}, // Link the new 2025 IB. { data: { id: 'fp-2026', name: 'Räkenskapsår 2026', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false, locked_at: null, opening_balance_entry_id: 'ib-2026-old', opening_balances_set: true, }, }, ] queues.journal_entries = [ // The chronological activity check excludes the already-imported 2026 // entries when deciding whether the 2025 #IB is legitimate. { count: 0 }, ] vi.mocked(createJournalEntry).mockResolvedValueOnce({ id: 'ib-2025', } as Awaited>) vi.mocked(replaceOpeningBalanceEntry).mockResolvedValueOnce({ newEntryId: 'ib-2026-new', stornoEntryId: 'storno-2026-old', newVoucherNumber: 2, stornoVoucherNumber: 3, }) const parsed = makeParsedFile({ header: { ...makeParsedFile().header, fiscalYears: [{ yearIndex: 0, start: '2025-01-01', end: '2025-12-31' }], }, openingBalances: [ { yearIndex: 0, account: '1930', amount: 100 }, { yearIndex: 0, account: '2010', amount: -100 }, ], closingBalances: [ { yearIndex: 0, account: '1930', amount: 150 }, { yearIndex: 0, account: '2010', amount: -150 }, ], stats: { totalAccounts: 2, totalVouchers: 0, totalTransactionLines: 0, fiscalYearStart: '2025-01-01', fiscalYearEnd: '2025-12-31', }, }) const result = await executeSIEImport( buildRoutingSupabase(queues), 'company-1', 'user-1', parsed, standardMappings, standardOptions, ) expect(result.success).toBe(true) expect(result.openingBalanceEntryId).toBe('ib-2025') expect(result.nextPeriodIBResync).toEqual({ nextPeriodId: 'fp-2026', nextPeriodName: 'Räkenskapsår 2026', stornoEntryId: 'storno-2026-old', newOpeningBalanceEntryId: 'ib-2026-new', }) expect(result.warnings.join(' ')).toMatch(/Räkenskapsår 2026.*synkades om/) expect(vi.mocked(createJournalEntry).mock.calls[0][3]).toMatchObject({ fiscal_period_id: 'fp-2025', source_type: 'opening_balance', entry_date: '2025-01-01', }) expect(replaceOpeningBalanceEntry).toHaveBeenCalledWith( expect.anything(), 'company-1', 'user-1', 'ib-2026-old', expect.objectContaining({ fiscal_period_id: 'fp-2026', source_type: 'opening_balance', entry_date: '2026-01-01', }), ) expect(vi.mocked(replaceOpeningBalanceEntry).mock.calls[0]?.[4]).not.toHaveProperty( 'voucher_series', ) }) it('warns without changing a locked successor opening balance', async () => { const queues = standardQueues() queues.fiscal_periods = [ { data: { id: 'fp-2025' } }, { data: { opening_balances_set: false, opening_balance_entry_id: null } }, {}, // Link the new 2025 IB. { data: { id: 'fp-2026', name: 'Räkenskapsår 2026', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false, locked_at: '2026-07-01T00:00:00Z', opening_balance_entry_id: 'ib-2026-old', opening_balances_set: true, }, }, ] queues.journal_entries = [{ count: 0 }] vi.mocked(createJournalEntry).mockResolvedValueOnce({ id: 'ib-2025', } as Awaited>) const parsed = makeParsedFile({ header: { ...makeParsedFile().header, fiscalYears: [{ yearIndex: 0, start: '2025-01-01', end: '2025-12-31' }], }, openingBalances: [ { yearIndex: 0, account: '1930', amount: 100 }, { yearIndex: 0, account: '2010', amount: -100 }, ], closingBalances: [ { yearIndex: 0, account: '1930', amount: 150 }, { yearIndex: 0, account: '2010', amount: -150 }, ], stats: { totalAccounts: 2, totalVouchers: 0, totalTransactionLines: 0, fiscalYearStart: '2025-01-01', fiscalYearEnd: '2025-12-31', }, }) const result = await executeSIEImport( buildRoutingSupabase(queues), 'company-1', 'user-1', parsed, standardMappings, standardOptions, ) expect(result.success).toBe(true) expect(result.openingBalanceEntryId).toBe('ib-2025') expect(result.nextPeriodIBResync).toBeUndefined() expect(result.nextPeriodIBResyncSkipped).toEqual({ reason: 'locked', nextPeriodName: 'Räkenskapsår 2026', }) expect(result.warnings.join(' ')).toMatch(/Räkenskapsår 2026.*är låst/) expect(createJournalEntry).toHaveBeenCalledTimes(1) expect(replaceOpeningBalanceEntry).not.toHaveBeenCalled() }) it('still rejects a file whose vouchers cross the target fiscal-period boundary', async () => { const queues = standardQueues() queues.fiscal_periods = [ { data: { id: 'fp-2025' } }, { data: { opening_balances_set: false, opening_balance_entry_id: null } }, {}, // Link the 2025 IB before transaction-range validation. { data: { period_start: '2025-01-01', period_end: '2025-12-31' } }, ] queues.journal_entries = [{ count: 0 }] vi.mocked(createJournalEntry).mockResolvedValueOnce({ id: 'ib-2025', } as Awaited>) const parsed = makeParsedFile({ header: { ...makeParsedFile().header, fiscalYears: [{ yearIndex: 0, start: '2025-01-01', end: '2025-12-31' }], }, openingBalances: [ { yearIndex: 0, account: '1930', amount: 100 }, { yearIndex: 0, account: '2010', amount: -100 }, ], closingBalances: [], vouchers: [ { series: 'A', number: 1, date: new Date(2026, 0, 2), description: 'Voucher from another fiscal year', lines: [ { account: '1930', amount: 10 }, { account: '2010', amount: -10 }, ], }, ], stats: { totalAccounts: 2, totalVouchers: 1, totalTransactionLines: 2, fiscalYearStart: '2025-01-01', fiscalYearEnd: '2025-12-31', }, }) const result = await executeSIEImport( buildRoutingSupabase(queues), 'company-1', 'user-1', parsed, standardMappings, standardOptions, ) expect(result.success).toBe(false) expect(result.errors.join(' ')).toMatch(/datum utanför räkenskapsåret/) expect(result.errors.join(' ')).toMatch(/flera år i samma fil stöds inte/) }) }) // --- Untransferred prior-year results (post-import walk) --- // Prod incident: a multi-year migration where one middle year's file lacked // the omföring av årets resultat. Every later year's derived IB inherited // the residual as a permanent balansräkning differens. The import must // surface the culprit years — as warning strings for the wizard and as // structured details for UIs that don't render warnings. describe('executeSIEImport — untransferred prior-year results', () => { beforeEach(() => { vi.clearAllMocks() }) const culprit = { fiscal_period_id: 'fp-0', period_name: 'Räkenskapsår 2024/2025', pl_net: 97, } it('surfaces culprit years as warnings and structured details', async () => { vi.mocked(findUntransferredResults).mockResolvedValue([culprit]) const supabase = buildRoutingSupabase(standardQueues()) const result = await executeSIEImport( supabase, 'company-1', 'user-1', makeParsedFile(), standardMappings, standardOptions, ) expect(result.success).toBe(true) expect(result.warnings.join(' ')).toMatch( /Resultatet för Räkenskapsår 2024\/2025 .* har inte förts om till eget kapital/ ) expect(result.details?.untransferredResults).toEqual([culprit]) }) it('adds nothing when every prior year transferred its result', async () => { vi.mocked(findUntransferredResults).mockResolvedValue([]) const supabase = buildRoutingSupabase(standardQueues()) const result = await executeSIEImport( supabase, 'company-1', 'user-1', makeParsedFile(), standardMappings, standardOptions, ) expect(result.success).toBe(true) expect(result.warnings.join(' ')).not.toMatch(/förts om till eget kapital/) expect(result.details?.untransferredResults).toBeUndefined() }) it('a diagnosis failure never fails the import', async () => { vi.mocked(findUntransferredResults).mockRejectedValue(new Error('boom')) const supabase = buildRoutingSupabase(standardQueues()) const result = await executeSIEImport( supabase, 'company-1', 'user-1', makeParsedFile(), standardMappings, standardOptions, ) expect(result.success).toBe(true) expect(result.errors).toEqual([]) expect(result.details?.untransferredResults).toBeUndefined() }) })