e35714518f
Feedback seq 249297: run_year_end on Räkenskapsår 2024/2025 seeded the closing balances into an existing 2026/2027 period and left no period at all for 2025/2026. Root cause: SIE import wires previous_period_id to the NEAREST later period regardless of gap (the company had an onboarding-seeded 2026/2027 when 2024/2025 was imported), and findNextPeriod trusted the chain unchecked. - findNextPeriod: a chained period is only the next period when it starts the day after the current one; otherwise log and fall through to the date lookup so year-end creates the contiguous period. - createNextPeriod: relink a successor that was chained across the gap onto the newly created period, healing the chain. - SIE import: wire predecessor and successor links only when date-adjacent. A gap stays unlinked until the missing year exists. Prod scan 2026-08-24: 40 non-adjacent links across 39 companies (mostly an old historical year chained to an onboarding-seeded current year). The read-side guard neutralizes all of them for year-end; the data repair is a separate, founder-approved step. The reporting company (23dc3c97) self-repaired the same evening via the fiscal-periods gap-fill route; the stray IB entry is reversed. Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1551 lines
50 KiB
TypeScript
1551 lines
50 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
||
import {
|
||
generateImportPreview,
|
||
validateIBBalance,
|
||
isBalanceSheetAccount,
|
||
ensureFiscalPeriod,
|
||
importVouchers,
|
||
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)
|
||
})
|
||
})
|
||
})
|