fix(pending-ops): record posted ids and land failed_partial instead of clean rejected after partial commits (#842) (#1110)
Multi-step executors (match_transaction_invoice, credit_invoice) post an irreversible voucher or persist a credit note and then run later fallible steps. A failure there previously marked the whole op status=rejected, hiding the posted entity and its id from operators. - new migration 20260722134114: add failed_partial to the pending_operations status CHECK and treat it as terminal in both immutability triggers (immutable, undeletable, never re-claimable) - PartialCommitError + ExecutorResult.partialPostedIds carry the posted ids; the dispatcher writes status=failed_partial with result_data.posted_ids and returns code=partial_commit - instrument only the two named executors; hoist the read-only settlement-account resolution above the storno in the match executor - consumer sweep: status union + query schema widened, failed_partial folds into the Avvisade tab with a badge and posted-ids detail line, bulk/reject routes and MCP tools message it explicitly, worklist and expiry sweep intentionally untouched (not pending work) - tests: pg-real coverage for the new terminal semantics, dispatcher unit tests for both partial paths plus byte-for-byte regression guards Fixes #842 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
27ef398623
commit
3e1ea29d02
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* pg-real coverage for migration 20260722134114_pending_operations_failed_partial_status.
|
||||
*
|
||||
* 'failed_partial' (issue #842) is the terminal state for a pending_operation
|
||||
* whose executor posted an irreversible side-effect (storno voucher, credit
|
||||
* note) and then failed a later step. These tests prove the schema semantics
|
||||
* the dispatcher relies on:
|
||||
* - the CHECK constraint accepts the new status
|
||||
* - committing -> failed_partial is a legal transition (the dispatcher
|
||||
* always claims to 'committing' first)
|
||||
* - failed_partial rows are immutable and undeletable (terminal, BFL 7 kap.)
|
||||
* - the CAS claim (UPDATE ... WHERE status = 'pending') can never pick up a
|
||||
* failed_partial row, so the op is not re-committable
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from './setup'
|
||||
import { seedCompany } from './fixtures'
|
||||
|
||||
async function insertOp(
|
||||
userId: string,
|
||||
companyId: string,
|
||||
status: string,
|
||||
extra: { resolvedAt?: boolean; resultData?: Record<string, unknown> } = {},
|
||||
): Promise<string> {
|
||||
const result = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.pending_operations
|
||||
(user_id, company_id, operation_type, status, title, params, preview_data, resolved_at, result_data)
|
||||
VALUES ($1, $2, 'match_transaction_invoice', $3, 'failed-partial pg test', '{}', '{}',
|
||||
${extra.resolvedAt ? 'now()' : 'NULL'}, $4)
|
||||
RETURNING id`,
|
||||
[userId, companyId, status, JSON.stringify(extra.resultData ?? {})],
|
||||
)
|
||||
return result.rows[0]!.id
|
||||
}
|
||||
|
||||
describe('pending_operations: failed_partial status (migration 20260722134114)', () => {
|
||||
it('CHECK constraint accepts failed_partial with posted ids in result_data', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const id = await insertOp(userId, companyId, 'failed_partial', {
|
||||
resolvedAt: true,
|
||||
resultData: {
|
||||
error: 'payment JE failed after storno',
|
||||
posted_ids: { reversal_journal_entry_id: '00000000-0000-0000-0000-000000000001' },
|
||||
},
|
||||
})
|
||||
|
||||
const row = await getPool().query<{ status: string; result_data: { posted_ids?: Record<string, string> } }>(
|
||||
`SELECT status, result_data FROM public.pending_operations WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
expect(row.rows[0]?.status).toBe('failed_partial')
|
||||
expect(row.rows[0]?.result_data.posted_ids?.reversal_journal_entry_id).toBe(
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
)
|
||||
})
|
||||
|
||||
it('CHECK constraint still rejects unknown statuses', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await expect(insertOp(userId, companyId, 'failed')).rejects.toThrow(
|
||||
/pending_operations_status_check|check constraint/i,
|
||||
)
|
||||
})
|
||||
|
||||
it('allows the committing -> failed_partial transition the dispatcher performs', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const id = await insertOp(userId, companyId, 'committing')
|
||||
|
||||
const upd = await getPool().query(
|
||||
`UPDATE public.pending_operations
|
||||
SET status = 'failed_partial',
|
||||
resolved_at = now(),
|
||||
result_data = '{"error":"second step failed","posted_ids":{"credit_note_id":"cn-1"}}'
|
||||
WHERE id = $1 RETURNING id, status`,
|
||||
[id],
|
||||
)
|
||||
expect(upd.rowCount).toBe(1)
|
||||
expect(upd.rows[0]?.status).toBe('failed_partial')
|
||||
})
|
||||
|
||||
it('blocks UPDATE on failed_partial rows (terminal-state immutability trigger)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const id = await insertOp(userId, companyId, 'failed_partial', { resolvedAt: true })
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.pending_operations SET title = 'tampered' WHERE id = $1`,
|
||||
[id],
|
||||
),
|
||||
).rejects.toThrow(/terminal state|BFL 7/i)
|
||||
|
||||
// Including status rewrites: failed_partial can never go back to pending.
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.pending_operations SET status = 'pending' WHERE id = $1`,
|
||||
[id],
|
||||
),
|
||||
).rejects.toThrow(/terminal state|BFL 7/i)
|
||||
})
|
||||
|
||||
it('blocks DELETE on failed_partial rows', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const id = await insertOp(userId, companyId, 'failed_partial', { resolvedAt: true })
|
||||
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.pending_operations WHERE id = $1`, [id]),
|
||||
).rejects.toThrow(/terminal state|BFL 7/i)
|
||||
})
|
||||
|
||||
it('CAS claim (status = pending guard) never picks up a failed_partial row', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const id = await insertOp(userId, companyId, 'failed_partial', { resolvedAt: true })
|
||||
|
||||
const claim = await getPool().query(
|
||||
`UPDATE public.pending_operations SET status = 'committing'
|
||||
WHERE id = $1 AND status = 'pending' RETURNING id`,
|
||||
[id],
|
||||
)
|
||||
expect(claim.rowCount).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps committed and rejected terminal behavior intact (regression)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const committedId = await insertOp(userId, companyId, 'committed', { resolvedAt: true })
|
||||
const rejectedId = await insertOp(userId, companyId, 'rejected', { resolvedAt: true })
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.pending_operations SET title = 'tampered' WHERE id = $1`,
|
||||
[committedId],
|
||||
),
|
||||
).rejects.toThrow(/terminal state|BFL 7/i)
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.pending_operations SET title = 'tampered' WHERE id = $1`,
|
||||
[rejectedId],
|
||||
),
|
||||
).rejects.toThrow(/terminal state|BFL 7/i)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user