Don't let a compensating payment call swallow the original error

Both placeOrder and cancelOrderItem call a compensating payment action
(refund / re-charge) after catching a failure, then rethrow. If that
compensating call itself threw, its error replaced the original one,
hiding the actual root cause behind whatever the payment provider
happened to fail with on the second attempt. Wrap each compensation in
its own try/catch, log the compensation failure, and always rethrow
the original error.

Also fixes a stale comment left over from renaming
createFirstStepForOrderItem to initializeOrderItemRecipe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 20:07:14 +02:00
co-authored by Claude Sonnet 5
parent 506319ce4f
commit b7fdbd4779
2 changed files with 90 additions and 4 deletions
+65
View File
@@ -7,6 +7,7 @@ import {
describe,
expect,
it,
vi,
} from 'vitest'
import * as schema from '#/db/schema.ts'
@@ -159,6 +160,34 @@ describe('placing an order', () => {
seed.menuItems.pizza.priceCents,
)
})
it('surfaces the original transaction failure even if the compensating refund also fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const failingRefundPayment: typeof payment = {
...payment,
refund: async () => {
throw new Error('simulated payment provider outage')
},
}
const dbWithFailingTransaction = Object.create(
testDb.db,
) as typeof testDb.db
dbWithFailingTransaction.transaction = () => {
throw new Error('simulated DB failure after payment')
}
await expect(
ordering.placeOrder(dbWithFailingTransaction, failingRefundPayment, {
items: [{ menuItemId: seed.menuItems.pizza.id }],
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
}),
).rejects.toThrow('simulated DB failure after payment')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('cancelling an Order Item', () => {
@@ -287,6 +316,42 @@ describe('cancelling an Order Item', () => {
expect(rechargedAmountCents).toBe(seed.menuItems.pizza.priceCents)
})
it('surfaces the original cancellation failure even if the compensating re-charge also fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const itemId = (await getStatus(order)).items[0].id
const failingRechargePayment: typeof payment = {
...payment,
charge: async () => {
throw new Error('simulated payment provider outage')
},
}
const dbWithFailingTransaction = Object.create(
testDb.db,
) as typeof testDb.db
dbWithFailingTransaction.transaction = () => {
throw new Error('simulated DB failure after refund')
}
await expect(
ordering.cancelOrderItem(
dbWithFailingTransaction,
failingRechargePayment,
itemId,
accessFor(order),
),
).rejects.toThrow('simulated DB failure after refund')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
it('gives an accurate message for a double-cancel', async () => {
await placeGuestOrder([{ menuItemId: seed.menuItems.burger.id }]) // occupies the only grill
const order = await placeGuestOrder([