Skip to content

AMSError.paymentSheetFailed incorrectly mapped to PURCHASE_CANCELLED during third-party payment app flows (UPI) #6194

@facumenzella

Description

@facumenzella

Summary

When users complete purchases via third-party payment apps (e.g., UPI in India), switching to the external app causes AMSError.paymentSheetFailed to be returned. This error is currently mapped to PURCHASE_CANCELLED, even though the user may be completing (not cancelling) the payment.

Reported in react-native-purchases: RevenueCat/react-native-purchases#1570

Root Cause

In Sources/Error Handling/SKError+Extensions.swift:67-76:

case .unhandledException:  // Error code 907
    if let error = self.userInfo[NSUnderlyingErrorKey] as? NSError {
        switch error.domain {
        case AMSError.domain:
            switch AMSError.Code(rawValue: error.code) {
            // See https://github.com/RevenueCat/purchases-ios/issues/1445
            case .paymentSheetFailed:
                return ErrorUtils.purchaseCancelledError(error: self)  // ← Problem

This mapping was added for #1445 where cancellations sometimes appeared as AMSError.paymentSheetFailed instead of the documented SKError.paymentCancelled.

The Problem

Apple returns the same error for two different scenarios:

Scenario Error User Intent
User taps "Cancel" AMSError.paymentSheetFailed (6) Cancel
User switches to UPI/external payment app AMSError.paymentSheetFailed (6) Complete payment

The SDK cannot currently distinguish between these cases.

Flow for UPI Payments

  1. User taps "Subscribe"
  2. StoreKit payment sheet appears
  3. User selects UPI payment method
  4. iOS opens UPI app → your app backgrounds
  5. Apple payment sheet returns AMSError.paymentSheetFailed
  6. SDK maps this to PURCHASE_CANCELLED
  7. User completes payment in UPI app
  8. User returns to app seeing "cancelled" error
  9. Payment actually succeeded on Apple's servers

Potential Solutions

Option 1: App Lifecycle Awareness (Recommended)

The SDK already tracks app lifecycle via systemInfo.isAppBackgroundedState. The fix would correlate this with in-flight purchases:

// Pseudocode
if error == AMSError.paymentSheetFailed {
    if purchaseWasInFlightWhenAppBackgrounded {
        return ErrorUtils.purchaseInterruptedError(error: self)  // New error type
    } else {
        return ErrorUtils.purchaseCancelledError(error: self)
    }
}

This requires PurchasesOrchestrator to track "purchase started" → "app backgrounded" → "error received" timing.

Option 2: New Error Code

Introduce PURCHASE_INTERRUPTED for ambiguous cases where the payment sheet failed but we can't confirm user intent. Let the app developer decide how to handle it (e.g., call getCustomerInfo() to verify).

Option 3: Documentation

Document that PURCHASE_CANCELLED may occur during third-party payment flows and recommend calling getCustomerInfo() to verify actual status.

Evidence Needed

We've asked the reporter to provide debug logs showing the actual error codes during a UPI flow to confirm this hypothesis.

Workaround

Developers can verify purchase status after receiving PURCHASE_CANCELLED:

do {
    let result = try await Purchases.shared.purchase(package: pkg)
} catch let error as PurchasesError {
    if error.code == .purchaseCancelledError {
        // Check if payment actually succeeded
        let customerInfo = try await Purchases.shared.customerInfo()
        if customerInfo.entitlements["entitlement_id"]?.isActive == true {
            // Payment succeeded despite the error
        }
    }
}

Related

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions