diff --git a/modules/expensify-react-native-wallet.tgz b/modules/expensify-react-native-wallet.tgz index 1ae0b279de47..c2de445997b0 100644 Binary files a/modules/expensify-react-native-wallet.tgz and b/modules/expensify-react-native-wallet.tgz differ diff --git a/package-lock.json b/package-lock.json index 81f6bf22c625..ad57631fecbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3841,7 +3841,7 @@ "node_modules/@expensify/react-native-wallet": { "version": "0.1.0", "resolved": "file:modules/expensify-react-native-wallet.tgz", - "integrity": "sha512-Ep28REFzl0slpkCk896C4lospoa+NMQjSfrAQDD4mojJ4OWcWyNlD5bZzF5VRSENWideo7F5BdP7NI6ulZ15OQ==", + "integrity": "sha512-9v+fsSOwda6Evm/Q9pXniX8HM/RZ7932EChVHawTxfOFIDWiGu0WAbQci/uUGYIvv5CVUuZ5N+CqXDts9bfrYw==", "license": "MIT", "workspaces": [ "./example" diff --git a/react-native.config.js b/react-native.config.js index a93656177c58..2c5acba774a8 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -1,13 +1,18 @@ -module.exports = { +const isHybrid = process.env.IS_HYBRID_APP === 'true' ? true : false; + +const config = { assets: ['./assets/fonts/native'], - dependencies: { - // We need to unlink the react-native-wallet package from the android build - // because it's not supported yet and we want to prevent the build from failing - // due to missing Google TapAndPay SDK - '@expensify/react-native-wallet': { - platforms: { - android: null, - }, - }, - }, + dependencies: {}, }; + +// We need to unlink the react-native-wallet package from the android standalone build +// to prevent the build from failing due to missing Google TapAndPay SDK +if (!isHybrid) { + config.dependencies['@expensify/react-native-wallet'] = { + platforms: { + android: null, + }, + }; +} + +module.exports = config; diff --git a/src/components/AddToWalletButton/index.ios.tsx b/src/components/AddToWalletButton/index.native.tsx similarity index 55% rename from src/components/AddToWalletButton/index.ios.tsx rename to src/components/AddToWalletButton/index.native.tsx index ee8720213a22..4248c224deac 100644 --- a/src/components/AddToWalletButton/index.ios.tsx +++ b/src/components/AddToWalletButton/index.native.tsx @@ -1,15 +1,24 @@ import {AddToWalletButton as RNAddToWalletButton} from '@expensify/react-native-wallet'; -import React, {useCallback, useEffect} from 'react'; -import {View} from 'react-native'; +import React, {useCallback, useEffect, useState} from 'react'; +import {ActivityIndicator, View} from 'react-native'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import getPlatform from '@libs/getPlatform'; import {checkIfWalletIsAvailable, handleAddCardToWallet, isCardInWallet} from '@libs/Wallet/index'; +import CONST from '@src/CONST'; import type AddToWalletButtonProps from './types'; function AddToWalletButton({card, cardHolderName, cardDescription, buttonStyle}: AddToWalletButtonProps) { const [isWalletAvailable, setIsWalletAvailable] = React.useState(false); const [isInWallet, setIsInWallet] = React.useState(null); const {translate} = useLocalize(); + const isCardAvailable = card.state === CONST.EXPENSIFY_CARD.STATE.OPEN; + const [isLoading, setIsLoading] = useState(false); + const theme = useTheme(); + const platform = getPlatform() === CONST.PLATFORM.IOS ? 'Apple' : 'Google'; + const styles = useThemeStyles(); const checkIfCardIsInWallet = useCallback(() => { isCardInWallet(card) @@ -18,18 +27,30 @@ function AddToWalletButton({card, cardHolderName, cardDescription, buttonStyle}: }) .catch(() => { setIsInWallet(false); + }) + .finally(() => { + setIsLoading(false); }); }, [card]); const handleOnPress = useCallback(() => { - handleAddCardToWallet(card, cardHolderName, cardDescription, checkIfCardIsInWallet); - }, [card, cardDescription, cardHolderName, checkIfCardIsInWallet]); + setIsLoading(true); + handleAddCardToWallet(card, cardHolderName, cardDescription, () => setIsLoading(false)); + }, [card, cardDescription, cardHolderName]); useEffect(() => { + if (!isCardAvailable) { + return; + } + checkIfCardIsInWallet(); - }, [checkIfCardIsInWallet]); + }, [checkIfCardIsInWallet, isCardAvailable, card]); useEffect(() => { + if (!isCardAvailable) { + return; + } + checkIfWalletIsAvailable() .then((result) => { setIsWalletAvailable(result); @@ -37,16 +58,25 @@ function AddToWalletButton({card, cardHolderName, cardDescription, buttonStyle}: .catch(() => { setIsWalletAvailable(false); }); - }, []); + }, [isCardAvailable]); - if (!isWalletAvailable || isInWallet == null) { + if (!isWalletAvailable || isInWallet == null || !isCardAvailable) { return null; } + if (isLoading) { + return ( + + ); + } + if (isInWallet) { return ( - {translate('cardPage.cardAlreadyInWallet')}; + {translate('cardPage.cardAddedToWallet', {platform})} ); } diff --git a/src/languages/en.ts b/src/languages/en.ts index f7d16b0a552c..2ada51cf84e1 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1732,7 +1732,7 @@ const translations = { copyCardNumber: 'Copy card number', updateAddress: 'Update address', }, - cardAlreadyInWallet: 'Card is already in wallet', + cardAddedToWallet: ({platform}: {platform: 'Google' | 'Apple'}) => `Added to ${platform} Wallet`, cardDetailsLoadingFailure: 'An error occurred while loading the card details. Please check your internet connection and try again.', validateCardTitle: "Let's make sure it's you", enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod} to view your card details. It should arrive within a minute or two.`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 95ad439d4a16..f4eba5df28d9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1730,7 +1730,7 @@ const translations = { copyCardNumber: 'Copiar número de la tarjeta', updateAddress: 'Actualizar dirección', }, - cardAlreadyInWallet: 'La tarjeta ya está en la billetera', + cardAddedToWallet: ({platform}: {platform: 'Google' | 'Apple'}) => `Añadida a ${platform} Wallet`, cardDetailsLoadingFailure: 'Se ha producido un error al cargar los datos de la tarjeta. Comprueba tu conexión a Internet e inténtalo de nuevo.', validateCardTitle: 'Asegurémonos de que eres tú', enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => diff --git a/src/libs/API/parameters/CreateAppleDigitalWalletParams.ts b/src/libs/API/parameters/CreateAppleDigitalWalletParams.ts deleted file mode 100644 index 702687c23c17..000000000000 --- a/src/libs/API/parameters/CreateAppleDigitalWalletParams.ts +++ /dev/null @@ -1,10 +0,0 @@ -type CreateAppleSigitalWalletParams = { - platform: string; - appVersion: string; - // stringified {"certificates": string[]} - certificates: string; - nonce: string; - nonceSignature: string; -}; - -export default CreateAppleSigitalWalletParams; diff --git a/src/libs/API/parameters/CreateDigitalWalletParams.ts b/src/libs/API/parameters/CreateDigitalWalletParams.ts new file mode 100644 index 000000000000..fe87393f6032 --- /dev/null +++ b/src/libs/API/parameters/CreateDigitalWalletParams.ts @@ -0,0 +1,20 @@ +type CreateDigitalAppleWalletParams = { + platform: 'ios'; + appVersion: string; + // stringified {"certificates": string[]} object + certificates: string; + nonce: string; + nonceSignature: string; +}; + +type CreateDigitalGoogleWalletParams = { + platform: 'android'; + appVersion: string; + walletAccountID: string; + deviceID: string; +}; + +type CreateDigitalWalletParams = CreateDigitalAppleWalletParams | CreateDigitalGoogleWalletParams; + +export type {CreateDigitalAppleWalletParams, CreateDigitalGoogleWalletParams}; +export default CreateDigitalWalletParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index cf800b9c2a94..88663a07e23c 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -389,9 +389,9 @@ export type {ChangeTransactionsReportParams, TransactionThreadInfo} from './Chan export type {default as ResetBankAccountSetupParams} from './ResetBankAccountSetupParams'; export type {default as SendRecapInAdminsRoomParams} from './SendRecapInAdminsRoomParams'; export type {default as SetPolicyProhibitedExpensesParams} from './SetPolicyProhibitedExpensesParams'; +export type {default as CreateDigitalWalletParams} from './CreateDigitalWalletParams'; export type {default as GetGuideCallAvailabilityScheduleParams} from './GetGuideCallAvailabilitySchedule'; export type {default as GetEmphemeralTokenParams} from './GetEmphemeralTokenParams'; -export type {default as CreateAppleDigitalWalletParams} from './CreateAppleDigitalWalletParams'; export type {default as CompleteConciergeCallParams} from './CompleteConciergeCallParams'; export type {default as FinishCorpayBankAccountOnboardingParams} from './FinishCorpayBankAccountOnboardingParams'; export type {default as ReopenReportParams} from './ReopenReportParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index a31f36070785..e47fe2499bd5 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1160,7 +1160,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.MERGE_INTO_ACCOUNT_AND_LOGIN]: Parameters.MergeIntoAccountAndLogInParams; [SIDE_EFFECT_REQUEST_COMMANDS.LOG_OUT]: Parameters.LogOutParams; [SIDE_EFFECT_REQUEST_COMMANDS.GET_EMPHEMERAL_TOKEN]: Parameters.GetEmphemeralTokenParams; - [SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET]: Parameters.CreateAppleDigitalWalletParams; + [SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET]: Parameters.CreateDigitalWalletParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/Wallet/index.android.ts b/src/libs/Wallet/index.android.ts new file mode 100644 index 000000000000..958cbedbc8cb --- /dev/null +++ b/src/libs/Wallet/index.android.ts @@ -0,0 +1,53 @@ +import {addCardToGoogleWallet, checkWalletAvailability, getCardStatusBySuffix, getSecureWalletInfo} from '@expensify/react-native-wallet'; +import type {AndroidCardData, AndroidWalletData, CardStatus, TokenizationStatus} from '@expensify/react-native-wallet'; +import {Alert} from 'react-native'; +import {openWalletPage} from '@libs/actions/PaymentMethods'; +import {createDigitalGoogleWallet} from '@libs/actions/Wallet'; +import Log from '@libs/Log'; +import type {Card} from '@src/types/onyx'; + +function checkIfWalletIsAvailable(): Promise { + return checkWalletAvailability(); +} + +function handleAddCardToWallet(card: Card, cardHolderName: string, cardDescription: string, onFinished?: () => void) { + getSecureWalletInfo() + .then((walletData: AndroidWalletData) => { + createDigitalGoogleWallet({cardHolderName, ...walletData}) + .then((cardData: AndroidCardData) => { + addCardToGoogleWallet(cardData) + .then((status: TokenizationStatus) => { + if (status === 'success') { + Log.info('Card added to wallet'); + openWalletPage(); + } else { + onFinished?.(); + } + }) + .catch((error) => { + Log.warn(`addCardToGoogleWallet error: ${error}`); + Alert.alert('Failed to add card to wallet.', 'Please try again later.'); + }); + }) + + .catch((error) => Log.warn(`createDigitalWallet error: ${error}`)); + }) + .catch((error) => Log.warn(`getSecureWalletInfo error: ${error}`)); +} + +function isCardInWallet(card: Card): Promise { + if (!card.lastFourPAN) { + return Promise.resolve(false); + } + return getCardStatusBySuffix(card.lastFourPAN) + .then((status: CardStatus) => { + Log.info(`Card status: ${status}`); + return status === 'active'; + }) + .catch((error) => { + Log.warn(`getCardTokenStatus error: ${error}`); + return false; + }); +} + +export {handleAddCardToWallet, isCardInWallet, checkIfWalletIsAvailable}; diff --git a/src/libs/Wallet/index.ios.ts b/src/libs/Wallet/index.ios.ts index 565deeb7b003..2cfdfea99596 100644 --- a/src/libs/Wallet/index.ios.ts +++ b/src/libs/Wallet/index.ios.ts @@ -1,20 +1,18 @@ import {addCardToAppleWallet, checkWalletAvailability, getCardStatusByIdentifier, getCardStatusBySuffix} from '@expensify/react-native-wallet'; -import type {IOSCardData} from '@expensify/react-native-wallet/lib/typescript/src/NativeWallet'; +import type {IOSCardData} from '@expensify/react-native-wallet'; import {Alert} from 'react-native'; import {issuerEncryptPayloadCallback} from '@libs/actions/Wallet'; import Log from '@libs/Log'; import CONST from '@src/CONST'; import type {Card} from '@src/types/onyx'; -const ExpensifyCardNetwork = CONST.COMPANY_CARDS.CARD_TYPE.VISA.toUpperCase(); - function checkIfWalletIsAvailable(): Promise { return checkWalletAvailability(); } function handleAddCardToWallet(card: Card, cardHolderName: string, cardDescription: string, onFinished?: () => void) { const data = { - network: ExpensifyCardNetwork, + network: CONST.COMPANY_CARDS.CARD_TYPE.VISA, lastDigits: card.lastFourPAN, cardDescription, cardHolderName, @@ -38,7 +36,7 @@ function isCardInWallet(card: Card): Promise { let callback = null; if (card.token) { - callback = getCardStatusByIdentifier(card.token, ExpensifyCardNetwork); + callback = getCardStatusByIdentifier(card.token, CONST.COMPANY_CARDS.CARD_TYPE.VISA); } else if (card.lastFourPAN) { callback = getCardStatusBySuffix(card.lastFourPAN); } diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index de240fbc6837..ceee796a100e 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -1,4 +1,4 @@ -import type {IOSEncryptPayload} from '@expensify/react-native-wallet/lib/typescript/src/NativeWallet'; +import type {AndroidCardData, IOSEncryptPayload} from '@expensify/react-native-wallet'; import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -8,7 +8,7 @@ import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs import Log from '@libs/Log'; import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {WalletAdditionalQuestionDetails} from '@src/types/onyx'; +import type {ProvisioningCardData, WalletAdditionalQuestionDetails} from '@src/types/onyx'; import pkg from '../../../package.json'; type WalletQuestionAnswer = { @@ -267,12 +267,51 @@ function issuerEncryptPayloadCallback(nonce: string, nonceSignature: string, cer ephemeralPublicKey: data.ephemeralPublicKey, } as IOSEncryptPayload; }) - .catch((e) => { - Log.warn(`issuerEncryptPayloadCallback error: ${e}`); + .catch((error) => { + Log.warn(`issuerEncryptPayloadCallback error: ${error}`); return {} as IOSEncryptPayload; }); } +/** + * Add card to digital wallet + * + * @param walletAcountID ID of the wallet on user's phone + * @param deviceID ID of user's phone + */ +function createDigitalGoogleWallet({walletAccountID, deviceID, cardHolderName}: {deviceID: string; walletAccountID: string; cardHolderName: string}): Promise { + // eslint-disable-next-line rulesdir/no-api-side-effects-method + return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, { + platform: 'android', + appVersion: pkg.version, + walletAccountID, + deviceID, + }) + .then((response) => { + const data = response as unknown as ProvisioningCardData; + return { + network: data.network, + opaquePaymentCard: data.opaquePaymentCard, + cardHolderName, + lastDigits: data.lastDigits, + userAddress: { + name: data.userAddress.name, + addressOne: data.userAddress.address1, + addressTwo: data.userAddress.address2, + administrativeArea: data.userAddress.state, + locality: data.userAddress.city, + countryCode: data.userAddress.country, + postalCode: data.userAddress.postal_code, + phoneNumber: data.userAddress.phone, + }, + } as AndroidCardData; + }) + .catch((error) => { + Log.warn(`createDigitalGoogleWallet error: ${error}`); + return {} as AndroidCardData; + }); +} + export { openOnfidoFlow, openInitialSettingsPage, @@ -286,4 +325,5 @@ export { setKYCWallSource, resetWalletAdditionalDetailsDraft, issuerEncryptPayloadCallback, + createDigitalGoogleWallet, }; diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts index 3c0c2f093078..f8b8177659e9 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -111,6 +111,10 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Card expiration date */ expirationDate?: string; + /** List of token reference ids */ + // eslint-disable-next-line @typescript-eslint/naming-convention + expensifyCard_tokenReferenceIdList?: string[]; + /** Collection of errors coming from BE */ errors?: OnyxCommon.Errors; @@ -123,6 +127,61 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ }>; }>; +/** Model of card just added to a wallet */ +type ProvisioningCardData = { + /** Card identifier */ + cardToken: string; + + /** Card display name */ + displayName: string; + + /** Last 4 digits of the card */ + lastDigits: string; + + /** Name of a payment card network e.g. visa */ + network: string; + + /** Binary blob of information Google Pay receives from the issuer app that could be presented to TSP to receive a token */ + opaquePaymentCard: string; + + /** Service that enhances payment security by replacing a credit card number during transactions with a unique digital identifier - token. */ + tokenServiceProvider: string; + + /** Whether the request is being processed */ + isLoading?: boolean; + + /** Error message */ + errors?: OnyxCommon.Errors; + + /** User's address, required to add card to wallet */ + userAddress: { + /** Name of card holder */ + name: string; + + /** Phone numer of card holder */ + phone: string; + + /** First line of address */ + address1: string; + + /** Optionally second line of address */ + address2?: string; + + /** Card holder's city of living */ + city: string; + + /** Postal code of the city */ + // eslint-disable-next-line @typescript-eslint/naming-convention + postal_code: string; + + /** Card holder's state of living */ + state: string; + + /** Card holder's country of living */ + country: string; + }; +}; + /** Model of Expensify card details */ type ExpensifyCardDetails = { /** Card Primary Account Number */ @@ -193,4 +252,4 @@ type WorkspaceCardsList = Record & { type FilteredCardList = Record; export default Card; -export type {ExpensifyCardDetails, CardList, IssueNewCard, IssueNewCardStep, IssueNewCardData, WorkspaceCardsList, CardLimitType, FilteredCardList}; +export type {ExpensifyCardDetails, CardList, IssueNewCard, IssueNewCardStep, IssueNewCardData, WorkspaceCardsList, CardLimitType, FilteredCardList, ProvisioningCardData}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 031aa613cc86..8e859de2ae01 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -12,7 +12,7 @@ import type BillingStatus from './BillingStatus'; import type BlockedFromConcierge from './BlockedFromConcierge'; import type CancellationDetails from './CancellationDetails'; import type Card from './Card'; -import type {CardList, IssueNewCard, WorkspaceCardsList} from './Card'; +import type {CardList, IssueNewCard, ProvisioningCardData, WorkspaceCardsList} from './Card'; import type CardFeeds from './CardFeeds'; import type {AddNewCompanyCardFeed, CompanyCardFeed, FundID} from './CardFeeds'; import type CardOnWaitlist from './CardOnWaitlist'; @@ -132,6 +132,7 @@ export type { Card, CardList, CardOnWaitlist, + ProvisioningCardData, Credentials, CorpayOnboardingFields, Currency,