-
Notifications
You must be signed in to change notification settings - Fork 412
Description
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) // ← ProblemThis 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
- User taps "Subscribe"
- StoreKit payment sheet appears
- User selects UPI payment method
- iOS opens UPI app → your app backgrounds
- Apple payment sheet returns
AMSError.paymentSheetFailed - SDK maps this to
PURCHASE_CANCELLED - User completes payment in UPI app
- User returns to app seeing "cancelled" error
- 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
Purchases.shared.purchase(package: ) asyncnot being called when the user cancels #1445 - Original issue that led to mappingAMSError.paymentSheetFailedas cancellation- IOS: Payment cancelled while doing payment from UPI react-native-purchases#1570 - User report