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:
Jakob Wennberg
2026-08-27 22:24:36 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 4f939ebb21
commit 533df34369
7 changed files with 798 additions and 75 deletions
+1
View File
@@ -60,6 +60,7 @@ Use the `/supabase-migration` skill for new migrations.
- `user_company_ids()`: RLS helper returning user's company IDs
- `current_active_company_id()`: RLS-side read of `user_preferences.active_company_id`; the same value the middleware resolves, so Next.js and RLS agree
- `claim_due_webhook_deliveries()`: Concurrent-safe claim of due `automation_webhooks` deliveries for the cron sender
- `create_supplier_payment_batch()`: Atomic betalfil batch creation (locks invoices, rechecks active batches in-transaction, header + items together)
- `get_unlinked_1930_lines()`: Bank reconciliation helper
- `cleanup_sandbox_user()`, `cleanup_expired_sandbox_users()`: Sandbox lifecycle
+1
View File
@@ -1298,6 +1298,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-27] New `unlinked_documents` category on the Accounted://attention resource, backed by lib/documents/unlinked-documents.ts. The whole design is the mime ALLOW-LIST, and the naive predicate is a trap: "current version, no journal_entry_id, referenced by none of the eight linking tables" returns 15 806 rows on prod, of which 11 309 are application/json and every single one is named psd2-response_<ts>_pN.json, the archived PSD2 bank-API responses the integration stores as evidence of each fetch. Those are unlinked BY DESIGN; surfacing them would hand an agent 11 309 items of work it must not action, which is worse than showing nothing. Measured 2026-08-27: application/json was 11 309 of 11 309 psd2, and pdf/png/jpeg/heic were 0 of 4 495, so the split is clean. Chose an allow-list of underlag-shaped mime types over excluding known-bad filenames, so a future machine-payload format (XML, CSV, an audit bundle) stays out by default instead of leaking until someone notices. Real remaining surface: 4 497 documents across 210 companies, median 3 per company, 481 in the preceding week, and NOT agent-specific (2 374 upload_source=api vs 1 623 file_upload from the web UI). Two-pass fetch mirroring fetchPurchasesWithoutUnderlag: indexed column filter, then eight reference lookups that run only when candidates exist, so the common case costs one query. Scan cap is 300 and is set by URL LENGTH, not table size: each candidate id is echoed through eight .in(column, ids) lookups at ~38 bytes per UUID, and a cap in the thousands would exceed the gateway limit, fail the lookups, and the "claims nothing" fallback would turn every candidate into a false positive. A failing lookup is deliberately treated as "claims nothing" (can only ADD a row) rather than dropping the category, so one misbehaving table cannot hide real work. UnlinkedDocument is a type alias not an interface: the resource assigns it into samples: Record<string, unknown>[] and an interface has no implicit index signature; vitest does not typecheck so this only fails in npm run build.
[2026-08-27] NOT fixed, and recorded so the next person does not act on an inflated number: the agent-facing readers (resources/attention.ts, resources/recent-activity.ts) still test booked-ness with a raw journal_entry_id null check instead of the canonical isTransactionBooked, which misses the bulk-book (transaction_voucher_links) and multi-allocation (invoice_payments / supplier_invoice_payments) cases. Real scale measured on prod 2026-08-27: 4 transactions, in 1 company, out of 567 column-filtered unbooked, all 4 via transaction_voucher_links and 0 via either payments table. Worth fixing as hygiene, but it is a 4-row problem and doing it properly in attention.ts needs the same two-pass treatment plus a decision about count semantics for a tenant with thousands of unbooked rows, so it does not belong bolted onto this change.
[2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up.
[2026-08-27] Supplier payment batch creation is atomic via create_supplier_payment_batch (SECURITY DEFINER, #1503): TS keeps the eligibility evaluation and msg_id minting (branding lives in TS), the RPC locks the invoices FOR UPDATE in id order, rechecks active batches inside the transaction, and writes header + items in one statement; domain refusals return jsonb (match_batch_allocate shape) because no write precedes them, the tenant guard raises 42501 (detach_underlag_duplicate shape), and the best-effort cancel of an empty header is gone because the header can no longer outlive its items.
[2026-08-27] Jämkning UI (EmployeeTaxCard) requires both jamkning_valid_from and jamkning_valid_to while the API keeps valid_to optional: isJamkningValid (calculation-engine.ts) never applies a beslut without an end date, so the form enforces what the engine needs; the API/MCP contract is left untouched for compatibility (follow-up: align engine or API, founder call). The legacy PATCH /api/salary/employees/[id] gets the same merged start-date/ordering check as v1 so the UI gets a 400 instead of a silently inert beslut.
[2026-08-27] Jämkning keys reach the employee PATCH body only when the inputs were visible (A-skatt, not sidoinkomst) and edited this session (jamkningPatch + EmployeeTaxValue.jamkning_touched), otherwise omitted like every other sparse field; native required on the two dates is likewise gated on touched: the card reports null for hidden inputs and the route spreads the body, so forwarding those nulls wiped a stored beslut on a sidoinkomst toggle or an FA-skatt phone-number edit (the engine still applies it for FA-skatt), and an API/MCP row without valid_to blocked native form validation on unrelated edits. Untouched-omit over touched-null: the route's own touched-gate already assumes absent keys leave the row alone.
[2026-08-27] #1898 line-pattern 3740 rounding leg now lands opposite the business side when the ratios over-allocate (negative diff) and stays on the business side when they under-allocate (positive diff), in buildMultiLineMappingResult AND computeProposalLines in the same change, superseding the 2026-08-25 note that kept business-side placement for both signs: that note assumed the imbalance never reached a user, but since #1894 the prefilled form carries it and the confirm fails with JournalEntryNotBalancedError. Reachable with normalized ratios too (50/50 on 100.03 kr rounds to 50.02 + 50.02), not only with un-normalized patterns. The flip is applied AFTER the mirror on both sides (the engine's businessSide is already mirrored via side(), the proposal's raw side is mirrored first), no new Math.round(x*100)/100 was written (the existing rounded diff is reused, naiveOreRound baseline unchanged), positive-diff bookings are byte-identical to before, and parity is executable: proposal-lines.test.ts sweeps 0.01..50.00 kr against the engine.
@@ -194,13 +194,14 @@ describe('POST /api/supplier-invoices/payment-batches', () => {
})
it('creates a batch and returns 201', async () => {
// Queue is shared between from() and rpc(): the last entry is the
// create_supplier_payment_batch RPC result.
enqueueMany([
{ data: companyRow },
{ data: settingsRow },
{ data: [invoiceRow()] },
{ data: [] },
{ data: batchRow() },
{ data: null },
{ data: { ok: true, batch: batchRow() } },
])
const response = await createBatch(
createMockRequest('/api/supplier-invoices/payment-batches', {
+174 -37
View File
@@ -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()
})
})
+55 -35
View File
@@ -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 {
@@ -0,0 +1,226 @@
-- create_supplier_payment_batch: the single write path for supplier payment
-- batch (betalfil) creation. Closes #1503.
--
-- Before this RPC, createSupplierPaymentBatch wrote the batch header and its
-- items as two separate PostgREST inserts, and the active-batch recheck ran
-- app-side before either. Two failure modes followed:
--
-- 1. Race: two concurrent creates selecting the same invoice could both pass
-- the app-side active-batch check and both land an active batch without
-- confirm_already_batched, so the same invoice could be handed to the
-- bank twice.
-- 2. Empty header: if the item insert failed after the header landed, the
-- best-effort cancel of the header could itself fail, leaving an empty
-- 'created' batch behind (inert, because the file route refuses it, but
-- wrong in history).
--
-- This function is the authority. TypeScript keeps the eligibility, amount
-- and date evaluation (shared with the preview so nothing is created that the
-- preview would not show) and the minting of the batch id + pain.001 MsgId
-- (MsgId derives from the branded app name, a TS-only white-label concept).
-- The RPC then, inside ONE transaction:
--
-- a. locks the selected supplier_invoices FOR UPDATE, in id order so two
-- overlapping concurrent creates queue instead of deadlocking;
-- b. re-checks the invoices under the lock (still present, still payable,
-- amount still within remaining), reusing the service's result codes;
-- c. re-checks active batches AFTER the lock unless the caller confirmed
-- already-batched invoices: under READ COMMITTED a creator that waited on
-- the lock sees the winner's committed items here, which is the recheck
-- the app-side pre-check cannot provide;
-- d. inserts header + items. Totals are computed from the items so header
-- and rows can never disagree, and a constraint violation on either
-- table (payee_fields_match, uq_supplier_payment_batch_invoice, the
-- composite company FKs, the CHECKs) aborts the whole call: the header
-- can no longer outlive its items.
--
-- Domain refusals return jsonb {ok:false, code, details} in the
-- match_batch_allocate shape: no write precedes them, so nothing needs
-- rolling back and the service maps them onto its existing result union.
-- The tenant guard RAISEs 42501 (detach_underlag_duplicate shape): JWT callers
-- must be members of p_company_id and always act as auth.uid(); p_user_id is
-- honored only for service-role callers, which authenticate the user
-- application-side. The guard parses request.jwt.claims directly rather than
-- calling auth.role(): the CI auth shim leaves auth.role() NULL under a
-- claims-only session.
--
-- The write_audit_log triggers on both tables fire as before; the RLS insert
-- policies are bypassed by SECURITY DEFINER, which is why the membership guard
-- above is mandatory.
CREATE OR REPLACE FUNCTION public.create_supplier_payment_batch(
p_company_id uuid,
p_batch_id uuid,
p_format text,
p_msg_id text,
p_debtor_snapshot jsonb,
p_items jsonb,
p_confirm_already_batched boolean DEFAULT false,
p_user_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_actor uuid := COALESCE(p_user_id, auth.uid());
v_caller_role text;
v_ids uuid[];
v_missing jsonb;
v_not_payable jsonb;
v_excessive jsonb;
v_already jsonb;
v_total numeric;
v_count integer;
v_batch public.supplier_payment_batches%ROWTYPE;
BEGIN
-- 1. Actor + tenant guard.
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF NOT public.caller_is_company_member(p_company_id) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
-- A JWT caller can never act as someone else: p_user_id is only for
-- service-role paths, which authenticate the user application-side.
v_actor := auth.uid();
END IF;
IF v_actor IS NULL THEN
RAISE EXCEPTION 'unauthorized: no actor' USING ERRCODE = '42501';
END IF;
SELECT cm.role INTO v_caller_role
FROM public.company_members cm
WHERE cm.company_id = p_company_id AND cm.user_id = v_actor;
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin', 'member') THEN
RAISE EXCEPTION 'unauthorized: caller has no write role in company %', p_company_id
USING ERRCODE = '42501';
END IF;
-- 2. Payload shape.
IF p_batch_id IS NULL OR p_msg_id IS NULL OR p_debtor_snapshot IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'create_failed', 'details', 'missing header fields');
END IF;
IF jsonb_typeof(p_items) IS DISTINCT FROM 'array' OR jsonb_array_length(p_items) = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'create_failed', 'details', 'no items');
END IF;
IF p_format NOT IN ('pain001', 'bg_lb') THEN
RETURN jsonb_build_object('ok', false, 'code', 'create_failed', 'details', 'unsupported format');
END IF;
SELECT array_agg((x ->> 'supplier_invoice_id')::uuid)
INTO v_ids
FROM jsonb_array_elements(p_items) AS x;
-- 3. Lock the invoices in id order. Two concurrent creates with overlapping
-- invoice sets queue on the first shared row instead of deadlocking, and
-- the loser proceeds only after the winner has committed or rolled back.
PERFORM si.id
FROM public.supplier_invoices si
WHERE si.company_id = p_company_id AND si.id = ANY(v_ids)
ORDER BY si.id
FOR UPDATE;
-- 4. Defense-in-depth rechecks under the lock. TypeScript already evaluated
-- every invoice; these close the "changed meanwhile" window. The payable
-- status list must match PAYABLE_SUPPLIER_INVOICE_STATUSES in
-- lib/payments/batch-eligibility.ts.
SELECT jsonb_agg(jsonb_build_object('id', x.id, 'reason', 'not_found'))
INTO v_missing
FROM unnest(v_ids) AS x(id)
WHERE NOT EXISTS (
SELECT 1 FROM public.supplier_invoices si
WHERE si.id = x.id AND si.company_id = p_company_id
);
IF v_missing IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'ineligible', 'details', v_missing);
END IF;
SELECT jsonb_agg(jsonb_build_object(
'id', si.id,
'reason', CASE WHEN si.is_credit_note THEN 'credit_note' ELSE 'not_payable' END))
INTO v_not_payable
FROM public.supplier_invoices si
WHERE si.company_id = p_company_id
AND si.id = ANY(v_ids)
AND (si.status NOT IN ('registered', 'approved', 'partially_paid', 'overdue')
OR si.is_credit_note);
IF v_not_payable IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'ineligible', 'details', v_not_payable);
END IF;
SELECT jsonb_agg(jsonb_build_object('id', si.id))
INTO v_excessive
FROM jsonb_array_elements(p_items) AS x
JOIN public.supplier_invoices si
ON si.id = (x ->> 'supplier_invoice_id')::uuid AND si.company_id = p_company_id
WHERE (x ->> 'amount')::numeric > si.remaining_amount + 0.005;
IF v_excessive IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'amount_exceeds_remaining', 'details', v_excessive);
END IF;
-- 5. The recheck #1503 asks for: active batches, inside the transaction,
-- after the lock. A creator that waited on step 3 sees the winner's
-- committed items here (READ COMMITTED: each statement in a VOLATILE
-- function takes a fresh snapshot).
IF NOT COALESCE(p_confirm_already_batched, false) THEN
SELECT jsonb_agg(jsonb_build_object('id', i.supplier_invoice_id, 'batch_id', b.id))
INTO v_already
FROM public.supplier_payment_batch_items i
JOIN public.supplier_payment_batches b
ON b.id = i.batch_id AND b.company_id = i.company_id
WHERE i.company_id = p_company_id
AND b.status = 'created'
AND i.supplier_invoice_id = ANY(v_ids);
IF v_already IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'already_batched', 'details', v_already);
END IF;
END IF;
-- 6. Header + items in this one transaction. Totals come from the items so
-- header and rows can never disagree; the table CHECKs (amount > 0,
-- total_amount > 0, item_count > 0, payee_fields_match) and the
-- uniqueness / composite company FKs still fire and abort the whole call.
SELECT round(sum((x ->> 'amount')::numeric), 2), count(*)
INTO v_total, v_count
FROM jsonb_array_elements(p_items) AS x;
INSERT INTO public.supplier_payment_batches
(id, company_id, user_id, format, status, currency, total_amount, item_count, msg_id, debtor_snapshot)
VALUES
(p_batch_id, p_company_id, v_actor, p_format, 'created', 'SEK', v_total, v_count, p_msg_id, p_debtor_snapshot)
RETURNING * INTO v_batch;
INSERT INTO public.supplier_payment_batch_items
(batch_id, company_id, supplier_invoice_id, amount, payment_date, payee_type,
payee_bankgiro, payee_plusgiro, payee_clearing, payee_account, payee_name, payee_city,
reference_type, reference)
SELECT p_batch_id, p_company_id, r.supplier_invoice_id, r.amount, r.payment_date, r.payee_type,
r.payee_bankgiro, r.payee_plusgiro, r.payee_clearing, r.payee_account, r.payee_name, r.payee_city,
r.reference_type, r.reference
FROM jsonb_to_recordset(p_items) AS r(
supplier_invoice_id uuid,
amount numeric,
payment_date date,
payee_type text,
payee_bankgiro text,
payee_plusgiro text,
payee_clearing text,
payee_account text,
payee_name text,
payee_city text,
reference_type text,
reference text
);
RETURN jsonb_build_object('ok', true, 'batch', to_jsonb(v_batch));
END;
$function$;
REVOKE ALL ON FUNCTION public.create_supplier_payment_batch(uuid, uuid, text, text, jsonb, jsonb, boolean, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.create_supplier_payment_batch(uuid, uuid, text, text, jsonb, jsonb, boolean, uuid) TO authenticated, service_role;
NOTIFY pgrst, 'reload schema';
+338 -1
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import type { PoolClient } from 'pg'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from './setup'
import { getClient, getPool, withUserContext } from './setup'
import { seedCompany, insertAuthUser } from './fixtures'
// pg-real coverage for 20260810160748_supplier_payment_batches.sql: RLS
@@ -8,6 +9,11 @@ import { seedCompany, insertAuthUser } from './fixtures'
// a payment instruction undeletable, the payee_fields_match CHECK, the
// per-batch invoice uniqueness, item immutability (no UPDATE/DELETE policies),
// and the updated_at trigger.
//
// Also covers 20260827100000_create_supplier_payment_batch_rpc.sql (#1503):
// the atomic create RPC (happy path, in-transaction active-batch recheck,
// header + items rolling back together, FOR UPDATE serialization of two
// concurrent creates, tenant guard and actor pinning, EXECUTE privileges).
async function insertSupplier(companyId: string, userId: string): Promise<string> {
const id = randomUUID()
@@ -346,3 +352,334 @@ describe('supplier_payment_batches constraints', () => {
)
})
})
// ---------------------------------------------------------------------------
// create_supplier_payment_batch RPC (#1503)
// ---------------------------------------------------------------------------
type RpcResult =
| { ok: true; batch: Record<string, unknown> }
| { ok: false; code: string; details?: unknown }
function itemsPayload(invoiceId: string, overrides: Record<string, unknown> = {}) {
return [
{
supplier_invoice_id: invoiceId,
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 AB',
payee_city: null,
reference_type: 'invoice_number',
reference: 'CD3014794407',
...overrides,
},
]
}
const DEBTOR = {
name: 'Test AB',
org_number: '556677-8899',
iban: 'SE3550000000054910000003',
bic: 'ESSESESS',
bankgiro: null,
city: null,
}
async function callRpc(
client: PoolClient,
params: {
companyId: string
batchId: string
items: unknown[]
confirm?: boolean
userId?: string | null
},
): Promise<RpcResult> {
const { rows } = await client.query<{ result: RpcResult }>(
`SELECT public.create_supplier_payment_batch(
$1, $2, 'pain001', $3, $4::jsonb, $5::jsonb, $6, $7
) AS result`,
[
params.companyId,
params.batchId,
`ACCOUNTED-5566778899-B${params.batchId.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
JSON.stringify(DEBTOR),
JSON.stringify(params.items),
params.confirm ?? false,
params.userId ?? null,
],
)
return rows[0].result
}
async function seedInvoiceOnly() {
const ctx = await seedCompany()
const supplierId = await insertSupplier(ctx.companyId, ctx.userId)
const invoiceId = await insertSupplierInvoice(ctx.companyId, ctx.userId, supplierId)
return { ...ctx, supplierId, invoiceId }
}
async function countBatches(client: PoolClient | null, batchId: string): Promise<number> {
const runner = client ?? getPool()
const { rows } = await runner.query<{ n: string }>(
`SELECT count(*)::text AS n FROM public.supplier_payment_batches WHERE id = $1`,
[batchId],
)
return Number(rows[0].n)
}
async function countItems(client: PoolClient | null, batchId: string): Promise<number> {
const runner = client ?? getPool()
const { rows } = await runner.query<{ n: string }>(
`SELECT count(*)::text AS n FROM public.supplier_payment_batch_items WHERE batch_id = $1`,
[batchId],
)
return Number(rows[0].n)
}
describe('create_supplier_payment_batch RPC', () => {
it('creates header + items for an authenticated member and returns the batch row', async () => {
const ctx = await seedInvoiceOnly()
const batchId = randomUUID()
const { result, items } = await withUserContext(ctx.userId, async (client) => {
const result = await callRpc(client, {
companyId: ctx.companyId,
batchId,
items: itemsPayload(ctx.invoiceId),
})
// withUserContext rolls back, so the row count is asserted inside.
const items = await countItems(client, batchId)
return { result, items }
})
expect(result.ok).toBe(true)
if (!result.ok) throw new Error('unreachable')
expect(result.batch.id).toBe(batchId)
expect(result.batch.company_id).toBe(ctx.companyId)
expect(result.batch.user_id).toBe(ctx.userId)
expect(result.batch.status).toBe('created')
expect(result.batch.format).toBe('pain001')
expect(result.batch.currency).toBe('SEK')
expect(result.batch.item_count).toBe(1)
expect(Number(result.batch.total_amount)).toBe(737.5)
expect(result.batch.msg_id).toBe(
`ACCOUNTED-5566778899-B${batchId.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
)
expect(result.batch.debtor_snapshot).toEqual(DEBTOR)
expect(typeof result.batch.created_at).toBe('string')
expect(items).toBe(1)
})
it('rechecks active batches inside the transaction and honors the confirmation', async () => {
// seedBatchWithItem leaves an active ('created') batch on invoiceId.
const ctx = await seedBatchWithItem()
const batchId = randomUUID()
const refused = await withUserContext(ctx.userId, (client) =>
callRpc(client, { companyId: ctx.companyId, batchId, items: itemsPayload(ctx.invoiceId) }),
)
expect(refused).toEqual({
ok: false,
code: 'already_batched',
details: [{ id: ctx.invoiceId, batch_id: ctx.batchId }],
})
expect(await countBatches(null, batchId)).toBe(0)
const confirmed = await withUserContext(ctx.userId, (client) =>
callRpc(client, {
companyId: ctx.companyId,
batchId,
items: itemsPayload(ctx.invoiceId),
confirm: true,
}),
)
expect(confirmed.ok).toBe(true)
})
it('refuses under the lock when the invoice is no longer payable or the amount exceeds remaining', async () => {
const ctx = await seedInvoiceOnly()
await getPool().query(`UPDATE public.supplier_invoices SET status = 'paid' WHERE id = $1`, [
ctx.invoiceId,
])
const notPayable = await withUserContext(ctx.userId, (client) =>
callRpc(client, {
companyId: ctx.companyId,
batchId: randomUUID(),
items: itemsPayload(ctx.invoiceId),
}),
)
expect(notPayable).toEqual({
ok: false,
code: 'ineligible',
details: [{ id: ctx.invoiceId, reason: 'not_payable' }],
})
await getPool().query(
`UPDATE public.supplier_invoices SET status = 'approved', remaining_amount = 100 WHERE id = $1`,
[ctx.invoiceId],
)
const excessive = await withUserContext(ctx.userId, (client) =>
callRpc(client, {
companyId: ctx.companyId,
batchId: randomUUID(),
items: itemsPayload(ctx.invoiceId),
}),
)
expect(excessive).toEqual({
ok: false,
code: 'amount_exceeds_remaining',
details: [{ id: ctx.invoiceId }],
})
const ghost = await withUserContext(ctx.userId, (client) =>
callRpc(client, {
companyId: ctx.companyId,
batchId: randomUUID(),
items: itemsPayload(randomUUID()),
}),
)
expect(ghost.ok).toBe(false)
if (ghost.ok) throw new Error('unreachable')
expect(ghost.code).toBe('ineligible')
expect(ghost.details).toEqual([expect.objectContaining({ reason: 'not_found' })])
})
it('rolls the header back with the items on a constraint violation (no empty created batch)', async () => {
const ctx = await seedInvoiceOnly()
const batchId = randomUUID()
// Plain pool: superuser, no JWT claims, so the guard is bypassed and
// p_user_id supplies the actor. The item violates payee_fields_match.
await expect(
callRpc(getPool() as unknown as PoolClient, {
companyId: ctx.companyId,
batchId,
items: itemsPayload(ctx.invoiceId, { payee_bankgiro: null }),
userId: ctx.userId,
}),
).rejects.toThrow(/payee_fields_match/)
expect(await countBatches(null, batchId)).toBe(0)
expect(await countItems(null, batchId)).toBe(0)
})
it('serializes two concurrent creates on the same invoice: the loser gets already_batched', async () => {
const ctx = await seedInvoiceOnly()
const batchIdA = randomUUID()
const batchIdB = randomUUID()
const clientA = await getClient()
const clientB = await getClient()
try {
await clientA.query('BEGIN')
const resultA = await callRpc(clientA, {
companyId: ctx.companyId,
batchId: batchIdA,
items: itemsPayload(ctx.invoiceId),
userId: ctx.userId,
})
expect(resultA.ok).toBe(true)
// B starts while A holds the FOR UPDATE lock on the invoice: it must
// block rather than pass the app-side-style check and land a second
// active batch.
await clientB.query('BEGIN')
const pendingB = callRpc(clientB, {
companyId: ctx.companyId,
batchId: batchIdB,
items: itemsPayload(ctx.invoiceId),
userId: ctx.userId,
})
let settled = false
void pendingB.then(
() => {
settled = true
},
() => {
settled = true
},
)
await new Promise((resolve) => setTimeout(resolve, 150))
expect(settled).toBe(false)
await clientA.query('COMMIT')
const resultB = await pendingB
expect(resultB).toEqual({
ok: false,
code: 'already_batched',
details: [{ id: ctx.invoiceId, batch_id: batchIdA }],
})
await clientB.query('ROLLBACK')
expect(await countBatches(null, batchIdB)).toBe(0)
// With explicit consent a second active batch is allowed.
const confirmed = await callRpc(getPool() as unknown as PoolClient, {
companyId: ctx.companyId,
batchId: randomUUID(),
items: itemsPayload(ctx.invoiceId),
confirm: true,
userId: ctx.userId,
})
expect(confirmed.ok).toBe(true)
} finally {
await clientA.query('ROLLBACK').catch(() => {})
await clientB.query('ROLLBACK').catch(() => {})
clientA.release()
clientB.release()
}
})
it('raises 42501 for a non-member and pins the actor to auth.uid() for JWT callers', async () => {
const ctx = await seedInvoiceOnly()
const stranger = await insertAuthUser()
let guardError: { code?: string } | null = null
try {
await withUserContext(stranger, (client) =>
callRpc(client, {
companyId: ctx.companyId,
batchId: randomUUID(),
items: itemsPayload(ctx.invoiceId),
}),
)
} catch (err) {
guardError = err as { code?: string }
}
expect(guardError?.code).toBe('42501')
// An authenticated owner passing p_user_id = stranger still owns the batch.
const spoofed = await withUserContext(ctx.userId, (client) =>
callRpc(client, {
companyId: ctx.companyId,
batchId: randomUUID(),
items: itemsPayload(ctx.invoiceId),
userId: stranger,
}),
)
expect(spoofed.ok).toBe(true)
if (!spoofed.ok) throw new Error('unreachable')
expect(spoofed.batch.user_id).toBe(ctx.userId)
})
it('is executable by authenticated and service_role but not anon', async () => {
const sig = 'public.create_supplier_payment_batch(uuid,uuid,text,text,jsonb,jsonb,boolean,uuid)'
const { rows } = await getPool().query<{
anon_can: boolean
authenticated_can: boolean
service_role_can: boolean
}>(
`SELECT has_function_privilege('anon', $1, 'EXECUTE') AS anon_can,
has_function_privilege('authenticated', $1, 'EXECUTE') AS authenticated_can,
has_function_privilege('service_role', $1, 'EXECUTE') AS service_role_can`,
[sig],
)
expect(rows[0]).toEqual({ anon_can: false, authenticated_can: true, service_role_can: true })
})
})