a84d2723e0
* 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>
1657 lines
58 KiB
TypeScript
1657 lines
58 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
parseSIEFile,
|
|
validateSIEFile,
|
|
detectEncoding,
|
|
decodeBuffer,
|
|
getEffectiveOpeningBalances,
|
|
hasOpeningBalanceVoucherCandidate,
|
|
} from '../sie-parser'
|
|
|
|
// --- SIE content fixtures ---
|
|
|
|
const MINIMAL_SIE = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#PROGRAM "TestProg" "1.0"',
|
|
'#FORMAT PC8',
|
|
'#GEN 20240101',
|
|
'#FNAMN "Test AB"',
|
|
'#ORGNR 5566778899',
|
|
'#VALUTA SEK',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 3001 "Försäljning varor 25%"',
|
|
].join('\n')
|
|
|
|
const SIE_WITH_BALANCES = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Balans AB"',
|
|
'#ORGNR 1234567890',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 2440 "Leverantörsskulder"',
|
|
'#IB 0 1510 50000.00',
|
|
'#IB 0 1930 100000.00',
|
|
'#IB 0 2440 -150000.00',
|
|
'#UB 0 1510 75000.00',
|
|
'#UB 0 1930 125000.00',
|
|
'#UB 0 2440 -200000.00',
|
|
].join('\n')
|
|
|
|
const SIE_WITH_VOUCHERS = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Voucher AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#KONTO 2611 "Utgående moms 25%"',
|
|
'#VER A 1 20240115 "Faktura 1001"',
|
|
'{',
|
|
'#TRANS 1510 {} 12500.00',
|
|
'#TRANS 3001 {} -10000.00',
|
|
'#TRANS 2611 {} -2500.00',
|
|
'}',
|
|
'#VER A 2 20240220 "Inbetalning faktura 1001"',
|
|
'{',
|
|
'#TRANS 1930 {} 12500.00',
|
|
'#TRANS 1510 {} -12500.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const SIE_TYPE_1 = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 1',
|
|
'#FNAMN "SIE1 AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#IB 0 1510 50000.00',
|
|
'#UB 0 1510 75000.00',
|
|
].join('\n')
|
|
|
|
const SIE_WITH_SRU = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "SRU AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#SRU 1510 7251',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#SRU 3001 7410',
|
|
].join('\n')
|
|
|
|
const SIE_UNBALANCED_VOUCHER = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Obalanserad AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#VER A 1 20240115 "Obalanserad verifikation"',
|
|
'{',
|
|
'#TRANS 1510 {} 10000.00',
|
|
'#TRANS 3001 {} -5000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const SIE_WITH_OBJECT_LIST = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Objects AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 5010 "Lokalhyra"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#VER A 1 20240115 "Hyra januari"',
|
|
'{',
|
|
'#TRANS 5010 {1 "Kontor"} 15000.00',
|
|
'#TRANS 1930 {} -15000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
// SIE file where all VER/TRANS fields are quoted (common from some accounting programs)
|
|
const SIE_QUOTED_FIELDS = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Quoted AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#KONTO 2611 "Utgående moms 25%"',
|
|
'#VER "A" "1" "20240115" "Faktura 1001"',
|
|
'{',
|
|
'#TRANS "1510" {} "12500.00"',
|
|
'#TRANS "3001" {} "-10000.00"',
|
|
'#TRANS "2611" {} "-2500.00"',
|
|
'}',
|
|
].join('\n')
|
|
|
|
// SIE file with empty series (some programs use "" for series)
|
|
const SIE_EMPTY_SERIES = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Empty Series AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#VER "" 1 20240115 "No series"',
|
|
'{',
|
|
'#TRANS 1930 {} 10000.00',
|
|
'#TRANS 3001 {} -10000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
// SIE 4B corrected voucher (Fortnox-style): the original 5010 line was
|
|
// struck (#BTRANS) and replaced by 6540 (#RTRANS, twinned by an identical
|
|
// #TRANS). Final state = the #TRANS rows only, and it balances.
|
|
const SIE_WITH_CORRECTIONS = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Rättat AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 5010 "Lokalhyra"',
|
|
'#KONTO 6540 "IT-tjänster"',
|
|
'#VER A 7 20240301 "Faktura IT"',
|
|
'{',
|
|
'#BTRANS 5010 {} 1200.00 20240301 "Lokalhyra" 0 "EL"',
|
|
'#RTRANS 6540 {} 1200.00 20240301 "IT-tjänster" 0 "EL"',
|
|
'#TRANS 6540 {} 1200.00 20240301 "IT-tjänster"',
|
|
'#TRANS 1930 {} -1200.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
// Spec violation: an #RTRANS with no identical #TRANS twin after it.
|
|
const SIE_RTRANS_WITHOUT_TWIN = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Trasig AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 6540 "IT-tjänster"',
|
|
'#VER A 8 20240301 "Utan tvilling"',
|
|
'{',
|
|
'#RTRANS 6540 {} 1200.00',
|
|
'#TRANS 1930 {} -1200.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
// SIE file with { on same line as #VER
|
|
const SIE_BRACE_ON_VER_LINE = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Brace AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#VER A 1 20240115 "Inline brace" {',
|
|
'#TRANS 1930 {} 10000.00',
|
|
'#TRANS 3001 {} -10000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
// --- parseSIEFile tests ---
|
|
|
|
describe('parseSIEFile', () => {
|
|
describe('header parsing', () => {
|
|
it('parses SIE type', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.sieType).toBe(4)
|
|
})
|
|
|
|
it('parses company name from #FNAMN', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.companyName).toBe('Test AB')
|
|
})
|
|
|
|
it('parses org number from #ORGNR', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.orgNumber).toBe('5566778899')
|
|
})
|
|
|
|
it('parses fiscal year from #RAR', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.fiscalYears).toHaveLength(1)
|
|
expect(result.header.fiscalYears[0].yearIndex).toBe(0)
|
|
expect(result.header.fiscalYears[0].start).toBe('2024-01-01')
|
|
expect(result.header.fiscalYears[0].end).toBe('2024-12-31')
|
|
})
|
|
|
|
it('parses currency from #VALUTA', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.currency).toBe('SEK')
|
|
})
|
|
|
|
it('defaults currency to SEK when not specified', () => {
|
|
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"\n#RAR 0 20240101 20241231'
|
|
const result = parseSIEFile(content)
|
|
expect(result.header.currency).toBe('SEK')
|
|
})
|
|
|
|
it('parses program info', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.program).toBe('TestProg')
|
|
expect(result.header.programVersion).toBe('1.0')
|
|
})
|
|
|
|
it('parses generated date', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.header.generatedDate).toBe('2024-01-01')
|
|
})
|
|
|
|
it('parses SIE type 1', () => {
|
|
const result = parseSIEFile(SIE_TYPE_1)
|
|
expect(result.header.sieType).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('account parsing', () => {
|
|
it('parses #KONTO with number and name', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.accounts).toHaveLength(3)
|
|
expect(result.accounts[0]).toEqual({ number: '1510', name: 'Kundfordringar' })
|
|
expect(result.accounts[1]).toEqual({ number: '1930', name: 'Företagskonto' })
|
|
})
|
|
|
|
it('parses #SRU codes onto accounts', () => {
|
|
const result = parseSIEFile(SIE_WITH_SRU)
|
|
const account1510 = result.accounts.find((a) => a.number === '1510')
|
|
expect(account1510?.sruCode).toBe('7251')
|
|
const account3001 = result.accounts.find((a) => a.number === '3001')
|
|
expect(account3001?.sruCode).toBe('7410')
|
|
})
|
|
})
|
|
|
|
describe('balance parsing', () => {
|
|
it('parses opening balances (#IB) with positive amounts', () => {
|
|
const result = parseSIEFile(SIE_WITH_BALANCES)
|
|
const ib1510 = result.openingBalances.find((b) => b.account === '1510')
|
|
expect(ib1510?.amount).toBe(50000)
|
|
expect(ib1510?.yearIndex).toBe(0)
|
|
})
|
|
|
|
it('parses opening balances (#IB) with negative amounts', () => {
|
|
const result = parseSIEFile(SIE_WITH_BALANCES)
|
|
const ib2440 = result.openingBalances.find((b) => b.account === '2440')
|
|
expect(ib2440?.amount).toBe(-150000)
|
|
})
|
|
|
|
it('parses closing balances (#UB)', () => {
|
|
const result = parseSIEFile(SIE_WITH_BALANCES)
|
|
expect(result.closingBalances).toHaveLength(3)
|
|
const ub1930 = result.closingBalances.find((b) => b.account === '1930')
|
|
expect(ub1930?.amount).toBe(125000)
|
|
})
|
|
})
|
|
|
|
describe('voucher parsing', () => {
|
|
it('parses #VER with series, number, date, description', () => {
|
|
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
|
expect(result.vouchers).toHaveLength(2)
|
|
|
|
const v1 = result.vouchers[0]
|
|
expect(v1.series).toBe('A')
|
|
expect(v1.number).toBe(1)
|
|
expect(v1.date).toEqual(new Date(2024, 0, 15))
|
|
expect(v1.description).toBe('Faktura 1001')
|
|
})
|
|
|
|
it('unescapes \\" and \\\\ in quoted text, as the export writes them', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FORMAT PC8',
|
|
'#FNAMN "Test AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#VER A 1 20240115 "Sökväg C:\\\\temp\\\\\\"fil\\""',
|
|
'{',
|
|
'#TRANS 1930 {} 100.00',
|
|
'#TRANS 3001 {} -100.00',
|
|
'}',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
expect(result.vouchers[0].description).toBe('Sökväg C:\\temp\\"fil"')
|
|
})
|
|
|
|
it('parses #TRANS lines within a voucher', () => {
|
|
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
|
const v1 = result.vouchers[0]
|
|
|
|
expect(v1.lines).toHaveLength(3)
|
|
expect(v1.lines[0]).toMatchObject({ account: '1510', amount: 12500 })
|
|
expect(v1.lines[1]).toMatchObject({ account: '3001', amount: -10000 })
|
|
expect(v1.lines[2]).toMatchObject({ account: '2611', amount: -2500 })
|
|
})
|
|
|
|
it('handles object lists in braces and captures them as dimensions', () => {
|
|
const result = parseSIEFile(SIE_WITH_OBJECT_LIST)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
|
|
const v = result.vouchers[0]
|
|
expect(v.lines).toHaveLength(2)
|
|
expect(v.lines[0]).toMatchObject({ account: '5010', amount: 15000 })
|
|
// The object list is data, not noise: lossless import (PR5).
|
|
expect(v.lines[0].dimensions).toEqual({ '1': 'Kontor' })
|
|
expect(v.lines[1]).toMatchObject({ account: '1930', amount: -15000 })
|
|
// Empty object list {} → no dimensions key at all.
|
|
expect(v.lines[1].dimensions).toBeUndefined()
|
|
})
|
|
|
|
it('parses multi-pair object lists with quoted codes and canonical keys', () => {
|
|
const sie = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#RAR 0 20240101 20241231',
|
|
'#VER A 1 20240115 "Projektköp"',
|
|
'{',
|
|
'#TRANS 5010 {"1" "KS 01" 06 "P001"} 15000.00',
|
|
'#TRANS 1930 {} -15000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(sie)
|
|
// '06' canonicalizes to '6' (matches normalizeLineDimensions); quoted
|
|
// codes may contain spaces.
|
|
expect(result.vouchers[0].lines[0].dimensions).toEqual({ '1': 'KS 01', '6': 'P001' })
|
|
})
|
|
|
|
it('warns on a malformed (odd-field) object list but keeps the line', () => {
|
|
const sie = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#RAR 0 20240101 20241231',
|
|
'#VER A 1 20240115 "Trasig objektlista"',
|
|
'{',
|
|
'#TRANS 5010 {6} 100.00',
|
|
'#TRANS 1930 {} -100.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(sie)
|
|
expect(result.vouchers[0].lines[0]).toMatchObject({ account: '5010', amount: 100 })
|
|
expect(result.vouchers[0].lines[0].dimensions).toBeUndefined()
|
|
expect(result.issues.some((i) => i.severity === 'warning' && i.message.toLowerCase().includes('objektlista'))).toBe(true)
|
|
})
|
|
|
|
it('surfaces OIB/OUB drops and dimension presence as info issues', () => {
|
|
const sie = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#RAR 0 20240101 20241231',
|
|
'#DIM 6 "Projekt"',
|
|
'#OIB 0 1930 {6 "P001"} 5000.00',
|
|
'#OUB 0 1930 {6 "P001"} 7000.00',
|
|
'#VER A 1 20240115 "Taggad"',
|
|
'{',
|
|
'#TRANS 5010 {6 "P001"} 100.00',
|
|
'#TRANS 1930 {} -100.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(sie)
|
|
const infos = result.issues.filter((i) => i.severity === 'info').map((i) => i.message)
|
|
expect(infos.some((m) => m.includes('2 objektbalansrader'))).toBe(true)
|
|
expect(infos.some((m) => m.includes('dimensionsdata'))).toBe(true)
|
|
// Silence preserved for files without any dimension data.
|
|
const plain = parseSIEFile(['#FLAGGA 0', '#SIETYP 4', '#RAR 0 20240101 20241231'].join('\n'))
|
|
expect(plain.issues.some((i) => i.tag === 'DIM' || i.tag === 'OIB')).toBe(false)
|
|
})
|
|
|
|
it('parses #DIM, #UNDERDIM and #OBJEKT into the registry arrays', () => {
|
|
const sie = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#RAR 0 20240101 20241231',
|
|
'#DIM 1 "Kostnadsställe"',
|
|
'#DIM 6 "Projekt"',
|
|
'#UNDERDIM 2 "Kostnadsbärare" 1',
|
|
'#OBJEKT 1 "KS01" "Butiken"',
|
|
'#OBJEKT 6 "P001" "Villa Almgren"',
|
|
'#OBJEKT 6 "P002" ""',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(sie)
|
|
expect(result.dimensions).toEqual([
|
|
{ sieDimNo: 1, name: 'Kostnadsställe' },
|
|
{ sieDimNo: 6, name: 'Projekt' },
|
|
{ sieDimNo: 2, name: 'Kostnadsbärare', parentSieDimNo: 1 },
|
|
])
|
|
expect(result.dimensionValues).toEqual([
|
|
{ sieDimNo: 1, code: 'KS01', name: 'Butiken' },
|
|
{ sieDimNo: 6, code: 'P001', name: 'Villa Almgren' },
|
|
// Nameless objekt falls back to its code.
|
|
{ sieDimNo: 6, code: 'P002', name: 'P002' },
|
|
])
|
|
})
|
|
|
|
it('parses quoted VER fields (series, number, date)', () => {
|
|
const result = parseSIEFile(SIE_QUOTED_FIELDS)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
|
|
const v = result.vouchers[0]
|
|
expect(v.series).toBe('A')
|
|
expect(v.number).toBe(1)
|
|
expect(v.date).toEqual(new Date(2024, 0, 15))
|
|
expect(v.description).toBe('Faktura 1001')
|
|
expect(v.lines).toHaveLength(3)
|
|
expect(v.lines[0]).toMatchObject({ account: '1510', amount: 12500 })
|
|
expect(v.lines[1]).toMatchObject({ account: '3001', amount: -10000 })
|
|
expect(v.lines[2]).toMatchObject({ account: '2611', amount: -2500 })
|
|
|
|
const errors = result.issues.filter((i) => i.severity === 'error')
|
|
expect(errors).toHaveLength(0)
|
|
})
|
|
|
|
it('allows empty series in VER', () => {
|
|
const result = parseSIEFile(SIE_EMPTY_SERIES)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
|
|
const v = result.vouchers[0]
|
|
expect(v.series).toBe('')
|
|
expect(v.number).toBe(1)
|
|
expect(v.lines).toHaveLength(2)
|
|
|
|
const errors = result.issues.filter((i) => i.severity === 'error')
|
|
expect(errors).toHaveLength(0)
|
|
})
|
|
|
|
it('handles { on same line as #VER', () => {
|
|
const result = parseSIEFile(SIE_BRACE_ON_VER_LINE)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
|
|
const v = result.vouchers[0]
|
|
expect(v.series).toBe('A')
|
|
expect(v.number).toBe(1)
|
|
expect(v.lines).toHaveLength(2)
|
|
|
|
const errors = result.issues.filter((i) => i.severity === 'error')
|
|
expect(errors).toHaveLength(0)
|
|
})
|
|
|
|
it('detects unbalanced vouchers as errors', () => {
|
|
const result = parseSIEFile(SIE_UNBALANCED_VOUCHER)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
|
|
const errors = result.issues.filter((i) => i.severity === 'error')
|
|
expect(errors.length).toBeGreaterThanOrEqual(1)
|
|
expect(errors.some((e) => e.message.includes('balanserar inte'))).toBe(true)
|
|
})
|
|
|
|
// SIE 4B #BTRANS / #RTRANS (#2427): final state stays #TRANS-only, the
|
|
// correction history is kept aside on the voucher.
|
|
it('books #TRANS only and keeps #BTRANS/#RTRANS as correction history', () => {
|
|
const result = parseSIEFile(SIE_WITH_CORRECTIONS)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
const v = result.vouchers[0]
|
|
|
|
// Final state: exactly the #TRANS rows, balanced, no double counting.
|
|
expect(v.lines).toHaveLength(2)
|
|
expect(v.lines[0]).toMatchObject({ account: '6540', amount: 1200 })
|
|
expect(v.lines[1]).toMatchObject({ account: '1930', amount: -1200 })
|
|
expect(result.issues.filter((i) => i.severity === 'error')).toHaveLength(0)
|
|
|
|
// History: the struck original and the added replacement, with the
|
|
// SIE `sign` (who corrected in the source system).
|
|
expect(v.corrections).toBeDefined()
|
|
expect(v.corrections!.struck).toHaveLength(1)
|
|
expect(v.corrections!.struck[0]).toMatchObject({ account: '5010', amount: 1200, description: 'Lokalhyra', signature: 'EL' })
|
|
expect(v.corrections!.added).toHaveLength(1)
|
|
expect(v.corrections!.added[0]).toMatchObject({ account: '6540', amount: 1200, signature: 'EL' })
|
|
expect(result.stats.totalTransactionLines).toBe(2)
|
|
})
|
|
|
|
it('leaves corrections undefined on a voucher without #BTRANS/#RTRANS', () => {
|
|
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
|
for (const v of result.vouchers) {
|
|
expect(v.corrections).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
it('warns when an #RTRANS is not twinned by an identical #TRANS', () => {
|
|
const result = parseSIEFile(SIE_RTRANS_WITHOUT_TWIN)
|
|
const v = result.vouchers[0]
|
|
|
|
// Spec rule: #RTRANS is not part of the final state on its own.
|
|
expect(v.lines).toHaveLength(1)
|
|
expect(v.corrections!.added).toHaveLength(1)
|
|
|
|
const twinWarnings = result.issues.filter((i) => i.tag === 'RTRANS' && i.severity === 'warning')
|
|
expect(twinWarnings).toHaveLength(1)
|
|
expect(twinWarnings[0].message).toContain('följs inte av en identisk #TRANS')
|
|
})
|
|
})
|
|
|
|
describe('statistics', () => {
|
|
it('calculates account count', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.stats.totalAccounts).toBe(3)
|
|
})
|
|
|
|
it('calculates voucher count', () => {
|
|
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
|
expect(result.stats.totalVouchers).toBe(2)
|
|
})
|
|
|
|
it('calculates transaction line count', () => {
|
|
const result = parseSIEFile(SIE_WITH_VOUCHERS)
|
|
// Voucher 1: 3 lines, Voucher 2: 2 lines
|
|
expect(result.stats.totalTransactionLines).toBe(5)
|
|
})
|
|
|
|
it('sets fiscal year start/end from RAR 0', () => {
|
|
const result = parseSIEFile(MINIMAL_SIE)
|
|
expect(result.stats.fiscalYearStart).toBe('2024-01-01')
|
|
expect(result.stats.fiscalYearEnd).toBe('2024-12-31')
|
|
})
|
|
|
|
it('returns null fiscal year dates when no RAR', () => {
|
|
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"'
|
|
const result = parseSIEFile(content)
|
|
expect(result.stats.fiscalYearStart).toBeNull()
|
|
expect(result.stats.fiscalYearEnd).toBeNull()
|
|
})
|
|
})
|
|
})
|
|
|
|
// --- validateSIEFile tests ---
|
|
|
|
describe('validateSIEFile', () => {
|
|
it('returns valid for a complete SIE file', () => {
|
|
const parsed = parseSIEFile(SIE_WITH_VOUCHERS)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.valid).toBe(true)
|
|
expect(validation.errors).toHaveLength(0)
|
|
})
|
|
|
|
it('adds error for unbalanced vouchers', () => {
|
|
const parsed = parseSIEFile(SIE_UNBALANCED_VOUCHER)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.valid).toBe(false)
|
|
expect(validation.errors.some((e) => e.includes('balanserar inte'))).toBe(true)
|
|
})
|
|
|
|
it('no longer warns about accounts referenced in #IB since parser auto-adds them', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#IB 0 9999 50000.00',
|
|
].join('\n')
|
|
|
|
const parsed = parseSIEFile(content)
|
|
// Parser now auto-adds 9999 to accounts list from #IB data
|
|
expect(parsed.accounts.map((a) => a.number)).toContain('9999')
|
|
|
|
const validation = validateSIEFile(parsed)
|
|
// No warning since account was auto-added by the parser
|
|
expect(validation.warnings.some((w) => w.includes('9999') && w.includes('not defined'))).toBe(false)
|
|
})
|
|
|
|
it('adds error for missing #RAR', () => {
|
|
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"\n#KONTO 1510 "Kund"'
|
|
const parsed = parseSIEFile(content)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.valid).toBe(false)
|
|
expect(validation.errors.some((e) => e.includes('fiscal year') || e.includes('#RAR'))).toBe(true)
|
|
})
|
|
|
|
it('adds warning for unbalanced opening balances', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#IB 0 1510 50000.00',
|
|
].join('\n')
|
|
|
|
const parsed = parseSIEFile(content)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.warnings.some((w) => w.includes('Ingående balanser balanserar inte'))).toBe(true)
|
|
})
|
|
|
|
it('passes with balanced opening balances', () => {
|
|
const parsed = parseSIEFile(SIE_WITH_BALANCES)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
// IB: 50000 + 100000 + (-150000) = 0 → balanced
|
|
const ibWarning = validation.warnings.find((w) => w.includes('Ingående balanser balanserar inte'))
|
|
expect(ibWarning).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// --- validateSIEFile: missing omföring av årets resultat ---
|
|
// A completed fiscal year whose vouchers leave a residual on P&L accounts
|
|
// never moved its result to equity. Later imported years derive IB from
|
|
// balance-sheet accounts only, so the residual becomes a permanent
|
|
// balansräkning differens (prod incident: 97 kr across three years).
|
|
|
|
const OMFORING_HEADER = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Omföring AB"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#KONTO 8999 "Årets resultat"',
|
|
'#KONTO 2099 "Årets resultat"',
|
|
]
|
|
|
|
const OMFORING_MISSING_VOUCHERS = [
|
|
'#VER A 1 20240401 "Försäljning"',
|
|
'{',
|
|
'#TRANS 1930 {} 97.00',
|
|
'#TRANS 3001 {} -97.00',
|
|
'}',
|
|
]
|
|
|
|
const OMFORING_PRESENT_VOUCHERS = [
|
|
...OMFORING_MISSING_VOUCHERS,
|
|
'#VER A 2 20250228 "Omföring av årets resultat"',
|
|
'{',
|
|
'#TRANS 8999 {} 97.00',
|
|
'#TRANS 2099 {} -97.00',
|
|
'}',
|
|
]
|
|
|
|
describe('validateSIEFile — untransferred result (omföring saknas)', () => {
|
|
it('warns when a completed year leaves a residual on P&L accounts', () => {
|
|
const content = [
|
|
...OMFORING_HEADER,
|
|
'#RAR 0 20240301 20250228',
|
|
...OMFORING_MISSING_VOUCHERS,
|
|
].join('\n')
|
|
|
|
const validation = validateSIEFile(parseSIEFile(content))
|
|
|
|
const warning = validation.warnings.find((w) => w.includes('omföring av årets resultat'))
|
|
expect(warning).toBeDefined()
|
|
expect(warning).toContain('97.00 kr')
|
|
// A missing omföring is a data-quality heads-up, never an import blocker.
|
|
expect(validation.valid).toBe(true)
|
|
})
|
|
|
|
it('does not warn when the file contains the result transfer', () => {
|
|
const content = [
|
|
...OMFORING_HEADER,
|
|
'#RAR 0 20240301 20250228',
|
|
...OMFORING_PRESENT_VOUCHERS,
|
|
].join('\n')
|
|
|
|
const validation = validateSIEFile(parseSIEFile(content))
|
|
|
|
expect(
|
|
validation.warnings.some((w) => w.includes('omföring av årets resultat'))
|
|
).toBe(false)
|
|
})
|
|
|
|
it('does not warn for a running fiscal year', () => {
|
|
const content = [
|
|
...OMFORING_HEADER,
|
|
// Fiscal year end far in the future — the running year legitimately
|
|
// carries its result on class 3-8 until bokslut.
|
|
'#RAR 0 20990101 20991231',
|
|
...OMFORING_MISSING_VOUCHERS.map((line) =>
|
|
line.replace('20240401', '20990401')
|
|
),
|
|
].join('\n')
|
|
|
|
const validation = validateSIEFile(parseSIEFile(content))
|
|
|
|
expect(
|
|
validation.warnings.some((w) => w.includes('omföring av årets resultat'))
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
// --- Fix 2: Windows-1252 encoding detection and decoding ---
|
|
|
|
describe('detectEncoding: #FORMAT PC8 detection', () => {
|
|
it('ignores #FORMAT PC8 and detects UTF-8 from byte patterns', () => {
|
|
// #FORMAT PC8 is unreliable: most cloud software (Fortnox, Bokio etc.)
|
|
// exports UTF-8 but still declares #FORMAT PC8.
|
|
// UTF-8 encoded: "Företagskonto" → 0xC3 0xB6 for ö
|
|
const text = '#FLAGGA 0\n#FORMAT PC8\n#FNAMN "Företagskonto"\n'
|
|
const encoder = new TextEncoder() // TextEncoder outputs UTF-8
|
|
const buf = encoder.encode(text)
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('utf8')
|
|
})
|
|
|
|
it('detects Win-1252 when actual byte values are in Win-1252 range', () => {
|
|
// Win-1252 bytes for Swedish chars: ö=0xF6, ä=0xE4, å=0xE5
|
|
const prefix = new TextEncoder().encode('#FORMAT PC8\n#FNAMN F')
|
|
const buf = new Uint8Array(prefix.length + 3)
|
|
buf.set(prefix)
|
|
buf[prefix.length] = 0xf6 // ö in Win-1252
|
|
buf[prefix.length + 1] = 0xe4 // ä in Win-1252
|
|
buf[prefix.length + 2] = 0xe5 // å in Win-1252
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('windows1252')
|
|
})
|
|
|
|
it('returns utf8 for pure ASCII files (no high bytes)', () => {
|
|
const text = '#FLAGGA 0\n#FORMAT PC8\n#SIETYP 4\n'
|
|
const encoder = new TextEncoder()
|
|
const buf = encoder.encode(text)
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('utf8')
|
|
})
|
|
})
|
|
|
|
describe('detectEncoding: range-based discrimination', () => {
|
|
it('detects CP437 when bytes are in 0x80-0x9F range only', () => {
|
|
// 0x84=ä, 0x86=å, 0x94=ö in CP437: all in 0x80-0x9F
|
|
const buf = new Uint8Array([0x23, 0x84, 0x86, 0x94, 0x84, 0x86])
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('cp437')
|
|
})
|
|
|
|
it('detects Win-1252 when bytes are in 0xC0-0xFF range only', () => {
|
|
// 0xE4=ä, 0xE5=å, 0xF6=ö in Win-1252: all in 0xC0-0xFF
|
|
const buf = new Uint8Array([0x23, 0xe4, 0xe5, 0xf6, 0xe4, 0xe5])
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('windows1252')
|
|
})
|
|
|
|
it('does not double-count UTF-8 continuation bytes as CP437', () => {
|
|
// UTF-8: ä = C3 A4, å = C3 A5, ö = C3 B6
|
|
// Without skipping, 0xA4/0xA5/0xB6 are NOT in CP437 map so no false count,
|
|
// but 0x84/0x85 ARE in CP437 map: test that C3 84 (Ä in UTF-8) is not
|
|
// counted as CP437 0x84 (ä)
|
|
const buf = new Uint8Array([
|
|
0x23, // #
|
|
0xc3, 0x84, // Ä in UTF-8
|
|
0xc3, 0x85, // Å in UTF-8
|
|
0xc3, 0x96, // Ö in UTF-8
|
|
0xc3, 0xa4, // ä in UTF-8
|
|
0xc3, 0xa5, // å in UTF-8
|
|
])
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('utf8')
|
|
})
|
|
})
|
|
describe('detectEncoding: Windows-1252', () => {
|
|
it('detects Windows-1252 when Swedish chars use Win-1252 byte values', () => {
|
|
// Build a buffer with Windows-1252 encoded Swedish text: "#FNAMN Företag"
|
|
// å=0xE5, ä=0xE4, ö=0xF6 in Windows-1252 (NOT in CP437 map)
|
|
const text = '#FNAMN F'
|
|
const encoder = new TextEncoder()
|
|
const prefix = encoder.encode(text)
|
|
// Add ö (0xF6) r (0x72) e (0x65) t (0x74) a (0x61) g (0x67)
|
|
const buf = new Uint8Array(prefix.length + 6)
|
|
buf.set(prefix)
|
|
buf[prefix.length] = 0xf6 // ö in Windows-1252
|
|
buf[prefix.length + 1] = 0x72 // r
|
|
buf[prefix.length + 2] = 0x65 // e
|
|
buf[prefix.length + 3] = 0x74 // t
|
|
buf[prefix.length + 4] = 0x61 // a
|
|
buf[prefix.length + 5] = 0x67 // g
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('windows1252')
|
|
})
|
|
|
|
it('detects UTF-8 BOM even when Windows-1252 bytes are present', () => {
|
|
const buf = new Uint8Array([0xef, 0xbb, 0xbf, 0x23, 0xe5]) // BOM + # + å-win1252
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('utf8')
|
|
})
|
|
|
|
it('detects CP437 when CP437-specific bytes are present', () => {
|
|
// 0x86 = å in CP437 (not in Win-1252 Swedish set)
|
|
const buf = new Uint8Array([0x23, 0x86, 0x86, 0x86])
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('cp437')
|
|
})
|
|
|
|
it('detects UTF-8 multi-byte Swedish chars', () => {
|
|
// å in UTF-8 = C3 A5, ä = C3 A4
|
|
const buf = new Uint8Array([0x23, 0xc3, 0xa5, 0xc3, 0xa4])
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('utf8')
|
|
})
|
|
})
|
|
|
|
describe('decodeBuffer: Windows-1252', () => {
|
|
it('decodes Windows-1252 Swedish characters correctly', () => {
|
|
// "åäö" in Windows-1252 = [0xE5, 0xE4, 0xF6]
|
|
const buf = new Uint8Array([0xe5, 0xe4, 0xf6])
|
|
const result = decodeBuffer(buf.buffer, 'windows1252')
|
|
expect(result).toBe('åäö')
|
|
})
|
|
|
|
it('decodes Windows-1252 uppercase Swedish characters correctly', () => {
|
|
// "ÅÄÖ" in Windows-1252 = [0xC5, 0xC4, 0xD6]
|
|
const buf = new Uint8Array([0xc5, 0xc4, 0xd6])
|
|
const result = decodeBuffer(buf.buffer, 'windows1252')
|
|
expect(result).toBe('ÅÄÖ')
|
|
})
|
|
|
|
it('decodes CP437 Swedish characters correctly', () => {
|
|
// å in CP437 = 0x86, ä = 0x84, ö = 0x94
|
|
const buf = new Uint8Array([0x86, 0x84, 0x94])
|
|
const result = decodeBuffer(buf.buffer, 'cp437')
|
|
expect(result).toBe('åäö')
|
|
})
|
|
})
|
|
|
|
// --- Defensive encoding: handle files where the detector picks the wrong encoding ---
|
|
|
|
describe('detectEncoding: full-buffer scan', () => {
|
|
it('detects Win-1252 even when Swedish chars appear past the legacy 4KB sample boundary', () => {
|
|
// Build a buffer where the first 8000 bytes are pure ASCII header + filler,
|
|
// and the Swedish Win-1252 byte appears only at byte 8000+. The old
|
|
// implementation sampled the first 4000 bytes and would default to UTF-8.
|
|
const filler = new Uint8Array(8000).fill(0x20) // spaces
|
|
const tail = new Uint8Array([
|
|
0x46, 0x4f, 0x52, 0x45, 0x4e, 0x49, 0x4e, 0x47, // FORENING
|
|
0xd6, // Ö in Win-1252 (0xD6), invalid lone UTF-8 byte
|
|
])
|
|
const buf = new Uint8Array(filler.length + tail.length)
|
|
buf.set(filler, 0)
|
|
buf.set(tail, filler.length)
|
|
const encoding = detectEncoding(buf.buffer)
|
|
expect(encoding).toBe('windows1252')
|
|
})
|
|
})
|
|
|
|
describe('decodeBuffer: fallback on U+FFFD', () => {
|
|
it('falls back from utf8 to windows1252 when the result has replacement characters', () => {
|
|
// "F" "Ö" "RENING" in Windows-1252: Ö is lone byte 0xD6, not valid UTF-8
|
|
const buf = new Uint8Array([0x46, 0xd6, 0x52, 0x45, 0x4e, 0x49, 0x4e, 0x47])
|
|
const result = decodeBuffer(buf.buffer, 'utf8')
|
|
expect(result).toBe('FÖRENING')
|
|
expect(result.includes('\uFFFD')).toBe(false)
|
|
})
|
|
|
|
it('falls back from utf8 to cp437 when both windows1252 also fails', () => {
|
|
// 0x94 is "ö" in CP437; in Win-1252 it's an unprintable "" but textually different;
|
|
// in UTF-8 it's invalid → U+FFFD. Verify CP437 path is reachable when chosen wrong.
|
|
const buf = new Uint8Array([0x66, 0x94, 0x72]) // f + ö-cp437 + r
|
|
const result = decodeBuffer(buf.buffer, 'utf8')
|
|
// Either windows1252 or cp437 fallback produces a non-FFFD result; both are
|
|
// acceptable here since the byte 0x94 is interpretable in both: what matters
|
|
// is no U+FFFD leaks through.
|
|
expect(result.includes('\uFFFD')).toBe(false)
|
|
})
|
|
|
|
it('returns primary decode unchanged when it contains no U+FFFD', () => {
|
|
const buf = new TextEncoder().encode('Företag').buffer
|
|
const result = decodeBuffer(buf, 'utf8')
|
|
expect(result).toBe('Företag')
|
|
})
|
|
})
|
|
|
|
// --- Fix 3: Invalid date rejection ---
|
|
|
|
describe('parseSIEFile: invalid date handling', () => {
|
|
it('rejects Feb 30 (auto-rolled dates) in #RAR', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20240230', // Feb 30 is invalid
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
// RAR with invalid end date should produce a warning and not add the fiscal year
|
|
expect(result.header.fiscalYears).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.message.includes('Invalid fiscal year dates'))).toBe(true)
|
|
})
|
|
|
|
it('rejects Apr 31 in #VER date', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#VER A 1 20240431 "Invalid date"', // Apr 31 is invalid
|
|
'{',
|
|
'#TRANS 1930 {} 1000.00',
|
|
'#TRANS 3001 {} -1000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
// Voucher should not be created because date is invalid
|
|
expect(result.vouchers).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'error' && i.message.includes('Ogiltig verifikationsdefinition'))).toBe(true)
|
|
})
|
|
|
|
it('accepts valid leap year date Feb 29', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Konto"',
|
|
'#KONTO 3001 "Konto"',
|
|
'#VER A 1 20240229 "Leap year"',
|
|
'{',
|
|
'#TRANS 1930 {} 1000.00',
|
|
'#TRANS 3001 {} -1000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
expect(result.vouchers[0].date).toEqual(new Date(2024, 1, 29))
|
|
})
|
|
|
|
it('rejects Feb 29 in non-leap year', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20230101 20231231',
|
|
'#KONTO 1930 "Konto"',
|
|
'#VER A 1 20230229 "Not a leap year"',
|
|
'{',
|
|
'#TRANS 1930 {} 1000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.vouchers).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.message.includes('Ogiltig verifikationsdefinition'))).toBe(true)
|
|
})
|
|
})
|
|
|
|
// --- Fix 4: Missing amount handling ---
|
|
|
|
describe('parseSIEFile: missing amount handling', () => {
|
|
it('skips #IB with missing amount and adds warning', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#IB 0 1510', // No amount field
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.openingBalances).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #IB'))).toBe(true)
|
|
})
|
|
|
|
it('skips #UB with missing amount and adds warning', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#UB 0 1510', // No amount field
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.closingBalances).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #UB'))).toBe(true)
|
|
})
|
|
|
|
it('skips #RES with missing amount and adds warning', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#RES 0 3001', // No amount field
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.resultBalances).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #RES'))).toBe(true)
|
|
})
|
|
|
|
it('skips #TRANS with missing amount and adds warning', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#VER A 1 20240115 "Test"',
|
|
'{',
|
|
'#TRANS 1930 {}', // No amount field
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
expect(result.vouchers[0].lines).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'warning' && i.message.includes('Belopp saknas i #TRANS'))).toBe(true)
|
|
})
|
|
|
|
it('still parses valid #IB lines alongside missing-amount ones', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#IB 0 1510', // Missing → skipped
|
|
'#IB 0 1930 100000.00', // Valid → kept
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.openingBalances).toHaveLength(1)
|
|
expect(result.openingBalances[0].account).toBe('1930')
|
|
expect(result.openingBalances[0].amount).toBe(100000)
|
|
})
|
|
})
|
|
|
|
// --- Fix B4: Account collection from transaction data ---
|
|
|
|
describe('parseSIEFile: account collection from transaction data', () => {
|
|
it('adds accounts from #TRANS that are missing from #KONTO', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
// 3001 is NOT defined in #KONTO but used in #TRANS
|
|
'#VER A 1 20240115 "Test"',
|
|
'{',
|
|
'#TRANS 1510 {} 10000.00',
|
|
'#TRANS 3001 {} -10000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
// Should have both 1510 (from #KONTO) and 3001 (from #TRANS)
|
|
expect(result.accounts.map((a) => a.number)).toContain('1510')
|
|
expect(result.accounts.map((a) => a.number)).toContain('3001')
|
|
// The auto-added account should have empty name
|
|
const added = result.accounts.find((a) => a.number === '3001')
|
|
expect(added?.name).toBe('')
|
|
})
|
|
|
|
it('adds accounts from #IB that are missing from #KONTO', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#IB 0 1510 50000.00',
|
|
'#IB 0 2440 -50000.00', // 2440 not in #KONTO
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.accounts.map((a) => a.number)).toContain('2440')
|
|
})
|
|
|
|
it('does not duplicate accounts already in #KONTO', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#KONTO 3001 "Försäljning"',
|
|
'#VER A 1 20240115 "Test"',
|
|
'{',
|
|
'#TRANS 1510 {} 10000.00',
|
|
'#TRANS 3001 {} -10000.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
const count1510 = result.accounts.filter((a) => a.number === '1510').length
|
|
expect(count1510).toBe(1)
|
|
})
|
|
|
|
it('adds accounts from #UB and #RES that are missing from #KONTO', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Test"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1510 "Kundfordringar"',
|
|
'#UB 0 1930 100000.00', // 1930 not in #KONTO
|
|
'#RES 0 3001 -50000.00', // 3001 not in #KONTO
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.accounts.map((a) => a.number)).toContain('1930')
|
|
expect(result.accounts.map((a) => a.number)).toContain('3001')
|
|
})
|
|
})
|
|
|
|
describe('parseSIEFile: tab-separated fields (Bollbok export shape)', () => {
|
|
// Bollbok exports tab-separated SIE files, valid per the SIE 4 spec
|
|
// (separator may be space OR tab). Every record except #RAR uses tabs;
|
|
// #RAR uses spaces. Both 2025 (UTF-8, unquoted #KTYP value) and 2026
|
|
// (CP437, quoted #KTYP value) shapes are exercised here.
|
|
|
|
const BOLLBOK_TAB_2025_SHAPE = [
|
|
'#FLAGGA\t0',
|
|
'#PROGRAM\t"Bollbok"\t2078',
|
|
'#GEN\t20260512\t""',
|
|
'#SIETYP\t4',
|
|
'#ORGNR\t"950406-3679"',
|
|
'#FNAMN\t"Erik Hellqvist "',
|
|
'#RAR 0 20250101 20251231',
|
|
'#KPTYP\tEUBAS97',
|
|
'#KONTO\t1510\t"Kundfordringar"',
|
|
'#KTYP\t1510\tT',
|
|
'#KONTO\t1930\t"Företagskonto"',
|
|
'#KTYP\t1930\tT',
|
|
'#KONTO\t3001\t"Försäljning"',
|
|
'#KTYP\t3001\tI',
|
|
'#IB\t0\t1510\t50000.00',
|
|
'#IB\t0\t1930\t100000.00',
|
|
'#VER\t""\t"1"\t20250116\t"Kundbetalning"\t20260508',
|
|
'{',
|
|
'#TRANS\t1930\t{}\t12500.00',
|
|
'#TRANS\t1510\t{}\t-12500.00',
|
|
'}',
|
|
].join('\n')
|
|
|
|
const BOLLBOK_TAB_2026_SHAPE = [
|
|
'#FLAGGA\t0',
|
|
'#PROGRAM\t"Bollbok"\t2078',
|
|
'#FORMAT\tPC8',
|
|
'#GEN\t20260514\t""',
|
|
'#SIETYP\t4',
|
|
'#ORGNR\t"950406-3679"',
|
|
'#FNAMN\t"Erik Hellqvist "',
|
|
'#RAR 0 20260101 20261231',
|
|
'#KPTYP\tEUBAS97',
|
|
'#KONTO\t1510\t"Kundfordringar"',
|
|
'#KTYP\t1510\t"T"',
|
|
'#KONTO\t1930\t"Företagskonto"',
|
|
'#KTYP\t1930\t"T"',
|
|
'#IB\t0\t1510\t75000.00',
|
|
'#IB\t0\t1930\t125000.00',
|
|
'#UB\t0\t1510\t75000.00',
|
|
'#UB\t0\t1930\t125000.00',
|
|
].join('\n')
|
|
|
|
it('parses tab-separated #IB into openingBalances (2025 shape)', () => {
|
|
const result = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
|
|
expect(result.openingBalances).toHaveLength(2)
|
|
expect(result.openingBalances).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ account: '1510', amount: 50000 }),
|
|
expect.objectContaining({ account: '1930', amount: 100000 }),
|
|
])
|
|
)
|
|
})
|
|
|
|
it('does not warn about a non-BAS kontoplan for `#KPTYP` EUBAS97', () => {
|
|
// EUBAS97 is a standard SIE kontoplanstyp (the spec routes every BAS2xxx
|
|
// chart through it): flagging it as non-BAS would put a false warning on
|
|
// every WINT-rendered and Bollbok file.
|
|
const parsed = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
|
|
const validation = validateSIEFile(parsed)
|
|
expect(parsed.header.kontoPlanType).toBe('EUBAS97')
|
|
expect(validation.warnings.filter((w) => w.includes('inte BAS-baserad'))).toEqual([])
|
|
})
|
|
|
|
it('parses tab-separated #KONTO into accounts (2025 shape)', () => {
|
|
const result = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
|
|
expect(result.accounts.map((a) => a.number)).toEqual(
|
|
expect.arrayContaining(['1510', '1930', '3001'])
|
|
)
|
|
const kund = result.accounts.find((a) => a.number === '1510')
|
|
expect(kund?.name).toBe('Kundfordringar')
|
|
})
|
|
|
|
it('parses tab-separated #VER + #TRANS block (2025 shape)', () => {
|
|
const result = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
|
|
expect(result.vouchers).toHaveLength(1)
|
|
const v = result.vouchers[0]
|
|
expect(v.lines).toHaveLength(2)
|
|
expect(v.lines[0]).toMatchObject({ account: '1930', amount: 12500 })
|
|
expect(v.lines[1]).toMatchObject({ account: '1510', amount: -12500 })
|
|
// Verification balances to zero
|
|
expect(v.lines.reduce((sum, l) => sum + l.amount, 0)).toBe(0)
|
|
})
|
|
|
|
it('parses space-separated #RAR even when other records use tabs', () => {
|
|
const result = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
|
|
expect(result.stats.fiscalYearStart).toBe('2025-01-01')
|
|
expect(result.stats.fiscalYearEnd).toBe('2025-12-31')
|
|
})
|
|
|
|
it('accepts both unquoted (2025) and quoted (2026) #KTYP values and stores them without surrounding quotes', () => {
|
|
const result2025 = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
|
|
const result2026 = parseSIEFile(BOLLBOK_TAB_2026_SHAPE)
|
|
// Both shapes parse the chart of accounts without complaint
|
|
expect(result2025.accounts.length).toBeGreaterThan(0)
|
|
expect(result2026.accounts.length).toBeGreaterThan(0)
|
|
// accountType should be the bare letter, never the quoted form
|
|
const acc2025 = result2025.accounts.find((a) => a.number === '1510')
|
|
const acc2026 = result2026.accounts.find((a) => a.number === '1510')
|
|
expect(acc2025?.accountType).toBe('T')
|
|
expect(acc2026?.accountType).toBe('T')
|
|
})
|
|
|
|
it('parses tab-separated opening-only file (2026 shape): IB + UB but no vouchers', () => {
|
|
const result = parseSIEFile(BOLLBOK_TAB_2026_SHAPE)
|
|
expect(result.openingBalances).toHaveLength(2)
|
|
expect(result.closingBalances).toHaveLength(2)
|
|
expect(result.vouchers).toHaveLength(0)
|
|
expect(result.stats.fiscalYearStart).toBe('2026-01-01')
|
|
})
|
|
|
|
it('preserves interior tabs inside quoted field values', () => {
|
|
const content = [
|
|
'#FLAGGA\t0',
|
|
'#SIETYP\t4',
|
|
'#FNAMN\t"Has\ttab inside"',
|
|
'#RAR 0 20240101 20241231',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
// The header name should preserve the embedded tab character
|
|
expect(result.header.companyName).toContain('\t')
|
|
expect(result.header.companyName).toBe('Has\ttab inside')
|
|
})
|
|
|
|
it('treats consecutive separator runs (mixed space+tab) as a single separator', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "T"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO\t \t1510\t"Kund"',
|
|
'#IB \t 0\t \t1510 \t50000.00',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
expect(result.accounts.map((a) => a.number)).toContain('1510')
|
|
expect(result.openingBalances[0]).toMatchObject({ account: '1510', amount: 50000 })
|
|
})
|
|
})
|
|
|
|
describe('parseSIEFile: silent-failure diagnostic warnings', () => {
|
|
it('emits a warning when raw #IB lines exist but none could be parsed', () => {
|
|
// Construct a malformed file where #IB lines are present but unparseable.
|
|
// We do this by referencing #IB records with an explicitly empty account
|
|
// field so parsing succeeds tokenization but rejects the record.
|
|
// Simpler approach: rely on a malformed encoding-like situation by
|
|
// providing #IB lines whose account field is whitespace-only.
|
|
//
|
|
// Instead, prove the diagnostic fires by parsing real-world malformed
|
|
// input: lines that look like #IB but are followed by no useful fields.
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "T"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#IB', // Bare #IB with no fields: won't parse
|
|
'#IB',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
expect(result.openingBalances).toHaveLength(0)
|
|
// Per-line warnings ("Belopp saknas i #IB") share severity+tag with the
|
|
// aggregate diagnostic, so match on the diagnostic message specifically.
|
|
const aggregateWarning = result.issues.find(
|
|
(i) => i.severity === 'warning' && i.tag === 'IB' && i.message.includes('#IB-rader hittades')
|
|
)
|
|
expect(aggregateWarning).toBeTruthy()
|
|
expect(aggregateWarning?.message).toContain('2 #IB-rader')
|
|
expect(aggregateWarning?.message).toContain('fältavskiljare och teckenkodning')
|
|
})
|
|
|
|
it('emits a warning when raw #VER lines exist but no voucher was committed', () => {
|
|
// #VER lines parse the header fine (no per-record error) but the surrounding
|
|
// { } block is missing, so currentVoucher is never pushed onto vouchers.
|
|
// This is the "silent loss" case the aggregate diagnostic is designed for.
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "T"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#VER A 1 20240115 "Test1"',
|
|
'#VER A 2 20240116 "Test2"',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
expect(result.vouchers).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'error' && i.tag === 'VER')).toBe(false)
|
|
const verWarning = result.issues.find(
|
|
(i) => i.severity === 'warning' && i.tag === 'VER' && i.message.includes('#VER-rader hittades')
|
|
)
|
|
expect(verWarning).toBeTruthy()
|
|
expect(verWarning?.message).toContain('2 #VER-rader')
|
|
})
|
|
|
|
it('suppresses the aggregate VER warning when a per-record VER error already exists', () => {
|
|
// Bare #VER lines (no fields) emit per-record 'error'-severity issues with
|
|
// tag='VER'. In that case the aggregate "check separator/encoding" hint is
|
|
// misleading: the parser already pinpointed the real problem: so we
|
|
// suppress it.
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "T"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#VER',
|
|
'#VER',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
expect(result.vouchers).toHaveLength(0)
|
|
expect(result.issues.some((i) => i.severity === 'error' && i.tag === 'VER')).toBe(true)
|
|
const aggregateVerWarning = result.issues.find(
|
|
(i) => i.severity === 'warning' && i.tag === 'VER' && i.message.includes('#VER-rader hittades')
|
|
)
|
|
expect(aggregateVerWarning).toBeUndefined()
|
|
})
|
|
|
|
it('does NOT emit IB/VER warnings on a normal file with parsed records', () => {
|
|
const result = parseSIEFile(SIE_WITH_BALANCES)
|
|
const spurious = result.issues.filter(
|
|
(i) => i.severity === 'warning' && (i.tag === 'IB' || i.tag === 'VER')
|
|
)
|
|
expect(spurious).toHaveLength(0)
|
|
})
|
|
|
|
it('does NOT emit warnings on a legitimately empty current-year file (no #IB lines)', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Just opened"',
|
|
'#RAR 0 20260101 20261231',
|
|
'#KONTO 1930 "Bank"',
|
|
].join('\n')
|
|
const result = parseSIEFile(content)
|
|
const spurious = result.issues.filter(
|
|
(i) => i.severity === 'warning' && (i.tag === 'IB' || i.tag === 'VER')
|
|
)
|
|
expect(spurious).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('getEffectiveOpeningBalances: derive IB from #UB -1 (issue #675)', () => {
|
|
// Issue #675 (ro66an): some systems export no #IB 0 records: the current
|
|
// year's IB exists only via the continuity invariant IB(0) = UB(-1).
|
|
const SIE_NO_IB0 = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Continuity AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#RAR -1 20230101 20231231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 2010 "Eget kapital"',
|
|
'#IB -1 1930 9483.08',
|
|
'#UB 0 1930 160406.00',
|
|
'#UB -1 1930 37400.78',
|
|
'#UB -1 2010 -37400.78',
|
|
].join('\n')
|
|
|
|
it('derives current-year IB from #UB -1 when no #IB 0 exists (issue example)', () => {
|
|
const parsed = parseSIEFile(SIE_NO_IB0)
|
|
const { balances, derivedFromPriorYearUB } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(derivedFromPriorYearUB).toBe(true)
|
|
expect(balances).toEqual([
|
|
{ yearIndex: 0, account: '1930', amount: 37400.78 },
|
|
{ yearIndex: 0, account: '2010', amount: -37400.78 },
|
|
])
|
|
})
|
|
|
|
it('never uses #IB -1 (previous year IB) as the derivation source', () => {
|
|
const parsed = parseSIEFile(SIE_NO_IB0)
|
|
const { balances } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(balances.some((b) => b.amount === 9483.08)).toBe(false)
|
|
})
|
|
|
|
it('returns explicit #IB 0 untouched when present: #UB -1 is never merged in', () => {
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#IB 0 1930 37400.78',
|
|
'#IB 0 2010 -37400.78',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const { balances, derivedFromPriorYearUB } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(derivedFromPriorYearUB).toBe(false)
|
|
expect(balances).toHaveLength(2)
|
|
expect(balances.every((b) => b.yearIndex === 0)).toBe(true)
|
|
})
|
|
|
|
it('yields to an opening-balance voucher candidate: no derivation (precedence 2 beats 3)', () => {
|
|
// The voucher serves as IB during import (tagged source_type
|
|
// 'opening_balance'); deriving from #UB -1 as well would double-count.
|
|
// Also the timezone regression test: the voucher date is a local-time
|
|
// Date, so a toISOString()-based comparison would miss the FY start on
|
|
// machines west or east of UTC and wrongly re-enable derivation.
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#VER A 1 20240101 "Ingående balans"',
|
|
'{',
|
|
'#TRANS 1930 {} 37400.78',
|
|
'#TRANS 2010 {} -37400.78',
|
|
'}',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
|
|
expect(hasOpeningBalanceVoucherCandidate(parsed)).toBe(true)
|
|
|
|
const { balances, derivedFromPriorYearUB } = getEffectiveOpeningBalances(parsed)
|
|
expect(derivedFromPriorYearUB).toBe(false)
|
|
expect(balances).toEqual([])
|
|
})
|
|
|
|
it('does not treat a share-capital voucher on FY start as an OB candidate', () => {
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#VER A 1 20240101 "Insättning aktiekapital ingående balans"',
|
|
'{',
|
|
'#TRANS 1930 {} 25000.00',
|
|
'#TRANS 2081 {} -25000.00',
|
|
'}',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
|
|
expect(hasOpeningBalanceVoucherCandidate(parsed)).toBe(false)
|
|
expect(getEffectiveOpeningBalances(parsed).derivedFromPriorYearUB).toBe(true)
|
|
})
|
|
|
|
it('does not treat a voucher with P&L lines as an OB candidate', () => {
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#VER A 1 20240101 "Ingående balans"',
|
|
'{',
|
|
'#TRANS 1930 {} 1000.00',
|
|
'#TRANS 3001 {} -1000.00',
|
|
'}',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
|
|
expect(hasOpeningBalanceVoucherCandidate(parsed)).toBe(false)
|
|
expect(getEffectiveOpeningBalances(parsed).derivedFromPriorYearUB).toBe(true)
|
|
})
|
|
|
|
it('does not treat an IB-worded voucher on another date as an OB candidate', () => {
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#VER A 1 20240315 "Ingående balans"',
|
|
'{',
|
|
'#TRANS 1930 {} 1000.00',
|
|
'#TRANS 2010 {} -1000.00',
|
|
'}',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
|
|
expect(hasOpeningBalanceVoucherCandidate(parsed)).toBe(false)
|
|
expect(getEffectiveOpeningBalances(parsed).derivedFromPriorYearUB).toBe(true)
|
|
})
|
|
|
|
it('filters P&L accounts out of the derived set (result accounts open at zero)', () => {
|
|
const content = [
|
|
SIE_NO_IB0,
|
|
'#UB -1 3001 -5000.00',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const { balances } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(balances.some((b) => b.account === '3001')).toBe(false)
|
|
expect(balances).toHaveLength(2)
|
|
})
|
|
|
|
it('returns nothing when neither #IB 0 nor #UB -1 exists', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "First Year AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#IB -1 1930 9483.08',
|
|
'#UB 0 1930 160406.00',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const { balances, derivedFromPriorYearUB } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(derivedFromPriorYearUB).toBe(false)
|
|
expect(balances).toEqual([])
|
|
})
|
|
|
|
it('carries quantity along on derived balances', () => {
|
|
const content = [
|
|
SIE_NO_IB0.replace('#UB -1 1930 37400.78', '#UB -1 1930 37400.78 5'),
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const { balances } = getEffectiveOpeningBalances(parsed)
|
|
|
|
expect(balances.find((b) => b.account === '1930')?.quantity).toBe(5)
|
|
})
|
|
|
|
describe('validateSIEFile with derived IB', () => {
|
|
it('warns that IB will be derived from #UB -1', () => {
|
|
const parsed = parseSIEFile(SIE_NO_IB0)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.valid).toBe(true)
|
|
expect(validation.warnings.join(' ')).toMatch(/härleds från föregående års utgående balans/i)
|
|
})
|
|
|
|
it('runs the imbalance check on the derived set (unallocated prior-year result)', () => {
|
|
const content = [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
'#FNAMN "Obalans AB"',
|
|
'#RAR 0 20240101 20241231',
|
|
'#KONTO 1930 "Företagskonto"',
|
|
// Derived IB sums to +37400.78: prior-year result never allocated
|
|
'#UB -1 1930 37400.78',
|
|
].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.warnings.join(' ')).toMatch(/balanserar inte/i)
|
|
expect(validation.warnings.join(' ')).toMatch(/37400\.78/)
|
|
})
|
|
|
|
it('does not warn about derivation when explicit #IB 0 exists', () => {
|
|
const content = [SIE_NO_IB0, '#IB 0 1930 37400.78', '#IB 0 2010 -37400.78'].join('\n')
|
|
const parsed = parseSIEFile(content)
|
|
const validation = validateSIEFile(parsed)
|
|
|
|
expect(validation.warnings.join(' ')).not.toMatch(/härleds från föregående års utgående balans/i)
|
|
})
|
|
})
|
|
})
|
|
|
|
// --- #RAR validation: prior-year records and malformed indexes ---
|
|
//
|
|
// The 18-month BFL 3 kap. cap is enforced as a hard refusal for #RAR 0 at
|
|
// import time (ensureFiscalPeriod). The parser layer validates EVERY #RAR
|
|
// record, including prior years (-1, -2, ...): malformed records (bad index,
|
|
// invalid dates, reversed dates) are reported and skipped, an over-long span
|
|
// is reported as a warning but kept so the import layer's precise error for
|
|
// year 0 still sees the real dates.
|
|
|
|
describe('parseSIEFile: #RAR record validation', () => {
|
|
const HEADER = ['#FLAGGA 0', '#SIETYP 4', '#FNAMN "Test AB"']
|
|
|
|
it('keeps parsing a well-formed multi-year file exactly as before', () => {
|
|
const content = [
|
|
...HEADER,
|
|
'#RAR 0 20240101 20241231',
|
|
'#RAR -1 20230101 20231231',
|
|
'#RAR -2 20220101 20221231',
|
|
].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.header.fiscalYears).toHaveLength(3)
|
|
expect(result.header.fiscalYears.map((fy) => fy.yearIndex)).toEqual([0, -1, -2])
|
|
expect(result.header.fiscalYears[1].start).toBe('2023-01-01')
|
|
expect(result.header.fiscalYears[1].end).toBe('2023-12-31')
|
|
expect(result.issues.filter((i) => i.tag === 'RAR')).toHaveLength(0)
|
|
})
|
|
|
|
it('accepts an 18-month förlängt räkenskapsår without warnings', () => {
|
|
const content = [...HEADER, '#RAR 0 20230701 20241231'].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.header.fiscalYears).toHaveLength(1)
|
|
expect(result.issues.filter((i) => i.tag === 'RAR')).toHaveLength(0)
|
|
})
|
|
|
|
it('skips a #RAR record whose year index does not parse to an integer', () => {
|
|
const content = [...HEADER, '#RAR 0 20240101 20241231', '#RAR abc 20230101 20231231'].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.header.fiscalYears).toHaveLength(1)
|
|
expect(result.header.fiscalYears[0].yearIndex).toBe(0)
|
|
expect(result.issues.some((i) => i.message.includes('Ogiltigt årsindex'))).toBe(true)
|
|
})
|
|
|
|
it('skips a prior-year #RAR with an invalid calendar date', () => {
|
|
const content = [...HEADER, '#RAR 0 20240101 20241231', '#RAR -1 20230101 20230230'].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.header.fiscalYears).toHaveLength(1)
|
|
expect(result.issues.some((i) => i.message.includes('Invalid fiscal year dates'))).toBe(true)
|
|
})
|
|
|
|
it('skips a prior-year #RAR whose end date precedes its start date', () => {
|
|
const content = [...HEADER, '#RAR 0 20240101 20241231', '#RAR -1 20231231 20230101'].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.header.fiscalYears).toHaveLength(1)
|
|
expect(
|
|
result.issues.some((i) => i.message.includes('ligger före startdatumet'))
|
|
).toBe(true)
|
|
})
|
|
|
|
it('warns about a 24-month prior-year #RAR but keeps the record', () => {
|
|
const content = [...HEADER, '#RAR 0 20240101 20241231', '#RAR -1 20220101 20231231'].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.header.fiscalYears).toHaveLength(2)
|
|
expect(
|
|
result.issues.some(
|
|
(i) => i.message.includes('24 månader') && i.message.includes('BFL 3 kap.')
|
|
)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('warns about an over-long current-year #RAR but keeps the record for the import-layer refusal', () => {
|
|
const content = [...HEADER, '#RAR 0 20230101 20241231'].join('\n')
|
|
|
|
const result = parseSIEFile(content)
|
|
expect(result.header.fiscalYears).toHaveLength(1)
|
|
expect(result.header.fiscalYears[0].start).toBe('2023-01-01')
|
|
expect(result.header.fiscalYears[0].end).toBe('2024-12-31')
|
|
expect(result.issues.some((i) => i.message.includes('högst 18 månader'))).toBe(true)
|
|
})
|
|
})
|