* 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>
375 lines
14 KiB
TypeScript
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/)
|
|
})
|
|
})
|