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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Here you can find data elements used in the library, essential to work with Goog
- **Ephemeral Public Key** - a key used by elliptic curve cryptography (ECC) (Base 64 encoded).

# API Reference
The library offers five functions for seamless integration and use of the Apple Wallet and Google Wallet APIs. Additionally, it includes one listener that informs when the added card has been activated. Below, these functions are described along with the data types involved.
The library offers seven functions for seamless integration and use of the Apple Wallet and Google Wallet APIs. Additionally, it includes one listener that informs when the added card has been activated. Below, these functions are described along with the data types involved.

## Functions

Expand All @@ -134,6 +134,8 @@ The library offers five functions for seamless integration and use of the Apple
| **getCardStatusBySuffix** | Retrieves the current status of a card in the wallet. | `lastDigits: string`<br>(The last few digits of the card number) | `CardStatus` | ✅ | ✅ |
| **getCardStatusByIdentifier** | Returns the state of a card based on a platform-specific identifier. On Android, it's `Token Reference ID` and on iOS, it's `Primary Account Identifier`. | `identifier: string`,<br>`tsp: string` | `CardStatus` | ✅ | ✅ |
| **addCardToGoogleWallet** | Initiates native Push Provisioning flow for adding a card to the Google Wallet. | `data`: `AndroidCardData` | `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`,<br>`issuerEncrypt-`<br>`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ |


Expand All @@ -143,11 +145,13 @@ The library offers five functions for seamless integration and use of the Apple
|------|-------------|--------|
| **AndroidWalletData** | Specific information for Android devices required for wallet transactions. | `deviceID: string`,<br>`walletAccountID: string` |
| **AndroidCardData** | Data related to a card that is to be added on Android platform wallets. | `network: string`,<br>`opaquePaymentCard: string`,<br>`cardHolderName: string`,<br>`lastDigits: string`,<br>`userAddress: UserAddress` |
| **AndroidResumeCardData** | Simplified data structure for resuming card addition to Google Wallet using existing token reference ID. | `network: string`,<br>`tokenReferenceID: string`,<br>`cardHolderName?: string`,<br>`lastDigits?: string` |
| **UserAddress** | Structured address used for cardholder verification. | `name: string`,<br>`addressOne: string`,<br>`addressTwo: string`,<br>`city: string`,<br>`administrativeArea: string`,<br>`countryCode: string`,<br>`postalCode: string`,<br>`phoneNumber: string` |
| **IOSCardData** | Data related to a card that is to be added on iOS platform. | `network: string`,<br>`activationData: string`,<br>`encryptedPassData: string`,<br>`ephemeralPublicKey: string`,<br>`cardHolderTitle: string`,<br>`cardHolderName: string`,<br>`lastDigits: string`,<br>`cardDescription: string`,<br>`cardDescriptionComment: string` |
| **onCardActivatedPayload** | Data used by listener to notice when a card’s status changes. | `tokenId: string`,<br> `actionStatus: 'activated' \| 'canceled'`<br> |
| **IOSIssuerCallback** | This callback is invoked with a nonce, its signature, and a certificate array obtained from Apple. It is expected that you will forward these details to your server or the card issuer's API to securely encrypt the payload required for adding cards to the Apple Wallet. | `(nonce: string, nonceSignature: string, certificate: string[]) => IOSEncryptPayload` |
| **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` |
| **TokenInfo** | Information about a token stored in Google Wallet. | `identifier: string`,<br>`lastDigits: string`,<br>`tokenState: number` |

## Card Status

Expand Down
70 changes: 69 additions & 1 deletion android/src/main/java/com/expensify/wallet/WalletModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,14 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
val cardData = data.toCardData() ?: return promise.reject(E_INVALID_DATA, "Insufficient data")
val cardNetwork = getCardNetwork(cardData.network)
val tokenServiceProvider = getTokenServiceProvider(cardData.network)
val displayName = getDisplayName(data, cardData.network)
pendingPushTokenizePromise = promise

val pushTokenizeRequest = PushTokenizeRequest.Builder()
.setOpaquePaymentCard(cardData.opaquePaymentCard.toByteArray(Charset.forName("UTF-8")))
.setNetwork(cardNetwork)
.setTokenServiceProvider(tokenServiceProvider)
.setDisplayName(cardData.cardHolderName)
.setDisplayName(displayName)
.setLastDigits(cardData.lastDigits)
.setUserAddress(cardData.userAddress)
.build()
Expand All @@ -207,6 +208,59 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
}
}

@ReactMethod
override fun resumeAddCardToGoogleWallet(data: ReadableMap, promise: Promise) {
try {
val tokenReferenceID = data.getString("tokenReferenceID")
?: return promise.reject(E_INVALID_DATA, "Missing tokenReferenceID")

val network = data.getString("network")
?: return promise.reject(E_INVALID_DATA, "Missing network")

val cardNetwork = getCardNetwork(network)
val tokenServiceProvider = getTokenServiceProvider(network)
val displayName = getDisplayName(data, network)
pendingPushTokenizePromise = promise

tapAndPayClient.tokenize(
activity,
tokenReferenceID,
tokenServiceProvider,
displayName,
cardNetwork,
REQUEST_CODE_PUSH_TOKENIZE
)
} catch (e: java.lang.Exception) {
promise.reject(e)
}
}

@ReactMethod
override fun listTokens(promise: Promise) {
tapAndPayClient.listTokens()
.addOnCompleteListener { task ->
if (!task.isSuccessful || task.result == null) {
promise.resolve(Arguments.createArray())
return@addOnCompleteListener
}

val tokensArray = Arguments.createArray()
task.result.forEach { tokenInfo ->
val tokenData = Arguments.createMap().apply {
putString("identifier", tokenInfo.issuerTokenId)
putString("lastDigits", tokenInfo.fpanLastFour)
putInt("tokenState", tokenInfo.tokenState)
}
tokensArray.pushMap(tokenData)
}

promise.resolve(tokensArray)
}
.addOnFailureListener { e ->
promise.reject(E_OPERATION_FAILED, "listTokens: ${e.localizedMessage}")
}
}

private fun getWalletId(promise: Promise) {
tapAndPayClient.activeWalletId.addOnCompleteListener { task ->
if (task.isSuccessful) {
Expand Down Expand Up @@ -272,6 +326,20 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
getHardwareId(promise)
}

private fun getDisplayName(data: ReadableMap, network: String): String {
data.getString("cardHolderName")?.let { name ->
if (name.isNotEmpty()) return name
}

data.getString("lastDigits")?.let { digits ->
if (digits.isNotEmpty()) {
return "${network.uppercase(Locale.getDefault())} Card *$digits"
}
}

return "${network.uppercase(Locale.getDefault())} Card"
}

private fun sendEvent(reactContext: ReactContext, eventName: String, params: WritableMap?) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
Expand Down
12 changes: 12 additions & 0 deletions android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ public NativeWalletSpec(ReactApplicationContext reactContext) {
@DoNotStrip
public abstract void checkWalletAvailability(Promise promise);

@ReactMethod
@DoNotStrip
public abstract void ensureGoogleWalletInitialized(Promise promise);

@ReactMethod
@DoNotStrip
public abstract void getSecureWalletInfo(Promise promise);
Expand All @@ -53,6 +57,14 @@ public NativeWalletSpec(ReactApplicationContext reactContext) {
@DoNotStrip
public abstract void addCardToGoogleWallet(ReadableMap cardData, Promise promise);

@ReactMethod
@DoNotStrip
public abstract void resumeAddCardToGoogleWallet(ReadableMap cardData, Promise promise);

@ReactMethod
@DoNotStrip
public abstract void listTokens(Promise promise);

@ReactMethod
@DoNotStrip
public abstract void IOSPresentAddPaymentPassView(ReadableMap cardData, Promise promise);
Expand Down
4 changes: 2 additions & 2 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@ export default function App() {
}, []);

const handleAddCardToWallet = useCallback(() => {
addCardToWallet()
addCardToWallet(cardStatus)
.then(status => {
setAddCardStatus(status);
})
.catch(e => {
console.error(e);
setAddCardStatus('failed');
});
}, []);
}, [cardStatus]);

const walletSecureInfo = useMemo(
() => getWalletInfoTextValue(walletData),
Expand Down
10 changes: 9 additions & 1 deletion example/src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
UserAddress,
IOSEncryptPayload,
IOSCardData,
AndroidResumeCardData,
} from '../../src/NativeWallet';

const dummyAddress: UserAddress = {
Expand All @@ -24,6 +25,13 @@ const AndroidDummyCardData: AndroidCardData = {
userAddress: dummyAddress,
};

const AndroidDummyResumeCardData: AndroidResumeCardData = {
network: 'VISA',
cardHolderName: 'John Doe',
lastDigits: '4321',
tokenReferenceID: '',
};

const IOSDummyCardData: IOSCardData = {
network: 'VISA',
cardHolderName: 'John Doe',
Expand All @@ -38,4 +46,4 @@ const IOSDummyEncryptPayload: IOSEncryptPayload = {
ephemeralPublicKey: 'ZXBoZW1lcmFsUHVibGljS2V5MTIz',
};

export {AndroidDummyCardData, IOSDummyCardData, IOSDummyEncryptPayload};
export {AndroidDummyCardData, AndroidDummyResumeCardData, IOSDummyCardData, IOSDummyEncryptPayload};
23 changes: 22 additions & 1 deletion example/src/walletUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
addCardToAppleWallet,
addCardToGoogleWallet,
listTokens,
resumeAddCardToGoogleWallet,
type CardStatus,
} from '@expensify/react-native-wallet';
import * as CONST from './CONST';
import {Platform} from 'react-native';
Expand All @@ -15,8 +18,26 @@ function issuerEncryptPayloadCallback(
return Promise.resolve(CONST.IOSDummyEncryptPayload);
}

async function addCardToWallet() {
async function addCardToWallet(cardStatus?: CardStatus) {
if (Platform.OS === 'android') {
if (cardStatus === 'requireActivation') {
const tokens = await listTokens();
const existingToken = tokens.find(
token =>
token.lastDigits === CONST.AndroidDummyResumeCardData.lastDigits,
);

if (!existingToken) {
throw new Error(
`Token not found for card ending with ${CONST.AndroidDummyResumeCardData.lastDigits}`,
);
}

return await resumeAddCardToGoogleWallet({
...CONST.AndroidDummyResumeCardData,
tokenReferenceID: existingToken.identifier,
});
}
return addCardToGoogleWallet(CONST.AndroidDummyCardData);
} else {
return addCardToAppleWallet(
Expand Down
30 changes: 29 additions & 1 deletion src/NativeWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ type AndroidCardData = {
userAddress: UserAddress;
};

type AndroidResumeCardData = {
network: string;
tokenReferenceID: string;
cardHolderName?: string;
lastDigits?: string;
};

type IOSCardData = {
network: string;
cardHolderName: string;
Expand Down Expand Up @@ -56,13 +63,21 @@ type IOSEncryptPayload = {

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

type TokenInfo = {
identifier: string;
lastDigits: string;
tokenState: number;
};

export interface Spec extends TurboModule {
checkWalletAvailability(): Promise<boolean>;
ensureGoogleWalletInitialized(): Promise<boolean>;
getSecureWalletInfo(): Promise<AndroidWalletData>;
getCardStatusBySuffix(last4Digits: string): Promise<number>;
getCardStatusByIdentifier(identifier: string, tsp: string): Promise<number>;
addCardToGoogleWallet(cardData: AndroidCardData): Promise<number>;
resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise<number>;
listTokens(): Promise<TokenInfo[]>;
IOSPresentAddPaymentPassView(cardData: IOSCardData): Promise<IOSAddPaymentPassData>;
IOSHandleAddPaymentPassResponse(payload: IOSEncryptPayload): Promise<IOSAddPaymentPassData | null>;
addListener: (eventType: string) => void;
Expand All @@ -84,4 +99,17 @@ try {
}
export default Wallet;
export {PACKAGE_NAME};
export type {AndroidCardData, IOSCardData, AndroidWalletData, CardStatus, UserAddress, onCardActivatedPayload, Platform, IOSAddPaymentPassData, IOSEncryptPayload, TokenizationStatus};
export type {
AndroidCardData,
AndroidResumeCardData,
IOSCardData,
AndroidWalletData,
CardStatus,
UserAddress,
onCardActivatedPayload,
Platform,
IOSAddPaymentPassData,
IOSEncryptPayload,
TokenizationStatus,
TokenInfo,
};
48 changes: 46 additions & 2 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@
import {NativeEventEmitter, Platform} from 'react-native';
import type {EmitterSubscription} from 'react-native';
import Wallet, {PACKAGE_NAME} from './NativeWallet';
import type {TokenizationStatus, AndroidCardData, CardStatus, IOSCardData, IOSEncryptPayload, AndroidWalletData, onCardActivatedPayload, IOSAddPaymentPassData} from './NativeWallet';
import type {
TokenizationStatus,
AndroidCardData,
AndroidResumeCardData,
CardStatus,
IOSCardData,
IOSEncryptPayload,
AndroidWalletData,
onCardActivatedPayload,
IOSAddPaymentPassData,
TokenInfo,
} from './NativeWallet';
import {getCardState, getTokenizationStatus} from './utils';
import AddToWalletButton from './AddToWalletButton';

Expand Down Expand Up @@ -83,6 +94,37 @@ async function addCardToGoogleWallet(cardData: AndroidCardData): Promise<Tokeniz
return getTokenizationStatus(tokenizationStatus);
}

async function resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise<TokenizationStatus> {
if (Platform.OS === 'ios') {
throw new Error('resumeAddCardToGoogleWallet is not available on iOS');
}

if (!Wallet) {
return getModuleLinkingRejection();
}
const isWalletInitialized = await Wallet.ensureGoogleWalletInitialized();
if (!isWalletInitialized) {
throw new Error('Wallet could not be initialized');
}
const tokenizationStatus = await Wallet.resumeAddCardToGoogleWallet(cardData);
return getTokenizationStatus(tokenizationStatus);
}

async function listTokens(): Promise<TokenInfo[]> {
if (Platform.OS === 'ios') {
return Promise.resolve([]);
}

if (!Wallet) {
return getModuleLinkingRejection();
}
const isWalletInitialized = await Wallet.ensureGoogleWalletInitialized();
if (!isWalletInitialized) {
throw new Error('Wallet could not be initialized');
}
return Wallet.listTokens();
}

async function addCardToAppleWallet(
cardData: IOSCardData,
issuerEncryptPayloadCallback: (nonce: string, nonceSignature: string, certificate: string[]) => Promise<IOSEncryptPayload>,
Expand Down Expand Up @@ -110,14 +152,16 @@ async function addCardToAppleWallet(
return getTokenizationStatus(status);
}

export type {AndroidCardData, AndroidWalletData, CardStatus, IOSEncryptPayload, IOSCardData, IOSAddPaymentPassData, onCardActivatedPayload, TokenizationStatus};
export type {AndroidCardData, AndroidWalletData, CardStatus, IOSEncryptPayload, IOSCardData, IOSAddPaymentPassData, onCardActivatedPayload, TokenizationStatus, TokenInfo};
export {
AddToWalletButton,
checkWalletAvailability,
getSecureWalletInfo,
getCardStatusBySuffix,
getCardStatusByIdentifier,
addCardToGoogleWallet,
resumeAddCardToGoogleWallet,
listTokens,
addCardToAppleWallet,
addListener,
removeListener,
Expand Down