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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
4e2ca3f2a8
commit
2a1ec5ec2f
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user