* feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import A verifikat migrated from Fortnox/Visma lost the trail of what had been corrected in the source system: the parser skipped #BTRANS (struck lines) and #RTRANS (lines added by a rättelse) and nothing else read them. The final state is still built from #TRANS only, exactly as SIE 4B prescribes (#RTRANS is always twinned by an identical #TRANS, so summing all three double-counts, #63). The two history record types now ride along the voucher as `corrections` and land, inside the same atomic import transaction, as one journal_entry_rattelse_log row per corrected voucher with source='sie_import', the file's sie_import_id and the SIE `sign` (who corrected in the source system; SIE carries who, never when). Why the problem occurred: the March fix for double-counting chose "skip" over "keep aside" because nowhere existed to keep the history. The inline rättelse log (July) created that place, and every reader of it (verifikat page, "Rättad" marker, behandlingshistorik, full archive) already renders struck/added snapshots, so the history now flows through one table. What was removed or simplified instead: no new table, no per-import toggle, no fifth RPC parameter (sie_import_id travels inside each payload entry so the (uuid,uuid,uuid,jsonb) signature, grants and statement_timeout stay put and PostgREST sees no overload). The parser's three identical TRANS/RTRANS/BTRANS field parsers collapsed into one helper; the TRANS-only ledger path is byte-for-byte the same. Why this over the proposed shape: the reporter suggested an own table or column. A separate store would need its own readers, RLS, archive classification and behandlingshistorik wiring; the rättelselogg already has all four. Storing history in sie_imports.migration_documentation was rejected as aggregate JSON that no per-verifikat surface reads. Import-sourced log rows survive undo/replace like every other log row (no FK on purpose); a re-import writes fresh rows against fresh entry ids. Parser also warns when an #RTRANS is not followed by its identical #TRANS twin (a spec violation that would silently drop a line from the final state) and the record-type comments now match the spec wording. Migration 20260909132618: three nullable/defaulted columns + CHECKs on journal_entry_rattelse_log, sie_correction_snapshots() helper, import_sie_journal_entries body verbatim plus the history insert. No backfill; existing imports and log rows untouched. Fixes #2427 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2FcXNv8qRp4GaXCtzEdyn * fix(import): verify the SIE import id before it becomes provenance, keep per-line signatures Review findings on PR #2458, one pass: - Superagent P2: import_sie_journal_entries stored the caller-supplied sieImportId as WORM audit provenance without checking it. The RPC now requires the id to be one of the importing company's own sie_imports rows and fails closed (42501, whole import rolled back) on a foreign or fabricated id. pg-real test added. - Compliance review: the voucher-level external_signature collapsed distinct correctors per line. Each struck/added snapshot now carries its own SIE sign (importer + sie_correction_snapshots), the summary column stays as the first one. - Compliance review: created_at on imported rows is the import moment. Behandlingshistoriken now says so in the event details instead of leaving it implicit (the verifikat page already avoided a date). Migration file is unshipped (not on main); staging re-applied under the same version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2FcXNv8qRp4GaXCtzEdyn --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1943 lines
63 KiB
TypeScript
1943 lines
63 KiB
TypeScript
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('sends #BTRANS/#RTRANS history as corrections on the RPC payload, never as lines (#2427)', async () => {
|
||
const { supabase, rpcCalls } = buildCapturingSupabase()
|
||
const corrected = {
|
||
...makeVoucher('A', 7, [
|
||
{ account: '3001', amount: 1200 },
|
||
{ account: '1510', amount: -1200 },
|
||
]),
|
||
corrections: {
|
||
struck: [{ account: '1510', amount: 1200, description: 'Fel konto', signature: 'EL' }],
|
||
added: [{ account: '3001', amount: 1200, signature: 'EL' }],
|
||
},
|
||
}
|
||
const parsed = makeParsedFile({ vouchers: [makeVoucher('A', 6), corrected] })
|
||
|
||
const result = await importVouchers(
|
||
supabase,
|
||
'company-1',
|
||
'user-1',
|
||
'period-1',
|
||
parsed,
|
||
baseMap,
|
||
'A',
|
||
'import-42',
|
||
)
|
||
|
||
expect(result.created).toBe(2)
|
||
const importCalls = rpcCalls.filter((c) => c.name === 'import_sie_journal_entries')
|
||
expect(importCalls).toHaveLength(1)
|
||
const entries = importCalls[0].args.p_entries as Array<Record<string, unknown>>
|
||
|
||
// The plain voucher carries no history keys at all.
|
||
expect(entries[0]).not.toHaveProperty('corrections')
|
||
expect(entries[0]).not.toHaveProperty('sieImportId')
|
||
|
||
// The corrected voucher: lines are the #TRANS rows only ...
|
||
expect(entries[1].lines).toHaveLength(2)
|
||
// ... and the history rides alongside, debit/credit split like lines,
|
||
// stamped with the import id and the source-system signature.
|
||
expect(entries[1].sieImportId).toBe('import-42')
|
||
expect(entries[1].corrections).toEqual({
|
||
struck: [{ account_number: '1510', debit_amount: 1200, credit_amount: 0, line_description: 'Fel konto', sort_order: 0, signature: 'EL' }],
|
||
added: [{ account_number: '3001', debit_amount: 1200, credit_amount: 0, line_description: null, sort_order: 0, signature: 'EL' }],
|
||
signature: 'EL',
|
||
})
|
||
})
|
||
|
||
it('keeps the source account on history rows whose account is unmapped', async () => {
|
||
const { supabase, rpcCalls } = buildCapturingSupabase()
|
||
const corrected = {
|
||
...makeVoucher('A', 1),
|
||
corrections: {
|
||
struck: [{ account: '9999', amount: -1000 }],
|
||
added: [],
|
||
},
|
||
}
|
||
const parsed = makeParsedFile({ vouchers: [corrected] })
|
||
|
||
const result = await importVouchers(supabase, 'company-1', 'user-1', 'period-1', parsed, baseMap, 'A')
|
||
|
||
// The voucher itself (mapped #TRANS rows) still imports; only history
|
||
// references the unmapped account, verbatim, as the source showed it.
|
||
expect(result.created).toBe(1)
|
||
const entries = rpcCalls.find((c) => c.name === 'import_sie_journal_entries')!.args.p_entries as Array<Record<string, unknown>>
|
||
expect(entries[0].corrections).toEqual({
|
||
struck: [{ account_number: '9999', debit_amount: 0, credit_amount: 1000, line_description: null, sort_order: 0, signature: null }],
|
||
added: [],
|
||
signature: null,
|
||
})
|
||
// No import record in this call: the id is null, not absent.
|
||
expect(entries[0].sieImportId).toBeNull()
|
||
})
|
||
|
||
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([])
|
||
})
|
||
})
|