# Purchase flow handles cancellation, failure, and network errors gracefully

- **Pattern:** `ab-000402` (`app-store-iap-subscriptions.iap-integration.purchase-errors`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000402
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000402)

## Why it matters

Every IAP submission is exercised by an Apple reviewer who will attempt to cancel the OS payment sheet. CWE-755 (Improper Handling of Exceptional Conditions) applies directly: an unhandled cancellation error that surfaces a crash or an error dialog is an automatic rejection under Apple guideline 3.1.1. Beyond review, a purchase flow that crashes or shows a confusing error when a user declines to buy drives uninstalls and negative reviews — user cancellation is a normal event, not an error state. A broken purchase error flow also risks leaving entitlement state inconsistent: the user may be charged by the platform but denied access by the app if the success path is never reached.

## Severity rationale

High because a crash or error dialog on user cancellation causes immediate App Store rejection and is exercised by every reviewer examining an IAP app.

## Remediation

Wrap every purchase SDK call in a try/catch and explicitly distinguish user cancellation (silent, no UI) from billing failures (show a toast or alert). Verify entitlement state after a successful purchase rather than assuming the success callback equals granted access.

```tsx
// React Native IAP
try {
  await requestSubscription({ sku: PRODUCT_IDS.MONTHLY });
} catch (error) {
  if (error instanceof PurchaseError && error.code === 'E_USER_CANCELLED') {
    return; // Silent — user chose not to subscribe
  }
  showErrorToast('Purchase failed. Please try again.');
}

// RevenueCat
try {
  const { customerInfo } = await Purchases.purchasePackage(pkg);
  if (customerInfo.entitlements.active['premium']) unlockPremium();
} catch (e) {
  if (!e.userCancelled) showErrorToast('Something went wrong. Please try again.');
}
```

Also handle network-level failures — users on flaky connections will trigger purchase timeouts that must recover gracefully without leaving the purchase screen in a broken state.

## Detection

- **ID:** `purchase-errors`
- **Severity:** `high`
- **What to look for:** Count all relevant instances and enumerate each. Trace the purchase initiation code path — the function or handler called when a user taps "Subscribe" or "Buy". Look for the SDK call and examine ALL branches it can return. For `react-native-iap`: look for `try/catch` around `requestSubscription()` or `requestPurchase()`, handling of `PurchaseError` with code `E_USER_CANCELLED` (must be silent, not shown as an error). For RevenueCat: look for `catch` on `Purchases.purchasePackage()`, distinguishing `PurchasesErrorCode.purchaseCancelledError` (silent) from real errors. For Flutter `in_app_purchase`: look for `purchaseStream` listener handling `PurchaseStatus.error`, `PurchaseStatus.canceled`. For StoreKit 2 (Swift): look for `try await product.purchase()` with `switch result { case .success, .userCancelled, .pending }`. For Play Billing (Kotlin): look for `BillingResult` with `responseCode == BillingClient.BillingResponseCode.USER_CANCELED`. Critical failure patterns: (1) showing an error dialog when user taps "Cancel" on the OS payment sheet — Apple explicitly prohibits this; (2) no error handling at all (bare `await sdk.purchase()` with no catch); (3) app crashes or hangs after a failed purchase; (4) purchase state is left inconsistent after an error (user charged but entitlement not granted, or vice versa — check for post-purchase verification).
- **Pass criteria:** Purchase flow has: a try/catch or equivalent error handler; explicit handling for user cancellation (silent, no error shown); user-facing error message for billing failures (network error, card declined); and state recovery (purchase state restored after error). At least 1 implementation must be verified. A partial or placeholder implementation does not count as pass.
- **Fail criteria:** No error handling around purchase calls; user cancellation displays an error dialog; app crashes or freezes after failed purchase; no network error handling.
- **Skip (N/A) when:** No IAP detected in the app.
- **Detail on fail:** `"requestSubscription() in src/screens/PaywallScreen.tsx has no try/catch — any purchase error crashes the screen"` or `"User cancellation triggers an error dialog in PurchaseButton.tsx — Apple Guideline 3.1.1 violation"`
- **Remediation:** Purchase flows are exercised by every reviewer who examines an IAP app. A crash on cancellation is an immediate rejection.
  1. For React Native IAP:
     ```tsx
     try {
       await requestSubscription({ sku: PRODUCT_IDS.MONTHLY });
     } catch (error) {
       if (error instanceof PurchaseError && error.code === 'E_USER_CANCELLED') {
         return; // Silent — user chose not to subscribe
       }
       showErrorToast('Purchase failed. Please try again.');
     }
     ```
  2. For RevenueCat:
     ```tsx
     try {
       const { customerInfo } = await Purchases.purchasePackage(pkg);
       if (customerInfo.entitlements.active['premium']) {
         unlockPremium();
       }
     } catch (e) {
       if (!e.userCancelled) {
         showErrorToast('Something went wrong. Please try again.');
       }
     }
     ```
  3. Always verify entitlement state after a successful purchase rather than assuming the purchase = entitlement

## External references

- cwe CWE-755
- external apple-guideline-3.1.1 — https://developer.apple.com/app-store/review/guidelines/#in-app-purchase

Taxons: error-resilience, regulatory-conformance

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