fix(payments): make supplier payment batch creation atomic via create_supplier_payment_batch RPC (#1989)
createSupplierPaymentBatch wrote the batch header and its items as two separate PostgREST inserts, and the active-batch recheck ran app-side before either. Two concurrent creates selecting the same invoice could both pass that check and both land an active batch without confirm_already_batched, and an item-insert failure after the header landed could leave an empty 'created' batch behind when the best-effort cancel also failed. The new SECURITY DEFINER RPC is now the single write path: it locks the selected invoices FOR UPDATE in id order, re-checks payability, amounts and active batches inside the transaction, and inserts header + items together so a constraint violation rolls both back. TypeScript keeps the shared eligibility evaluation and the msg_id minting (branding lives in TS); the service result union is unchanged so the route and UI are untouched. Closes #1503 Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
4f939ebb21
commit
533df34369
@@ -178,17 +178,18 @@ describe('createSupplierPaymentBatch', () => {
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
// from() order for create: companies, settings (debtor first), then
|
||||
// supplier_invoices, batch items, then the two inserts.
|
||||
it('creates a batch with snapshotted payee rows and a derived msg_id', async () => {
|
||||
// Queue order for create (shared between from() and rpc()): companies,
|
||||
// settings (debtor first), then supplier_invoices, batch items (active-batch
|
||||
// fast path), then the single create_supplier_payment_batch RPC call that
|
||||
// writes header + items atomically.
|
||||
it('creates a batch through the atomic RPC with snapshotted payee rows and a derived msg_id', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ data: batchRow() },
|
||||
{ data: null },
|
||||
{ data: { ok: true, batch: batchRow() } },
|
||||
])
|
||||
|
||||
const result = await createSupplierPaymentBatch(
|
||||
@@ -198,21 +199,21 @@ describe('createSupplierPaymentBatch', () => {
|
||||
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1' }] },
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result).toEqual({ ok: true, batch: batchRow() })
|
||||
|
||||
const batchInsert = mock.findCall('supplier_payment_batches', 'insert')?.[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(batchInsert).toMatchObject({
|
||||
company_id: COMPANY_ID,
|
||||
user_id: USER_ID,
|
||||
format: 'pain001',
|
||||
status: 'created',
|
||||
currency: 'SEK',
|
||||
total_amount: 737.5,
|
||||
item_count: 1,
|
||||
debtor_snapshot: {
|
||||
// No direct table writes: the RPC is the only write path.
|
||||
expect(mock.findCall('supplier_payment_batches', 'insert')).toBeUndefined()
|
||||
expect(mock.findCall('supplier_payment_batch_items', 'insert')).toBeUndefined()
|
||||
|
||||
expect(mock.supabase.rpc).toHaveBeenCalledTimes(1)
|
||||
const [fnName, args] = mock.supabase.rpc.mock.calls[0] as [string, Record<string, unknown>]
|
||||
expect(fnName).toBe('create_supplier_payment_batch')
|
||||
expect(args).toMatchObject({
|
||||
p_company_id: COMPANY_ID,
|
||||
p_user_id: USER_ID,
|
||||
p_format: 'pain001',
|
||||
p_confirm_already_batched: false,
|
||||
p_debtor_snapshot: {
|
||||
name: 'Testbolaget AB',
|
||||
org_number: '556677-8899',
|
||||
iban: 'SE3550000000054910000003',
|
||||
@@ -221,27 +222,157 @@ describe('createSupplierPaymentBatch', () => {
|
||||
city: 'Stockholm',
|
||||
},
|
||||
})
|
||||
const msgId = batchInsert.msg_id as string
|
||||
expect(args.p_batch_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
|
||||
const msgId = args.p_msg_id as string
|
||||
expect(msgId.startsWith('ACCOUNTED-5566778899-B')).toBe(true)
|
||||
expect(msgId.length).toBeLessThanOrEqual(35)
|
||||
|
||||
const itemsInsert = mock.findCall('supplier_payment_batch_items', 'insert')?.[0] as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
expect(itemsInsert).toHaveLength(1)
|
||||
expect(itemsInsert[0]).toMatchObject({
|
||||
batch_id: batchInsert.id,
|
||||
company_id: COMPANY_ID,
|
||||
const items = args.p_items as Array<Record<string, unknown>>
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toMatchObject({
|
||||
supplier_invoice_id: 'inv-1',
|
||||
amount: 737.5,
|
||||
payment_date: '2099-08-20',
|
||||
payee_type: 'bankgiro',
|
||||
payee_bankgiro: '50501055',
|
||||
payee_plusgiro: null,
|
||||
payee_clearing: null,
|
||||
payee_account: null,
|
||||
payee_name: 'Derome Bygg & Industri AB',
|
||||
payee_city: 'Veddige',
|
||||
reference_type: 'invoice_number',
|
||||
reference: 'CD3014794407',
|
||||
})
|
||||
// batch_id and company_id come from p_batch_id / p_company_id in SQL.
|
||||
expect(items[0]).not.toHaveProperty('batch_id')
|
||||
expect(items[0]).not.toHaveProperty('company_id')
|
||||
})
|
||||
|
||||
it('maps an in-transaction already_batched refusal from the RPC onto the same result', async () => {
|
||||
// The app-side pre-check saw no active batch (empty map), but a concurrent
|
||||
// create committed one before the RPC took its lock: the RPC's recheck
|
||||
// refuses and the service must surface it exactly like the pre-check does.
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{
|
||||
data: {
|
||||
ok: false,
|
||||
code: 'already_batched',
|
||||
details: [{ id: 'inv-1', batch_id: 'batch-9' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const result = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1' }] },
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'already_batched',
|
||||
details: [{ id: 'inv-1', batch_id: 'batch-9' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('maps in-transaction ineligible and amount refusals from the RPC', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ data: { ok: false, code: 'ineligible', details: [{ id: 'inv-1', reason: 'not_payable' }] } },
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ data: { ok: false, code: 'amount_exceeds_remaining', details: [{ id: 'inv-1' }] } },
|
||||
])
|
||||
const input = { format: 'pain001' as const, items: [{ supplier_invoice_id: 'inv-1' }] }
|
||||
|
||||
const ineligible = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
input,
|
||||
)
|
||||
expect(ineligible).toEqual({
|
||||
ok: false,
|
||||
code: 'ineligible',
|
||||
details: [{ id: 'inv-1', reason: 'not_payable' }],
|
||||
})
|
||||
|
||||
const excessive = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
input,
|
||||
)
|
||||
expect(excessive).toEqual({
|
||||
ok: false,
|
||||
code: 'amount_exceeds_remaining',
|
||||
details: [{ id: 'inv-1' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('maps an RPC error (constraint violation, guard) to create_failed', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ error: { message: 'payee_fields_match', code: '23514' } },
|
||||
])
|
||||
|
||||
const result = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1' }] },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: false, code: 'create_failed' })
|
||||
})
|
||||
|
||||
it('maps an unknown RPC refusal code and an empty RPC payload to create_failed', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ data: { ok: false, code: 'something_new' } },
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ data: null },
|
||||
])
|
||||
const input = { format: 'pain001' as const, items: [{ supplier_invoice_id: 'inv-1' }] }
|
||||
|
||||
const unknown = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
input,
|
||||
)
|
||||
expect(unknown).toEqual({ ok: false, code: 'create_failed' })
|
||||
|
||||
const empty = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
input,
|
||||
)
|
||||
expect(empty).toEqual({ ok: false, code: 'create_failed' })
|
||||
})
|
||||
|
||||
it('rejects the whole batch when any invoice is ineligible', async () => {
|
||||
@@ -268,7 +399,7 @@ describe('createSupplierPaymentBatch', () => {
|
||||
code: 'ineligible',
|
||||
details: [{ id: 'inv-2', reason: 'foreign_currency' }],
|
||||
})
|
||||
expect(mock.findCall('supplier_payment_batches', 'insert')).toBeUndefined()
|
||||
expect(mock.supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an amount override above the remaining amount', async () => {
|
||||
@@ -292,6 +423,7 @@ describe('createSupplierPaymentBatch', () => {
|
||||
code: 'amount_exceeds_remaining',
|
||||
details: [{ id: 'inv-1' }],
|
||||
})
|
||||
expect(mock.supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an invoice already in an active batch unless confirmed', async () => {
|
||||
@@ -318,9 +450,10 @@ describe('createSupplierPaymentBatch', () => {
|
||||
code: 'already_batched',
|
||||
details: [{ id: 'inv-1', batch_id: 'batch-9' }],
|
||||
})
|
||||
expect(mock.supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds past an active batch when explicitly confirmed', async () => {
|
||||
it('proceeds past an active batch when explicitly confirmed and forwards the confirmation to the RPC', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
const activeItems = [
|
||||
{ supplier_invoice_id: 'inv-1', batch: { id: 'batch-9', status: 'created' } },
|
||||
@@ -330,8 +463,7 @@ describe('createSupplierPaymentBatch', () => {
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: activeItems },
|
||||
{ data: batchRow() },
|
||||
{ data: null },
|
||||
{ data: { ok: true, batch: batchRow() } },
|
||||
])
|
||||
|
||||
const result = await createSupplierPaymentBatch(
|
||||
@@ -346,6 +478,10 @@ describe('createSupplierPaymentBatch', () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(mock.supabase.rpc).toHaveBeenCalledWith(
|
||||
'create_supplier_payment_batch',
|
||||
expect.objectContaining({ p_confirm_already_batched: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when the active-batch lookup errors instead of skipping the guard', async () => {
|
||||
@@ -363,7 +499,7 @@ describe('createSupplierPaymentBatch', () => {
|
||||
items: [{ supplier_invoice_id: 'inv-1' }],
|
||||
}),
|
||||
).rejects.toBeTruthy()
|
||||
expect(mock.findCall('supplier_payment_batches', 'insert')).toBeUndefined()
|
||||
expect(mock.supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails up front when the debtor is incomplete', async () => {
|
||||
@@ -378,6 +514,7 @@ describe('createSupplierPaymentBatch', () => {
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: false, code: 'debtor_incomplete', missing: 'iban' })
|
||||
expect(mock.supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops an invalid company bankgiro from the snapshot instead of debiting it', async () => {
|
||||
@@ -387,8 +524,7 @@ describe('createSupplierPaymentBatch', () => {
|
||||
{ data: { ...settingsRow, bankgiro: '1234-5678' } },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ data: batchRow() },
|
||||
{ data: null },
|
||||
{ data: { ok: true, batch: batchRow() } },
|
||||
])
|
||||
|
||||
const result = await createSupplierPaymentBatch(
|
||||
@@ -399,10 +535,10 @@ describe('createSupplierPaymentBatch', () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
const batchInsert = mock.findCall('supplier_payment_batches', 'insert')?.[0] as {
|
||||
debtor_snapshot: { bankgiro: string | null }
|
||||
const args = mock.supabase.rpc.mock.calls[0][1] as {
|
||||
p_debtor_snapshot: { bankgiro: string | null }
|
||||
}
|
||||
expect(batchInsert.debtor_snapshot.bankgiro).toBeNull()
|
||||
expect(args.p_debtor_snapshot.bankgiro).toBeNull()
|
||||
})
|
||||
|
||||
it('requires an organisation number for the InitgPty OrgId', async () => {
|
||||
@@ -420,6 +556,7 @@ describe('createSupplierPaymentBatch', () => {
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: false, code: 'debtor_incomplete', missing: 'org_number' })
|
||||
expect(mock.supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
* row that changed since the preview (settled meanwhile, supplier edited) is
|
||||
* rejected rather than paid on stale terms.
|
||||
*
|
||||
* The write itself is one transactional RPC (create_supplier_payment_batch):
|
||||
* it locks the selected invoices, re-checks active batches inside the
|
||||
* transaction, and inserts header + items atomically, so two concurrent
|
||||
* creates can never both land an active batch for the same invoice and a
|
||||
* header can never outlive its items (#1503).
|
||||
*
|
||||
* The file is rendered deterministically from the stored batch + item rows
|
||||
* alone: msg_id and created_at are fixed at creation, so every download of a
|
||||
* batch is byte-identical and bank-side duplicate detection (keyed on MsgId)
|
||||
@@ -240,6 +246,11 @@ export type CreateBatchResult =
|
||||
| { ok: false; code: 'already_batched'; details: Array<{ id: string; batch_id: string }> }
|
||||
| { ok: false; code: 'create_failed' }
|
||||
|
||||
/** Shape returned by the create_supplier_payment_batch RPC. */
|
||||
type CreateBatchRpcResult =
|
||||
| { ok: true; batch: SupplierPaymentBatch }
|
||||
| { ok: false; code: string; details?: unknown }
|
||||
|
||||
export async function createSupplierPaymentBatch(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
@@ -331,48 +342,57 @@ export async function createSupplierPaymentBatch(
|
||||
if (itemRows.length === 0) return { ok: false, code: 'create_failed' }
|
||||
|
||||
// The id is minted here (not by the DB default) because msg_id derives from
|
||||
// it and both must land in the same INSERT.
|
||||
// it and both must land in the same transaction.
|
||||
const batchId = crypto.randomUUID()
|
||||
const orgDigits = debtor.org_number.replace(/\D/g, '')
|
||||
const msgId = `${getBranding().appName.toUpperCase()}-${orgDigits}-B${batchId.replace(/-/g, '').slice(0, 8).toUpperCase()}`.slice(0, 35)
|
||||
const totalAmount = sumOre(itemRows.map((row) => row.amount))
|
||||
|
||||
const { data: batch, error: batchError } = await supabase
|
||||
.from('supplier_payment_batches')
|
||||
.insert({
|
||||
id: batchId,
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
format: input.format,
|
||||
status: 'created',
|
||||
currency: 'SEK',
|
||||
total_amount: totalAmount,
|
||||
item_count: itemRows.length,
|
||||
msg_id: msgId,
|
||||
debtor_snapshot: debtor,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
// The RPC is the authority: it locks the invoices, re-runs the active-batch
|
||||
// check inside the transaction (the loadActiveBatchMap pass above is the
|
||||
// friendly fast path, not the guarantee), and writes header + items
|
||||
// atomically. company_id rides in p_company_id, so it is stripped from the
|
||||
// item rows. Domain refusals come back as { ok: false, code }; constraint
|
||||
// violations and the tenant guard surface as an error.
|
||||
const { data, error } = await supabase.rpc('create_supplier_payment_batch', {
|
||||
p_company_id: companyId,
|
||||
p_batch_id: batchId,
|
||||
p_format: input.format,
|
||||
p_msg_id: msgId,
|
||||
p_debtor_snapshot: debtor,
|
||||
p_items: itemRows.map(({ company_id: _companyId, ...row }) => row),
|
||||
p_confirm_already_batched: input.confirm_already_batched ?? false,
|
||||
p_user_id: userId,
|
||||
})
|
||||
if (error) return { ok: false, code: 'create_failed' }
|
||||
|
||||
if (batchError || !batch) return { ok: false, code: 'create_failed' }
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('supplier_payment_batch_items')
|
||||
.insert(itemRows.map((row) => ({ ...row, batch_id: batchId })))
|
||||
|
||||
if (itemsError) {
|
||||
// Best-effort rollback: without its items the batch must not exist. There
|
||||
// is no DELETE policy, so flag it cancelled instead of leaving an empty
|
||||
// "created" batch behind.
|
||||
await supabase
|
||||
.from('supplier_payment_batches')
|
||||
.update({ status: 'cancelled', cancelled_at: new Date().toISOString(), cancelled_by: userId })
|
||||
.eq('id', batchId)
|
||||
.eq('company_id', companyId)
|
||||
return { ok: false, code: 'create_failed' }
|
||||
const result = data as CreateBatchRpcResult | null
|
||||
if (!result) return { ok: false, code: 'create_failed' }
|
||||
if (!result.ok) {
|
||||
switch (result.code) {
|
||||
case 'already_batched':
|
||||
return {
|
||||
ok: false,
|
||||
code: 'already_batched',
|
||||
details: result.details as Array<{ id: string; batch_id: string }>,
|
||||
}
|
||||
case 'amount_exceeds_remaining':
|
||||
return {
|
||||
ok: false,
|
||||
code: 'amount_exceeds_remaining',
|
||||
details: result.details as Array<{ id: string }>,
|
||||
}
|
||||
case 'ineligible':
|
||||
return {
|
||||
ok: false,
|
||||
code: 'ineligible',
|
||||
details: result.details as Array<{ id: string; reason: string }>,
|
||||
}
|
||||
default:
|
||||
return { ok: false, code: 'create_failed' }
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, batch: batch as SupplierPaymentBatch }
|
||||
return { ok: true, batch: result.batch }
|
||||
}
|
||||
|
||||
export interface RenderedBatchFile {
|
||||
|
||||
Reference in New Issue
Block a user