# Rollback or cleanup logic exists if post-payment database updates fail

- **Pattern:** `ab-001164` (`ecommerce-payment-security.client-side-handling.post-payment-rollback`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001164
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001164)

## 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.

```ts
// 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:

  ```ts
  // 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
- iso-25010 reliability.fault-tolerance

Taxons: data-integrity, error-resilience

HTML version: https://auditbuffet.com/patterns/ab-001164
