b556475b01
The /sie-data route parsed only the newest fiscal year's SIE file for the import preview and the returned SIEData.parsed. Mid-year provider exports have few or zero vouchers in the newest year, so the first real Fortnox migration (3 fiscal years, 4153 vouchers) previewed "0 verifikationer" and drew an almost-empty migration theater while the import itself landed all 4153 vouchers from the older files. - New mergeParsedSIEFiles (lib/import/sie-merge.ts): pure, browser-clean whole-dataset merge (accounts union first-wins, vouchers concatenated, fiscal years union oldest-first re-indexed newest=0, balances and issues concatenated, dimensions deduped), with unit tests. - /sie-data parses each file exactly once, builds the preview from the merged parse and returns parsed: merged; response shape unchanged. Validation stays newest-file-only so no previously accepted dataset is newly rejected. - /preview drops latestOnly and computes sieStats from the merged parse: the connect step's "Hittade X konton och Y verifikationer" line renders from THESE stats, so this is where the founder-visible count was lying. - The migration theater spreads its account waves across ~10s and births an additional wave on each real step label during the SIE phase (progress <= 55), through a shared rate-limited gate, so the canvas keeps performing over a multi-minute run. Narration labels and progress remain the wizard's real values; reduced motion unchanged. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
176 lines
6.4 KiB
TypeScript
176 lines
6.4 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { parseSIEFile } from '../sie-parser'
|
|
import { mergeParsedSIEFiles } from '../sie-merge'
|
|
import { generateImportPreview } from '../sie-import'
|
|
import { buildTheaterModel } from '../theater-model'
|
|
import type { AccountMapping } from '../types'
|
|
|
|
/**
|
|
* One provider-style SIE 4 export per fiscal year, like the files
|
|
* fetchProviderSieFiles returns (oldest first).
|
|
*/
|
|
function sieYear(opts: {
|
|
year: number
|
|
companyName?: string
|
|
konto?: string[]
|
|
vouchers?: string[][]
|
|
extra?: string[]
|
|
}): string {
|
|
const { year, companyName = 'One Punkt Com AB', konto = [], vouchers = [], extra = [] } = opts
|
|
return [
|
|
'#FLAGGA 0',
|
|
'#SIETYP 4',
|
|
`#FNAMN "${companyName}"`,
|
|
'#ORGNR 5566778899',
|
|
`#RAR 0 ${year}0101 ${year}1231`,
|
|
`#RAR -1 ${year - 1}0101 ${year - 1}1231`,
|
|
'#KONTO 1930 "Företagskonto"',
|
|
'#KONTO 2641 "Ingående moms"',
|
|
...konto,
|
|
...extra,
|
|
...vouchers.flat(),
|
|
].join('\n')
|
|
}
|
|
|
|
function voucher(num: number, date: string, description: string, account: string, amount = 100): string[] {
|
|
return [
|
|
`#VER A ${num} ${date} "${description}"`,
|
|
'{',
|
|
`#TRANS ${account} {} ${amount}.00`,
|
|
`#TRANS 1930 {} -${amount}.00`,
|
|
'}',
|
|
]
|
|
}
|
|
|
|
describe('mergeParsedSIEFiles', () => {
|
|
it('throws on an empty list and passes a single file through unchanged', () => {
|
|
expect(() => mergeParsedSIEFiles([])).toThrow()
|
|
const parsed = parseSIEFile(sieYear({ year: 2026 }))
|
|
expect(mergeParsedSIEFiles([parsed])).toBe(parsed)
|
|
})
|
|
|
|
it('merges distinct years: vouchers concatenated, fiscal years union oldest first', () => {
|
|
const y2024 = parseSIEFile(
|
|
sieYear({
|
|
year: 2024,
|
|
konto: ['#KONTO 4010 "Material"'],
|
|
vouchers: [voucher(1, '20240315', 'Bolagsverket', '4010'), voucher(2, '20240601', 'Loopia', '4010')],
|
|
}),
|
|
)
|
|
const y2025 = parseSIEFile(
|
|
sieYear({
|
|
year: 2025,
|
|
konto: ['#KONTO 3041 "Försäljning tjänster"'],
|
|
vouchers: [voucher(1, '20250210', 'Kund AB', '3041', 250)],
|
|
}),
|
|
)
|
|
const y2026 = parseSIEFile(sieYear({ year: 2026 }))
|
|
|
|
const merged = mergeParsedSIEFiles([y2024, y2025, y2026])
|
|
|
|
expect(merged.vouchers).toHaveLength(3)
|
|
expect(merged.stats.totalVouchers).toBe(3)
|
|
expect(merged.stats.totalTransactionLines).toBe(6)
|
|
// Union of #RAR periods: 2023 (from y2024's #RAR -1) through 2026,
|
|
// oldest first, newest re-indexed to yearIndex 0.
|
|
expect(merged.header.fiscalYears.map((fy) => fy.start)).toEqual([
|
|
'2023-01-01',
|
|
'2024-01-01',
|
|
'2025-01-01',
|
|
'2026-01-01',
|
|
])
|
|
expect(merged.header.fiscalYears.map((fy) => fy.yearIndex)).toEqual([-3, -2, -1, 0])
|
|
expect(merged.stats.fiscalYearStart).toBe('2026-01-01')
|
|
expect(merged.stats.fiscalYearEnd).toBe('2026-12-31')
|
|
// Accounts are a union across files.
|
|
expect(merged.accounts.map((a) => a.number).sort()).toEqual(['1930', '2641', '3041', '4010'])
|
|
expect(merged.stats.totalAccounts).toBe(4)
|
|
expect(merged.header.companyName).toBe('One Punkt Com AB')
|
|
expect(merged.header.orgNumber).toBe('5566778899')
|
|
})
|
|
|
|
it('dedupes duplicate account numbers: first name wins, later files fill missing metadata', () => {
|
|
const older = parseSIEFile(
|
|
sieYear({ year: 2024, konto: ['#KONTO 4010 "Material och varor"'] }),
|
|
)
|
|
const newer = parseSIEFile(
|
|
sieYear({
|
|
year: 2025,
|
|
konto: ['#KONTO 4010 "Inköp material"'],
|
|
extra: ['#SRU 4010 7512'],
|
|
}),
|
|
)
|
|
|
|
const merged = mergeParsedSIEFiles([older, newer])
|
|
|
|
const acc = merged.accounts.filter((a) => a.number === '4010')
|
|
expect(acc).toHaveLength(1)
|
|
expect(acc[0].name).toBe('Material och varor')
|
|
// The older file had no #SRU for 4010: the newer file's code fills it in.
|
|
expect(acc[0].sruCode).toBe('7512')
|
|
})
|
|
|
|
it('an empty newest file plus a rich older file yields a rich merge', () => {
|
|
const rich = parseSIEFile(
|
|
sieYear({
|
|
year: 2025,
|
|
konto: ['#KONTO 4010 "Material"', '#KONTO 3041 "Försäljning tjänster"'],
|
|
vouchers: [
|
|
voucher(1, '20250110', 'Loopia', '4010'),
|
|
voucher(2, '20250211', 'Loopia', '4010'),
|
|
voucher(3, '20250320', 'Kund AB', '3041', 900),
|
|
],
|
|
extra: ['#IB 0 1930 5000.00', '#IB 0 2081 -5000.00'],
|
|
}),
|
|
)
|
|
// Mid-year export: the newest fiscal year has accounts but zero vouchers.
|
|
const empty = parseSIEFile(sieYear({ year: 2026 }))
|
|
|
|
const merged = mergeParsedSIEFiles([rich, empty])
|
|
|
|
expect(merged.stats.totalVouchers).toBe(3)
|
|
expect(merged.openingBalances).toHaveLength(2)
|
|
|
|
// The preview built from the merge reports the whole dataset, not the
|
|
// empty newest file (the "Hittade 740 konton och 0 verifikationer" bug).
|
|
const mappings: AccountMapping[] = []
|
|
const preview = generateImportPreview(merged, mappings)
|
|
expect(preview.voucherCount).toBe(3)
|
|
// 1930, 2641, 4010, 3041 from #KONTO plus 2081 auto-registered from #IB.
|
|
expect(preview.accountCount).toBe(5)
|
|
|
|
// The theater model gets the full history: both years, real accounts,
|
|
// a counterparty seen twice.
|
|
const model = buildTheaterModel(merged)
|
|
expect(model.totalVouchers).toBe(3)
|
|
expect(model.years.length).toBeGreaterThanOrEqual(2)
|
|
expect(model.accounts.length).toBeGreaterThan(0)
|
|
expect(model.counterparties.map((c) => c.name)).toContain('Loopia')
|
|
})
|
|
|
|
it('keeps the most common non-empty company name and concatenates issues', () => {
|
|
const a = parseSIEFile(sieYear({ year: 2024, companyName: 'Gamla Namnet AB' }))
|
|
const b = parseSIEFile(sieYear({ year: 2025, companyName: 'One Punkt Com AB' }))
|
|
const c = parseSIEFile(sieYear({ year: 2026, companyName: 'One Punkt Com AB' }))
|
|
|
|
const merged = mergeParsedSIEFiles([a, b, c])
|
|
expect(merged.header.companyName).toBe('One Punkt Com AB')
|
|
expect(merged.issues).toEqual([...a.issues, ...b.issues, ...c.issues])
|
|
})
|
|
|
|
it('dedupes dimension registrations by number and (number, code)', () => {
|
|
const dims = [
|
|
'#DIM 6 "Projekt"',
|
|
'#OBJEKT 6 "P1" "Bryggan"',
|
|
]
|
|
const a = parseSIEFile(sieYear({ year: 2024, extra: dims }))
|
|
const b = parseSIEFile(
|
|
sieYear({ year: 2025, extra: [...dims, '#OBJEKT 6 "P2" "Kajen"'] }),
|
|
)
|
|
|
|
const merged = mergeParsedSIEFiles([a, b])
|
|
expect(merged.dimensions).toHaveLength(1)
|
|
expect(merged.dimensionValues.map((v) => v.code).sort()).toEqual(['P1', 'P2'])
|
|
})
|
|
})
|