Files
accounted/lib/import/__tests__/sie-import-atomic.pg.test.ts
MattssonandClaude Fable 5.1 a84d2723e0 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>
2026-09-09 14:45:29 +02:00

375 lines
14 KiB
TypeScript

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()
const payload = [
{
sourceId: 'A1',
series: 'A',
date: '2026-01-15',
description: 'Bad imported voucher',
sourceSeries: 'A',
sourceNumber: 1,
sourceType: 'import',
lines: [
{
account_number: '1930',
debit_amount: 100,
credit_amount: 0,
currency: 'SEK',
line_description: 'Bank',
sort_order: 0,
},
{
account_number: null,
debit_amount: 0,
credit_amount: 100,
currency: 'SEK',
line_description: 'Invalid line',
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(/null value in column "account_number"|violates not-null constraint/i)
const headers = await getPool().query<{ count: string }>(
`SELECT count(*)::text AS count
FROM public.journal_entries
WHERE company_id = $1
AND fiscal_period_id = $2
AND description = 'Bad imported voucher'`,
[companyId, fiscalPeriodId],
)
expect(headers.rows[0]!.count).toBe('0')
const sequence = await getPool().query<{ last_number: number }>(
`SELECT last_number
FROM public.voucher_sequences
WHERE company_id = $1
AND fiscal_period_id = $2
AND voucher_series = 'A'`,
[companyId, fiscalPeriodId],
)
expect(sequence.rowCount).toBe(0)
})
it('posts a balanced voucher and carries the dimensions jsonb through to the generated mirrors', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const payload = [
{
sourceId: 'A1',
series: 'A',
date: '2026-02-01',
description: 'Dimensioned import',
sourceSeries: 'A',
sourceNumber: 1,
sourceType: 'import',
lines: [
{
account_number: '5010',
debit_amount: 100,
credit_amount: 0,
currency: 'SEK',
line_description: 'Lokalhyra',
sort_order: 0,
// SIE object-list codes: 1 = kostnadsställe, 6 = projekt.
dimensions: { '1': 'CC-10', '6': 'PROJ-X' },
},
{
account_number: '1930',
debit_amount: 0,
credit_amount: 100,
currency: 'SEK',
line_description: 'Bank',
sort_order: 1,
},
],
},
]
const res = await getPool().query<{ import_sie_journal_entries: { inserted_entries: unknown[] } }>(
`SELECT public.import_sie_journal_entries($1::uuid, $2::uuid, $3::uuid, $4::jsonb)`,
[companyId, userId, fiscalPeriodId, JSON.stringify(payload)],
)
expect(res.rows[0]!.import_sie_journal_entries.inserted_entries).toHaveLength(1)
const posted = await getPool().query<{ count: string }>(
`SELECT count(*)::text AS count
FROM public.journal_entries
WHERE company_id = $1 AND status = 'posted' AND description = 'Dimensioned import'`,
[companyId],
)
expect(posted.rows[0]!.count).toBe('1')
const dimLine = await getPool().query<{
dimensions: Record<string, string>
cost_center: string | null
project: string | null
}>(
`SELECT l.dimensions, l.cost_center, l.project
FROM public.journal_entry_lines l
JOIN public.journal_entries je ON je.id = l.journal_entry_id
WHERE je.company_id = $1 AND l.account_number = '5010'`,
[companyId],
)
expect(dimLine.rows[0]!.dimensions).toEqual({ '1': 'CC-10', '6': 'PROJ-X' })
// GENERATED mirrors derive from the jsonb: both must be populated.
expect(dimLine.rows[0]!.cost_center).not.toBeNull()
expect(dimLine.rows[0]!.project).not.toBeNull()
})
it('rejects an unbalanced voucher and rolls the whole import back', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const payload = [
{
sourceId: 'A1',
series: 'A',
date: '2026-02-01',
description: 'Unbalanced import',
sourceType: 'import',
lines: [
{ account_number: '5010', debit_amount: 100, credit_amount: 0, currency: 'SEK', sort_order: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 90, currency: 'SEK', 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(/unbalanced/i)
const headers = await getPool().query<{ count: string }>(
`SELECT count(*)::text AS count FROM public.journal_entries
WHERE company_id = $1 AND description = 'Unbalanced import'`,
[companyId],
)
expect(headers.rows[0]!.count).toBe('0')
})
it('rejects a fiscal period that belongs to another company', async () => {
const a = await seedCompany()
const b = await seedCompany()
const payload = [
{
sourceId: 'A1',
series: 'A',
date: '2026-02-01',
description: 'Foreign fiscal period',
sourceType: 'import',
lines: [
{ account_number: '5010', debit_amount: 100, credit_amount: 0, currency: 'SEK', sort_order: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 100, currency: 'SEK', sort_order: 1 },
],
},
]
// company A's id + user, but company B's fiscal period.
await expect(
getPool().query(
`SELECT public.import_sie_journal_entries($1::uuid, $2::uuid, $3::uuid, $4::jsonb)`,
[a.companyId, a.userId, b.fiscalPeriodId, JSON.stringify(payload)],
),
).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/)
})
})