From 313fec1c6265283c875ee7295a29c72e75eb27ef Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Tue, 27 Oct 2026 17:42:56 -0300 Subject: [PATCH 1/3] feat: report how an Apple Wallet add finished A pass waiting on verification, a cancel, and a decline Wallet already explained now come back as a result instead of one rejection. --- README.md | 2 +- ios/WalletManager.swift | 24 +++++++++++++++++++++--- src/index.tsx | 33 +++++++++++++++++++++++++++++---- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7ad2db33..77bc36c6 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. `onAddFinished` receives the pass `activationState` on success, or `errorDomain` and `errorCode` when Wallet declines the card after showing its own message. Cancelling returns `canceled`. A decline no longer rejects, because Wallet has already explained it. | `data`: `IOSCardData`,
`issuerEncrypt-`
`PayloadCallback: IOSIssuerCallback`,
`onAddFinished?: (result: {activationState?: CardStatus, errorDomain?: string, errorCode?: number}) => void` | `TokenizationStatus` | ✅ | ❌ | ## Data Types diff --git a/ios/WalletManager.swift b/ios/WalletManager.swift index 6b3e4c35..c61b175d 100644 --- a/ios/WalletManager.swift +++ b/ios/WalletManager.swift @@ -309,10 +309,28 @@ extension WalletManager: PKAddPaymentPassViewControllerDelegate { handler(.canceled, response.toNSDictionary()) } - // If the pass is returned complete the IOSHandleAddPaymentPassResponse function + // If the pass is returned complete the IOSHandleAddPaymentPassResponse function. + // A resolved dictionary (result < .error) carries the outcome. Rejecting + // would hide whether Wallet already showed its own message. if let addPaymentPassHandler = addPaymentPassHandler { - if pass != nil { - addPaymentPassHandler(.completed, nil) + if let pass = pass { + let activationState = (pass.secureElementPass?.passActivationState ?? pass.passActivationState).rawValue + addPaymentPassHandler(.completed, [ + "status": NSNumber(value: 0), + "activationState": NSNumber(value: activationState), + ]) + } else if let nsError = error as NSError?, + nsError.domain == PKPassKitErrorDomain, + nsError.code == PKAddPaymentPassError.userCancelled.rawValue { + addPaymentPassHandler(.canceled, [ + "status": NSNumber(value: 1), + ]) + } else if let nsError = error as NSError? { + addPaymentPassHandler(.completed, [ + "status": NSNumber(value: -1), + "errorDomain": nsError.domain, + "errorCode": NSNumber(value: nsError.code), + ]) } else { addPaymentPassHandler(.error, [ "errorMessage": "Could not add card. \(errorMessage)." diff --git a/src/index.tsx b/src/index.tsx index ec3d62bb..bc553090 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -20,6 +20,19 @@ import type { import {getCardState, getTokenizationStatus} from './utils'; import AddToWalletButton from './AddToWalletButton'; +type AppleWalletAddResult = { + activationState?: CardStatus; + errorDomain?: string; + errorCode?: number; +}; + +/** Result of IOSHandleAddPaymentPassResponse: a retry nonce, or a finished add. */ +type AddPaymentPassResult = IOSAddPaymentPassData & { + activationState?: number; + errorDomain?: string; + errorCode?: number; +}; + function getModuleLinkingRejection() { return Promise.reject(new Error(`Failed to load Wallet module, make sure to link ${PACKAGE_NAME} correctly`)); } @@ -185,6 +198,7 @@ async function listTokens(): Promise { async function addCardToAppleWallet( cardData: IOSCardData, issuerEncryptPayloadCallback: (nonce: string, nonceSignature: string, certificate: string[]) => Promise, + onAddFinished?: (result: AppleWalletAddResult) => void, ): Promise { if (Platform.OS === 'android') { throw new Error('addCardToAppleWallet is not available on Android'); @@ -203,12 +217,23 @@ async function addCardToAppleWallet( async function addPaymentPassToWallet(paymentPassData: IOSAddPaymentPassData): Promise { const responseData = await issuerEncryptPayloadCallback(paymentPassData.nonce, paymentPassData.nonceSignature, paymentPassData.certificates); - 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 - if (response) { + const response = (await wallet.IOSHandleAddPaymentPassResponse(responseData)) as AddPaymentPassResult | null; + // Try again returns a new nonce. A finished add has no nonce: status 1 is a + // user cancel, status -1 is an error Wallet already showed, otherwise success. + if (response?.nonce) { return addPaymentPassToWallet(response); } + if (response?.status === 1) { + return 1; + } + if (response?.status === -1) { + onAddFinished?.({ + errorDomain: response.errorDomain, + errorCode: response.errorCode, + }); + return -1; + } + onAddFinished?.(typeof response?.activationState === 'number' ? {activationState: getCardState(response.activationState)} : {}); return 0; } const status = await addPaymentPassToWallet(passData); From 4efcf7d67e1da6d3e43313cac2b7c0bd8f951db0 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Thu, 29 Oct 2026 09:51:26 -0300 Subject: [PATCH 2/3] feat: return the Apple Wallet add result The outcome comes back on the resolved value, including a decline Wallet already explained, instead of a second callback. --- README.md | 9 ++++- apps/common-app/src/walletUtils.ts | 3 +- ios/WalletManager.swift | 8 ++--- ios/stucts/AddPassResponse.swift | 1 + src/NativeWallet.ts | 8 +++++ src/index.tsx | 56 ++++++++++++++++-------------- 6 files changed, 53 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 5d74d991..a165ed31 100644 --- a/README.md +++ b/README.md @@ -186,9 +186,15 @@ 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. `onAddFinished` receives the pass `activationState` on success, or `errorDomain` and `errorCode` when Wallet declines the card after showing its own message. Cancelling returns `canceled`. A decline no longer rejects, because Wallet has already explained it. | `data`: `IOSCardData`,
`issuerEncrypt-`
`PayloadCallback: IOSIssuerCallback`,
`onAddFinished?: (result: {activationState?: CardStatus, errorDomain?: string, errorCode?: number}) => void` | `TokenizationStatus` | ✅ | ❌ | +| **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. Resolves `IOSAddCardResult`. A successful add includes `activationState` when PassKit reports a known state. A decline includes `errorDomain` and `errorCode` and does not reject, because Wallet has already shown its own message. See Breaking change. | `data`: `IOSCardData`,
`issuerEncrypt-`
`PayloadCallback: IOSIssuerCallback` | `IOSAddCardResult` | ✅ | ❌ | | **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` | ✅ | ✅ | +### Breaking change + +`addCardToAppleWallet` resolves `IOSAddCardResult` instead of `TokenizationStatus`. Read `result.status`. + +A Wallet decline resolves `{ status: 'error', errorDomain, errorCode }` instead of rejecting. A cancel after the issuer step resolves `{ status: 'canceled' }` instead of rejecting. An error thrown by the issuer callback still rejects. + ## Data Types | Type | Description | Fields | @@ -205,6 +211,7 @@ The library offers functions for seamless integration and use of the Apple Walle | **IOSDeviceType** | Best-effort device the card is being provisioned to. PassKit does not report the selected device, so this is inferred from existing passes when `primaryAccountIdentifier` is set, using the same id lookup as `addCardToAppleWallet`. The value is computed when Apple asks for the encrypted payload, including when the user tries again. Passes on a paired Apple Watch are never counted as iPhone passes. `phone` means the card is already on the paired Apple Watch only. `watch` means it is already on the iPhone only. `unknown` means the card is on neither device, or no identifier was provided, so the user may still choose either device. Passes in the `deactivated` state count as not present. | `'phone' \| 'watch' \| 'unknown'` | | **IOSAddPaymentPassData** | Data returned when the user continues the provisioning flow. Apple supplies the nonce, its signature, and the certificates. The library adds `deviceType`; Apple does not return it. See `IOSDeviceType`. | `status: number`,
`nonce: string`,
`nonceSignature: string`,
`certificates: string[]`,
`deviceType?: string` | | **IOSEncryptPayload** | An object containing the necessary elements to complete the addition of a card to Apple Wallet. | `encryptedPassData: string`,
`activationData: string`,
`ephemeralPublicKey: string` | +| **IOSAddCardResult** | Result of `addCardToAppleWallet`. `activationState` is set on success when PassKit reports a known card state. `errorDomain` and `errorCode` are set when Wallet declines the card after showing its own message. | `status: TokenizationStatus`,
`activationState?: CardStatus`,
`errorDomain?: string`,
`errorCode?: number` | | **TokenInfo** | Information about a token stored in Google Wallet. | `identifier: string`,
`lastDigits: string`,
`tokenState: number` | ### AndroidCardData Optional Fields diff --git a/apps/common-app/src/walletUtils.ts b/apps/common-app/src/walletUtils.ts index 8498306e..47095554 100644 --- a/apps/common-app/src/walletUtils.ts +++ b/apps/common-app/src/walletUtils.ts @@ -32,7 +32,8 @@ async function addCardToWallet(cardStatus?: CardStatus) { } return addCardToGoogleWallet(CONST.AndroidDummyCardData, handlePaymentCredentialsGeneration); } else { - return addCardToAppleWallet(CONST.IOSDummyCardData, issuerEncryptPayloadCallback); + const result = await addCardToAppleWallet(CONST.IOSDummyCardData, issuerEncryptPayloadCallback); + return result.status; } } diff --git a/ios/WalletManager.swift b/ios/WalletManager.swift index 40e67884..fc77e2ab 100644 --- a/ios/WalletManager.swift +++ b/ios/WalletManager.swift @@ -402,18 +402,18 @@ extension WalletManager: PKAddPaymentPassViewControllerDelegate { if let pass = pass { let activationState = (pass.secureElementPass?.passActivationState ?? pass.passActivationState).rawValue addPaymentPassHandler(.completed, [ - "status": NSNumber(value: 0), + "status": NSNumber(value: OperationResult.completed.rawValue), "activationState": NSNumber(value: activationState), ]) } else if let nsError = error as NSError?, nsError.domain == PKPassKitErrorDomain, nsError.code == PKAddPaymentPassError.userCancelled.rawValue { addPaymentPassHandler(.canceled, [ - "status": NSNumber(value: 1), + "status": NSNumber(value: OperationResult.canceled.rawValue), ]) } else if let nsError = error as NSError? { - addPaymentPassHandler(.completed, [ - "status": NSNumber(value: -1), + addPaymentPassHandler(.declined, [ + "status": NSNumber(value: OperationResult.declined.rawValue), "errorDomain": nsError.domain, "errorCode": NSNumber(value: nsError.code), ]) diff --git a/ios/stucts/AddPassResponse.swift b/ios/stucts/AddPassResponse.swift index 3512e45d..67a27fc0 100644 --- a/ios/stucts/AddPassResponse.swift +++ b/ios/stucts/AddPassResponse.swift @@ -52,6 +52,7 @@ struct AddPassResponse { } @objc public enum OperationResult: Int { + case declined = -1 case completed = 0 case canceled case retry diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index 94d5787f..84bb10fa 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -77,6 +77,13 @@ type IOSEncryptPayload = { type TokenizationStatus = 'canceled' | 'success' | 'error'; +type IOSAddCardResult = { + status: TokenizationStatus; + activationState?: CardStatus; + errorDomain?: string; + errorCode?: number; +}; + type TokenInfo = { identifier: string; lastDigits: string; @@ -135,6 +142,7 @@ export type { IOSAddPaymentPassData, IOSDeviceType, IOSEncryptPayload, + IOSAddCardResult, TokenizationStatus, TokenInfo, AndroidPaymentCredentialsResponse, diff --git a/src/index.tsx b/src/index.tsx index 905ee860..807587b3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,7 @@ import type { onCardActivatedPayload, onPaymentCredentialsRequestPayload, IOSAddPaymentPassData, + IOSAddCardResult, IOSDeviceType, TokenInfo, AndroidPaymentCredentialsResponse, @@ -21,12 +22,6 @@ import type { import {getCardState, getTokenizationStatus} from './utils'; import AddToWalletButton from './AddToWalletButton'; -type AppleWalletAddResult = { - activationState?: CardStatus; - errorDomain?: string; - errorCode?: number; -}; - /** Result of IOSHandleAddPaymentPassResponse: a retry nonce, or a finished add. */ type AddPaymentPassResult = IOSAddPaymentPassData & { activationState?: number; @@ -34,6 +29,20 @@ type AddPaymentPassResult = IOSAddPaymentPassData & { errorCode?: number; }; +function toCardStatus(stateId: number | undefined): CardStatus | undefined { + switch (stateId) { + case -1: + case 0: + case 1: + case 2: + case 3: + case 4: + return getCardState(stateId); + default: + return undefined; + } +} + function getModuleLinkingRejection() { return Promise.reject(new Error(`Failed to load Wallet module, make sure to link ${PACKAGE_NAME} correctly`)); } @@ -222,8 +231,7 @@ async function canAddCardToAppleWallet(primaryAccountIdentifier: string, lastDig async function addCardToAppleWallet( cardData: IOSCardData, issuerEncryptPayloadCallback: (nonce: string, nonceSignature: string, certificate: string[], deviceType: IOSDeviceType) => Promise, - onAddFinished?: (result: AppleWalletAddResult) => void, -): Promise { +): Promise { if (Platform.OS === 'android') { throw new Error('addCardToAppleWallet is not available on Android'); } @@ -236,32 +244,27 @@ async function addCardToAppleWallet( const passData = await wallet.IOSPresentAddPaymentPassView(cardData); if (!passData || passData.status !== 0) { - return getTokenizationStatus(passData?.status || -1); + return {status: getTokenizationStatus(passData?.status || -1)}; } - async function addPaymentPassToWallet(paymentPassData: IOSAddPaymentPassData): Promise { + async function addPaymentPassToWallet(paymentPassData: IOSAddPaymentPassData): Promise { const responseData = await issuerEncryptPayloadCallback(paymentPassData.nonce, paymentPassData.nonceSignature, paymentPassData.certificates, toIOSDeviceType(paymentPassData.deviceType)); const response = (await wallet.IOSHandleAddPaymentPassResponse(responseData)) as AddPaymentPassResult | null; - // Try again returns a new nonce. A finished add has no nonce: status 1 is a - // user cancel, status -1 is an error Wallet already showed, otherwise success. + // Try again returns a new nonce. A finished add has no nonce, and status + // matches OperationResult: declined, completed, or canceled. if (response?.nonce) { return addPaymentPassToWallet(response); } - if (response?.status === 1) { - return 1; - } - if (response?.status === -1) { - onAddFinished?.({ - errorDomain: response.errorDomain, - errorCode: response.errorCode, - }); - return -1; - } - onAddFinished?.(typeof response?.activationState === 'number' ? {activationState: getCardState(response.activationState)} : {}); - return 0; + + const activationState = toCardStatus(response?.activationState); + return { + status: getTokenizationStatus(response?.status ?? 0), + ...(activationState ? {activationState} : {}), + ...(typeof response?.errorDomain === 'string' ? {errorDomain: response.errorDomain} : {}), + ...(typeof response?.errorCode === 'number' ? {errorCode: response.errorCode} : {}), + }; } - const status = await addPaymentPassToWallet(passData); - return getTokenizationStatus(status); + return addPaymentPassToWallet(passData); } export type { @@ -271,6 +274,7 @@ export type { IOSEncryptPayload, IOSCardData, IOSAddPaymentPassData, + IOSAddCardResult, IOSDeviceType, onCardActivatedPayload, onPaymentCredentialsRequestPayload, From 597610b6884b0e3c21d156c79742f67063eb2727 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Thu, 29 Oct 2026 11:32:31 -0300 Subject: [PATCH 3/3] chore: bump version to 0.3.0-0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 70b10eb7..e0098359 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@expensify/react-native-wallet", - "version": "0.2.26", + "version": "0.3.0-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@expensify/react-native-wallet", - "version": "0.2.26", + "version": "0.3.0-0", "license": "MIT", "workspaces": [ "apps/common-app", diff --git a/package.json b/package.json index 56fd3c25..36941ebf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@expensify/react-native-wallet", - "version": "0.2.26", + "version": "0.3.0-0", "description": "A React Native module designed for seamless integration of Card Push Provisioning into Apple Wallet and Google Wallet, enabling easy and secure addition of payment cards directly from your application.", "source": "./src/index.tsx", "main": "lib/commonjs/index",