From bacde54496e4e54568c6c26caa42ee1fe072c4e3 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Tue, 27 Oct 2026 17:42:06 -0300 Subject: [PATCH 1/3] fix: close the Apple Wallet sheet when the issuer call fails A thrown encrypt callback used to leave the Add Payment Pass sheet waiting for a payload that would never arrive. --- README.md | 2 +- .../main/java/com/expensify/wallet/WalletModule.kt | 4 ++++ .../java/com/expensify/wallet/NativeWalletSpec.java | 4 ++++ ios/RNWallet.mm | 10 ++++++++++ ios/WalletManager.swift | 9 +++++++++ src/NativeWallet.ts | 1 + src/index.tsx | 12 +++++++++++- 7 files changed, 40 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7ad2db33..9722ae35 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ The library offers functions for seamless integration and use of the Apple Walle | **addCardToGoogleWallet** | Initiates native Push Provisioning flow for adding a card to the Google Wallet. Requires a handler that returns the payment credentials for the UAPP flow. [Learn more →](./docs/UAPP.md) | `data`: `AndroidCardData`,
`handlePaymentCredentials`: `(data: onPaymentCredentialsRequestPayload) => Promise` | `TokenizationStatus` | ❌ | ✅ | | **resumeAddCardToGoogleWallet** | Resumes the Push Provisioning flow for adding a card to the Google Wallet using existing token reference ID. | `data`: `AndroidResumeCardData` | `TokenizationStatus` | ❌ | ✅ | | **listTokens** | Lists all tokens currently stored in the Google Wallet. | None | `TokenInfo[]` | ❌ | ✅ | -| **addCardToAppleWallet** | Initiates native Push Provisioning flow for adding a card to the Apple Wallet. | `data`: `IOSCardData`,
`issuerEncrypt-`
`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ | +| **addCardToAppleWallet** | Initiates native Push Provisioning flow for adding a card to the Apple Wallet. If the issuer callback throws, the Add Payment Pass sheet is closed and that error is returned. | `data`: `IOSCardData`,
`issuerEncrypt-`
`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ | ## Data Types diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index a77dee0a..45b8abea 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -588,6 +588,10 @@ class WalletModule internal constructor(context: ReactApplicationContext) : // no-op } + override fun IOSFailAddPaymentPass(promise: Promise?) { + promise?.resolve(null) + } + override fun addListener(eventType: String?) { // no-op } diff --git a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java index 052fb38b..3875bb5a 100644 --- a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java +++ b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java @@ -81,6 +81,10 @@ public NativeWalletSpec(ReactApplicationContext reactContext) { @DoNotStrip public abstract void IOSHandleAddPaymentPassResponse(ReadableMap payload, Promise promise); + @ReactMethod + @DoNotStrip + public abstract void IOSFailAddPaymentPass(Promise promise); + @ReactMethod @DoNotStrip public abstract void addListener(String eventType); diff --git a/ios/RNWallet.mm b/ios/RNWallet.mm index 79d8b56d..aa1f45ce 100644 --- a/ios/RNWallet.mm +++ b/ios/RNWallet.mm @@ -106,6 +106,16 @@ - (instancetype)init { } } +RCT_REMAP_METHOD(IOSFailAddPaymentPass, + IOSFailAddPaymentPass:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + [self->walletManager IOSFailAddPaymentPass]; + resolve(nil); + }); +} + RCT_REMAP_METHOD(getCardStatusBySuffix, getCardStatusBySuffix:(NSString *)last4Digits resolve:(RCTPromiseResolveBlock)resolve diff --git a/ios/WalletManager.swift b/ios/WalletManager.swift index 6b3e4c35..14d51f49 100644 --- a/ios/WalletManager.swift +++ b/ios/WalletManager.swift @@ -184,6 +184,15 @@ open class WalletManager: UIViewController { self.addPassHandler?(addPaymentPassRequest) self.addPassHandler = nil } + + /// Closes the Add Payment Pass sheet when the issuer encrypt call fails. + /// The PassKit completion handler is dropped: there is no payload to give it. + @objc + public func IOSFailAddPaymentPass() { + self.addPassHandler = nil + self.addPaymentPassCompletionHandler = nil + hideModal() + } private func getPassActivationState(matching condition: (PKSecureElementPass) -> Bool) -> NSNumber { let paymentPasses = passLibrary.passes(of: .payment) diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index e4100c74..508d1dee 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -98,6 +98,7 @@ export interface Spec extends TurboModule { listTokens(): Promise; IOSPresentAddPaymentPassView(cardData: IOSCardData): Promise; IOSHandleAddPaymentPassResponse(payload: IOSEncryptPayload): Promise; + IOSFailAddPaymentPass(): Promise; addListener: (eventType: string) => void; removeListeners: (count: number) => void; } diff --git a/src/index.tsx b/src/index.tsx index ec3d62bb..7bacbe57 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -202,7 +202,17 @@ async function addCardToAppleWallet( } async function addPaymentPassToWallet(paymentPassData: IOSAddPaymentPassData): Promise { - const responseData = await issuerEncryptPayloadCallback(paymentPassData.nonce, paymentPassData.nonceSignature, paymentPassData.certificates); + let responseData: IOSEncryptPayload; + try { + responseData = await issuerEncryptPayloadCallback(paymentPassData.nonce, paymentPassData.nonceSignature, paymentPassData.certificates); + } catch (error) { + try { + await wallet.IOSFailAddPaymentPass(); + } catch { + // Closing the sheet is best-effort. The issuer error is what the user sees. + } + throw error; + } const response = await wallet.IOSHandleAddPaymentPassResponse(responseData); // Response is null when a pass is successfully added to the wallet or the user cancels the process // In case the user presses the `Try again` option, new pass data is returned, and it should reenter the function From f2a77cfb18889ac916f6967a29186782a99f0b70 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Thu, 29 Oct 2026 09:49:30 -0300 Subject: [PATCH 2/3] docs: say the issuer error rejects the promise --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 91279ee3..e84cb6c9 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ The library offers functions for seamless integration and use of the Apple Walle | **addCardToGoogleWallet** | Initiates native Push Provisioning flow for adding a card to the Google Wallet. Requires a handler that returns the payment credentials for the UAPP flow. [Learn more →](./docs/UAPP.md) | `data`: `AndroidCardData`,
`handlePaymentCredentials`: `(data: onPaymentCredentialsRequestPayload) => Promise` | `TokenizationStatus` | ❌ | ✅ | | **resumeAddCardToGoogleWallet** | Resumes the Push Provisioning flow for adding a card to the Google Wallet using existing token reference ID. | `data`: `AndroidResumeCardData` | `TokenizationStatus` | ❌ | ✅ | | **listTokens** | Lists all tokens currently stored in the Google Wallet. | None | `TokenInfo[]` | ❌ | ✅ | -| **addCardToAppleWallet** | Initiates native Push Provisioning flow for adding a card to the Apple Wallet. When `IOSCardData.primaryAccountIdentifier` is set, the device picker only lists devices that do not already have the card. If PassKit cannot present the flow because the card is already on every device, the call fails with a clear error. If the issuer callback throws, the Add Payment Pass sheet is closed and that error is returned. | `data`: `IOSCardData`,
`issuerEncrypt-`
`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ | +| **addCardToAppleWallet** | Initiates native Push Provisioning flow for adding a card to the Apple Wallet. When `IOSCardData.primaryAccountIdentifier` is set, the device picker only lists devices that do not already have the card. If PassKit cannot present the flow because the card is already on every device, the call fails with a clear error. If the issuer callback throws, the Add Payment Pass sheet is closed and the promise rejects with that error. | `data`: `IOSCardData`,
`issuerEncrypt-`
`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ | | **canAddCardToAppleWallet** | Checks whether the card can still be added to the iPhone or a paired Apple Watch. Pass `lastDigits` to use the same id lookup as `addCardToAppleWallet`. Returns `false` on Android. | `primaryAccountIdentifier: string`,
`lastDigits?: string` | `boolean` | ✅ | ✅ | ## Data Types From 8ad860b48b89c1d19019b59496904af8b03845de Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Thu, 29 Oct 2026 10:42:00 -0300 Subject: [PATCH 3/3] fix: drop deviceType from cancel and error responses deviceType is only set on the completed response, so the other branch never has it. --- ios/stucts/AddPassResponse.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ios/stucts/AddPassResponse.swift b/ios/stucts/AddPassResponse.swift index 3512e45d..886928bf 100644 --- a/ios/stucts/AddPassResponse.swift +++ b/ios/stucts/AddPassResponse.swift @@ -23,13 +23,9 @@ struct AddPassResponse { func toNSDictionary() -> NSDictionary { if status.rawValue > 0 { - var response: [String: Any] = [ + return [ "status": status.rawValue, - ] - if let deviceType = deviceType { - response["deviceType"] = deviceType.rawValue - } - return response as NSDictionary + ] as NSDictionary } guard let nonce = nonce,