Files
accounted/lib/import/__tests__/sie-import.test.ts
T
162de2128a fix(import): show "created on import" as the mapping target for source accounts the chart lacks (#2342)
The guided Fortnox import self-mapped a source account that exists in
neither the company chart nor BAS (4599) and then rendered its Malkonto
select blank, because the dropdown only knew chart + BAS accounts. The
row looked unmapped and unmappable while the import created the account
correctly. A nameless account (referenced by #TRANS without #KONTO) was
worse: the mapper refused the self-map, so it stayed unmapped with no
self-target to pick.

- account-mapper: the bas_range self-map no longer requires a #KONTO
  name; unmapped now means exactly "outside 1000-8999". isValidBASRange
  exported as the auto-create boundary.
- AccountMappingStep (shared by both wizards): a target the list cannot
  name is an explicit "<nr> <name> (skapas vid importen)" option, a
  "nya konton skapas" badge/filter lists them, out-of-range accounts
  that block Continue are named, nameless sources say so.
- sie-import: skippedVouchers.unmappedAccounts (per account, voucher
  count) via summarizeUnmappedSkips; warning names the accounts.
- Migration result step: names created accounts and the accounts behind
  "med ej kopplade konton".

Closes #2212


Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:39:36 +02:00

1871 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from 'vitest'
import {
generateImportPreview,
validateIBBalance,
isBalanceSheetAccount,
ensureFiscalPeriod,
precheckFiscalPeriod,
importVouchers,
summarizeUnmappedSkips,
computeVoucherNumberRanges,
linkOpeningBalanceEntryToPeriod,
companyHasPriorActivity,
} from '../sie-import'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { ParsedSIEFile, AccountMapping } from '../types'
import type { SupabaseClient } from '@supabase/supabase-js'
// --- Helpers ---
function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
return {
header: {
sieType: 4,
flagga: 0,
program: 'TestProg',
programVersion: '1.0',
generatedDate: '2024-01-01',
format: 'PC8',
companyName: 'Test AB',
orgNumber: '5566778899',
address: null,
fiscalYears: [{ yearIndex: 0, start: '2024-01-01', end: '2024-12-31' }],
currency: 'SEK',
kontoPlanType: null,
},
accounts: [
{ number: '1510', name: 'Kundfordringar' },
{ number: '1930', name: 'Företagskonto' },
{ number: '2440', name: 'Leverantörsskulder' },
],
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000 },
{ yearIndex: 0, account: '1930', amount: 100000 },
{ yearIndex: 0, account: '2440', amount: -150000 },
],
closingBalances: [],
resultBalances: [],
dimensions: [],
dimensionValues: [],
vouchers: [
{
series: 'A',
number: 1,
date: new Date(2024, 0, 15),
description: 'Faktura 1001',
lines: [
{ account: '1510', amount: 12500 },
{ account: '3001', amount: -10000 },
{ account: '2611', amount: -2500 },
],
},
],
issues: [],
stats: {
totalAccounts: 3,
totalVouchers: 1,
totalTransactionLines: 3,
fiscalYearStart: '2024-01-01',
fiscalYearEnd: '2024-12-31',
},
...overrides,
}
}
function makeMapping(source: string, target: string, confidence: number = 1.0): AccountMapping {
return {
sourceAccount: source,
sourceName: `Account ${source}`,
targetAccount: target,
targetName: `Target ${target}`,
confidence,
matchType: target ? 'exact' : 'manual',
isOverride: false,
}
}
// --- Tests ---
describe('generateImportPreview', () => {
describe('trial balance from IB', () => {
it('calculates debit totals from positive IB amounts', () => {
const parsed = makeParsedFile()
const mappings = [
makeMapping('1510', '1510'),
makeMapping('1930', '1930'),
makeMapping('2440', '2440'),
]
const preview = generateImportPreview(parsed, mappings)
// Positive amounts: 50000 + 100000 = 150000
expect(preview.trialBalance.totalDebit).toBe(150000)
})
it('calculates credit totals from negative IB amounts', () => {
const parsed = makeParsedFile()
const mappings = [
makeMapping('1510', '1510'),
makeMapping('1930', '1930'),
makeMapping('2440', '2440'),
]
const preview = generateImportPreview(parsed, mappings)
// Negative amounts: |-150000| = 150000
expect(preview.trialBalance.totalCredit).toBe(150000)
})
it('detects balanced trial balance', () => {
const parsed = makeParsedFile()
const mappings = [makeMapping('1510', '1510')]
const preview = generateImportPreview(parsed, mappings)
// 150000 debit = 150000 credit
expect(preview.trialBalance.isBalanced).toBe(true)
})
it('detects unbalanced trial balance', () => {
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000 },
{ yearIndex: 0, account: '1930', amount: 100000 },
// Missing credit side: only 150000 debit, 0 credit
],
})
const mappings = [makeMapping('1510', '1510')]
const preview = generateImportPreview(parsed, mappings)
expect(preview.trialBalance.isBalanced).toBe(false)
})
it('handles zero opening balances', () => {
const parsed = makeParsedFile({ openingBalances: [] })
const mappings: AccountMapping[] = []
const preview = generateImportPreview(parsed, mappings)
expect(preview.trialBalance.totalDebit).toBe(0)
expect(preview.trialBalance.totalCredit).toBe(0)
expect(preview.trialBalance.isBalanced).toBe(true)
})
})
describe('company info passthrough', () => {
it('passes company name', () => {
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, [])
expect(preview.companyName).toBe('Test AB')
})
it('passes org number', () => {
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, [])
expect(preview.orgNumber).toBe('5566778899')
})
it('handles null company info', () => {
const parsed = makeParsedFile({
header: {
...makeParsedFile().header,
companyName: null,
orgNumber: null,
},
})
const preview = generateImportPreview(parsed, [])
expect(preview.companyName).toBeNull()
expect(preview.orgNumber).toBeNull()
})
})
describe('mapping status', () => {
it('reflects mapper output counts', () => {
const parsed = makeParsedFile()
const mappings = [
makeMapping('1510', '1510'), // mapped
makeMapping('1930', '1930'), // mapped
makeMapping('2440', '', 0), // unmapped
]
const preview = generateImportPreview(parsed, mappings)
expect(preview.mappingStatus.total).toBe(3)
expect(preview.mappingStatus.mapped).toBe(2)
expect(preview.mappingStatus.unmapped).toBe(1)
})
it('reports low confidence mappings', () => {
const mappings = [
makeMapping('1510', '1510', 1.0),
makeMapping('3400', '3001', 0.3), // low confidence
]
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, mappings)
expect(preview.mappingStatus.lowConfidence).toBe(1)
})
})
describe('statistics', () => {
it('passes account count', () => {
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, [])
expect(preview.accountCount).toBe(3)
})
it('passes voucher count', () => {
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, [])
expect(preview.voucherCount).toBe(1)
})
it('passes transaction line count', () => {
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, [])
expect(preview.transactionLineCount).toBe(3)
})
})
describe('issues passthrough', () => {
it('passes parse issues to preview', () => {
const parsed = makeParsedFile({
issues: [
{ severity: 'warning', line: 5, message: 'Okänd tagg: #FOO, ignoreras', tag: 'FOO' },
{ severity: 'error', line: 10, message: 'Invalid voucher', tag: 'VER' },
],
})
const preview = generateImportPreview(parsed, [])
expect(preview.issues).toHaveLength(2)
expect(preview.issues[0].severity).toBe('warning')
expect(preview.issues[1].severity).toBe('error')
})
})
})
describe('validateIBBalance', () => {
it('returns 0 roundingAdjustment when IB is balanced', () => {
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000 },
{ yearIndex: 0, account: '2440', amount: -50000 },
],
})
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
const result = validateIBBalance(parsed, accountMap)
expect(result.roundingAdjustment).toBe(0)
expect(result.fileImbalance).toBe(0)
expect(result.excludedAccountsTotal).toBe(0)
expect(result.lines).toHaveLength(2)
})
it('returns rounding adjustment for imbalance <= 1 SEK', () => {
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000.50 },
{ yearIndex: 0, account: '2440', amount: -50000 },
],
})
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
const result = validateIBBalance(parsed, accountMap)
expect(result.roundingAdjustment).toBe(0.5)
expect(result.fileImbalance).toBe(0.5)
})
it('returns large adjustment for file-level imbalance (unallocated årets resultat)', () => {
// Simulates a Fortnox export where previous year result hasn't been allocated
// to equity: BS accounts don't balance because årets resultat is implicit
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50100 },
{ yearIndex: 0, account: '2440', amount: -50000 },
],
})
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
const result = validateIBBalance(parsed, accountMap)
// The adjustment is 100 SEK: caller should book to 2099, never reject
expect(result.roundingAdjustment).toBe(100)
expect(result.fileImbalance).toBe(100)
expect(result.excludedAccountsTotal).toBe(0)
})
it('tracks excluded accounts separately from file imbalance (Fortnox system accounts)', () => {
// Simulates Fortnox 0099 carrying IB balance: file is balanced,
// but mapped accounts are not because 0099 is excluded from mapping
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000 },
{ yearIndex: 0, account: '2440', amount: -150000 },
{ yearIndex: 0, account: '0099', amount: 100000 }, // System account, not mapped
],
})
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
const result = validateIBBalance(parsed, accountMap)
// File-level: 50000 + (-150000) + 100000 = 0, balanced
expect(result.fileImbalance).toBe(0)
// Mapped-level: 50000 debit, 150000 credit = -100000 diff
expect(result.roundingAdjustment).toBe(-100000)
// The excluded 0099 accounts for the entire difference
expect(result.excludedAccountsTotal).toBe(100000)
// Only 2 lines (0099 excluded)
expect(result.lines).toHaveLength(2)
})
it('ignores non-current-year balances', () => {
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000 },
{ yearIndex: 0, account: '2440', amount: -50000 },
{ yearIndex: -1, account: '1510', amount: 99999 }, // Previous year, ignored
],
})
const accountMap = new Map([['1510', '1510'], ['2440', '2440']])
const result = validateIBBalance(parsed, accountMap)
expect(result.roundingAdjustment).toBe(0)
expect(result.lines).toHaveLength(2)
})
})
describe('ensureFiscalPeriod validation', () => {
// Mirrors the `enforce_period_start_day` DB trigger so users get an
// actionable Swedish error instead of a raw Postgres message.
type Supabase = Parameters<typeof ensureFiscalPeriod>[0]
it('rejects mid-month start when an earlier period already exists', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null }, // containing check, no match
{ data: [], error: null }, // overlapping check, none
{ data: [{ id: 'earlier' }], error: null }, // earlier period exists
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2026-04-16',
'2026-12-31',
),
).rejects.toThrow(/kronologiskt första räkenskapsår får börja mitt i månaden/)
})
it('rejects end date that is not the last day of the month', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
{ data: [], error: null }, // no earlier period
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2026-01-01',
'2026-12-30', // not the last day of December
),
).rejects.toThrow(/måste sluta på månadens sista dag/)
})
it('allows mid-month start for the company first fiscal period', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
{ data: [], error: null }, // no earlier period
{ data: [], error: null }, // no predecessor in the continuity chain
{ data: { id: 'new-period-id' }, error: null }, // insert result
{ data: [], error: null }, // no successor to relink
])
const id = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2026-04-16',
'2026-12-31',
)
expect(id).toBe('new-period-id')
})
it('allows mid-month start when importing a retroactive earliest period', async () => {
// Scenario: onboarding created a 2026 fiscal period, user now imports
// an SIE for their förlängt första räkenskapsår 2017-07-28 to 2018-12-31.
// The 2017 period is chronologically earliest, so mid-month start is
// legal under BFL 3 kap.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null }, // containing check, no match
{ data: [], error: null }, // overlapping check, none (2017 vs 2026)
{ data: [], error: null }, // no earlier period than 2017-07-28
{ data: [], error: null }, // no predecessor in the continuity chain
{ data: { id: 'retro-first-year-id' }, error: null }, // insert
{ data: [], error: null }, // no successor to relink
])
const id = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2017-07-28',
'2018-12-31',
)
expect(id).toBe('retro-first-year-id')
})
it('reuses an existing period that contains the range (no validation needed)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: { id: 'existing-period-id' }, error: null }, // containing match
])
const id = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2026-04-16',
'2026-12-31',
)
expect(id).toBe('existing-period-id')
})
it('rejects when an existing period overlaps the range but already has posted entries', 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',
is_closed: false,
locked_at: null,
opening_balances_set: false,
},
],
error: null,
},
{ data: [{ id: 'entry-1' }], error: null }, // journal_entries: has at least one
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-03-01', // Capelix-style broken FY March-Feb
'2026-02-28',
),
).rejects.toThrow(/Inställningar → Företag/)
})
it('replaces an overlapping period when it is empty (onboarding-seeded)', async () => {
// Real-world Zerify AB case: onboarding seeded Räkenskapsår 2026 =
// 2026-01-01 to 2026-12-31; the user has a förlängt första räkenskapsår
// 2025-10-20 to 2026-12-31 (BFL 3 kap.) and imports an SIE for it.
// The seeded period carries no data, so we replace it.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null }, // containing check, no match
{
data: [
{
id: 'seeded-2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
name: 'Räkenskapsår 2026',
is_closed: false,
locked_at: null,
opening_balances_set: false,
},
],
error: null,
},
{ data: [], error: null }, // journal_entries: none
{ data: [], error: null }, // earlier-period check, none (mid-month start)
{ data: null, error: null }, // delete result
{ data: [], error: null }, // no predecessor in the continuity chain
{ data: { id: 'replaced-id' }, error: null }, // insert result
{ data: [], error: null }, // no successor to relink
])
const id = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-10-20',
'2026-12-31',
)
expect(id).toBe('replaced-id')
})
it('refuses to replace an overlapping period whose opening balances are already set', async () => {
// opening_balances_set: true short-circuits the replaceability gate before
// we even look at journal_entries: the period clearly carries user data.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{
data: [
{
id: 'with-ib-2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
name: 'Räkenskapsår 2026',
is_closed: false,
locked_at: null,
opening_balances_set: true,
},
],
error: null,
},
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-10-20',
'2026-12-31',
),
).rejects.toThrow(/Inställningar → Företag/)
})
it('refuses to replace an overlapping period that is locked', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{
data: [
{
id: 'locked-2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
name: 'Räkenskapsår 2026',
is_closed: false,
locked_at: '2026-03-15T10:00:00Z',
opening_balances_set: false,
},
],
error: null,
},
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-10-20',
'2026-12-31',
),
).rejects.toThrow(/överlappar men matchar inte/)
})
it('relinks the immediate successor when an earlier period is imported later', async () => {
const { supabase, enqueueMany, findCalls } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: { id: 'fp-2025' }, error: null },
{ data: [{ id: 'fp-2026', period_start: '2026-01-01' }], error: null },
{ data: null, error: null },
])
const id = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-01-01',
'2025-12-31',
)
expect(id).toBe('fp-2025')
expect(findCalls('fiscal_periods', 'update')).toContainEqual([
{ previous_period_id: 'fp-2025' },
])
})
// previous_period_id means "the räkenskapsår immediately before". Linking
// the NEAREST period across a gap of missing years made year-end seed a
// company's opening balances two years forward (feedback seq 249297): the
// chain must only ever be wired between date-adjacent periods.
it('does not relink a successor that is not date-adjacent (gap of missing years)', async () => {
const { supabase, enqueueMany, findCalls } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: { id: 'fp-2024-25' }, error: null },
// Onboarding-seeded 2026/2027 exists; 2025/2026 is missing.
{ data: [{ id: 'fp-2026-27', period_start: '2026-05-01' }], error: null },
])
const id = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2024-05-01',
'2025-04-30',
)
expect(id).toBe('fp-2024-25')
expect(findCalls('fiscal_periods', 'update')).toEqual([])
})
it('links the predecessor only when it ends the day before the new period starts', async () => {
const { supabase, enqueueMany, findCalls } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
{ data: [{ id: 'fp-2022', period_end: '2022-12-31' }], error: null }, // nearest, but 2023-2024 missing
{ data: { id: 'fp-2025' }, error: null },
{ data: [], error: null },
])
await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-01-01',
'2025-12-31',
)
const [insertPayload] = findCalls('fiscal_periods', 'insert')[0] as [{ previous_period_id: string | null }]
expect(insertPayload.previous_period_id).toBeNull()
const adjacent = createQueuedMockSupabase()
adjacent.enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
{ data: [{ id: 'fp-2024', period_end: '2024-12-31' }], error: null },
{ data: { id: 'fp-2025' }, error: null },
{ data: [], error: null },
])
await ensureFiscalPeriod(
adjacent.supabase as unknown as Supabase,
'company-id',
'2025-01-01',
'2025-12-31',
)
const [adjacentPayload] = adjacent.findCalls('fiscal_periods', 'insert')[0] as [{ previous_period_id: string | null }]
expect(adjacentPayload.previous_period_id).toBe('fp-2024')
})
// BFL 3 kap. caps any räkenskapsår at 18 months (12 is the norm; 18 is the
// ceiling for a förlängt/omlagt year). #RAR used to be validated for start
// and end DAY only, so a 24-month räkenskapsår from a foreign system
// imported cleanly and stamped every voucher with an illegal period. The cap
// now mirrors validatePeriodDuration(), which the fiscal-periods routes,
// period-service and onboarding already enforce.
describe('18-month cap (BFL 3 kap.)', () => {
/** Queue for the happy path: day-1 start, so the earlier-period lookup is skipped. */
const createQueue = (newId: string) => [
{ data: null, error: null }, // containing check, no match
{ data: [], error: null }, // overlapping check, none
{ data: [], error: null }, // no predecessor in the continuity chain
{ data: { id: newId }, error: null }, // insert result
{ data: [], error: null }, // no successor to relink
]
it('allows a normal 12-month räkenskapsår (unaffected by the cap)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany(createQueue('calendar-2026'))
const id = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2026-01-01',
'2026-12-31',
)
expect(id).toBe('calendar-2026')
})
it('allows exactly 18 months (förlängt räkenskapsår, the legal maximum)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany(createQueue('extended-first-year'))
const id = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-07-01',
'2026-12-31',
)
expect(id).toBe('extended-first-year')
})
it('refuses 19 months, one month past the cap', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null }, // containing check, no match
{ data: [], error: null }, // overlapping check, none
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-06-01',
'2026-12-31',
),
).rejects.toThrow(/omfattar 19 månader.*högst 18 månader \(BFL 3 kap\.\)/s)
})
it('refuses a 24-month räkenskapsår and names the recovery path', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-01-01',
'2026-12-31',
),
).rejects.toThrow(/omfattar 24 månader/)
// The message has to tell an accountant who cannot edit the SIE file what
// to do instead: re-export one räkenskapsår per file from the source system.
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-01-01',
'2026-12-31',
),
).rejects.toThrow(/exportera om från källsystemet med ett räkenskapsår per fil/)
})
it('refuses before deleting an empty seeded period, leaving the company untouched', async () => {
// The over-long range overlaps an onboarding-seeded period that the
// importer would otherwise replace. The cap runs before that destructive
// delete, so a refused import costs the user nothing.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null }, // containing check, no match
{
data: [
{
id: 'seeded-2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
name: 'Räkenskapsår 2026',
is_closed: false,
locked_at: null,
opening_balances_set: false,
},
],
error: null,
},
{ data: [], error: null }, // journal_entries: none, so the period is replaceable
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-01-01',
'2026-12-31',
),
).rejects.toThrow(/högst 18 månader/)
// containing + overlapping + journal_entries only: no delete, no insert.
expect(supabase.from).toHaveBeenCalledTimes(3)
})
it('checks every #RAR range it is handed, not just the first', async () => {
// A SIE migration walks one räkenskapsår per file (#RAR 0 each time), so
// the realistic failure is a valid current year followed by a broken
// prior year. Each range is validated on its own; a good year importing
// does not buy the next one a pass.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany(createQueue('year-2026'))
const currentYear = await ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2026-01-01',
'2026-12-31',
)
expect(currentYear).toBe('year-2026')
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
])
await expect(
ensureFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2024-01-01',
'2025-12-31', // 24 months masquerading as the prior year
),
).rejects.toThrow(/omfattar 24 månader/)
})
})
})
describe('linkOpeningBalanceEntryToPeriod', () => {
// Regression: SIE import created the opening-balance entry but never wrote
// its ID back to fiscal_periods. Without the link, getOpeningBalances falls
// through to summing all prior journal lines, which inflates balance-sheet
// accounts across multi-year imports (each year's IB double-counted against
// the prior year's UB).
type Supabase = Parameters<typeof linkOpeningBalanceEntryToPeriod>[0]
it('writes opening_balance_entry_id and opening_balances_set to the fiscal period', async () => {
const updates: Array<{ payload: Record<string, unknown>; filters: Record<string, unknown> }> = []
const supabase = {
from: (table: string) => {
if (table !== 'fiscal_periods') {
throw new Error(`Unexpected table: ${table}`)
}
let pendingPayload: Record<string, unknown> = {}
const filters: Record<string, unknown> = {}
const chain = {
update: (payload: Record<string, unknown>) => {
pendingPayload = payload
return chain
},
eq: (col: string, val: unknown) => {
filters[col] = val
return chain
},
then: (resolve: (v: unknown) => void) => {
updates.push({ payload: pendingPayload, filters: { ...filters } })
resolve({ data: null, error: null })
},
}
return chain
},
}
await linkOpeningBalanceEntryToPeriod(
supabase as unknown as Supabase,
'company-1',
'period-1',
'ob-entry-1',
)
expect(updates).toHaveLength(1)
expect(updates[0].payload).toEqual({
opening_balance_entry_id: 'ob-entry-1',
opening_balances_set: true,
})
expect(updates[0].filters).toEqual({
id: 'period-1',
company_id: 'company-1',
})
})
it('throws a descriptive error when the update fails', async () => {
const supabase = {
from: () => {
const chain = {
update: () => chain,
eq: () => chain,
then: (resolve: (v: unknown) => void) =>
resolve({ data: null, error: { message: 'permission denied' } }),
}
return chain
},
}
await expect(
linkOpeningBalanceEntryToPeriod(
supabase as unknown as Supabase,
'company-1',
'period-1',
'ob-entry-1',
),
).rejects.toThrow(/Failed to link opening balance entry.*permission denied/)
})
})
describe('companyHasPriorActivity', () => {
// Guards multi-year SIE imports: when the company already has posted
// non-IB journal entries, creating another IB entry would double-count
// one year's movements against every balance-sheet account.
type Supabase = Parameters<typeof companyHasPriorActivity>[0]
function buildCountingSupabase(count: number) {
const capturedFilters: Record<string, unknown> = {}
const supabase = {
from: (table: string) => {
if (table !== 'journal_entries') {
throw new Error(`Unexpected table: ${table}`)
}
const chain = {
select: (_cols: string, opts?: { count?: string; head?: boolean }) => {
capturedFilters['_opts'] = opts
return chain
},
eq: (col: string, val: unknown) => {
capturedFilters[`eq:${col}`] = val
return chain
},
neq: (col: string, val: unknown) => {
const key = `neq:${col}`
const existing = capturedFilters[key]
if (Array.isArray(existing)) {
existing.push(val)
} else if (existing !== undefined) {
capturedFilters[key] = [existing, val]
} else {
capturedFilters[key] = val
}
return chain
},
in: (col: string, val: unknown) => {
capturedFilters[`in:${col}`] = val
return chain
},
lte: (col: string, val: unknown) => {
capturedFilters[`lte:${col}`] = val
return chain
},
then: (resolve: (v: { count: number; error: null }) => void) =>
resolve({ count, error: null }),
}
return chain
},
}
return { supabase, capturedFilters }
}
it('returns false when the company has no prior posted entries', async () => {
const { supabase } = buildCountingSupabase(0)
const result = await companyHasPriorActivity(
supabase as unknown as Supabase,
'company-1',
'2025-12-31',
)
expect(result).toBe(false)
})
it('returns true when the company has prior posted non-IB entries', async () => {
const { supabase } = buildCountingSupabase(42)
const result = await companyHasPriorActivity(
supabase as unknown as Supabase,
'company-1',
'2025-12-31',
)
expect(result).toBe(true)
})
it('excludes opening_balance and storno entries, and only counts posted', async () => {
const { supabase, capturedFilters } = buildCountingSupabase(0)
await companyHasPriorActivity(
supabase as unknown as Supabase,
'company-1',
'2025-12-31',
)
expect(capturedFilters['neq:source_type']).toEqual(['opening_balance', 'storno'])
expect(capturedFilters['eq:status']).toBe('posted')
expect(capturedFilters['eq:company_id']).toBe('company-1')
expect(capturedFilters['lte:entry_date']).toBe('2025-12-31')
})
it('treats null/undefined count as zero', async () => {
const supabase = {
from: () => ({
select: () => ({
eq: () => ({
neq: () => ({
neq: () => ({
eq: () => ({
lte: () => ({
then: (resolve: (v: { count: null; error: null }) => void) =>
resolve({ count: null, error: null }),
}),
}),
}),
}),
}),
}),
}),
}
const result = await companyHasPriorActivity(
supabase as unknown as Supabase,
'company-1',
'2025-12-31',
)
expect(result).toBe(false)
})
})
describe('isBalanceSheetAccount', () => {
it('returns true for class 1 (assets)', () => {
expect(isBalanceSheetAccount('1510')).toBe(true)
expect(isBalanceSheetAccount('1930')).toBe(true)
})
it('returns true for class 2 (liabilities/equity)', () => {
expect(isBalanceSheetAccount('2099')).toBe(true)
expect(isBalanceSheetAccount('2440')).toBe(true)
})
it('returns false for class 3 (revenue)', () => {
expect(isBalanceSheetAccount('3001')).toBe(false)
expect(isBalanceSheetAccount('3740')).toBe(false)
})
it('returns false for class 4-8 (expenses)', () => {
expect(isBalanceSheetAccount('4010')).toBe(false)
expect(isBalanceSheetAccount('5010')).toBe(false)
expect(isBalanceSheetAccount('6211')).toBe(false)
expect(isBalanceSheetAccount('7210')).toBe(false)
expect(isBalanceSheetAccount('8999')).toBe(false)
})
})
describe('computeVoucherNumberRanges', () => {
it('returns empty array for no mapping', () => {
expect(computeVoucherNumberRanges([])).toEqual([])
})
it('produces one range per series with correct from/to', () => {
const ranges = computeVoucherNumberRanges([
{ sourceId: 'B1', series: 'B', targetNumber: 1 },
{ sourceId: 'B2', series: 'B', targetNumber: 2 },
{ sourceId: 'B3', series: 'B', targetNumber: 3 },
{ sourceId: 'C1', series: 'C', targetNumber: 1 },
{ sourceId: 'C2', series: 'C', targetNumber: 2 },
{ sourceId: 'V1', series: 'V', targetNumber: 1 },
])
expect(ranges).toEqual([
{ series: 'B', from: 1, to: 3 },
{ series: 'C', from: 1, to: 2 },
{ series: 'V', from: 1, to: 1 },
])
})
it('handles non-contiguous target numbers per series', () => {
const ranges = computeVoucherNumberRanges([
{ sourceId: 'B1', series: 'B', targetNumber: 5 },
{ sourceId: 'B2', series: 'B', targetNumber: 9 },
])
expect(ranges).toEqual([{ series: 'B', from: 5, to: 9 }])
})
})
describe('importVouchers: per-voucher series preservation', () => {
// Captures the rows passed to `.insert()` so the test can assert on
// voucher_series per inserted record. Uses a hand-rolled mock rather than
// createQueuedMockSupabase because we need to inspect arguments, not just
// return queued data.
function buildCapturingSupabase(options: { failImportRpc?: boolean } = {}) {
const journalEntryInserts: Array<Record<string, unknown>> = []
const journalEntryLineInserts: Array<Record<string, unknown>> = []
const rpcCalls: Array<{ name: string; args: Record<string, unknown> }> = []
const nextNumberBySeries = new Map<string, number>()
let syntheticEntryId = 1
const supabase = {
from: vi.fn((table: string) => {
if (table === 'chart_of_accounts') {
// Return all accounts used in test vouchers as if already active
return {
select: () => ({
eq: () => ({
in: (_col: string, accountNumbers: string[]) => ({
then: (resolve: (v: { data: { id: string; account_number: string }[]; error: null }) => void) =>
resolve({
data: accountNumbers.map((num, i) => ({ id: `acc-${i}`, account_number: num })),
error: null,
}),
}),
}),
}),
}
}
throw new Error(`Unexpected table: ${table}`)
}),
rpc: vi.fn(async (name: string, args: Record<string, unknown>) => {
rpcCalls.push({ name, args })
if (name === 'import_sie_journal_entries') {
if (options.failImportRpc) {
return {
data: null,
error: { message: 'line insert failed' },
}
}
const entries = args.p_entries as Array<{
sourceId: string
series: string
sourceType: string
lines: Array<Record<string, unknown>>
}>
const inserted_entries = entries.map((entry) => {
const current = nextNumberBySeries.get(entry.series) ?? 0
const next = current + 1
nextNumberBySeries.set(entry.series, next)
const id = `entry-${syntheticEntryId++}`
journalEntryInserts.push({
...entry,
id,
voucher_series: entry.series,
voucher_number: next,
source_type: entry.sourceType,
source_voucher_series: (entry as { sourceSeries?: string | null }).sourceSeries ?? null,
source_voucher_number: (entry as { sourceNumber?: number | null }).sourceNumber ?? null,
})
journalEntryLineInserts.push(...entry.lines.map((line) => ({ ...line, journal_entry_id: id })))
return {
id,
sourceId: entry.sourceId,
series: entry.series,
voucherNumber: next,
sourceType: entry.sourceType,
}
})
return {
data: {
inserted_entries,
skipped_duplicates: [],
validation_errors: [],
},
error: null,
}
}
throw new Error(`Unexpected RPC: ${name}`)
}),
}
return {
supabase: supabase as unknown as SupabaseClient,
journalEntryInserts,
journalEntryLineInserts,
rpcCalls,
}
}
function makeVoucher(
series: string,
number: number,
lines: Array<{ account: string; amount: number }> = [
{ account: '1510', amount: 1000 },
{ account: '3001', amount: -1000 },
],
) {
return {
series,
number,
date: new Date(2024, 0, 15),
description: `Voucher ${series}${number}`,
lines,
}
}
const baseMap = new Map([
['1510', '1510'],
['3001', '3001'],
])
it('routes each voucher to its source series (B, C, V → B, C, V)', async () => {
const { supabase, journalEntryInserts, rpcCalls } = buildCapturingSupabase()
const parsed = makeParsedFile({
vouchers: [
makeVoucher('B', 1),
makeVoucher('B', 2),
makeVoucher('C', 1),
makeVoucher('V', 1),
],
})
const result = await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
baseMap,
'B', // fallback (should not be used here: all vouchers have series)
)
expect(result.created).toBe(4)
expect(new Set(result.seriesUsed)).toEqual(new Set(['B', 'C', 'V']))
const seriesInInserts = journalEntryInserts.map((r) => r.voucher_series)
expect(seriesInInserts).toEqual(['B', 'B', 'C', 'V'])
const importCalls = rpcCalls.filter((c) => c.name === 'import_sie_journal_entries')
expect(importCalls).toHaveLength(1)
expect((importCalls[0].args.p_entries as Array<{ series: string }>).map((e) => e.series)).toEqual(['B', 'B', 'C', 'V'])
})
it('falls back to defaultSeries when source voucher has empty series (SIE4I)', async () => {
const { supabase, journalEntryInserts } = buildCapturingSupabase()
const parsed = makeParsedFile({
vouchers: [
{ ...makeVoucher('', 1) },
{ ...makeVoucher('', 2) },
],
})
const result = await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
baseMap,
'V', // fallback used because source series is empty
)
expect(result.created).toBe(2)
expect(result.seriesUsed).toEqual(['V'])
expect(journalEntryInserts.every((r) => r.voucher_series === 'V')).toBe(true)
})
it('records source series in voucherNumberMapping for audit trail', async () => {
const { supabase } = buildCapturingSupabase()
const parsed = makeParsedFile({
vouchers: [
makeVoucher('B', 1),
makeVoucher('C', 7),
],
})
const result = await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
baseMap,
'B',
)
expect(result.voucherNumberMapping).toEqual([
{ sourceId: 'B1', series: 'B', targetNumber: 1 },
{ sourceId: 'C7', series: 'C', targetNumber: 1 },
])
})
it('assigns independent sequential target numbers per series', async () => {
const { supabase, journalEntryInserts } = buildCapturingSupabase()
const parsed = makeParsedFile({
vouchers: [
makeVoucher('B', 1),
makeVoucher('B', 2),
makeVoucher('B', 3),
makeVoucher('C', 1),
makeVoucher('C', 2),
],
})
await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
baseMap,
'B',
)
const bNumbers = journalEntryInserts
.filter((r) => r.voucher_series === 'B')
.map((r) => r.voucher_number)
const cNumbers = journalEntryInserts
.filter((r) => r.voucher_series === 'C')
.map((r) => r.voucher_number)
// Each series starts at 1 and increments independently, not globally continuous
expect(bNumbers).toEqual([1, 2, 3])
expect(cNumbers).toEqual([1, 2])
})
it('preserves original source series/number on each imported entry, even across skipped vouchers', async () => {
const { supabase, journalEntryInserts } = buildCapturingSupabase()
// A2 is an empty voucher (no lines), will be skipped. A1 and A3 survive.
// Accounted assigns target numbers 1 and 2 (contiguous), but source_voucher_number
// must preserve the SIE originals (1 and 3) so traceability is not lost.
const parsed = makeParsedFile({
vouchers: [
makeVoucher('A', 1),
{ ...makeVoucher('A', 2), lines: [] },
makeVoucher('A', 3),
],
})
const result = await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
baseMap,
'A',
)
expect(result.created).toBe(2)
expect(result.skippedEmpty).toBe(1)
expect(journalEntryInserts.map((r) => r.voucher_number)).toEqual([1, 2])
expect(journalEntryInserts.map((r) => r.source_voucher_series)).toEqual(['A', 'A'])
expect(journalEntryInserts.map((r) => r.source_voucher_number)).toEqual([1, 3])
})
it('does not report imported IDs or counts when the atomic RPC fails', async () => {
const { supabase, journalEntryInserts, journalEntryLineInserts } = buildCapturingSupabase({
failImportRpc: true,
})
const parsed = makeParsedFile({
vouchers: [
makeVoucher('A', 1),
],
})
const result = await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
baseMap,
'A',
)
expect(result.created).toBe(0)
expect(result.ids).toEqual([])
expect(result.importTypedIds).toEqual([])
expect(result.voucherNumberMapping).toEqual([])
expect(result.errors.join(' ')).toContain('line insert failed')
expect(journalEntryInserts).toEqual([])
expect(journalEntryLineInserts).toEqual([])
})
it('stores NULL source series/number when the source voucher has no series (SIE4I subsystem import)', async () => {
const { supabase, journalEntryInserts } = buildCapturingSupabase()
const parsed = makeParsedFile({
vouchers: [
{ ...makeVoucher('', 1) },
],
})
await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
baseMap,
'V',
)
expect(journalEntryInserts[0].source_voucher_series).toBeNull()
expect(journalEntryInserts[0].source_voucher_number).toBe(1)
})
describe('opening-balance voucher tagging vs derived IB (issue #675)', () => {
const obMap = new Map([
['1930', '1930'],
['2010', '2010'],
])
it('tags a qualifying OB voucher opening_balance even when #UB -1 records exist', async () => {
// Precedence 2 beats 3: the OB-voucher candidate makes
// getEffectiveOpeningBalances yield no balances, so hasCurrentYearIb is
// false and the voucher keeps serving as the IB. Without that yield, a
// derived IB entry AND this voucher would both book the same amounts.
const { supabase, journalEntryInserts } = buildCapturingSupabase()
const parsed = makeParsedFile({
openingBalances: [],
closingBalances: [
{ yearIndex: -1, account: '1930', amount: 37400.78 },
{ yearIndex: -1, account: '2010', amount: -37400.78 },
],
vouchers: [
{
series: 'A',
number: 1,
date: new Date(2024, 0, 1),
description: 'Ingående balans',
lines: [
{ account: '1930', amount: 37400.78 },
{ account: '2010', amount: -37400.78 },
],
},
],
})
const result = await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
obMap,
'A',
)
expect(result.created).toBe(1)
expect(journalEntryInserts[0].source_type).toBe('opening_balance')
})
it('keeps an FY-start voucher without IB wording as import when IB is derived from #UB -1', async () => {
const { supabase, journalEntryInserts } = buildCapturingSupabase()
const parsed = makeParsedFile({
openingBalances: [],
closingBalances: [
{ yearIndex: -1, account: '1930', amount: 37400.78 },
{ yearIndex: -1, account: '2010', amount: -37400.78 },
],
vouchers: [
{
series: 'A',
number: 1,
date: new Date(2024, 0, 1),
description: 'Omföring',
lines: [
{ account: '1930', amount: 1000 },
{ account: '2010', amount: -1000 },
],
},
],
})
await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
obMap,
'A',
)
expect(journalEntryInserts[0].source_type).toBe('import')
})
})
})
describe('IB derivation from #UB -1 (issue #675)', () => {
const derivedOverrides: Partial<ParsedSIEFile> = {
openingBalances: [],
closingBalances: [
{ yearIndex: -1, account: '1930', amount: 37400.78 },
{ yearIndex: -1, account: '2440', amount: -37400.78 },
{ yearIndex: 0, account: '1930', amount: 160406.0 },
{ yearIndex: 0, account: '2440', amount: -160406.0 },
],
}
describe('generateImportPreview', () => {
it('computes opening balance totals from the derived set', () => {
const parsed = makeParsedFile(derivedOverrides)
const preview = generateImportPreview(parsed, [
makeMapping('1930', '1930'),
makeMapping('2440', '2440'),
])
// Derived from #UB -1: 37400.78 debit / 37400.78 credit. This is also
// what enables the IB toggle in ImportReviewStep (openingBalanceTotal > 0).
expect(preview.openingBalanceTotal).toBe(37400.78)
expect(preview.trialBalance.totalDebit).toBe(37400.78)
expect(preview.trialBalance.totalCredit).toBe(37400.78)
expect(preview.trialBalance.isBalanced).toBe(true)
})
it('appends an info issue explaining the derivation without mutating parsed.issues', () => {
const parsed = makeParsedFile(derivedOverrides)
const preview = generateImportPreview(parsed, [makeMapping('1930', '1930')])
const infoMessages = preview.issues.filter((i) => i.severity === 'info')
expect(infoMessages.map((i) => i.message).join(' ')).toMatch(/härleds från föregående års utgående balans/i)
expect(parsed.issues).toHaveLength(0)
})
it('does not append the derivation issue when explicit #IB 0 exists', () => {
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, [makeMapping('1930', '1930')])
expect(preview.issues).toHaveLength(0)
})
})
describe('validateIBBalance', () => {
it('builds journal lines from the derived #UB -1 set', () => {
const parsed = makeParsedFile(derivedOverrides)
const accountMap = new Map([
['1930', '1930'],
['2440', '2440'],
])
const result = validateIBBalance(parsed, accountMap)
expect(result.lines).toEqual([
{ account_number: '1930', debit_amount: 37400.78, credit_amount: 0, line_description: 'IB 1930' },
{ account_number: '2440', debit_amount: 0, credit_amount: 37400.78, line_description: 'IB 2440' },
])
expect(result.roundingAdjustment).toBe(0)
expect(result.fileImbalance).toBe(0)
})
it('reports the imbalance when the derived set carries an unallocated prior-year result', () => {
const parsed = makeParsedFile({
openingBalances: [],
closingBalances: [
{ yearIndex: -1, account: '1930', amount: 37400.78 },
{ yearIndex: -1, account: '2440', amount: -30000.0 },
],
})
const accountMap = new Map([
['1930', '1930'],
['2440', '2440'],
])
const result = validateIBBalance(parsed, accountMap)
// 37400.78 − 30000.00 → diff booked to 2099 by createOpeningBalanceEntry
expect(result.roundingAdjustment).toBe(7400.78)
expect(result.fileImbalance).toBe(7400.78)
})
})
})
describe('precheckFiscalPeriod', () => {
// The read-only verdict the parse preview shows. Mirrors the
// ensureFiscalPeriod cases above one-to-one: whatever this says at preview
// is what the import does.
type Supabase = Parameters<typeof precheckFiscalPeriod>[0]
const seededPeriod = {
id: 'seeded-2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
name: 'Räkenskapsår 2026',
is_closed: false,
locked_at: null,
opening_balances_set: false,
}
it('reports match when an existing period contains the range', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: { id: 'existing-period-id' }, error: null }])
const verdict = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2026-01-01',
'2026-12-31',
)
expect(verdict).toEqual({ verdict: 'match', periodId: 'existing-period-id' })
})
// A containing period that is closed or locked used to answer 'match', so
// the import ran into the DB trigger ("Cannot write to locked/closed fiscal
// period") with no way forward. Each state names its own remedy.
const closedPeriod = {
id: 'fy-2024-2025',
name: 'Räkenskapsår 2024/2025',
period_start: '2024-06-03',
period_end: '2025-08-31',
}
it('refuses a klarmarkerad containing period and points at Öppna igen', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
...closedPeriod,
is_closed: true,
locked_at: '2026-09-04T10:55:54Z',
closed_externally: true,
closing_entry_id: null,
},
error: null,
},
])
const verdict = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2024-06-03',
'2025-08-31',
)
expect(verdict.verdict).toBe('conflict')
if (verdict.verdict !== 'conflict') return
expect(verdict.existingPeriod).toEqual({
id: 'fy-2024-2025',
name: 'Räkenskapsår 2024/2025',
periodStart: '2024-06-03',
periodEnd: '2025-08-31',
})
expect(verdict.message).toMatch(/avslutat i ett tidigare program/)
expect(verdict.message).toMatch(/Öppna igen/)
expect(verdict.message).toMatch(/Inställningar > Bokföring > Räkenskapsår/)
})
it('refuses a period closed by a year-end run without offering Öppna igen', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
...closedPeriod,
is_closed: true,
locked_at: '2026-09-04T12:14:16Z',
closed_externally: false,
closing_entry_id: 'closing-entry',
},
error: null,
},
])
const verdict = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2024-06-03',
'2025-08-31',
)
expect(verdict.verdict).toBe('conflict')
if (verdict.verdict !== 'conflict') return
expect(verdict.message).toMatch(/stängt med ett årsbokslut/)
expect(verdict.message).not.toMatch(/Öppna igen/)
})
it('refuses a locked (not closed) containing period and points at Lås upp', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
...closedPeriod,
is_closed: false,
locked_at: '2026-09-04T10:55:54Z',
closed_externally: false,
closing_entry_id: null,
},
error: null,
},
])
const verdict = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2024-06-03',
'2025-08-31',
)
expect(verdict.verdict).toBe('conflict')
if (verdict.verdict !== 'conflict') return
expect(verdict.message).toMatch(/är låst\. Lås upp det/)
})
it('reports create with nothing to replace when no period overlaps', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null }, // containing check, no match
{ data: [], error: null }, // overlapping check, none
])
const verdict = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-01-01',
'2025-12-31',
)
expect(verdict).toEqual({ verdict: 'create', replacesEmptyPeriodId: null })
})
it('reports invalid with the import refusal text for a 19-month #RAR', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
])
const verdict = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2024-01-01',
'2025-07-31',
)
expect(verdict.verdict).toBe('invalid')
if (verdict.verdict !== 'invalid') return
expect(verdict.message).toMatch(/omfattar 19 månader/)
})
it('reports invalid for an end date that is not the last day of its month', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
])
const verdict = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2024-01-01',
'2024-12-30',
)
expect(verdict.verdict).toBe('invalid')
if (verdict.verdict !== 'invalid') return
expect(verdict.message).toMatch(/måste sluta på månadens sista dag/)
})
it('reports invalid for a mid-month start when an earlier period exists', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [], error: null },
{ data: [{ id: 'earlier' }], error: null }, // earlier period exists
])
const verdict = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2026-04-16',
'2026-12-31',
)
expect(verdict.verdict).toBe('invalid')
if (verdict.verdict !== 'invalid') return
expect(verdict.message).toMatch(/kronologiskt första räkenskapsår får börja mitt i månaden/)
})
it('reports create naming the empty seeded period it will replace', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [seededPeriod], error: null },
{ data: [], error: null }, // journal_entries: none
{ data: [], error: null }, // earlier-period check, none (mid-month start)
])
const verdict = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-10-20',
'2026-12-31',
)
expect(verdict).toEqual({ verdict: 'create', replacesEmptyPeriodId: 'seeded-2026' })
})
it('reports conflict with the import refusal text when the overlapping period has entries', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [seededPeriod], error: null },
{ data: [{ id: 'entry-1' }], error: null },
])
const verdict = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-03-01',
'2026-02-28',
)
expect(verdict.verdict).toBe('conflict')
if (verdict.verdict !== 'conflict') return
expect(verdict.existingPeriod).toEqual({
id: 'seeded-2026',
name: 'Räkenskapsår 2026',
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
})
expect(verdict.message).toMatch(/2025-03-01 till 2026-02-28/)
expect(verdict.message).toMatch(/Inställningar → Företag/)
})
it('reports conflict without reading entries when opening balances are set', async () => {
const { supabase, enqueueMany, calls } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [{ ...seededPeriod, opening_balances_set: true }], error: null },
])
const verdict = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-10-20',
'2026-12-31',
)
expect(verdict.verdict).toBe('conflict')
expect(calls.some((c) => c.table === 'journal_entries')).toBe(false)
})
it('is the verdict ensureFiscalPeriod acts on: conflict throws the same text', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: null, error: null },
{ data: [seededPeriod], error: null },
{ data: [{ id: 'entry-1' }], error: null },
])
const precheck = await precheckFiscalPeriod(
supabase as unknown as Supabase,
'company-id',
'2025-03-01',
'2026-02-28',
)
const again = createQueuedMockSupabase()
again.enqueueMany([
{ data: null, error: null },
{ data: [seededPeriod], error: null },
{ data: [{ id: 'entry-1' }], error: null },
])
await expect(
ensureFiscalPeriod(again.supabase as unknown as Supabase, 'company-id', '2025-03-01', '2026-02-28'),
).rejects.toThrow(precheck.verdict === 'conflict' ? precheck.message : 'unreachable')
})
})
describe('summarizeUnmappedSkips', () => {
// Issue #2212: the result step must name WHICH accounts excluded vouchers,
// not just how many vouchers were excluded.
it('counts vouchers per unmapped account, most excluded first', () => {
const summary = summarizeUnmappedSkips([
{ reason: 'unmapped', unmappedAccounts: ['0099'] },
{ reason: 'unmapped', unmappedAccounts: ['0099', '9100'] },
{ reason: 'unmapped', unmappedAccounts: ['9100', '9100'] },
{ reason: 'unbalanced' },
{ reason: 'single_line', unmappedAccounts: ['0099'] },
])
expect(summary).toEqual([
{ account: '0099', vouchers: 2 },
{ account: '9100', vouchers: 2 },
])
})
it('is empty when nothing was skipped for a missing mapping', () => {
expect(summarizeUnmappedSkips([{ reason: 'empty' }])).toEqual([])
})
})