feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import (#2458)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9a8291f454
commit
a84d2723e0
@@ -2,6 +2,17 @@ import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
|
||||
// A completed sie_imports row the correction history can point at.
|
||||
async function insertSieImport(companyId: string, userId: string): Promise<string> {
|
||||
const { rows } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.sie_imports (company_id, user_id, filename, file_hash, sie_type, status)
|
||||
VALUES ($1, $2, 'fixture.se', md5(gen_random_uuid()::text), 4, 'completed')
|
||||
RETURNING id`,
|
||||
[companyId, userId],
|
||||
)
|
||||
return rows[0]!.id
|
||||
}
|
||||
|
||||
describe('import_sie_journal_entries RPC', () => {
|
||||
it('rolls back the journal entry header when a line insert fails', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
@@ -188,4 +199,176 @@ describe('import_sie_journal_entries RPC', () => {
|
||||
),
|
||||
).rejects.toThrow(/does not belong to company/i)
|
||||
})
|
||||
// Migration 20260909132618 (#2427): #BTRANS/#RTRANS history rides on the
|
||||
// payload as `corrections` and lands as ONE rättelselogg row per voucher,
|
||||
// source='sie_import'. The ledger insert is unchanged.
|
||||
it('writes source-system correction history to the rättelselogg without touching the lines', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
const sieImportId = await insertSieImport(companyId, userId)
|
||||
|
||||
const payload = [
|
||||
{
|
||||
sourceId: 'A7',
|
||||
series: 'A',
|
||||
date: '2026-03-01',
|
||||
description: 'Corrected in source',
|
||||
sourceSeries: 'A',
|
||||
sourceNumber: 7,
|
||||
sourceType: 'import',
|
||||
sieImportId,
|
||||
corrections: {
|
||||
struck: [
|
||||
{ account_number: '5010', debit_amount: 1200, credit_amount: 0, line_description: 'Lokalhyra', sort_order: 0, signature: 'EL' },
|
||||
],
|
||||
added: [
|
||||
{ account_number: '6540', debit_amount: 1200, credit_amount: 0, line_description: 'IT', sort_order: 0, signature: 'AB' },
|
||||
],
|
||||
signature: 'EL',
|
||||
},
|
||||
lines: [
|
||||
{ account_number: '6540', debit_amount: 1200, credit_amount: 0, currency: 'SEK', line_description: 'IT', sort_order: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 1200, currency: 'SEK', line_description: 'Bank', sort_order: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
sourceId: 'A8',
|
||||
series: 'A',
|
||||
date: '2026-03-02',
|
||||
description: 'Plain voucher',
|
||||
sourceSeries: 'A',
|
||||
sourceNumber: 8,
|
||||
sourceType: 'import',
|
||||
lines: [
|
||||
{ account_number: '5010', debit_amount: 100, credit_amount: 0, currency: 'SEK', line_description: null, sort_order: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 100, currency: 'SEK', line_description: null, sort_order: 1 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const res = await getPool().query<{ import_sie_journal_entries: { inserted_entries: Array<{ id: string; sourceId: string }> } }>(
|
||||
`SELECT public.import_sie_journal_entries($1::uuid, $2::uuid, $3::uuid, $4::jsonb)`,
|
||||
[companyId, userId, fiscalPeriodId, JSON.stringify(payload)],
|
||||
)
|
||||
const inserted = res.rows[0]!.import_sie_journal_entries.inserted_entries
|
||||
expect(inserted).toHaveLength(2)
|
||||
const correctedId = inserted.find((e) => e.sourceId === 'A7')!.id
|
||||
|
||||
// Ledger: exactly the #TRANS rows, posted and balanced. History never
|
||||
// becomes a line.
|
||||
const lines = await getPool().query<{ account_number: string; debit_amount: string; credit_amount: string }>(
|
||||
`SELECT account_number, debit_amount::text, credit_amount::text
|
||||
FROM public.journal_entry_lines
|
||||
WHERE journal_entry_id = $1
|
||||
ORDER BY sort_order`,
|
||||
[correctedId],
|
||||
)
|
||||
expect(lines.rows.map((r) => r.account_number)).toEqual(['6540', '1930'])
|
||||
|
||||
// One log row for the corrected voucher, none for the plain one.
|
||||
const logs = await getPool().query<{
|
||||
journal_entry_id: string
|
||||
rattelse_type: string
|
||||
source: string
|
||||
sie_import_id: string | null
|
||||
external_signature: string | null
|
||||
actor: string | null
|
||||
struck_lines: Array<Record<string, unknown>>
|
||||
added_lines: Array<Record<string, unknown>>
|
||||
}>(
|
||||
`SELECT journal_entry_id, rattelse_type, source, sie_import_id, external_signature, actor, struck_lines, added_lines
|
||||
FROM public.journal_entry_rattelse_log
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(logs.rows).toHaveLength(1)
|
||||
const log = logs.rows[0]!
|
||||
expect(log).toMatchObject({
|
||||
journal_entry_id: correctedId,
|
||||
rattelse_type: 'lines',
|
||||
source: 'sie_import',
|
||||
sie_import_id: sieImportId,
|
||||
external_signature: 'EL',
|
||||
actor: null,
|
||||
})
|
||||
// Snapshot shape matches what correct_entry_lines_inline stores, so the
|
||||
// verifikat page renders both the same way.
|
||||
expect(log.struck_lines).toHaveLength(1)
|
||||
expect(log.struck_lines[0]).toMatchObject({
|
||||
journal_entry_id: correctedId,
|
||||
account_number: '5010',
|
||||
debit_amount: 1200,
|
||||
credit_amount: 0,
|
||||
line_description: 'Lokalhyra',
|
||||
sort_order: 0,
|
||||
currency: 'SEK',
|
||||
signature: 'EL',
|
||||
})
|
||||
expect(typeof log.struck_lines[0]!.id).toBe('string')
|
||||
// Per-line signatures survive: the added row names a different corrector.
|
||||
expect(log.added_lines[0]).toMatchObject({ account_number: '6540', debit_amount: 1200, signature: 'AB' })
|
||||
|
||||
// The log stays WORM for imported rows too.
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.journal_entry_rattelse_log WHERE company_id = $1`, [companyId]),
|
||||
).rejects.toThrow(/oföränderlig/)
|
||||
})
|
||||
|
||||
it('rejects correction history whose sie_import_id belongs to another company', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
const other = await seedCompany()
|
||||
const foreignImportId = await insertSieImport(other.companyId, other.userId)
|
||||
|
||||
const payload = [
|
||||
{
|
||||
sourceId: 'A1',
|
||||
series: 'A',
|
||||
date: '2026-03-01',
|
||||
description: 'Foreign provenance',
|
||||
sourceSeries: 'A',
|
||||
sourceNumber: 1,
|
||||
sourceType: 'import',
|
||||
sieImportId: foreignImportId,
|
||||
corrections: {
|
||||
struck: [{ account_number: '5010', debit_amount: 100, credit_amount: 0, line_description: null, sort_order: 0 }],
|
||||
added: [],
|
||||
signature: null,
|
||||
},
|
||||
lines: [
|
||||
{ account_number: '6540', debit_amount: 100, credit_amount: 0, currency: 'SEK', line_description: null, sort_order: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 100, currency: 'SEK', line_description: null, sort_order: 1 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`SELECT public.import_sie_journal_entries($1::uuid, $2::uuid, $3::uuid, $4::jsonb)`,
|
||||
[companyId, userId, fiscalPeriodId, JSON.stringify(payload)],
|
||||
),
|
||||
).rejects.toThrow(/does not belong to company/)
|
||||
|
||||
// Fail closed: the whole import rolled back, nothing posted, no log row.
|
||||
const posted = await getPool().query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM public.journal_entries WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(posted.rows[0]!.count).toBe('0')
|
||||
const logs = await getPool().query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM public.journal_entry_rattelse_log WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
expect(logs.rows[0]!.count).toBe('0')
|
||||
})
|
||||
|
||||
it('rejects an imported history row that claims an actor (provenance check)', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.journal_entry_rattelse_log
|
||||
(company_id, journal_entry_id, rattelse_type, struck_lines, added_lines, actor, source)
|
||||
VALUES ($1, gen_random_uuid(), 'lines', '[]', '[]', gen_random_uuid(), 'sie_import')`,
|
||||
[companyId],
|
||||
),
|
||||
).rejects.toThrow(/journal_entry_rattelse_log_import_provenance_check/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1220,6 +1220,78 @@ describe('importVouchers: per-voucher series preservation', () => {
|
||||
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({
|
||||
|
||||
@@ -145,6 +145,41 @@ const SIE_EMPTY_SERIES = [
|
||||
'}',
|
||||
].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',
|
||||
@@ -445,6 +480,49 @@ describe('parseSIEFile', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
FiscalYearPrecheck,
|
||||
SIEImport,
|
||||
MigrationDocumentation,
|
||||
SIETransactionLine,
|
||||
} from './types'
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
import { roundOre } from '@/lib/money'
|
||||
@@ -1257,7 +1258,12 @@ export async function importVouchers(
|
||||
fiscalPeriodId: string,
|
||||
parsed: ParsedSIEFile,
|
||||
accountMap: Map<string, string>,
|
||||
defaultSeries: string
|
||||
defaultSeries: string,
|
||||
// The sie_imports row this run belongs to. Stamped on the correction
|
||||
// history rows (journal_entry_rattelse_log.sie_import_id) so the log can
|
||||
// be traced back to the file that carried it. Null in callers that have
|
||||
// no import record (tests, legacy paths).
|
||||
sieImportId: string | null = null
|
||||
): Promise<{
|
||||
created: number
|
||||
ids: string[]
|
||||
@@ -1339,8 +1345,42 @@ export async function importVouchers(
|
||||
line_description: string | null
|
||||
dimensions?: Record<string, string>
|
||||
}[]
|
||||
// Source-system correction history (#BTRANS struck / #RTRANS added, see
|
||||
// SIEVoucherCorrections). Persisted by the RPC as a journal_entry_rattelse_log
|
||||
// row with source='sie_import'; never booked as lines.
|
||||
corrections?: {
|
||||
struck: CorrectionLineSnapshot[]
|
||||
added: CorrectionLineSnapshot[]
|
||||
signature: string | null
|
||||
}
|
||||
}
|
||||
|
||||
interface CorrectionLineSnapshot {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
line_description: string | null
|
||||
sort_order: number
|
||||
/** SIE `sign` of this row: who removed/added it in the source system. */
|
||||
signature: string | null
|
||||
}
|
||||
|
||||
// History keeps the source account when it is unmapped: the row is audit
|
||||
// trail, not a booking, and a rewritten account would misdescribe what the
|
||||
// source system showed. Zero-amount rows carry no information and are dropped
|
||||
// like their #TRANS counterparts.
|
||||
const toCorrectionSnapshots = (sourceLines: SIETransactionLine[]): CorrectionLineSnapshot[] =>
|
||||
sourceLines
|
||||
.filter((line) => line.amount !== 0)
|
||||
.map((line, index) => ({
|
||||
account_number: accountMap.get(line.account) ?? line.account,
|
||||
debit_amount: line.amount > 0 ? Math.round(line.amount * 100) / 100 : 0,
|
||||
credit_amount: line.amount < 0 ? Math.round(Math.abs(line.amount) * 100) / 100 : 0,
|
||||
line_description: line.description || null,
|
||||
sort_order: index,
|
||||
signature: line.signature?.trim() || null,
|
||||
}))
|
||||
|
||||
const preparedVouchers: PreparedVoucher[] = []
|
||||
|
||||
// A SIE file represents the opening balance either as #IB records (handled
|
||||
@@ -1507,6 +1547,22 @@ export async function importVouchers(
|
||||
OPENING_BALANCE_DESCRIPTION_RE.test(voucher.description || '') &&
|
||||
!SHARE_CAPITAL_DESCRIPTION_RE.test(voucher.description || '')
|
||||
|
||||
let corrections: PreparedVoucher['corrections']
|
||||
if (voucher.corrections) {
|
||||
const struck = toCorrectionSnapshots(voucher.corrections.struck)
|
||||
const added = toCorrectionSnapshots(voucher.corrections.added)
|
||||
if (struck.length > 0 || added.length > 0) {
|
||||
// SIE 4B: `sign` on #BTRANS/#RTRANS names who removed or added the row.
|
||||
// Each snapshot keeps its own; this voucher-level summary is the first
|
||||
// one, for the log row's external_signature column.
|
||||
const signature =
|
||||
[...voucher.corrections.struck, ...voucher.corrections.added]
|
||||
.map((line) => line.signature?.trim())
|
||||
.find((sig) => !!sig) ?? null
|
||||
corrections = { struck, added, signature }
|
||||
}
|
||||
}
|
||||
|
||||
preparedVouchers.push({
|
||||
sourceId: voucherId,
|
||||
series: resolvedSeries,
|
||||
@@ -1516,6 +1572,7 @@ export async function importVouchers(
|
||||
sourceNumber: rawSourceNumber,
|
||||
sourceType: isLikelyOpeningBalance ? 'opening_balance' : 'import',
|
||||
lines,
|
||||
...(corrections ? { corrections } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1572,6 +1629,7 @@ export async function importVouchers(
|
||||
sourceSeries: voucher.sourceSeries,
|
||||
sourceNumber: voucher.sourceNumber,
|
||||
sourceType: voucher.sourceType,
|
||||
...(voucher.corrections ? { corrections: voucher.corrections, sieImportId } : {}),
|
||||
lines: voucher.lines.map((line, lineIndex) => ({
|
||||
account_number: line.account_number,
|
||||
account_id: accountIdMap.get(line.account_number) || null,
|
||||
@@ -2914,7 +2972,8 @@ export async function executeSIEImport(
|
||||
result.fiscalPeriodId,
|
||||
parsed,
|
||||
accountMap,
|
||||
defaultSeries
|
||||
defaultSeries,
|
||||
result.importId
|
||||
)
|
||||
|
||||
result.journalEntriesCreated += voucherResults.created
|
||||
|
||||
+111
-48
@@ -396,6 +396,67 @@ function addIssue(
|
||||
issues.push({ severity, line, message, tag })
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the fields of a #TRANS / #RTRANS / #BTRANS record (identical layout):
|
||||
* #TAG accountNumber {objectList} amount [date] [description] [quantity] [signature]
|
||||
* Returns null (after reporting) when the amount is missing.
|
||||
*/
|
||||
function parseTransactionLine(
|
||||
fields: string[],
|
||||
tag: string,
|
||||
issues: ParseIssue[],
|
||||
lineNum: number
|
||||
): SIETransactionLine | null {
|
||||
// Parse account and capture the object list (in braces)
|
||||
let fieldIndex = 1
|
||||
const account = parseStringField(fields[fieldIndex++])
|
||||
|
||||
// Object list (single field thanks to brace-aware splitting):
|
||||
// dimension tags like {1 "KS01" 6 "P001"}. Parsed onto the line so
|
||||
// import is lossless (dimensions plan PR5).
|
||||
let objectListRaw: string | null = null
|
||||
if (fields[fieldIndex]?.startsWith('{')) {
|
||||
objectListRaw = fields[fieldIndex]
|
||||
fieldIndex++
|
||||
}
|
||||
|
||||
const transAmountStr = fields[fieldIndex]
|
||||
if (!transAmountStr || transAmountStr.trim() === '') {
|
||||
addIssue(issues, 'warning', lineNum, `Belopp saknas i #${tag}: raden hoppas över`, tag)
|
||||
return null
|
||||
}
|
||||
|
||||
const amount = parseNumberField(fields[fieldIndex++])
|
||||
|
||||
const transLine: SIETransactionLine = {
|
||||
account,
|
||||
amount,
|
||||
}
|
||||
|
||||
if (objectListRaw) {
|
||||
const dims = parseObjectList(objectListRaw, issues, lineNum)
|
||||
if (dims) {
|
||||
transLine.dimensions = dims
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.date = parseSIEDate(parseStringField(fields[fieldIndex++])) || undefined
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.description = parseStringField(fields[fieldIndex++])
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.quantity = parseNumberField(fields[fieldIndex++])
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.signature = parseStringField(fields[fieldIndex++])
|
||||
}
|
||||
|
||||
return transLine
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a SIE file content string
|
||||
*/
|
||||
@@ -432,6 +493,24 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
// Track current voucher being parsed (inside #VER { ... })
|
||||
let currentVoucher: SIEVoucher | null = null
|
||||
|
||||
// SIE 4B: an #RTRANS row must be immediately followed by an identical
|
||||
// #TRANS row (the twin older readers use). Remembered here so the twin
|
||||
// can be verified; a missing twin is reported, since the final state is
|
||||
// built from #TRANS only and would silently lack that line.
|
||||
let pendingRtrans: { line: SIETransactionLine; lineNum: number } | null = null
|
||||
|
||||
const reportMissingRtransTwin = (): void => {
|
||||
if (!pendingRtrans) return
|
||||
addIssue(
|
||||
issues,
|
||||
'warning',
|
||||
pendingRtrans.lineNum,
|
||||
`#RTRANS ${pendingRtrans.line.account} ${pendingRtrans.line.amount.toFixed(2)} följs inte av en identisk #TRANS-rad: rättelseraden ingår inte i verifikatets slutliga rader`,
|
||||
'RTRANS'
|
||||
)
|
||||
pendingRtrans = null
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNum = i + 1
|
||||
const line = lines[i].trim()
|
||||
@@ -441,6 +520,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
|
||||
// Handle voucher block end
|
||||
if (line === '}') {
|
||||
reportMissingRtransTwin()
|
||||
if (currentVoucher) {
|
||||
// Validate voucher balance
|
||||
const total = currentVoucher.lines.reduce((sum, l) => sum + l.amount, 0)
|
||||
@@ -473,6 +553,10 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
const fields = splitSIELine(line)
|
||||
const tag = fields[0].substring(1).toUpperCase()
|
||||
|
||||
if (pendingRtrans && tag !== 'TRANS') {
|
||||
reportMissingRtransTwin()
|
||||
}
|
||||
|
||||
try {
|
||||
switch (tag) {
|
||||
case 'FLAGGA':
|
||||
@@ -708,71 +792,50 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
case 'TRANS':
|
||||
case 'RTRANS':
|
||||
case 'BTRANS': {
|
||||
// #TRANS = final transaction lines (the current state of the voucher)
|
||||
// #RTRANS = supplementary/corrected transaction (must be followed by identical #TRANS for backward compat)
|
||||
// #BTRANS = removed/cancelled transaction (programs not understanding BTRANS simply ignore it)
|
||||
// #TRANS = the voucher's final lines (its current state).
|
||||
// #BTRANS = "removed transaction item": a line struck in the source
|
||||
// system after posting (how the voucher looked before the rättelse).
|
||||
// #RTRANS = "supplementary transaction item": a line added by a
|
||||
// rättelse. Per SIE 4B it is always immediately followed by an
|
||||
// identical #TRANS row, so the line is ALSO in the final state.
|
||||
//
|
||||
// When a voucher has been corrected, Fortnox/Visma emit all three types.
|
||||
// Only #TRANS represents the final voucher state; #RTRANS and #BTRANS are
|
||||
// supplementary history. We skip RTRANS/BTRANS to avoid double-counting
|
||||
// which would make balanced vouchers appear unbalanced.
|
||||
// When a voucher has been corrected, Fortnox/Visma emit all three
|
||||
// types. Only #TRANS is booked (summing all three would double-count
|
||||
// and make balanced vouchers look unbalanced, #63). #BTRANS/#RTRANS
|
||||
// are kept aside as `corrections`: the correction history behind the
|
||||
// verifikat, persisted by the import into the rättelselogg (#2427).
|
||||
if (!currentVoucher) {
|
||||
addIssue(issues, 'error', lineNum, `#${tag} utanför verifikationsblock (#VER): filen kan vara skadad`, tag)
|
||||
break
|
||||
}
|
||||
|
||||
// Skip RTRANS/BTRANS: they are correction audit trail, not final state
|
||||
if (tag === 'RTRANS' || tag === 'BTRANS') {
|
||||
const transLine = parseTransactionLine(fields, tag, issues, lineNum)
|
||||
if (!transLine) {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse account and capture the object list (in braces)
|
||||
let fieldIndex = 1
|
||||
const account = parseStringField(fields[fieldIndex++])
|
||||
|
||||
// Object list (single field thanks to brace-aware splitting):
|
||||
// dimension tags like {1 "KS01" 6 "P001"}. Parsed onto the line so
|
||||
// import is lossless (dimensions plan PR5).
|
||||
let objectListRaw: string | null = null
|
||||
if (fields[fieldIndex]?.startsWith('{')) {
|
||||
objectListRaw = fields[fieldIndex]
|
||||
fieldIndex++
|
||||
}
|
||||
|
||||
const transAmountStr = fields[fieldIndex]
|
||||
if (!transAmountStr || transAmountStr.trim() === '') {
|
||||
addIssue(issues, 'warning', lineNum, `Belopp saknas i #${tag}: raden hoppas över`, tag)
|
||||
if (tag === 'BTRANS') {
|
||||
const corrections = (currentVoucher.corrections ??= { struck: [], added: [] })
|
||||
corrections.struck.push(transLine)
|
||||
break
|
||||
}
|
||||
|
||||
const amount = parseNumberField(fields[fieldIndex++])
|
||||
|
||||
const transLine: SIETransactionLine = {
|
||||
account,
|
||||
amount,
|
||||
if (tag === 'RTRANS') {
|
||||
const corrections = (currentVoucher.corrections ??= { struck: [], added: [] })
|
||||
corrections.added.push(transLine)
|
||||
pendingRtrans = { line: transLine, lineNum }
|
||||
break
|
||||
}
|
||||
|
||||
if (objectListRaw) {
|
||||
const dims = parseObjectList(objectListRaw, issues, lineNum)
|
||||
if (dims) {
|
||||
transLine.dimensions = dims
|
||||
if (pendingRtrans) {
|
||||
const twin = pendingRtrans.line
|
||||
if (twin.account === transLine.account && Math.abs(twin.amount - transLine.amount) < 0.005) {
|
||||
pendingRtrans = null
|
||||
} else {
|
||||
reportMissingRtransTwin()
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.date = parseSIEDate(parseStringField(fields[fieldIndex++])) || undefined
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.description = parseStringField(fields[fieldIndex++])
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.quantity = parseNumberField(fields[fieldIndex++])
|
||||
}
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.signature = parseStringField(fields[fieldIndex++])
|
||||
}
|
||||
|
||||
currentVoucher.lines.push(transLine)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -109,6 +109,23 @@ export interface SIEDimensionValue {
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Correction history carried by a #VER (SIE 4B #BTRANS / #RTRANS).
|
||||
*
|
||||
* `struck` = #BTRANS rows: lines removed in the source system after
|
||||
* posting (how the voucher looked before the correction).
|
||||
* `added` = #RTRANS rows: lines added by a correction. Per spec each #RTRANS
|
||||
* is immediately followed by an identical #TRANS, so these lines are ALSO
|
||||
* present in `lines`; they are listed here only to mark them as corrections.
|
||||
*
|
||||
* Never part of the final voucher state: `lines` (#TRANS only) is what gets
|
||||
* booked, this is audit trail for the rättelselogg.
|
||||
*/
|
||||
export interface SIEVoucherCorrections {
|
||||
struck: SIETransactionLine[]
|
||||
added: SIETransactionLine[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Voucher/Journal entry from #VER tag
|
||||
*/
|
||||
@@ -120,6 +137,8 @@ export interface SIEVoucher {
|
||||
registrationDate?: Date
|
||||
signature?: string
|
||||
lines: SIETransactionLine[]
|
||||
/** Set only when the #VER carried #BTRANS or #RTRANS rows. */
|
||||
corrections?: SIEVoucherCorrections
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -375,6 +375,39 @@ describe('rattelseEvent', () => {
|
||||
expect(ev.details[1]).toContain('6550 D 1')
|
||||
})
|
||||
|
||||
it('labels imported SIE correction history apart from a rättelse made here (#2427)', () => {
|
||||
const ev = rattelseEvent(
|
||||
{
|
||||
id: 'r3',
|
||||
journal_entry_id: 'entry-1',
|
||||
rattelse_type: 'lines',
|
||||
old_description: null,
|
||||
new_description: null,
|
||||
old_entry_date: null,
|
||||
new_entry_date: null,
|
||||
struck_lines: [{ account_number: '5010', debit_amount: 1200, credit_amount: 0 }],
|
||||
added_lines: [{ account_number: '6540', debit_amount: 1200, credit_amount: 0 }],
|
||||
actor: null,
|
||||
created_at: '2026-03-11T08:00:00Z',
|
||||
source: 'sie_import',
|
||||
sie_import_id: 'import-1',
|
||||
external_signature: 'EL',
|
||||
},
|
||||
new Map([['entry-1', baseEntry]]),
|
||||
)
|
||||
expect(ev).toMatchObject({
|
||||
code: 'journal_entry.imported_correction_history',
|
||||
event: 'Rättelsehistorik från källsystemet (SIE-import)',
|
||||
object: 'A12',
|
||||
actor: { type: 'system', user_id: null, actor_label: 'SIE-import' },
|
||||
source: 'rattelse_log',
|
||||
})
|
||||
expect(ev.details[0]).toContain('Strukna rader i källsystemet (1): 5010 D 1')
|
||||
expect(ev.details[1]).toContain('Tillagda rader i källsystemet (1): 6540 D 1')
|
||||
expect(ev.details[2]).toBe('Signatur i källsystemet: EL')
|
||||
expect(ev.details[3]).toContain('registrering vid SIE-import')
|
||||
})
|
||||
|
||||
it('describes metadata changes', () => {
|
||||
const ev = rattelseEvent(
|
||||
{
|
||||
|
||||
@@ -133,6 +133,11 @@ interface RattelseRow {
|
||||
added_lines: unknown
|
||||
actor: string | null
|
||||
created_at: string
|
||||
/** 'sie_import' = correction history carried by the SIE file (#2427); absent/'user' = made here. */
|
||||
source?: 'user' | 'sie_import' | null
|
||||
sie_import_id?: string | null
|
||||
/** SIE `sign` on the #BTRANS/#RTRANS rows: who corrected in the source system. */
|
||||
external_signature?: string | null
|
||||
}
|
||||
|
||||
interface MigrationResetRow {
|
||||
@@ -714,6 +719,31 @@ export function rattelseEvent(
|
||||
): RawBehandlingshistorikEvent {
|
||||
const entry = entryById.get(row.journal_entry_id)
|
||||
const details: string[] = []
|
||||
if (row.source === 'sie_import') {
|
||||
// History the source system recorded before the migration (SIE 4B
|
||||
// #BTRANS/#RTRANS). Not a rättelse made here: no actor, and the row's
|
||||
// created_at is the import moment, not when the correction happened
|
||||
// (SIE carries who, never when). Labelled apart so behandlingshistoriken
|
||||
// never claims a correction Accounted did not perform.
|
||||
const struck = Array.isArray(row.struck_lines) ? row.struck_lines : []
|
||||
const added = Array.isArray(row.added_lines) ? row.added_lines : []
|
||||
if (struck.length > 0) details.push(`Strukna rader i källsystemet (${struck.length}): ${struck.map(describeLine).join('; ')}`)
|
||||
if (added.length > 0) details.push(`Tillagda rader i källsystemet (${added.length}): ${added.map(describeLine).join('; ')}`)
|
||||
if (row.external_signature) details.push(`Signatur i källsystemet: ${row.external_signature}`)
|
||||
details.push('Tidpunkt = registrering vid SIE-import; rättelsedatum saknas i SIE-formatet')
|
||||
return {
|
||||
id: `rattelse:${row.id}`,
|
||||
occurred_at: toIso(row.created_at)!,
|
||||
category: 'verifikation',
|
||||
code: 'journal_entry.imported_correction_history',
|
||||
event: 'Rättelsehistorik från källsystemet (SIE-import)',
|
||||
object: entry ? voucherLabel(entry.voucher_series, entry.voucher_number) : null,
|
||||
actor: { type: 'system', user_id: null, actor_label: 'SIE-import' },
|
||||
details,
|
||||
source: 'rattelse_log',
|
||||
count: 1,
|
||||
}
|
||||
}
|
||||
if (row.rattelse_type === 'metadata') {
|
||||
if (!same(row.old_description, row.new_description)) {
|
||||
details.push(`Beskrivning: ${fmtValue(row.old_description)} → ${fmtValue(row.new_description)}`)
|
||||
|
||||
Reference in New Issue
Block a user