Files
accounted/tests/pg/pending-operations-failed-partial.pg.test.ts
Jakob Wennberg 3e1ea29d02 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>
2026-07-22 18:33:49 +02:00

140 lines
5.5 KiB
TypeScript

/**
* 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)
})
})