Rollback or cleanup logic exists if post-payment database updates fail
Why it matters
When a Stripe charge succeeds but the subsequent database write fails, you have taken the customer's money without creating their order record. Without compensation logic, this is a silent failure: your system shows no order, the customer sees no confirmation, and no automated process triggers a refund or retry. CWE-362 (Race Condition / Improper Synchronization) applies when the payment and fulfillment steps are not treated as an atomic operation. In high-volume stores, even a 0.1% database failure rate translates to dozens of unreconciled charges per day — each requiring manual support intervention, chargeback risk, and reputational damage.
Severity rationale
High because an unhandled post-payment database failure charges the customer without creating their order, requiring manual intervention for every occurrence and creating chargeback exposure.
Remediation
Wrap all post-payment database writes in a transaction, and provide explicit compensation (refund or retry queue) if the transaction fails.
// In your webhook handler after verifying signature
if (event.type === 'payment_intent.succeeded') {
const pi = event.data.object as Stripe.PaymentIntent
try {
await db.transaction(async (tx) => {
await tx.orders.create({ stripePaymentIntentId: pi.id, status: 'confirmed' })
await tx.inventory.decrement(/* items */)
})
} catch (err) {
console.error('Post-payment DB failure', { paymentIntentId: pi.id, err })
// Compensation: refund the charge so the customer is not stuck
await stripe.refunds.create({ payment_intent: pi.id })
// Or: push to a dead-letter queue for manual review + retry
await queue.push({ type: 'fulfill-order', paymentIntentId: pi.id })
}
}
Use Stripe's metadata field on the PaymentIntent to store the internal order ID, so any retry or support lookup can correlate the charge to your records.
Detection
-
ID:
post-payment-rollback -
Severity:
high -
What to look for: Trace the code that runs after a payment is confirmed. List all post-payment database write operations (order records, inventory updates, subscription records) and for each, classify the error handling as: (a) transaction-wrapped, (b) compensating refund logic, (c) retry/queue mechanism, or (d) no error handling. Count the total operations and those with adequate protection.
-
Pass criteria: Post-payment database operations are either: (a) wrapped in a transaction that rolls back on failure, (b) accompanied by compensation logic that issues a refund if the DB write fails, or (c) designed to be idempotent and retryable. At least 1 of these mechanisms must be present for every post-payment write path.
-
Fail criteria: Post-payment database writes are bare
await db.create(...)calls with no error handling. If the database write fails after a successful charge, the customer is charged but receives no order, with no mechanism for recovery. -
Skip (N/A) when: The project has no post-payment database writes — for example, a pure PaymentIntent-only flow with no order record creation.
-
Detail on fail: Describe the unprotected sequence. Example:
"app/api/webhooks/stripe/route.ts: on payment_intent.succeeded, calls db.orders.create() with no try/catch and no refund logic — a DB failure would charge the customer without creating their order" -
Remediation: Wrap post-payment operations in try/catch with compensation logic:
// In your webhook handler after verifying signature if (event.type === 'payment_intent.succeeded') { const paymentIntent = event.data.object as Stripe.PaymentIntent try { await db.transaction(async (tx) => { await tx.orders.create({ stripePaymentIntentId: paymentIntent.id, amount: paymentIntent.amount, status: 'confirmed', // ... }) await tx.inventory.decrement(/* items */) }) } catch (err) { // Log the failure for manual review console.error('Post-payment DB write failed', { paymentIntentId: paymentIntent.id, err }) // Option A: Issue a refund immediately await stripe.refunds.create({ payment_intent: paymentIntent.id }) // Option B: Queue for retry with an idempotent worker await queue.push({ type: 'fulfill-order', paymentIntentId: paymentIntent.id }) } }
External references
- cwe · CWE-362 — Concurrent Execution Using Shared Resource with Improper Synchronization (Race Condition)
- iso-25010:2011 · reliability.fault-tolerance
Taxons
History
- 2026-04-18·v1.0.0·Initial import from ecommerce-payment-security·automated