fix: Make SIE imports atomic (#860)

* fix: Make SIE imports atomic

* fix(import): carry dimensions + harden the atomic SIE RPC

Rebased onto current main. The RPC now:
- carries the per-line dimensions jsonb through the payload + INSERT so
  imported SIE object-list codes are not dropped (dimensions PR5 #866);
- uses the NULL-safe caller_is_company_member guard (drops the banned
  NOT IN (SELECT user_company_ids()) pattern ratcheted since #881);
- verifies the fiscal period belongs to the company;
- enforces per-voucher balance (sum debit = sum credit > 0) since
  SECURITY DEFINER + the direct draft->posted UPDATE bypass the trigger path;
- ships REVOKE ALL FROM PUBLIC, anon / GRANT EXECUTE TO authenticated,
  service_role (house style).
Migration renamed to a current timestamp. Added pg-real coverage for the
dimensions round-trip, unbalanced rejection, and foreign-fiscal-period guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jack Ek
2026-07-16 16:00:01 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 4e2ca3f2a8
commit 2a1ec5ec2f
4 changed files with 579 additions and 282 deletions
@@ -0,0 +1,191 @@
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany } from '@/tests/pg/fixtures'
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)
})
})
+78 -48
View File
@@ -803,15 +803,12 @@ describe('importVouchers: per-voucher series preservation', () => {
// voucher_series per inserted record. Uses a hand-rolled mock rather than
// createQueuedMockSupabase because we need to inspect arguments, not just
// return queued data.
function buildCapturingSupabase() {
function buildCapturingSupabase(options: { failImportRpc?: boolean } = {}) {
const journalEntryInserts: Array<Record<string, unknown>> = []
const journalEntryLineInserts: Array<Record<string, unknown>> = []
const rpcCalls: Array<{ name: string; args: Record<string, unknown> }> = []
// Each `next_voucher_number` RPC call auto-increments per series, matching
// the DB function's ON CONFLICT behavior.
const nextNumberBySeries = new Map<string, number>()
let syntheticEntryId = 1
const supabase = {
@@ -833,52 +830,56 @@ describe('importVouchers: per-voucher series preservation', () => {
}
}
if (table === 'journal_entries') {
return {
insert: (rows: Array<Record<string, unknown>>) => {
journalEntryInserts.push(...rows)
return {
select: () => ({
then: (resolve: (v: { data: { id: string }[]; error: null }) => void) =>
resolve({
data: rows.map(() => ({ id: `entry-${syntheticEntryId++}` })),
error: null,
}),
}),
}
},
}
}
if (table === 'journal_entry_lines') {
return {
insert: (rows: Array<Record<string, unknown>>) => {
journalEntryLineInserts.push(...rows)
return Promise.resolve({ error: null })
},
}
}
throw new Error(`Unexpected table: ${table}`)
}),
rpc: vi.fn(async (name: string, args: Record<string, unknown>) => {
rpcCalls.push({ name, args })
if (name === 'next_voucher_number') {
const series = args.p_series as string
const current = nextNumberBySeries.get(series) ?? 0
const next = current + 1
nextNumberBySeries.set(series, next)
return { data: next, error: null }
}
if (name === 'reserve_voucher_range') {
const series = args.p_series as string
const highest = args.p_highest_used as number
nextNumberBySeries.set(series, highest)
return { data: null, error: null }
}
if (name === 'release_voucher_range') {
return { data: null, error: null }
if (name === 'import_sie_journal_entries') {
if (options.failImportRpc) {
return {
data: null,
error: { message: 'line insert failed' },
}
}
const entries = args.p_entries as Array<{
sourceId: string
series: string
sourceType: string
lines: Array<Record<string, unknown>>
}>
const inserted_entries = entries.map((entry) => {
const current = nextNumberBySeries.get(entry.series) ?? 0
const next = current + 1
nextNumberBySeries.set(entry.series, next)
const id = `entry-${syntheticEntryId++}`
journalEntryInserts.push({
...entry,
id,
voucher_series: entry.series,
voucher_number: next,
source_type: entry.sourceType,
source_voucher_series: (entry as { sourceSeries?: string | null }).sourceSeries ?? null,
source_voucher_number: (entry as { sourceNumber?: number | null }).sourceNumber ?? null,
})
journalEntryLineInserts.push(...entry.lines.map((line) => ({ ...line, journal_entry_id: id })))
return {
id,
sourceId: entry.sourceId,
series: entry.series,
voucherNumber: next,
sourceType: entry.sourceType,
}
})
return {
data: {
inserted_entries,
skipped_duplicates: [],
validation_errors: [],
},
error: null,
}
}
throw new Error(`Unexpected RPC: ${name}`)
}),
@@ -941,9 +942,9 @@ describe('importVouchers: per-voucher series preservation', () => {
const seriesInInserts = journalEntryInserts.map((r) => r.voucher_series)
expect(seriesInInserts).toEqual(['B', 'B', 'C', 'V'])
// Each series reserves its own voucher-number range independently
const reserveCalls = rpcCalls.filter((c) => c.name === 'reserve_voucher_range')
expect(reserveCalls.map((c) => c.args.p_series)).toEqual(['B', 'C', 'V'])
const importCalls = rpcCalls.filter((c) => c.name === 'import_sie_journal_entries')
expect(importCalls).toHaveLength(1)
expect((importCalls[0].args.p_entries as Array<{ series: string }>).map((e) => e.series)).toEqual(['B', 'B', 'C', 'V'])
})
it('falls back to defaultSeries when source voucher has empty series (SIE4I)', async () => {
@@ -1059,6 +1060,35 @@ describe('importVouchers: per-voucher series preservation', () => {
expect(journalEntryInserts.map((r) => r.source_voucher_number)).toEqual([1, 3])
})
it('does not report imported IDs or counts when the atomic RPC fails', async () => {
const { supabase, journalEntryInserts, journalEntryLineInserts } = buildCapturingSupabase({
failImportRpc: true,
})
const parsed = makeParsedFile({
vouchers: [
makeVoucher('A', 1),
],
})
const result = await importVouchers(
supabase,
'company-1',
'user-1',
'period-1',
parsed,
baseMap,
'A',
)
expect(result.created).toBe(0)
expect(result.ids).toEqual([])
expect(result.importTypedIds).toEqual([])
expect(result.voucherNumberMapping).toEqual([])
expect(result.errors.join(' ')).toContain('line insert failed')
expect(journalEntryInserts).toEqual([])
expect(journalEntryLineInserts).toEqual([])
})
it('stores NULL source series/number when the source voucher has no series (SIE4I subsystem import)', async () => {
const { supabase, journalEntryInserts } = buildCapturingSupabase()
const parsed = makeParsedFile({
+85 -234
View File
@@ -1247,249 +1247,100 @@ export async function importVouchers(
results.seriesUsed = [...seriesGroups.keys()]
// Batch insert journal entries (in chunks of 100) with retry logic.
// Retries handle transient errors (Supabase rate limits, Cloudflare 500s).
const BATCH_SIZE = 100
const MAX_RETRIES = 3
const INTER_BATCH_DELAY_MS = 50 // Prevent rate limiting under sustained load
let retriedBatches = 0
let failedBatches = 0
const voucherBySourceId = new Map(preparedVouchers.map((voucher) => [voucher.sourceId, voucher]))
const rpcPayload = preparedVouchers.map((voucher) => ({
sourceId: voucher.sourceId,
series: voucher.series,
date: voucher.date,
description: voucher.description,
sourceSeries: voucher.sourceSeries,
sourceNumber: voucher.sourceNumber,
sourceType: voucher.sourceType,
lines: voucher.lines.map((line, lineIndex) => ({
account_number: line.account_number,
account_id: accountIdMap.get(line.account_number) || null,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
currency: 'SEK',
line_description: line.line_description,
sort_order: lineIndex,
// dimensions jsonb is the source of truth; cost_center/project are
// GENERATED mirrors the DB derives from it. SIE object-list codes carry
// through so imported dimension data is not dropped (dimensions PR5 #866).
dimensions: normalizeLineDimensions({ dimensions: line.dimensions ?? null }),
})),
}))
// Process each series as an independent mini-import. Voucher numbers must
// be monotonically increasing within a series; grouping first guarantees
// that without needing to interleave series-specific counters in one loop.
let seriesIndex = 0
for (const [series, groupVouchers] of seriesGroups) {
// Get starting voucher number for this series
const { data: startNumber } = await supabase.rpc('next_voucher_number', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: series,
type ImportSieJournalEntriesRpcResult = {
inserted_entries?: Array<{
id: string
sourceId: string
series: string
voucherNumber: number
sourceType: 'import' | 'opening_balance'
}>
skipped_duplicates?: Array<{ sourceId?: string; reason?: string }>
validation_errors?: Array<{ sourceId?: string; message?: string }>
}
const { data: rpcResult, error: rpcError } = await supabase.rpc('import_sie_journal_entries', {
p_company_id: companyId,
p_user_id: userId,
p_fiscal_period_id: fiscalPeriodId,
p_entries: rpcPayload,
})
if (rpcError) {
results.errors.push(`SIE-verifikationer kunde inte importeras atomiskt: ${rpcError.message}`)
results.failedBatches = 1
return results
}
const structuredResult = (rpcResult ?? {}) as ImportSieJournalEntriesRpcResult
for (const validationError of structuredResult.validation_errors ?? []) {
results.errors.push(
validationError.sourceId
? `${validationError.sourceId}: ${validationError.message ?? 'valideringsfel'}`
: validationError.message ?? 'Valideringsfel vid SIE-import',
)
}
for (const skippedDuplicate of structuredResult.skipped_duplicates ?? []) {
results.errors.push(
skippedDuplicate.sourceId
? `${skippedDuplicate.sourceId}: duplicerad verifikation hoppades över`
: 'Duplicerad verifikation hoppades över',
)
}
if (results.errors.length > 0) {
return results
}
for (const inserted of structuredResult.inserted_entries ?? []) {
const voucher = voucherBySourceId.get(inserted.sourceId)
if (!voucher) continue
results.voucherNumberMapping.push({
sourceId: inserted.sourceId,
series: inserted.series,
targetNumber: inserted.voucherNumber,
})
const currentVoucherNumber = (startNumber as number) || 1
// Reserve the full voucher number range upfront to prevent concurrent
// operations from claiming numbers in our range during batch insertion.
const reservedHighest = currentVoucherNumber + groupVouchers.length - 1
await supabase.rpc('reserve_voucher_range', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: series,
p_highest_used: reservedHighest,
})
let highestInsertedVoucher = currentVoucherNumber - 1 // nothing inserted yet
for (let batchStart = 0; batchStart < groupVouchers.length; batchStart += BATCH_SIZE) {
const batch = groupVouchers.slice(batchStart, batchStart + BATCH_SIZE)
const batchNumber = Math.floor(batchStart / BATCH_SIZE) + 1
let batchWasRetried = false
// Prepare journal entry headers
const entryInserts = batch.map((v, i) => ({
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
voucher_number: currentVoucherNumber + batchStart + i,
voucher_series: series,
entry_date: v.date,
description: v.description,
source_type: v.sourceType,
source_voucher_series: v.sourceSeries,
source_voucher_number: v.sourceNumber,
status: 'posted',
committed_at: new Date().toISOString(),
}))
// Insert headers with retry
let entries: { id: string }[] | null = null
let lastEntryError: string | null = null
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 0) {
batchWasRetried = true
const backoffMs = Math.pow(2, attempt - 1) * 1000 // 1s, 2s, 4s
console.log(`[sie-import] Retrying batch ${batchNumber} (attempt ${attempt + 1}/${MAX_RETRIES + 1}) after ${backoffMs}ms`)
await new Promise(resolve => setTimeout(resolve, backoffMs))
}
const { data, error: entryError } = await supabase
.from('journal_entries')
.insert(entryInserts)
.select('id')
if (!entryError && data) {
entries = data
lastEntryError = null
break
}
lastEntryError = entryError?.message || 'Failed to insert entries'
results.ids.push(inserted.id)
if (inserted.sourceType === 'import') {
results.importTypedIds.push(inserted.id)
}
results.created++
if (!entries) {
failedBatches++
results.errors.push(
`Batch ${batchNumber} misslyckades efter ${MAX_RETRIES + 1} försök: ${lastEntryError}`
for (const line of voucher.lines) {
const net = line.debit_amount - line.credit_amount
results.movementsByAccount.set(
line.account_number,
(results.movementsByAccount.get(line.account_number) || 0) + net
)
continue
}
// Prepare all lines for this batch
const allLines: {
journal_entry_id: string
account_number: string
account_id: string | null
debit_amount: number
credit_amount: number
currency: string
line_description: string | null
sort_order: number
dimensions: Record<string, string>
}[] = []
for (let i = 0; i < batch.length; i++) {
const entryId = entries[i]?.id
if (!entryId) continue
const voucher = batch[i]
const assignedNumber = currentVoucherNumber + batchStart + i
voucher.lines.forEach((line, lineIndex) => {
// dimensions jsonb is the source of truth; cost_center/project are
// derived mirrors: the same dual-write every sanctioned writer uses
// (see lib/bookkeeping/dimension-resolver.ts). SIE object-list codes
// survive verbatim on lines (legacy free-text is a documented
// exception to the registry format rules).
const dims = normalizeLineDimensions({ dimensions: line.dimensions ?? null })
allLines.push({
journal_entry_id: entryId,
account_number: line.account_number,
account_id: accountIdMap.get(line.account_number) || null,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
currency: 'SEK',
line_description: line.line_description,
sort_order: lineIndex,
dimensions: dims,
})
})
results.voucherNumberMapping.push({
sourceId: voucher.sourceId,
series: voucher.series,
targetNumber: assignedNumber,
})
results.ids.push(entryId)
// #VER vouchers re-tagged as opening_balance never need an underlag and
// aren't in NEEDS_DOC_SOURCE_TYPES, so keep them out of the exempt set.
if (voucher.sourceType === 'import') {
results.importTypedIds.push(entryId)
}
results.created++
}
// Insert all lines with retry
if (allLines.length > 0) {
let linesInserted = false
let lastLinesError: string | null = null
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 0) {
batchWasRetried = true
const backoffMs = Math.pow(2, attempt - 1) * 1000
console.log(`[sie-import] Retrying batch ${batchNumber} lines (attempt ${attempt + 1}/${MAX_RETRIES + 1}) after ${backoffMs}ms`)
await new Promise(resolve => setTimeout(resolve, backoffMs))
}
const { error: linesError } = await supabase
.from('journal_entry_lines')
.insert(allLines)
if (!linesError) {
linesInserted = true
break
}
lastLinesError = linesError.message
}
if (linesInserted) {
// Track highest voucher number only after both headers AND lines succeed,
// to avoid counting orphaned entries with no lines as "used".
const batchHighest = currentVoucherNumber + batchStart + batch.length - 1
highestInsertedVoucher = Math.max(highestInsertedVoucher, batchHighest)
// Track movements ONLY for successfully inserted vouchers.
// This ensures the migration adjustment correctly compensates for
// any batches that failed completely.
for (let i = 0; i < batch.length; i++) {
const voucher = batch[i]
for (const line of voucher.lines) {
const net = line.debit_amount - line.credit_amount
results.movementsByAccount.set(
line.account_number,
(results.movementsByAccount.get(line.account_number) || 0) + net
)
}
}
} else {
failedBatches++
results.errors.push(
`Batch ${batchNumber} rader misslyckades efter ${MAX_RETRIES + 1} försök: ${lastLinesError}`
)
}
} else {
// No lines to insert: still count movements for vouchers with entries
for (let i = 0; i < batch.length; i++) {
const voucher = batch[i]
for (const line of voucher.lines) {
const net = line.debit_amount - line.credit_amount
results.movementsByAccount.set(
line.account_number,
(results.movementsByAccount.get(line.account_number) || 0) + net
)
}
}
}
// Count distinct batches that needed retries (not individual attempts)
if (batchWasRetried) {
retriedBatches++
}
// Small delay between batches to prevent Supabase/Cloudflare rate limiting
const isLastBatchInSeries = batchStart + BATCH_SIZE >= groupVouchers.length
const isLastSeries = seriesIndex === seriesGroups.size - 1
if (!isLastBatchInSeries || !isLastSeries) {
await new Promise(resolve => setTimeout(resolve, INTER_BATCH_DELAY_MS))
}
}
// Adjust voucher sequence after insertion for this series.
// Range was pre-reserved to `reservedHighest`. If some batches failed,
// release the unused portion to avoid burned numbers and gap-explanation friction.
if (highestInsertedVoucher < reservedHighest) {
const releaseTarget = highestInsertedVoucher >= currentVoucherNumber
? highestInsertedVoucher // partial success: set to actual highest
: currentVoucherNumber - 1 // total failure: roll back fully
await supabase.rpc('release_voucher_range', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: series,
p_actual_last: releaseTarget,
p_reserved_highest: reservedHighest,
})
}
seriesIndex++
}
// Propagate batch retry stats
results.retriedBatches = retriedBatches
results.failedBatches = failedBatches
return results
}