Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,16 @@ The library offers functions for seamless integration and use of the Apple Walle
| **listTokens** | Lists all tokens currently stored in the Google Wallet. | None | `TokenInfo[]` | ❌ | ✅ |
| **listAppleWalletPasses** | Lists Apple Wallet Secure Element passes available to the app, including activation state and whether the pass is remote (for example on Apple Watch). Filter the result for `activationState === 'requireActivation'` or `activationState === 'pending'` to build a pending-cards UI. | None | `IOSWalletPass[]` | ✅ | ❌ |
| **presentAppleWalletPass** | Opens the installed iPhone pass for these last digits, preferring one that still needs verification. Returns `false` when `lastDigits` is empty, when no pass matches, when more than one card ends in those digits, and on Android. | `lastDigits: string` | `boolean` | ✅ | ✅ |
| **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`,<br>`issuerEncrypt-`<br>`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. Otherwise it 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`,<br>`issuerEncrypt-`<br>`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`,<br>`lastDigits?: string` | `boolean` | ✅ | ✅ |
| **activateAppleWalletPass** | Activates an existing Secure Element pass in Apple Wallet using activation data from your issuer backend. Resolves `true` when the pass is already active. For `requireActivation`, returns Apple's result, which can be `false`. Rejects for any other state (`pending`, `suspended`), a missing pass, unavailable activation, or invalid or non-base64 activation data. | `data`: `IOSPassActivationData` | `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, and the Add Payment Pass sheet is closed.

## Data Types

| Type | Description | Fields |
Expand All @@ -210,6 +216,7 @@ The library offers functions for seamless integration and use of the Apple Walle
| **IOSEncryptPayload** | An object containing the necessary elements to complete the addition of a card to Apple Wallet. | `encryptedPassData: string`,<br>`activationData: string`,<br>`ephemeralPublicKey: string` |
| **IOSPassActivationData** | Identifies an existing Apple Wallet Secure Element pass and provides the activation payload returned by your issuer backend. `activationData` must be Base 64 encoded. Set `isRemote` to `true` for a paired Apple Watch pass or `false` for the iPhone pass when the same card is on both. Omit it to look up the iPhone pass first and then the Watch. | `passTypeIdentifier: string`,<br>`serialNumber: string`,<br>`activationData: string`,<br>`isRemote?: boolean` |
| **IOSWalletPass** | Information about an Apple Wallet Secure Element pass currently available on the device or a paired remote device. | `passTypeIdentifier: string`,<br>`serialNumber: string`,<br>`primaryAccountIdentifier?: string`,<br>`lastDigits?: string`,<br>`activationState: CardStatus`,<br>`isRemote: boolean` |
| **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`,<br>`activationState?: CardStatus`,<br>`errorDomain?: string`,<br>`errorCode?: number` |
| **TokenInfo** | Information about a token stored in Google Wallet. | `identifier: string`,<br>`lastDigits: string`,<br>`tokenState: number` |

### AndroidCardData Optional Fields
Expand Down
3 changes: 2 additions & 1 deletion apps/common-app/src/walletUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
24 changes: 21 additions & 3 deletions ios/WalletManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -570,10 +570,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: 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: OperationResult.canceled.rawValue),
])
} else if let nsError = error as NSError? {
addPaymentPassHandler(.declined, [
"status": NSNumber(value: OperationResult.declined.rawValue),
"errorDomain": nsError.domain,
"errorCode": NSNumber(value: nsError.code),
])
} else {
addPaymentPassHandler(.error, [
"errorMessage": "Could not add card. \(errorMessage)."
Expand Down
1 change: 1 addition & 0 deletions ios/stucts/AddPassResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ struct AddPassResponse {
}

@objc public enum OperationResult: Int {
case declined = -1
case completed = 0
case canceled
case retry
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/NativeWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ type IOSWalletPass = {

type TokenizationStatus = 'canceled' | 'success' | 'error';

type IOSAddCardResult = {
status: TokenizationStatus;
activationState?: CardStatus;
errorDomain?: string;
errorCode?: number;
};

type TokenInfo = {
identifier: string;
lastDigits: string;
Expand Down Expand Up @@ -167,6 +174,7 @@ export type {
IOSPassActivationData,
IOSWalletPass,
IOSWalletPassNative,
IOSAddCardResult,
TokenizationStatus,
TokenInfo,
AndroidPaymentCredentialsResponse,
Expand Down
49 changes: 39 additions & 10 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,35 @@ import type {
IOSAddPaymentPassData,
IOSPassActivationData,
IOSWalletPass,
IOSAddCardResult,
IOSDeviceType,
TokenInfo,
AndroidPaymentCredentialsResponse,
} from './NativeWallet';
import {getCardState, getTokenizationStatus} from './utils';
import AddToWalletButton from './AddToWalletButton';

/** Result of IOSHandleAddPaymentPassResponse: a retry nonce, or a finished add. */
type AddPaymentPassResult = IOSAddPaymentPassData & {
activationState?: number;
errorDomain?: string;
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`));
}
Expand Down Expand Up @@ -227,7 +249,7 @@ async function listAppleWalletPasses(): Promise<IOSWalletPass[]> {
async function addCardToAppleWallet(
cardData: IOSCardData,
issuerEncryptPayloadCallback: (nonce: string, nonceSignature: string, certificate: string[], deviceType: IOSDeviceType) => Promise<IOSEncryptPayload>,
): Promise<TokenizationStatus> {
): Promise<IOSAddCardResult> {
if (Platform.OS === 'android') {
throw new Error('addCardToAppleWallet is not available on Android');
}
Expand All @@ -240,10 +262,10 @@ 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<number> {
async function addPaymentPassToWallet(paymentPassData: IOSAddPaymentPassData): Promise<IOSAddCardResult> {
let responseData: IOSEncryptPayload;
try {
responseData = await issuerEncryptPayloadCallback(paymentPassData.nonce, paymentPassData.nonceSignature, paymentPassData.certificates, toIOSDeviceType(paymentPassData.deviceType));
Expand All @@ -255,16 +277,22 @@ async function addCardToAppleWallet(
}
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
if (response) {
const response = (await wallet.IOSHandleAddPaymentPassResponse(responseData)) as AddPaymentPassResult | null;
// 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);
}
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);
}

async function activateAppleWalletPass(passData: IOSPassActivationData): Promise<boolean> {
Expand Down Expand Up @@ -300,6 +328,7 @@ export type {
IOSAddPaymentPassData,
IOSPassActivationData,
IOSWalletPass,
IOSAddCardResult,
IOSDeviceType,
onCardActivatedPayload,
onPaymentCredentialsRequestPayload,
Expand Down
Loading