Skip to content

Commit dd85fd6

Browse files
dbracamonteos-botify[bot]claude
authored
feat: add Google Pay yellow path (#40)
* Update package.json version to 0.1.7 * feat: add Google Wallet token management methods - add resumeAddCardToGoogleWallet() method for resuming card provisioning using existing token reference ID - add listTokens() method to retrieve all tokens stored in Google Wallet - add AndroidResumeCardData and TokenInfo types for new functionality - update README.md with documentation for new methods These methods provide better token lifecycle management and support for existing card tokens in Google Wallet integration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat(android): add resumeAddCardToGoogleWallet example code * feat(types): rename TokenInfo fields for cross-platform compatibility * feat: add stub for listTokens on iOS, returning an empty array * feat: add getDisplayName to addCardToGoogleWallet --------- Co-authored-by: os-botify[bot] <140437396+os-botify[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 63ea11e commit dd85fd6

File tree

8 files changed

+194
-9
lines changed

8 files changed

+194
-9
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Here you can find data elements used in the library, essential to work with Goog
123123
- **Ephemeral Public Key** - a key used by elliptic curve cryptography (ECC) (Base 64 encoded).
124124

125125
# API Reference
126-
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.
126+
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.
127127

128128
## Functions
129129

@@ -134,6 +134,8 @@ The library offers five functions for seamless integration and use of the Apple
134134
| **getCardStatusBySuffix** | Retrieves the current status of a card in the wallet. | `lastDigits: string`<br>(The last few digits of the card number) | `CardStatus` |||
135135
| **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` |||
136136
| **addCardToGoogleWallet** | Initiates native Push Provisioning flow for adding a card to the Google Wallet. | `data`: `AndroidCardData` | `TokenizationStatus` |||
137+
| **resumeAddCardToGoogleWallet** | Resumes the Push Provisioning flow for adding a card to the Google Wallet using existing token reference ID. | `data`: `AndroidResumeCardData` | `TokenizationStatus` |||
138+
| **listTokens** | Lists all tokens currently stored in the Google Wallet. | None | `TokenInfo[]` |||
137139
| **addCardToAppleWallet** | Initiates native Push Provisioning flow for adding a card to the Apple Wallet. | `data`: `IOSCardData`,<br>`issuerEncrypt-`<br>`PayloadCallback: IOSIssuerCallback` | `void` |||
138140

139141

@@ -143,11 +145,13 @@ The library offers five functions for seamless integration and use of the Apple
143145
|------|-------------|--------|
144146
| **AndroidWalletData** | Specific information for Android devices required for wallet transactions. | `deviceID: string`,<br>`walletAccountID: string` |
145147
| **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` |
148+
| **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` |
146149
| **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` |
147150
| **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` |
148151
| **onCardActivatedPayload** | Data used by listener to notice when a card’s status changes. | `tokenId: string`,<br> `actionStatus: 'activated' \| 'canceled'`<br> |
149152
| **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` |
150153
| **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` |
154+
| **TokenInfo** | Information about a token stored in Google Wallet. | `identifier: string`,<br>`lastDigits: string`,<br>`tokenState: number` |
151155

152156
## Card Status
153157

android/src/main/java/com/expensify/wallet/WalletModule.kt

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,14 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
188188
val cardData = data.toCardData() ?: return promise.reject(E_INVALID_DATA, "Insufficient data")
189189
val cardNetwork = getCardNetwork(cardData.network)
190190
val tokenServiceProvider = getTokenServiceProvider(cardData.network)
191+
val displayName = getDisplayName(data, cardData.network)
191192
pendingPushTokenizePromise = promise
192193

193194
val pushTokenizeRequest = PushTokenizeRequest.Builder()
194195
.setOpaquePaymentCard(cardData.opaquePaymentCard.toByteArray(Charset.forName("UTF-8")))
195196
.setNetwork(cardNetwork)
196197
.setTokenServiceProvider(tokenServiceProvider)
197-
.setDisplayName(cardData.cardHolderName)
198+
.setDisplayName(displayName)
198199
.setLastDigits(cardData.lastDigits)
199200
.setUserAddress(cardData.userAddress)
200201
.build()
@@ -207,6 +208,59 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
207208
}
208209
}
209210

211+
@ReactMethod
212+
override fun resumeAddCardToGoogleWallet(data: ReadableMap, promise: Promise) {
213+
try {
214+
val tokenReferenceID = data.getString("tokenReferenceID")
215+
?: return promise.reject(E_INVALID_DATA, "Missing tokenReferenceID")
216+
217+
val network = data.getString("network")
218+
?: return promise.reject(E_INVALID_DATA, "Missing network")
219+
220+
val cardNetwork = getCardNetwork(network)
221+
val tokenServiceProvider = getTokenServiceProvider(network)
222+
val displayName = getDisplayName(data, network)
223+
pendingPushTokenizePromise = promise
224+
225+
tapAndPayClient.tokenize(
226+
activity,
227+
tokenReferenceID,
228+
tokenServiceProvider,
229+
displayName,
230+
cardNetwork,
231+
REQUEST_CODE_PUSH_TOKENIZE
232+
)
233+
} catch (e: java.lang.Exception) {
234+
promise.reject(e)
235+
}
236+
}
237+
238+
@ReactMethod
239+
override fun listTokens(promise: Promise) {
240+
tapAndPayClient.listTokens()
241+
.addOnCompleteListener { task ->
242+
if (!task.isSuccessful || task.result == null) {
243+
promise.resolve(Arguments.createArray())
244+
return@addOnCompleteListener
245+
}
246+
247+
val tokensArray = Arguments.createArray()
248+
task.result.forEach { tokenInfo ->
249+
val tokenData = Arguments.createMap().apply {
250+
putString("identifier", tokenInfo.issuerTokenId)
251+
putString("lastDigits", tokenInfo.fpanLastFour)
252+
putInt("tokenState", tokenInfo.tokenState)
253+
}
254+
tokensArray.pushMap(tokenData)
255+
}
256+
257+
promise.resolve(tokensArray)
258+
}
259+
.addOnFailureListener { e ->
260+
promise.reject(E_OPERATION_FAILED, "listTokens: ${e.localizedMessage}")
261+
}
262+
}
263+
210264
private fun getWalletId(promise: Promise) {
211265
tapAndPayClient.activeWalletId.addOnCompleteListener { task ->
212266
if (task.isSuccessful) {
@@ -272,6 +326,20 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
272326
getHardwareId(promise)
273327
}
274328

329+
private fun getDisplayName(data: ReadableMap, network: String): String {
330+
data.getString("cardHolderName")?.let { name ->
331+
if (name.isNotEmpty()) return name
332+
}
333+
334+
data.getString("lastDigits")?.let { digits ->
335+
if (digits.isNotEmpty()) {
336+
return "${network.uppercase(Locale.getDefault())} Card *$digits"
337+
}
338+
}
339+
340+
return "${network.uppercase(Locale.getDefault())} Card"
341+
}
342+
275343
private fun sendEvent(reactContext: ReactContext, eventName: String, params: WritableMap?) {
276344
reactContext
277345
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)

android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ public NativeWalletSpec(ReactApplicationContext reactContext) {
3737
@DoNotStrip
3838
public abstract void checkWalletAvailability(Promise promise);
3939

40+
@ReactMethod
41+
@DoNotStrip
42+
public abstract void ensureGoogleWalletInitialized(Promise promise);
43+
4044
@ReactMethod
4145
@DoNotStrip
4246
public abstract void getSecureWalletInfo(Promise promise);
@@ -53,6 +57,14 @@ public NativeWalletSpec(ReactApplicationContext reactContext) {
5357
@DoNotStrip
5458
public abstract void addCardToGoogleWallet(ReadableMap cardData, Promise promise);
5559

60+
@ReactMethod
61+
@DoNotStrip
62+
public abstract void resumeAddCardToGoogleWallet(ReadableMap cardData, Promise promise);
63+
64+
@ReactMethod
65+
@DoNotStrip
66+
public abstract void listTokens(Promise promise);
67+
5668
@ReactMethod
5769
@DoNotStrip
5870
public abstract void IOSPresentAddPaymentPassView(ReadableMap cardData, Promise promise);

example/src/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,15 @@ export default function App() {
5858
}, []);
5959

6060
const handleAddCardToWallet = useCallback(() => {
61-
addCardToWallet()
61+
addCardToWallet(cardStatus)
6262
.then(status => {
6363
setAddCardStatus(status);
6464
})
6565
.catch(e => {
6666
console.error(e);
6767
setAddCardStatus('failed');
6868
});
69-
}, []);
69+
}, [cardStatus]);
7070

7171
const walletSecureInfo = useMemo(
7272
() => getWalletInfoTextValue(walletData),

example/src/CONST.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
UserAddress,
44
IOSEncryptPayload,
55
IOSCardData,
6+
AndroidResumeCardData,
67
} from '../../src/NativeWallet';
78

89
const dummyAddress: UserAddress = {
@@ -24,6 +25,13 @@ const AndroidDummyCardData: AndroidCardData = {
2425
userAddress: dummyAddress,
2526
};
2627

28+
const AndroidDummyResumeCardData: AndroidResumeCardData = {
29+
network: 'VISA',
30+
cardHolderName: 'John Doe',
31+
lastDigits: '4321',
32+
tokenReferenceID: '',
33+
};
34+
2735
const IOSDummyCardData: IOSCardData = {
2836
network: 'VISA',
2937
cardHolderName: 'John Doe',
@@ -38,4 +46,4 @@ const IOSDummyEncryptPayload: IOSEncryptPayload = {
3846
ephemeralPublicKey: 'ZXBoZW1lcmFsUHVibGljS2V5MTIz',
3947
};
4048

41-
export {AndroidDummyCardData, IOSDummyCardData, IOSDummyEncryptPayload};
49+
export {AndroidDummyCardData, AndroidDummyResumeCardData, IOSDummyCardData, IOSDummyEncryptPayload};

example/src/walletUtils.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import {
22
addCardToAppleWallet,
33
addCardToGoogleWallet,
4+
listTokens,
5+
resumeAddCardToGoogleWallet,
6+
type CardStatus,
47
} from '@expensify/react-native-wallet';
58
import * as CONST from './CONST';
69
import {Platform} from 'react-native';
@@ -15,8 +18,26 @@ function issuerEncryptPayloadCallback(
1518
return Promise.resolve(CONST.IOSDummyEncryptPayload);
1619
}
1720

18-
async function addCardToWallet() {
21+
async function addCardToWallet(cardStatus?: CardStatus) {
1922
if (Platform.OS === 'android') {
23+
if (cardStatus === 'requireActivation') {
24+
const tokens = await listTokens();
25+
const existingToken = tokens.find(
26+
token =>
27+
token.lastDigits === CONST.AndroidDummyResumeCardData.lastDigits,
28+
);
29+
30+
if (!existingToken) {
31+
throw new Error(
32+
`Token not found for card ending with ${CONST.AndroidDummyResumeCardData.lastDigits}`,
33+
);
34+
}
35+
36+
return await resumeAddCardToGoogleWallet({
37+
...CONST.AndroidDummyResumeCardData,
38+
tokenReferenceID: existingToken.identifier,
39+
});
40+
}
2041
return addCardToGoogleWallet(CONST.AndroidDummyCardData);
2142
} else {
2243
return addCardToAppleWallet(

src/NativeWallet.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ type AndroidCardData = {
2929
userAddress: UserAddress;
3030
};
3131

32+
type AndroidResumeCardData = {
33+
network: string;
34+
tokenReferenceID: string;
35+
cardHolderName?: string;
36+
lastDigits?: string;
37+
};
38+
3239
type IOSCardData = {
3340
network: string;
3441
cardHolderName: string;
@@ -56,13 +63,21 @@ type IOSEncryptPayload = {
5663

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

66+
type TokenInfo = {
67+
identifier: string;
68+
lastDigits: string;
69+
tokenState: number;
70+
};
71+
5972
export interface Spec extends TurboModule {
6073
checkWalletAvailability(): Promise<boolean>;
6174
ensureGoogleWalletInitialized(): Promise<boolean>;
6275
getSecureWalletInfo(): Promise<AndroidWalletData>;
6376
getCardStatusBySuffix(last4Digits: string): Promise<number>;
6477
getCardStatusByIdentifier(identifier: string, tsp: string): Promise<number>;
6578
addCardToGoogleWallet(cardData: AndroidCardData): Promise<number>;
79+
resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise<number>;
80+
listTokens(): Promise<TokenInfo[]>;
6681
IOSPresentAddPaymentPassView(cardData: IOSCardData): Promise<IOSAddPaymentPassData>;
6782
IOSHandleAddPaymentPassResponse(payload: IOSEncryptPayload): Promise<IOSAddPaymentPassData | null>;
6883
addListener: (eventType: string) => void;
@@ -84,4 +99,17 @@ try {
8499
}
85100
export default Wallet;
86101
export {PACKAGE_NAME};
87-
export type {AndroidCardData, IOSCardData, AndroidWalletData, CardStatus, UserAddress, onCardActivatedPayload, Platform, IOSAddPaymentPassData, IOSEncryptPayload, TokenizationStatus};
102+
export type {
103+
AndroidCardData,
104+
AndroidResumeCardData,
105+
IOSCardData,
106+
AndroidWalletData,
107+
CardStatus,
108+
UserAddress,
109+
onCardActivatedPayload,
110+
Platform,
111+
IOSAddPaymentPassData,
112+
IOSEncryptPayload,
113+
TokenizationStatus,
114+
TokenInfo,
115+
};

src/index.tsx

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,18 @@
22
import {NativeEventEmitter, Platform} from 'react-native';
33
import type {EmitterSubscription} from 'react-native';
44
import Wallet, {PACKAGE_NAME} from './NativeWallet';
5-
import type {TokenizationStatus, AndroidCardData, CardStatus, IOSCardData, IOSEncryptPayload, AndroidWalletData, onCardActivatedPayload, IOSAddPaymentPassData} from './NativeWallet';
5+
import type {
6+
TokenizationStatus,
7+
AndroidCardData,
8+
AndroidResumeCardData,
9+
CardStatus,
10+
IOSCardData,
11+
IOSEncryptPayload,
12+
AndroidWalletData,
13+
onCardActivatedPayload,
14+
IOSAddPaymentPassData,
15+
TokenInfo,
16+
} from './NativeWallet';
617
import {getCardState, getTokenizationStatus} from './utils';
718
import AddToWalletButton from './AddToWalletButton';
819

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

97+
async function resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise<TokenizationStatus> {
98+
if (Platform.OS === 'ios') {
99+
throw new Error('resumeAddCardToGoogleWallet is not available on iOS');
100+
}
101+
102+
if (!Wallet) {
103+
return getModuleLinkingRejection();
104+
}
105+
const isWalletInitialized = await Wallet.ensureGoogleWalletInitialized();
106+
if (!isWalletInitialized) {
107+
throw new Error('Wallet could not be initialized');
108+
}
109+
const tokenizationStatus = await Wallet.resumeAddCardToGoogleWallet(cardData);
110+
return getTokenizationStatus(tokenizationStatus);
111+
}
112+
113+
async function listTokens(): Promise<TokenInfo[]> {
114+
if (Platform.OS === 'ios') {
115+
return Promise.resolve([]);
116+
}
117+
118+
if (!Wallet) {
119+
return getModuleLinkingRejection();
120+
}
121+
const isWalletInitialized = await Wallet.ensureGoogleWalletInitialized();
122+
if (!isWalletInitialized) {
123+
throw new Error('Wallet could not be initialized');
124+
}
125+
return Wallet.listTokens();
126+
}
127+
86128
async function addCardToAppleWallet(
87129
cardData: IOSCardData,
88130
issuerEncryptPayloadCallback: (nonce: string, nonceSignature: string, certificate: string[]) => Promise<IOSEncryptPayload>,
@@ -110,14 +152,16 @@ async function addCardToAppleWallet(
110152
return getTokenizationStatus(status);
111153
}
112154

113-
export type {AndroidCardData, AndroidWalletData, CardStatus, IOSEncryptPayload, IOSCardData, IOSAddPaymentPassData, onCardActivatedPayload, TokenizationStatus};
155+
export type {AndroidCardData, AndroidWalletData, CardStatus, IOSEncryptPayload, IOSCardData, IOSAddPaymentPassData, onCardActivatedPayload, TokenizationStatus, TokenInfo};
114156
export {
115157
AddToWalletButton,
116158
checkWalletAvailability,
117159
getSecureWalletInfo,
118160
getCardStatusBySuffix,
119161
getCardStatusByIdentifier,
120162
addCardToGoogleWallet,
163+
resumeAddCardToGoogleWallet,
164+
listTokens,
121165
addCardToAppleWallet,
122166
addListener,
123167
removeListener,

0 commit comments

Comments
 (0)