Refund before cancelling, not after, with compensation on failure

cancelOrderItem committed the cancellation (freeing the Machine slot,
marking the item cancelled) before calling the payment provider's
refund — so a refund failure (gateway timeout, provider outage) left
the item cancelled with no refund ever issued, and no record that one
was owed. placeOrder already avoids the equivalent problem by charging
before its DB transaction and refunding if the transaction then fails;
apply the same pattern here for consistency.

Refund now happens first; the DB transaction's atomic queued-status
check (added for the earlier cancellation race) doubles as the signal
that the refund needs to be undone if this transaction loses the race
or otherwise fails, so the catch block re-charges to compensate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 20:01:55 +02:00
co-authored by Claude Sonnet 5
parent 9c3ad51686
commit 506319ce4f
2 changed files with 107 additions and 60 deletions
+38
View File
@@ -249,6 +249,44 @@ describe('cancelling an Order Item', () => {
).rejects.toThrow(ordering.ForbiddenError)
})
it('recharges the customer if the DB transaction fails after the refund has already gone through', async () => {
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
let rechargedAmountCents: number | undefined
const spyingPayment: typeof payment = {
...payment,
charge: async (amountCents) => {
rechargedAmountCents = amountCents
return payment.charge(amountCents)
},
}
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,
spyingPayment,
itemId,
accessFor(order),
),
).rejects.toThrow('simulated DB failure after refund')
expect(payment.refundsFor(order.paymentChargeId!)).toBe(
seed.menuItems.pizza.priceCents,
)
expect(rechargedAmountCents).toBe(seed.menuItems.pizza.priceCents)
})
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([