From 4e1e6b972a925cfda8bb5cedd8b4d87b4c4c3c64 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 17 Jan 2026 18:38:46 -0800 Subject: [PATCH 01/15] feat: release 2.6 & 2.7 - reveal cvv flow --- __mocks__/Illustrations.ts | 2 + assets/images/travel-cvv.svg | 25 +++ src/ROUTES.ts | 1 + src/SCREENS.ts | 1 + src/components/Icon/Illustrations.ts | 2 + .../Icon/chunks/illustrations.chunk.ts | 2 + src/languages/de.ts | 6 + src/languages/en.ts | 6 + src/languages/es.ts | 6 + src/languages/fr.ts | 6 + src/languages/it.ts | 6 + src/languages/ja.ts | 6 + src/languages/nl.ts | 6 + src/languages/pl.ts | 6 + src/languages/pt-BR.ts | 6 + src/languages/zh-hans.ts | 6 + src/libs/Environment/Environment.ts | 9 +- .../ModalStackNavigators/index.tsx | 1 + .../RELATIONS/SETTINGS_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 4 + src/libs/Navigation/types.ts | 1 + src/libs/PersonalDetailsUtils.ts | 14 +- src/libs/TravelInvoicingUtils.ts | 53 +++++- .../Wallet/ExpensifyCardPage/index.tsx | 15 +- src/pages/settings/Wallet/TravelCVVPage.tsx | 180 ++++++++++++++++++ .../WalletPage/WalletTravelCVVSection.tsx | 49 +++++ .../settings/Wallet/WalletPage/index.tsx | 2 + .../WorkspaceTravelSettlementAccountPage.tsx | 7 +- src/styles/index.ts | 10 + tests/unit/TravelInvoicingUtilsTest.ts | 172 ++++++++++++++++- 30 files changed, 590 insertions(+), 21 deletions(-) create mode 100644 assets/images/travel-cvv.svg create mode 100644 src/pages/settings/Wallet/TravelCVVPage.tsx create mode 100644 src/pages/settings/Wallet/WalletPage/WalletTravelCVVSection.tsx diff --git a/__mocks__/Illustrations.ts b/__mocks__/Illustrations.ts index 300f7529830a8..e326c5a07441d 100644 --- a/__mocks__/Illustrations.ts +++ b/__mocks__/Illustrations.ts @@ -92,6 +92,7 @@ const CompanyCardsPendingState = 'CompanyCardsPendingState'; const VisaCompanyCardDetail = 'VisaCompanyCardDetail'; const MasterCardCompanyCardDetail = 'MasterCardCompanyCardDetail'; const AmexCardCompanyCardDetail = 'AmexCardCompanyCardDetail'; +const TravelCVV = 'TravelCVV'; const TurtleInShell = 'TurtleInShell'; const BankOfAmericaCompanyCardDetail = 'BankOfAmericaCompanyCardDetail'; const BrexCompanyCardDetail = 'BrexCompanyCardDetail'; @@ -210,6 +211,7 @@ export { VisaCompanyCardDetail, MasterCardCompanyCardDetail, AmexCardCompanyCardDetail, + TravelCVV, TurtleInShell, BankOfAmericaCompanyCardDetail, BrexCompanyCardDetail, diff --git a/assets/images/travel-cvv.svg b/assets/images/travel-cvv.svg new file mode 100644 index 0000000000000..72557b46a0ada --- /dev/null +++ b/assets/images/travel-cvv.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 8bd9670541820..aa4f43fa7b1c1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -386,6 +386,7 @@ const ROUTES = { route: 'settings/wallet/card/:cardID/activate', getRoute: (cardID: string) => `settings/wallet/card/${cardID}/activate` as const, }, + SETTINGS_WALLET_TRAVEL_CVV: 'settings/wallet/travel-cvv', SETTINGS_RULES: 'settings/rules', SETTINGS_LEGAL_NAME: 'settings/profile/legal-name', SETTINGS_DATE_OF_BIRTH: 'settings/profile/date-of-birth', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 36f38f4a61405..fd8f6455ad159 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -170,6 +170,7 @@ const SCREENS = { UNSHARE_BANK_ACCOUNT: 'Settings_Wallet_Unshare_Bank_Account', ENABLE_GLOBAL_REIMBURSEMENTS: 'Settings_Wallet_Enable_Global_Reimbursements', SHARE_BANK_ACCOUNT: 'Settings_Wallet_Share_Bank_Account', + TRAVEL_CVV: 'Settings_Wallet_Travel_CVV', }, EXIT_SURVEY: { diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 65851e0641676..f9c1826adfe1e 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -44,6 +44,7 @@ import MagnifyingGlassReceipt from '@assets/images/simple-illustrations/simple-i import Mailbox from '@assets/images/simple-illustrations/simple-illustration__mailbox.svg'; import Pencil from '@assets/images/simple-illustrations/simple-illustration__pencil.svg'; import ExpensifyApprovedLogo from '@assets/images/subscription-details__approvedlogo.svg'; +import TravelCVV from '@assets/images/travel-cvv.svg'; import TurtleInShell from '@assets/images/turtle-in-shell.svg'; export { @@ -93,5 +94,6 @@ export { Pencil, PendingTravel, Puzzle, + TravelCVV, TurtleInShell, }; diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index f86a9b9bd84a9..b3fc590281130 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -166,6 +166,7 @@ import UserShield from '@assets/images/simple-illustrations/simple-illustration_ import VirtualCard from '@assets/images/simple-illustrations/simple-illustration__virtualcard.svg'; import Workflows from '@assets/images/simple-illustrations/simple-illustration__workflows.svg'; import ExpensifyApprovedLogo from '@assets/images/subscription-details__approvedlogo.svg'; +import TravelCVV from '@assets/images/travel-cvv.svg'; import TurtleInShell from '@assets/images/turtle-in-shell.svg'; // Create the illustrations object with all imported illustrations @@ -255,6 +256,7 @@ const Illustrations = { RunningTurtle, Shutter, ExpensifyApprovedLogo, + TravelCVV, TurtleInShell, // Simple Illustrations diff --git a/src/languages/de.ts b/src/languages/de.ts index 03ebd6c4951b8..d510d7f3bcc44 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2160,6 +2160,12 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} verliert den Zugriff auf dieses Geschäftskonto. Laufende Zahlungen werden weiterhin ausgeführt.`, reachOutForHelp: 'Dieses Konto wird mit der Expensify Card verwendet. Wenden Sie sich an den Concierge, wenn Sie die Freigabe aufheben möchten.', unshareErrorModalTitle: 'Bankkonto kann nicht freigegeben werden', + travelCVV: { + title: 'Reise-CVV', + subtitle: 'Verwenden Sie dies bei der Buchung von Reisen', + description: 'Diese Karte fasst Ausgaben wie Flugtickets, Bahnfahrten, Mietwagen und manchmal auch Hotels in einem einzigen Konto zusammen.', + instructions: 'Du wirst nach den letzten 4 Ziffern gefragt. Du kannst sie unten über den Button anzeigen.', + }, }, cardPage: { expensifyCard: 'Expensify Card', diff --git a/src/languages/en.ts b/src/languages/en.ts index 9ea0669646788..742c7afadda3d 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2151,6 +2151,12 @@ const translations = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} will lose access to this business bank account. We’ll still complete any payments in process.`, reachOutForHelp: 'It’s being used with the Expensify Card. Reach out to Concierge if you need to unshare it.', unshareErrorModalTitle: 'Can’t unshare bank account', + travelCVV: { + title: 'Travel CVV', + subtitle: 'Use this when booking travel', + description: 'This card consolidates expenses like airfare, rail, car rentals, and sometimes hotels into a single account rather.', + instructions: "You'll be asked for the last 4 digits. You can reveal them below using the button.", + }, }, cardPage: { expensifyCard: 'Expensify Card', diff --git a/src/languages/es.ts b/src/languages/es.ts index f139dd7964bf0..aaf75bf686c2d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1864,6 +1864,12 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} perderá el acceso a esta cuenta bancaria comercial. Seguiremos completando los pagos en proceso.`, reachOutForHelp: 'Se está usando con la tarjeta Expensify. Contacte con Concierge si necesita dejar de compartirla.', unshareErrorModalTitle: 'No se puede dejar de compartir la cuenta bancaria', + travelCVV: { + title: 'CVV de viaje', + subtitle: 'Úsalo al reservar viajes', + description: 'Esta tarjeta consolida gastos como pasajes de avión, tren, alquiler de coches, y a veces, hoteles en una sola cuenta.', + instructions: 'Se te pedirá los últimos 4 dígitos. Puedes revelarlos abajo usando el botón.', + }, }, cardPage: { expensifyCard: 'Tarjeta Expensify', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c86cc230d2b48..f71aa844f5ae5 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2164,6 +2164,12 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} perdra l’accès à ce compte bancaire professionnel. Les paiements en cours seront honorés.`, reachOutForHelp: 'Ce compte est utilisé avec la carte Expensify. Contactez le service de conciergerie si vous souhaitez le retirer du partage.', unshareErrorModalTitle: 'Impossible de retirer le partage du compte bancaire', + travelCVV: { + title: 'Cryptogramme visuel de la carte de voyage (CVV)', + subtitle: 'À utiliser lors de la réservation de voyages', + description: 'Cette carte regroupe les dépenses comme les billets d’avion, le train, les locations de voiture et parfois les hôtels dans un seul compte.', + instructions: 'On vous demandera les 4 derniers chiffres. Vous pouvez les afficher ci-dessous en utilisant le bouton.', + }, }, cardPage: { expensifyCard: 'Carte Expensify', diff --git a/src/languages/it.ts b/src/languages/it.ts index 5de71dcc078d1..6bd0ea9f4a8b5 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2155,6 +2155,12 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} perderà l'accesso a questo conto bancario aziendale. Completeremo comunque tutti i pagamenti in corso.`, reachOutForHelp: 'È in uso con la carta Expensify. Contatta il Concierge se devi revocare la condivisione.', unshareErrorModalTitle: 'Impossibile revocare la condivisione del conto bancario', + travelCVV: { + title: 'CVV di viaggio', + subtitle: 'Usa questo quando prenoti viaggi', + description: 'Questa carta consolida spese come voli aerei, treni, noleggi auto e talvolta hotel in un unico conto.', + instructions: 'Ti verranno richieste le ultime 4 cifre. Puoi visualizzarle qui sotto usando il pulsante.', + }, }, cardPage: { expensifyCard: 'Carta Expensify', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a1744688f4031..8623448c75d9d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2151,6 +2151,12 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} はこのビジネス銀行口座にアクセスできなくなります。処理中のお支払いは引き続き完了します。`, reachOutForHelp: 'この口座は Expensify カードで使用されています。共有を解除する必要がある場合は、コンシェルジュまでお問い合わせください。', unshareErrorModalTitle: '銀行口座の共有を解除できません', + travelCVV: { + title: 'トラベルCVV', + subtitle: '出張を予約するときにこれを使用してください', + description: 'このカードは、航空運賃、鉄道、レンタカー、場合によってはホテルなどの経費を、1つの口座にまとめて集約します。', + instructions: '最後の4桁の入力を求められます。下のボタンを使って表示できます。', + }, }, cardPage: { expensifyCard: 'Expensify Card', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 7408ec09fa549..40dd67f6634b9 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2154,6 +2154,12 @@ const translations: TranslationDeepObject = { `${admin} verliest de toegang tot deze zakelijke bankrekening. We zullen alle betalingen die in behandeling zijn nog steeds voltooien.`, reachOutForHelp: 'Deze wordt gebruikt met de Expensify Card. Neem contact op met Concierge als u de deling ongedaan wilt maken.', unshareErrorModalTitle: 'Deling bankrekening kan niet ongedaan worden gemaakt', + travelCVV: { + title: 'Reis-CVV', + subtitle: 'Gebruik dit bij het boeken van reizen', + description: 'Deze kaart bundelt uitgaven zoals vliegtickets, treinreizen, autoverhuur en soms hotels in één enkele rekening.', + instructions: 'Je wordt gevraagd om de laatste 4 cijfers. Je kunt ze hieronder weergeven met de knop.', + }, }, cardPage: { expensifyCard: 'Expensify Card', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 87eea9aaea28b..108ec746aa959 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2149,6 +2149,12 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} utraci dostęp do tego firmowego konta bankowego. Nadal będziemy realizować wszystkie płatności w toku.`, reachOutForHelp: 'Jest ono używane z kartą Expensify. Skontaktuj się z Concierge, jeśli chcesz je anulować.', unshareErrorModalTitle: 'Nie można anulować udostępniania konta bankowego', + travelCVV: { + title: 'CVV podróży', + subtitle: 'Użyj tego podczas rezerwacji podróży', + description: 'Ta karta konsoliduje wydatki, takie jak przeloty, kolej, wynajem samochodów, a czasem także hotele, na jednym koncie.', + instructions: 'Zostaniesz poproszony o podanie ostatnich 4 cyfr. Możesz je wyświetlić poniżej za pomocą przycisku.', + }, }, cardPage: { expensifyCard: 'Karta Expensify', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 2b04957756a71..361bcd01d3f00 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2149,6 +2149,12 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} perderá o acesso a esta conta bancária comercial. Ainda concluiremos quaisquer pagamentos em andamento.`, reachOutForHelp: 'Está sendo usada com o Cartão Expensify. Entre em contato com o Concierge se precisar descompartilhar.', unshareErrorModalTitle: 'Não foi possível descompartilhar a conta bancária', + travelCVV: { + title: 'CVV de viagem', + subtitle: 'Use isto ao reservar viagens', + description: 'Este cartão consolida despesas como passagens aéreas, trem, aluguel de carros e, às vezes, hotéis em uma única conta.', + instructions: 'Será solicitado que você informe os últimos 4 dígitos. Você pode revelá-los abaixo usando o botão.', + }, }, cardPage: { expensifyCard: 'Cartão Expensify', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 05aae78e216fc..ea222cbf05052 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2122,6 +2122,12 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} 将失去对此企业银行账户的访问权限。我们仍将完成所有正在进行的付款。`, reachOutForHelp: '此账户正在与 Expensify 卡一起使用。如果您需要取消共享,请联系礼宾部。', unshareErrorModalTitle: '无法取消共享银行账户', + travelCVV: { + title: '旅行 CVV', + subtitle: '预订差旅时使用此选项', + description: '此卡将机票、火车、租车,有时还包括酒店等费用合并到一个账户中。', + instructions: '系统将要求您提供最后 4 位数字。您可以使用下面的按钮查看它们。', + }, }, cardPage: { expensifyCard: 'Expensify Card', diff --git a/src/libs/Environment/Environment.ts b/src/libs/Environment/Environment.ts index 23c7b360dc630..82805b5c79449 100644 --- a/src/libs/Environment/Environment.ts +++ b/src/libs/Environment/Environment.ts @@ -25,6 +25,13 @@ function isDevelopment(): boolean { return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.DEV; } +/** + * Are we running the app in staging? + */ +function isStaging(): boolean { + return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.STAGING; +} + /** * Are we running the app in production? */ @@ -62,4 +69,4 @@ function getOldDotEnvironmentURL(): Promise { return getEnvironment().then((environment) => OLDDOT_ENVIRONMENT_URLS[environment]); } -export {getEnvironment, isInternalTestBuild, isDevelopment, isProduction, getEnvironmentURL, getOldDotEnvironmentURL, getOldDotURLFromEnvironment}; +export {getEnvironment, isInternalTestBuild, isDevelopment, isStaging, isProduction, getEnvironmentURL, getOldDotEnvironmentURL, getOldDotURLFromEnvironment}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index e6a18e2638764..fa6a9adaa83c9 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -392,6 +392,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount').default, [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS]: () => require('../../../../pages/settings/Wallet/EnableGlobalReimbursements').default, [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/ShareBankAccount/ShareBankAccount').default, + [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: () => require('../../../../pages/settings/Wallet/TravelCVVPage').default, [SCREENS.SETTINGS.ADD_DEBIT_CARD]: () => require('../../../../pages/settings/Wallet/AddDebitCardPage').default, [SCREENS.SETTINGS.ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/NewBankAccountVerifyAccountPage').default, [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/InternationalDepositAccount').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index 95e867346e8a1..cc6fbb9042ba0 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -46,6 +46,7 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS.route, exact: true, }, + [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: { + path: ROUTES.SETTINGS_WALLET_TRAVEL_CVV, + exact: true, + }, [SCREENS.SETTINGS.ADD_DEBIT_CARD]: { path: ROUTES.SETTINGS_ADD_DEBIT_CARD, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 8589f9e83b96c..551a5a6cf1bc4 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -213,6 +213,7 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: { bankAccountID: string; }; + [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: undefined; [SCREENS.SETTINGS.ADD_DEBIT_CARD]: undefined; [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: undefined; [SCREENS.SETTINGS.ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: { diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 676e5742723fd..62b497ae385f7 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -4,7 +4,7 @@ import Onyx from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; +import type {Card, OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; import type {OnyxData} from '@src/types/onyx/Request'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -437,6 +437,17 @@ function arePersonalDetailsMissing(privatePersonalDetails: OnyxEntry, privatePersonalDetails: OnyxEntry): boolean { + const isUKOrEUCard = card?.nameValuePairs?.feedCountry === CONST.COUNTRY.GB; + const hasMissingDetails = arePersonalDetailsMissing(privatePersonalDetails); + + return hasMissingDetails && isUKOrEUCard; +} + export { getDisplayNameOrDefault, getPersonalDetailsByIDs, @@ -457,4 +468,5 @@ export { getLoginByAccountID, getPhoneNumber, arePersonalDetailsMissing, + shouldShowMissingDetailsPage, }; diff --git a/src/libs/TravelInvoicingUtils.ts b/src/libs/TravelInvoicingUtils.ts index 2996637a67a9b..fcbeb7f2fdcdd 100644 --- a/src/libs/TravelInvoicingUtils.ts +++ b/src/libs/TravelInvoicingUtils.ts @@ -1,8 +1,10 @@ import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; -import type {BankAccountList} from '@src/types/onyx'; +import type {BankAccountList, Beta, Card, CardList} from '@src/types/onyx'; import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings'; import {getLastFourDigits} from './BankAccountUtils'; +import {isDevelopment, isInternalTestBuild, isStaging} from './Environment/Environment'; +import Permissions from './Permissions'; /** * The Travel Invoicing feed type constant for PROGRAM_TRAVEL_US. @@ -10,6 +12,14 @@ import {getLastFourDigits} from './BankAccountUtils'; */ const PROGRAM_TRAVEL_US = 'TRAVEL_US'; +/** + * Feature flag to enable Travel CVV testing on Dev and Staging environments. + * When enabled, it allows using any card for CVV reveal testing if no specific Travel Card is found. + */ +function isTravelCVVTestingEnabled(): boolean { + return isDevelopment() || isInternalTestBuild() || isStaging(); +} + /** * Checks whether Travel Invoicing is enabled based on the card settings. * Travel Invoicing is considered enabled if the PROGRAM_TRAVEL_US feed has a valid paymentBankAccountID. @@ -96,6 +106,45 @@ function getTravelSettlementFrequency(cardSettings: OnyxEntry): Card | undefined { + if (!cardList) { + return undefined; + } + + const travelCard = Object.values(cardList)?.find((card) => card?.nameValuePairs?.isTravelCard); + // If no travel card is found and testing is enabled, return the first available card + if (!travelCard && isTravelCVVTestingEnabled()) { + return Object.values(cardList)?.at(0); + } + + return travelCard; +} + +/** + * Checks if user is eligible to see Travel CVV in Wallet. + * Requires: TRAVEL_INVOICING beta AND having a travel card. + */ +function isTravelCVVEligible(betas: OnyxEntry, cardList: OnyxEntry): boolean { + const hasBeta = Permissions.isBetaEnabled(CONST.BETAS.TRAVEL_INVOICING as Beta, betas); + const hasTravelCard = !!getTravelInvoicingCard(cardList); + return hasBeta && hasTravelCard; +} + +export { + PROGRAM_TRAVEL_US, + getIsTravelInvoicingEnabled, + hasTravelInvoicingSettlementAccount, + getTravelLimit, + getTravelSpend, + getTravelSettlementAccount, + getTravelSettlementFrequency, + getTravelInvoicingCard, + isTravelCVVEligible, + isTravelCVVTestingEnabled, +}; export type {TravelSettlementAccountInfo}; diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index 0ba5fbc7e95cc..995014677d6da 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -27,7 +27,7 @@ import {convertToDisplayString, getCurrencyKeyByCountryCode} from '@libs/Currenc import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {DomainCardNavigatorParamList, SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {arePersonalDetailsMissing} from '@libs/PersonalDetailsUtils'; +import {shouldShowMissingDetailsPage} from '@libs/PersonalDetailsUtils'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import RedDotCardSection from '@pages/settings/Wallet/RedDotCardSection'; @@ -39,7 +39,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {Card, CurrencyList, PrivatePersonalDetails} from '@src/types/onyx'; +import type {CurrencyList} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import useExpensifyCardContext from './useExpensifyCardContext'; @@ -54,17 +54,6 @@ type LimitTypeTranslationKeys = { limitTitleKey: PossibleTitles | undefined; }; -/** - * Determines if the user should be redirected to the missing details page - * before revealing their card details (for UK/EU cards only). - */ -function shouldShowMissingDetailsPage(card: OnyxEntry, privatePersonalDetails: OnyxEntry): boolean { - const isUKOrEUCard = card?.nameValuePairs?.feedCountry === 'GB'; - const hasMissingDetails = arePersonalDetailsMissing(privatePersonalDetails); - - return hasMissingDetails && isUKOrEUCard; -} - function getLimitTypeTranslationKeys(limitType: ValueOf | undefined): LimitTypeTranslationKeys { switch (limitType) { case CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART: diff --git a/src/pages/settings/Wallet/TravelCVVPage.tsx b/src/pages/settings/Wallet/TravelCVVPage.tsx new file mode 100644 index 0000000000000..2137a9ad9eb95 --- /dev/null +++ b/src/pages/settings/Wallet/TravelCVVPage.tsx @@ -0,0 +1,180 @@ +import React, {useCallback, useContext, useState} from 'react'; +import {View} from 'react-native'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import RESIZE_MODES from '@components/Image/resizeModes'; +import ImageSVG from '@components/ImageSVG'; +import {LockedAccountContext} from '@components/LockedAccountModalProvider'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import ValidateCodeActionContent from '@components/ValidateCodeActionModal/ValidateCodeActionContent'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {revealVirtualCardDetails} from '@libs/actions/Card'; +import {requestValidateCodeAction, resetValidateActionCodeSent} from '@libs/actions/User'; +import {filterPersonalCards} from '@libs/CardUtils'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {shouldShowMissingDetailsPage} from '@libs/PersonalDetailsUtils'; +import {getTravelInvoicingCard} from '@libs/TravelInvoicingUtils'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; + +/** + * TravelCVVPage - Displays the Travel CVV reveal interface. + * Shows a description of the travel card and allows users to reveal the CVV. + * CVV is stored only in local component state and never persisted in Onyx. + */ +function TravelCVVPage() { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['TravelCVV']); + + const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: false}); + const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {canBeMissing: false}); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST, {selector: filterPersonalCards, canBeMissing: true}); + + const {isAccountLocked, showLockedAccountModal} = useContext(LockedAccountContext); + + // Local state for CVV - never persisted in Onyx for security + const [cvv, setCvv] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [validateError, setValidateError] = useState({}); + + const travelCard = getTravelInvoicingCard(cardList); + + const primaryLogin = account?.primaryLogin ?? ''; + const isSignedInAsDelegate = !!account?.delegatedAccess?.delegate || false; + + const handleRevealDetailsPress = useCallback(() => { + if (isAccountLocked) { + showLockedAccountModal(); + return; + } + + // Check if user needs to add personal details first (UK/EU cards only) + if (shouldShowMissingDetailsPage(travelCard, privatePersonalDetails)) { + Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_MISSING_DETAILS.getRoute(String(travelCard?.cardID))); + return; + } + + // Switch to verification mode - shows ValidateCodeActionContent in RHP + setIsVerifying(true); + }, [isAccountLocked, showLockedAccountModal, travelCard, privatePersonalDetails]); + + const handleValidateCode = useCallback( + (validateCode: string) => { + if (!travelCard?.cardID) { + return; + } + + setIsLoading(true); + + // Call revealVirtualCardDetails and only extract CVV + // eslint-disable-next-line rulesdir/no-thenable-actions-in-views + revealVirtualCardDetails(travelCard.cardID, validateCode) + .then((cardDetails) => { + // Only store CVV in local state - never persist PAN or other details + setCvv(cardDetails.cvv ?? null); + setIsVerifying(false); + setValidateError({}); + }) + .catch((error: TranslationPaths) => { + setValidateError(getMicroSecondOnyxErrorWithTranslationKey(error)); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [travelCard?.cardID], + ); + + const handleCloseVerification = useCallback(() => { + resetValidateActionCodeSent(); + setIsVerifying(false); + setValidateError({}); + }, []); + + // When in verification mode, show magic code input directly in RHP + if (isVerifying) { + return ( + + requestValidateCodeAction()} + validateCodeActionErrorField="revealExpensifyCardDetails" + handleSubmitForm={handleValidateCode} + validateError={validateError} + clearError={() => setValidateError({})} + onClose={handleCloseVerification} + isLoading={isLoading} + /> + + ); + } + + return ( + + + + + + + + + + {translate('walletPage.travelCVV.description')} + {translate('walletPage.travelCVV.instructions')} + + + + ) : undefined + } + /> + + + + ); +} + +TravelCVVPage.displayName = 'TravelCVVPage'; + +export default TravelCVVPage; diff --git a/src/pages/settings/Wallet/WalletPage/WalletTravelCVVSection.tsx b/src/pages/settings/Wallet/WalletPage/WalletTravelCVVSection.tsx new file mode 100644 index 0000000000000..08dbaded12478 --- /dev/null +++ b/src/pages/settings/Wallet/WalletPage/WalletTravelCVVSection.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import MenuItem from '@components/MenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {filterPersonalCards} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {getTravelInvoicingCard, isTravelCVVEligible} from '@libs/TravelInvoicingUtils'; +import colors from '@styles/theme/colors'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +/** + * Renders a menu item in the Wallet's Assigned cards section that allows + * users to access their Travel Invoicing card CVV. + * Only renders when user is eligible (has TRAVEL_INVOICING beta and a travel card). + */ +function WalletTravelCVVSection() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['LuggageWithLines']); + + const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST, {selector: filterPersonalCards, canBeMissing: true}); + + const travelCard = getTravelInvoicingCard(cardList); + + if (!isTravelCVVEligible(betas, cardList) || !travelCard) { + return null; + } + + return ( + Navigation.navigate(ROUTES.SETTINGS_WALLET_TRAVEL_CVV)} + /> + ); +} + +WalletTravelCVVSection.displayName = 'WalletTravelCVVSection'; + +export default WalletTravelCVVSection; diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index cb87a2b8ee83b..27fce12fe42f2 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -48,6 +48,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {CardPressHandlerParams, PaymentMethodPressHandlerParams} from './types'; import useWalletSectionIllustration from './useWalletSectionIllustration'; +import WalletTravelCVVSection from './WalletTravelCVVSection'; const fundListSelector = (allFunds: OnyxEntry) => Object.fromEntries(Object.entries(allFunds ?? {}).filter(([, item]) => item.accountData?.additionalData?.isP2PDebitCard === true)); @@ -497,6 +498,7 @@ function WalletPage() { style={[styles.mt5, [shouldUseNarrowLayout ? styles.mhn5 : styles.mhn8]]} listItemStyle={shouldUseNarrowLayout ? styles.ph5 : styles.ph8} /> + ) : null} diff --git a/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx b/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx index dc746254cf07f..a364c8f94b927 100644 --- a/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx @@ -4,13 +4,13 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOffli import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import getBankIcon from '@components/Icon/BankIcons'; -import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -54,6 +54,7 @@ function WorkspaceTravelSettlementAccountPage({route}: WorkspaceTravelSettlement const styles = useThemeStyles(); const {translate} = useLocalize(); const policyID = route.params?.policyID; + const icons = useMemoizedLazyExpensifyIcons(['Plus']); const workspaceAccountID = useWorkspaceAccountID(policyID); const [bankAccountsList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true}); const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`, {canBeMissing: true}); @@ -111,7 +112,7 @@ function WorkspaceTravelSettlementAccountPage({route}: WorkspaceTravelSettlement initiallyFocusedItemKey={paymentBankAccountID?.toString()} listFooterContent={ Navigation.navigate( @@ -127,7 +128,7 @@ function WorkspaceTravelSettlementAccountPage({route}: WorkspaceTravelSettlement /> ) : ( Navigation.navigate( diff --git a/src/styles/index.ts b/src/styles/index.ts index 7506658f08659..01d1a5fe1ea51 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5097,6 +5097,16 @@ const staticStyles = (theme: ThemeColors) => height: 170, }, + travelCCVIllustration: { + width: 193, + height: 75, + }, + + travelInvoicingIcon: { + backgroundColor: colors.productLight700, + borderRadius: variables.componentBorderRadiusNormal, + }, + successBankSharedCardIllustration: { width: 164, height: 164, diff --git a/tests/unit/TravelInvoicingUtilsTest.ts b/tests/unit/TravelInvoicingUtilsTest.ts index d187978a0d05a..efaefc182083c 100644 --- a/tests/unit/TravelInvoicingUtilsTest.ts +++ b/tests/unit/TravelInvoicingUtilsTest.ts @@ -1,17 +1,37 @@ import CONST from '@src/CONST'; +// Needed for testing usage with jest.spyOn +// eslint-disable-next-line no-restricted-imports +import * as Environment from '@src/libs/Environment/Environment'; import { getIsTravelInvoicingEnabled, + getTravelInvoicingCard, getTravelLimit, getTravelSettlementAccount, getTravelSettlementFrequency, getTravelSpend, hasTravelInvoicingSettlementAccount, + isTravelCVVEligible, PROGRAM_TRAVEL_US, } from '@src/libs/TravelInvoicingUtils'; -import type {BankAccountList} from '@src/types/onyx'; +import type {BankAccountList, Beta, CardList} from '@src/types/onyx'; import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings'; +jest.mock('@src/libs/Environment/Environment'); + describe('TravelInvoicingUtils', () => { + let isDevelopmentSpy: jest.SpyInstance; + let isInternalTestBuildSpy: jest.SpyInstance; + + beforeAll(() => { + isDevelopmentSpy = jest.spyOn(Environment, 'isDevelopment').mockReturnValue(false); + isInternalTestBuildSpy = jest.spyOn(Environment, 'isInternalTestBuild').mockReturnValue(false); + }); + + afterEach(() => { + isDevelopmentSpy.mockReturnValue(false); + isInternalTestBuildSpy.mockReturnValue(false); + jest.clearAllMocks(); + }); describe('PROGRAM_TRAVEL_US constant', () => { it('Should be defined as TRAVEL_US', () => { expect(PROGRAM_TRAVEL_US).toBe('TRAVEL_US'); @@ -197,4 +217,154 @@ describe('TravelInvoicingUtils', () => { expect(result?.last4).toBe(''); }); }); + + describe('getTravelInvoicingCard', () => { + it('Should return undefined when cardList is undefined', () => { + const result = getTravelInvoicingCard(undefined); + expect(result).toBeUndefined(); + }); + + it('Should return undefined when cardList is empty', () => { + const result = getTravelInvoicingCard({}); + expect(result).toBeUndefined(); + }); + + it('Should return undefined when no travel card exists', () => { + const cardList = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1234': { + cardID: 1234, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: false, + }, + }, + } as unknown as CardList; + const result = getTravelInvoicingCard(cardList); + expect(result).toBeUndefined(); + }); + + it('Should return the travel card when isTravelCard is true', () => { + const cardList = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1234': { + cardID: 1234, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: true, + }, + }, + } as unknown as CardList; + const result = getTravelInvoicingCard(cardList); + expect(result).toBeDefined(); + expect(result?.cardID).toBe(1234); + }); + + it('Should return first travel card when multiple cards exist', () => { + const cardList = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1111': { + cardID: 1111, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: false, + }, + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + '2222': { + cardID: 2222, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: true, + }, + }, + } as unknown as CardList; + const result = getTravelInvoicingCard(cardList); + expect(result).toBeDefined(); + expect(result?.nameValuePairs?.isTravelCard).toBe(true); + }); + it('Should fallback to first available card when testing is enabled and no travel card exists', () => { + isDevelopmentSpy.mockReturnValue(true); + + const cardList = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '9999': { + cardID: 9999, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: false, + }, + }, + } as unknown as CardList; + + const result = getTravelInvoicingCard(cardList); + expect(result).toBeDefined(); + expect(result?.cardID).toBe(9999); + }); + }); + + describe('isTravelCVVEligible', () => { + const mockTravelCardList = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1234': { + cardID: 1234, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: true, + }, + }, + } as unknown as CardList; + + const mockNonTravelCardList = { + // eslint-disable-next-line @typescript-eslint/naming-convention + '5678': { + cardID: 5678, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: false, + }, + }, + } as unknown as CardList; + + const mockBetasWithTravelInvoicing: Beta[] = [CONST.BETAS.TRAVEL_INVOICING as Beta]; + const mockBetasWithoutTravelInvoicing: Beta[] = []; + + it('Should return false when betas is undefined', () => { + const result = isTravelCVVEligible(undefined, mockTravelCardList); + expect(result).toBe(false); + }); + + it('Should return false when cardList is undefined', () => { + const result = isTravelCVVEligible(mockBetasWithTravelInvoicing, undefined); + expect(result).toBe(false); + }); + + it('Should return false when no travel card exists', () => { + const result = isTravelCVVEligible(mockBetasWithTravelInvoicing, mockNonTravelCardList); + expect(result).toBe(false); + }); + + it('Should return false when beta is missing', () => { + const result = isTravelCVVEligible(mockBetasWithoutTravelInvoicing, mockTravelCardList); + expect(result).toBe(false); + }); + + it('Should return true when beta is present and travel card exists', () => { + const result = isTravelCVVEligible(mockBetasWithTravelInvoicing, mockTravelCardList); + expect(result).toBe(true); + }); + + it('Should return true when testing is enabled and beta is present even if no travel card exists', () => { + isDevelopmentSpy.mockReturnValue(true); + const result = isTravelCVVEligible(mockBetasWithTravelInvoicing, mockNonTravelCardList); + expect(result).toBe(true); + }); + }); }); From 2b3be0d959f78e0733fbeb9c2e77eeaf00fae127 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 17 Jan 2026 19:20:14 -0800 Subject: [PATCH 02/15] fix: eslint, svg compression --- assets/images/travel-cvv.svg | 26 +------------------ .../Wallet/ExpensifyCardPage/index.tsx | 1 - 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/assets/images/travel-cvv.svg b/assets/images/travel-cvv.svg index 72557b46a0ada..c05a800437add 100644 --- a/assets/images/travel-cvv.svg +++ b/assets/images/travel-cvv.svg @@ -1,25 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index 995014677d6da..51fa3056c7fcf 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -1,7 +1,6 @@ import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import AddToWalletButton from '@components/AddToWalletButton/index'; import Button from '@components/Button'; From 6f347f0756bb43b7a8b13fb9ec7540d45c3468b7 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 17 Jan 2026 19:45:13 -0800 Subject: [PATCH 03/15] fix: codex - validate code sent --- src/pages/settings/Wallet/TravelCVVPage.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/settings/Wallet/TravelCVVPage.tsx b/src/pages/settings/Wallet/TravelCVVPage.tsx index 2137a9ad9eb95..508a5e4f1ce65 100644 --- a/src/pages/settings/Wallet/TravelCVVPage.tsx +++ b/src/pages/settings/Wallet/TravelCVVPage.tsx @@ -68,6 +68,9 @@ function TravelCVVPage() { return; } + // ValidateCodeActionContent only sends a magic code when validateCodeSent is false + // so we need to reset it to ensure a code is always sent + resetValidateActionCodeSent(); // Switch to verification mode - shows ValidateCodeActionContent in RHP setIsVerifying(true); }, [isAccountLocked, showLockedAccountModal, travelCard, privatePersonalDetails]); From cb0ea07c7703d7d3fe3df596c8d0b998b0990acd Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 28 Jan 2026 17:04:23 -0800 Subject: [PATCH 04/15] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 4086ba4d3ee2f..a2c08091395ad 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 4086ba4d3ee2f9dac2b7dc49edf435e6b2ceb2eb +Subproject commit a2c08091395adc5fde962a17eddd09c565cc6384 From 92b3b887580c044e8a33fcac8b24cef73f2ff5ad Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 29 Jan 2026 14:45:38 -0800 Subject: [PATCH 05/15] fix: updated illustration & styles --- assets/images/travel-cvv.svg | 2 +- src/libs/TravelInvoicingUtils.ts | 2 +- src/pages/settings/Wallet/TravelCVVPage.tsx | 4 ++-- src/styles/index.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/assets/images/travel-cvv.svg b/assets/images/travel-cvv.svg index c05a800437add..2b5e140d2b60b 100644 --- a/assets/images/travel-cvv.svg +++ b/assets/images/travel-cvv.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/libs/TravelInvoicingUtils.ts b/src/libs/TravelInvoicingUtils.ts index 1c21e08bee01c..1bf372729a13a 100644 --- a/src/libs/TravelInvoicingUtils.ts +++ b/src/libs/TravelInvoicingUtils.ts @@ -11,7 +11,7 @@ import Permissions from './Permissions'; * When enabled, it allows using any card for CVV reveal testing if no specific Travel Card is found. */ function isTravelCVVTestingEnabled(): boolean { - return isDevelopment() || isInternalTestBuild() || isStaging(); + return isDevelopment() || isStaging() || isInternalTestBuild(); } /** diff --git a/src/pages/settings/Wallet/TravelCVVPage.tsx b/src/pages/settings/Wallet/TravelCVVPage.tsx index 83d08388a5852..40790dda7cba7 100644 --- a/src/pages/settings/Wallet/TravelCVVPage.tsx +++ b/src/pages/settings/Wallet/TravelCVVPage.tsx @@ -138,7 +138,7 @@ function TravelCVVPage() { /> - + - + {translate('walletPage.travelCVV.description')} {translate('walletPage.travelCVV.instructions')} diff --git a/src/styles/index.ts b/src/styles/index.ts index 6af179a2a0460..2b294bb3110d1 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5231,8 +5231,8 @@ const staticStyles = (theme: ThemeColors) => }, travelCCVIllustration: { - width: 193, - height: 75, + width: 240, + height: 100, }, travelInvoicingIcon: { From 9648d86648a998c0ec312c9c96c510598c99fe4d Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 29 Jan 2026 14:46:40 -0800 Subject: [PATCH 06/15] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index a2c08091395ad..7a4b4741a0a57 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit a2c08091395adc5fde962a17eddd09c565cc6384 +Subproject commit 7a4b4741a0a57f0de3f6d3124d377093c96df18d From 3897ab337cd7095d13c1093b4ecd4d30c6e0f991 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 30 Jan 2026 15:58:54 -0800 Subject: [PATCH 07/15] refactor: code review requests and organizing --- src/App.tsx | 2 + src/ROUTES.ts | 1 + src/SCREENS.ts | 1 + .../ModalStackNavigators/index.tsx | 3 +- .../RELATIONS/SETTINGS_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 4 + src/libs/Navigation/types.ts | 1 + src/libs/PersonalDetailsUtils.ts | 4 +- src/libs/TravelInvoicingUtils.ts | 22 ++- .../TravelCVVContextProvider.tsx | 61 +++++++ .../{ => TravelCVVPage}/TravelCVVPage.tsx | 83 ++-------- .../TravelCVVVerifyAccountPage.tsx | 76 +++++++++ .../WalletTravelCVVSection.tsx | 11 +- .../settings/Wallet/TravelCVVPage/types.ts | 15 ++ .../settings/Wallet/WalletPage/index.tsx | 2 +- tests/unit/TravelInvoicingUtilsTest.ts | 151 ++++++++++-------- 16 files changed, 276 insertions(+), 162 deletions(-) create mode 100644 src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx rename src/pages/settings/Wallet/{ => TravelCVVPage}/TravelCVVPage.tsx (60%) create mode 100644 src/pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage.tsx rename src/pages/settings/Wallet/{WalletPage => TravelCVVPage}/WalletTravelCVVSection.tsx (81%) create mode 100644 src/pages/settings/Wallet/TravelCVVPage/types.ts diff --git a/src/App.tsx b/src/App.tsx index 4298c48bf140d..a98c443783e4e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import {LogBox, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; +import TravelCVVContextProvider from '@pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider'; import '../wdyr'; import {ActionSheetAwareScrollViewProvider} from './components/ActionSheetAwareScrollView'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; @@ -131,6 +132,7 @@ function App() { ModalProvider, SidePanelContextProvider, ExpensifyCardContextProvider, + TravelCVVContextProvider, KYCWallContextProvider, WideRHPContextProvider, ]} diff --git a/src/ROUTES.ts b/src/ROUTES.ts index b1a4c0c5957bd..e3630971ad17c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -427,6 +427,7 @@ const ROUTES = { getRoute: (cardID: string) => `settings/wallet/card/${cardID}/activate` as const, }, SETTINGS_WALLET_TRAVEL_CVV: 'settings/wallet/travel-cvv', + SETTINGS_WALLET_TRAVEL_CVV_VERIFY_ACCOUNT: `settings/wallet/travel-cvv/${VERIFY_ACCOUNT}`, SETTINGS_RULES: 'settings/rules', SETTINGS_RULES_ADD: { route: 'settings/rules/new/:field?', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 13a83454aa7d3..8c7963e1cb39f 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -199,6 +199,7 @@ const SCREENS = { ENABLE_GLOBAL_REIMBURSEMENTS: 'Settings_Wallet_Enable_Global_Reimbursements', SHARE_BANK_ACCOUNT: 'Settings_Wallet_Share_Bank_Account', TRAVEL_CVV: 'Settings_Wallet_Travel_CVV', + TRAVEL_CVV_VERIFY_ACCOUNT: 'Settings_Wallet_Travel_CVV_VerifyAccount', PERSONAL_CARD_DETAILS: 'Settings_Wallet_Personal_Card_Details', PERSONAL_CARD_EDIT_NAME: 'Settings_Wallet_Personal_Card_Edit_Name', PERSONAL_CARD_EDIT_TRANSACTION_START_DATE: 'Settings_Wallet_Personal_Card_Edit_Transaction_Start_Date', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 839ececbf21fb..0b5e0cd299435 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -405,7 +405,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount').default, [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS]: () => require('../../../../pages/settings/Wallet/EnableGlobalReimbursements').default, [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/ShareBankAccount/ShareBankAccount').default, - [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: () => require('../../../../pages/settings/Wallet/TravelCVVPage').default, + [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: () => require('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVPage').default, + [SCREENS.SETTINGS.WALLET.TRAVEL_CVV_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage').default, [SCREENS.SETTINGS.ADD_DEBIT_CARD]: () => require('../../../../pages/settings/Wallet/AddDebitCardPage').default, [SCREENS.SETTINGS.ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/NewBankAccountVerifyAccountPage').default, [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/InternationalDepositAccount').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index 134e44464c4f6..1cb93a9eb353b 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -55,6 +55,7 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_WALLET_TRAVEL_CVV, exact: true, }, + [SCREENS.SETTINGS.WALLET.TRAVEL_CVV_VERIFY_ACCOUNT]: { + path: ROUTES.SETTINGS_WALLET_TRAVEL_CVV_VERIFY_ACCOUNT, + exact: true, + }, [SCREENS.SETTINGS.ADD_DEBIT_CARD]: { path: ROUTES.SETTINGS_ADD_DEBIT_CARD, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 488c9ce28a747..a8ac23153d2ec 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -231,6 +231,7 @@ type SettingsNavigatorParamList = { bankAccountID: string; }; [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: undefined; + [SCREENS.SETTINGS.WALLET.TRAVEL_CVV_VERIFY_ACCOUNT]: undefined; [SCREENS.SETTINGS.ADD_DEBIT_CARD]: undefined; [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: undefined; [SCREENS.SETTINGS.ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: { diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 645325472d7f1..17db6f596c74d 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -4,7 +4,7 @@ import Onyx from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Card, OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; +import type {OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; import type {OnyxData} from '@src/types/onyx/Request'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -453,7 +453,7 @@ function arePersonalDetailsMissing(privatePersonalDetails: OnyxEntry, privatePersonalDetails: OnyxEntry): boolean { +function shouldShowMissingDetailsPage(card: {nameValuePairs?: {feedCountry?: string}} | null | undefined, privatePersonalDetails: OnyxEntry): boolean { const isUKOrEUCard = card?.nameValuePairs?.feedCountry === CONST.COUNTRY.GB; const hasMissingDetails = arePersonalDetailsMissing(privatePersonalDetails); diff --git a/src/libs/TravelInvoicingUtils.ts b/src/libs/TravelInvoicingUtils.ts index cd9ee9402c8e4..b208fa1b549f3 100644 --- a/src/libs/TravelInvoicingUtils.ts +++ b/src/libs/TravelInvoicingUtils.ts @@ -1,15 +1,16 @@ import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {BankAccountList, Beta, Card, CardList} from '@src/types/onyx'; +import type {BankAccountList, Card, WorkspaceCardsList} from '@src/types/onyx'; import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings'; import {getLastFourDigits} from './BankAccountUtils'; import {isDevelopment, isInternalTestBuild, isStaging} from './Environment/Environment'; -import Permissions from './Permissions'; /** * Feature flag to enable Travel CVV testing on Dev and Staging environments. * When enabled, it allows using any card for CVV reveal testing if no specific Travel Card is found. + * + * TODO: Remove this function and associated logic when Travel Invoicing is fully released */ function isTravelCVVTestingEnabled(): boolean { return isDevelopment() || isStaging() || isInternalTestBuild(); @@ -113,15 +114,21 @@ function getTravelInvoicingCardSettingsKey(workspaceAccountID: number): `${typeo * Gets the user's Travel Invoicing card from the card list. * Returns the first card with isTravelCard NVP set to true. */ -function getTravelInvoicingCard(cardList: OnyxEntry): Card | undefined { +function getTravelInvoicingCard(cardList: Record | undefined) { if (!cardList) { return undefined; } - const travelCard = Object.values(cardList)?.find((card) => card?.nameValuePairs?.isTravelCard); + // Flatten all WorkspaceCardsList into a single array of Cards + // Filter out cardList entries (which are string values) to only get actual Card objects + const allCards = Object.values(cardList) + .filter((workspaceCards): workspaceCards is WorkspaceCardsList => !!workspaceCards) + .flatMap((workspaceCards) => Object.values(workspaceCards)) + .filter((card): card is Card => typeof card !== 'string' && typeof card?.cardID === 'number'); + const travelCard = allCards.find((card) => card.nameValuePairs?.isTravelCard); // If no travel card is found and testing is enabled, return the first available card if (!travelCard && isTravelCVVTestingEnabled()) { - return Object.values(cardList)?.at(0); + return allCards.find((card) => card.bank === CONST.EXPENSIFY_CARD.BANK); } return travelCard; @@ -131,10 +138,9 @@ function getTravelInvoicingCard(cardList: OnyxEntry): Card | undefined * Checks if user is eligible to see Travel CVV in Wallet. * Requires: TRAVEL_INVOICING beta AND having a travel card. */ -function isTravelCVVEligible(betas: OnyxEntry, cardList: OnyxEntry): boolean { - const hasBeta = Permissions.isBetaEnabled(CONST.BETAS.TRAVEL_INVOICING as Beta, betas); +function isTravelCVVEligible(isTravelInvoicingBetaEnabled: boolean, cardList: Record | undefined): boolean { const hasTravelCard = !!getTravelInvoicingCard(cardList); - return hasBeta && hasTravelCard; + return isTravelInvoicingBetaEnabled && hasTravelCard; } export { diff --git a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx new file mode 100644 index 0000000000000..62b1d55b0463a --- /dev/null +++ b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx @@ -0,0 +1,61 @@ +import type {PropsWithChildren} from 'react'; +import React, {createContext, useContext, useState} from 'react'; +import {Errors} from '@src/types/onyx/OnyxCommon'; +import {TravelCVVActionsContextType, TravelCVVStateContextType} from './types'; + +const defaultActionsContext: TravelCVVActionsContextType = { + setCvv: () => {}, + setIsLoading: () => {}, + setValidateError: () => {}, +}; + +const TravelCVVStateContext = createContext({ + cvv: null, + isLoading: false, + validateError: {}, +}); + +const TravelCVVActionsContext = createContext(defaultActionsContext); + +/** + * Context to display revealed travel card CVV data and pass it between screens. + * CVV is stored only in React state (never persisted) for security. + */ +function TravelCVVContextProvider({children}: PropsWithChildren) { + const [cvv, setCvv] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [validateError, setValidateError] = useState({}); + + // Because of the React Compiler we don't need to memoize it manually + // eslint-disable-next-line react/jsx-no-constructed-context-values + const actionsContextValue: TravelCVVActionsContextType = { + setCvv, + setIsLoading, + setValidateError, + }; + + // Because of the React Compiler we don't need to memoize it manually + // eslint-disable-next-line react/jsx-no-constructed-context-values + const stateContextValue: TravelCVVStateContextType = { + cvv, + isLoading, + validateError, + }; + + return ( + + {children} + + ); +} + +function useTravelCVVState(): TravelCVVStateContextType { + return useContext(TravelCVVStateContext); +} + +function useTravelCVVActions(): TravelCVVActionsContextType { + return useContext(TravelCVVActionsContext); +} + +export default TravelCVVContextProvider; +export {useTravelCVVState, useTravelCVVActions}; diff --git a/src/pages/settings/Wallet/TravelCVVPage.tsx b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx similarity index 60% rename from src/pages/settings/Wallet/TravelCVVPage.tsx rename to src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx index 40790dda7cba7..c00aa45b0f6cc 100644 --- a/src/pages/settings/Wallet/TravelCVVPage.tsx +++ b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx @@ -1,5 +1,4 @@ -import {filterPersonalCards} from '@selectors/Card'; -import React, {useCallback, useContext, useState} from 'react'; +import React, {useCallback, useContext} from 'react'; import {View} from 'react-native'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import Button from '@components/Button'; @@ -11,27 +10,23 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; -import ValidateCodeActionContent from '@components/ValidateCodeActionModal/ValidateCodeActionContent'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {revealVirtualCardDetails} from '@libs/actions/Card'; -import {requestValidateCodeAction, resetValidateActionCodeSent} from '@libs/actions/User'; -import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import {resetValidateActionCodeSent} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import {shouldShowMissingDetailsPage} from '@libs/PersonalDetailsUtils'; import {getTravelInvoicingCard} from '@libs/TravelInvoicingUtils'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Errors} from '@src/types/onyx/OnyxCommon'; +import {useTravelCVVState} from './TravelCVVContextProvider'; /** * TravelCVVPage - Displays the Travel CVV reveal interface. * Shows a description of the travel card and allows users to reveal the CVV. - * CVV is stored only in local component state and never persisted in Onyx. + * CVV is stored only in React Context state and never persisted in Onyx. */ function TravelCVVPage() { const styles = useThemeStyles(); @@ -41,19 +36,13 @@ function TravelCVVPage() { const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: false}); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {canBeMissing: false}); - const [cardList] = useOnyx(ONYXKEYS.CARD_LIST, {selector: filterPersonalCards, canBeMissing: true}); - + const [cardList] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {canBeMissing: true}); const {isAccountLocked, showLockedAccountModal} = useContext(LockedAccountContext); - // Local state for CVV - never persisted in Onyx for security - const [cvv, setCvv] = useState(null); - const [isVerifying, setIsVerifying] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [validateError, setValidateError] = useState({}); + // Get CVV from context - shared with TravelCVVVerifyAccountPage + const {cvv} = useTravelCVVState(); const travelCard = getTravelInvoicingCard(cardList); - - const primaryLogin = account?.primaryLogin ?? ''; const isSignedInAsDelegate = !!account?.delegatedAccess?.delegate || false; const handleRevealDetailsPress = useCallback(() => { @@ -71,62 +60,10 @@ function TravelCVVPage() { // ValidateCodeActionContent only sends a magic code when validateCodeSent is false // so we need to reset it to ensure a code is always sent resetValidateActionCodeSent(); - // Switch to verification mode - shows ValidateCodeActionContent in RHP - setIsVerifying(true); + // Navigate to the verify account page + Navigation.navigate(ROUTES.SETTINGS_WALLET_TRAVEL_CVV_VERIFY_ACCOUNT); }, [isAccountLocked, showLockedAccountModal, travelCard, privatePersonalDetails]); - const handleValidateCode = (validateCode: string) => { - if (!travelCard?.cardID) { - return; - } - - setIsLoading(true); - - // Call revealVirtualCardDetails and only extract CVV - // eslint-disable-next-line rulesdir/no-thenable-actions-in-views - revealVirtualCardDetails(travelCard.cardID, validateCode) - .then((cardDetails) => { - // Only store CVV in local state - never persist PAN or other details - setCvv(cardDetails.cvv ?? null); - setIsVerifying(false); - setValidateError({}); - }) - .catch((error: TranslationPaths) => { - setValidateError(getMicroSecondOnyxErrorWithTranslationKey(error)); - }) - .finally(() => { - setIsLoading(false); - }); - }; - - const handleCloseVerification = useCallback(() => { - resetValidateActionCodeSent(); - setIsVerifying(false); - setValidateError({}); - }, []); - - // When in verification mode, show magic code input directly in RHP - if (isVerifying) { - return ( - - requestValidateCodeAction()} - validateCodeActionErrorField="revealExpensifyCardDetails" - handleSubmitForm={handleValidateCode} - validateError={validateError} - clearError={() => setValidateError({})} - onClose={handleCloseVerification} - isLoading={isLoading} - /> - - ); - } - return ( { + Navigation.goBack(ROUTES.SETTINGS_WALLET_TRAVEL_CVV); + }, []); + + const handleRevealCardDetails = (validateCode: string) => { + if (!travelCard?.cardID) { + return; + } + + setIsLoading(true); + + // Call revealVirtualCardDetails and only extract CVV + // eslint-disable-next-line rulesdir/no-thenable-actions-in-views + revealVirtualCardDetails(+travelCard.cardID, validateCode) + .then((cardDetails) => { + // Only store CVV - never persist PAN or other details + setCvv(cardDetails.cvv ?? null); + navigateBack(); + }) + .catch((error: TranslationPaths) => { + setValidateError(getMicroSecondOnyxErrorWithTranslationKey(error)); + }) + .finally(() => { + setIsLoading(false); + }); + }; + + return ( + requestValidateCodeAction()} + validateCodeActionErrorField="revealExpensifyCardDetails" + handleSubmitForm={handleRevealCardDetails} + validateError={validateError} + clearError={() => setValidateError({})} + onClose={() => { + resetValidateActionCodeSent(); + navigateBack(); + }} + isLoading={isLoading} + /> + ); +} + +export default TravelCVVVerifyAccountPage; diff --git a/src/pages/settings/Wallet/WalletPage/WalletTravelCVVSection.tsx b/src/pages/settings/Wallet/TravelCVVPage/WalletTravelCVVSection.tsx similarity index 81% rename from src/pages/settings/Wallet/WalletPage/WalletTravelCVVSection.tsx rename to src/pages/settings/Wallet/TravelCVVPage/WalletTravelCVVSection.tsx index 65a97f3d9ce93..4ec7bd7354961 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletTravelCVVSection.tsx +++ b/src/pages/settings/Wallet/TravelCVVPage/WalletTravelCVVSection.tsx @@ -1,13 +1,14 @@ -import {filterPersonalCards} from '@selectors/Card'; import React from 'react'; import MenuItem from '@components/MenuItem'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import {getTravelInvoicingCard, isTravelCVVEligible} from '@libs/TravelInvoicingUtils'; import colors from '@styles/theme/colors'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -20,13 +21,13 @@ function WalletTravelCVVSection() { const {translate} = useLocalize(); const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['LuggageWithLines']); + const {isBetaEnabled} = usePermissions(); - const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); - const [cardList] = useOnyx(ONYXKEYS.CARD_LIST, {selector: filterPersonalCards, canBeMissing: true}); + const [cardList] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {canBeMissing: true}); const travelCard = getTravelInvoicingCard(cardList); - if (!isTravelCVVEligible(betas, cardList) || !travelCard) { + if (!isTravelCVVEligible(isBetaEnabled(CONST.BETAS.TRAVEL_INVOICING), cardList) || !travelCard) { return null; } @@ -44,6 +45,4 @@ function WalletTravelCVVSection() { ); } -WalletTravelCVVSection.displayName = 'WalletTravelCVVSection'; - export default WalletTravelCVVSection; diff --git a/src/pages/settings/Wallet/TravelCVVPage/types.ts b/src/pages/settings/Wallet/TravelCVVPage/types.ts new file mode 100644 index 0000000000000..2ddd02fd13a74 --- /dev/null +++ b/src/pages/settings/Wallet/TravelCVVPage/types.ts @@ -0,0 +1,15 @@ +import {Errors} from '@src/types/onyx/OnyxCommon'; + +type TravelCVVStateContextType = { + cvv: string | null; + isLoading: boolean; + validateError: Errors; +}; + +type TravelCVVActionsContextType = { + setCvv: React.Dispatch>; + setIsLoading: React.Dispatch>; + setValidateError: React.Dispatch>; +}; + +export type {TravelCVVStateContextType, TravelCVVActionsContextType}; diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index 1a518f6fbf203..05015707f3eb0 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -50,9 +50,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import WalletTravelCVVSection from '../TravelCVVPage/WalletTravelCVVSection'; import type {CardPressHandlerParams, PaymentMethodPressHandlerParams} from './types'; import useWalletSectionIllustration from './useWalletSectionIllustration'; -import WalletTravelCVVSection from './WalletTravelCVVSection'; const fundListSelector = (allFunds: OnyxEntry) => Object.fromEntries(Object.entries(allFunds ?? {}).filter(([, item]) => item.accountData?.additionalData?.isP2PDebitCard === true)); diff --git a/tests/unit/TravelInvoicingUtilsTest.ts b/tests/unit/TravelInvoicingUtilsTest.ts index 6264811272a5f..7dde9b9b88e0e 100644 --- a/tests/unit/TravelInvoicingUtilsTest.ts +++ b/tests/unit/TravelInvoicingUtilsTest.ts @@ -12,7 +12,7 @@ import { hasTravelInvoicingSettlementAccount, isTravelCVVEligible, } from '@src/libs/TravelInvoicingUtils'; -import type {BankAccountList, Beta, CardList} from '@src/types/onyx'; +import type {BankAccountList, WorkspaceCardsList} from '@src/types/onyx'; import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings'; jest.mock('@src/libs/Environment/Environment'); @@ -230,32 +230,36 @@ describe('TravelInvoicingUtils', () => { it('Should return undefined when no travel card exists', () => { const cardList = { - // eslint-disable-next-line @typescript-eslint/naming-convention - '1234': { - cardID: 1234, - state: 3, - nameValuePairs: { - isVirtual: true, - isTravelCard: false, + workspaceCards: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1234': { + cardID: 1234, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: false, + }, }, }, - } as unknown as CardList; + } as unknown as Record; const result = getTravelInvoicingCard(cardList); expect(result).toBeUndefined(); }); it('Should return the travel card when isTravelCard is true', () => { const cardList = { - // eslint-disable-next-line @typescript-eslint/naming-convention - '1234': { - cardID: 1234, - state: 3, - nameValuePairs: { - isVirtual: true, - isTravelCard: true, + workspaceCards: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1234': { + cardID: 1234, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: true, + }, }, }, - } as unknown as CardList; + } as unknown as Record; const result = getTravelInvoicingCard(cardList); expect(result).toBeDefined(); expect(result?.cardID).toBe(1234); @@ -263,25 +267,27 @@ describe('TravelInvoicingUtils', () => { it('Should return first travel card when multiple cards exist', () => { const cardList = { - // eslint-disable-next-line @typescript-eslint/naming-convention - '1111': { - cardID: 1111, - state: 3, - nameValuePairs: { - isVirtual: true, - isTravelCard: false, + workspaceCards: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1111': { + cardID: 1111, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: false, + }, }, - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - '2222': { - cardID: 2222, - state: 3, - nameValuePairs: { - isVirtual: true, - isTravelCard: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + '2222': { + cardID: 2222, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: true, + }, }, }, - } as unknown as CardList; + } as unknown as Record; const result = getTravelInvoicingCard(cardList); expect(result).toBeDefined(); expect(result?.nameValuePairs?.isTravelCard).toBe(true); @@ -290,16 +296,19 @@ describe('TravelInvoicingUtils', () => { isDevelopmentSpy.mockReturnValue(true); const cardList = { - // eslint-disable-next-line @typescript-eslint/naming-convention - '9999': { - cardID: 9999, - state: 3, - nameValuePairs: { - isVirtual: true, - isTravelCard: false, + workspaceCards: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '9999': { + cardID: 9999, + state: 3, + bank: 'Expensify Card', + nameValuePairs: { + isVirtual: true, + isTravelCard: false, + }, }, }, - } as unknown as CardList; + } as unknown as Record; const result = getTravelInvoicingCard(cardList); expect(result).toBeDefined(); @@ -309,60 +318,62 @@ describe('TravelInvoicingUtils', () => { describe('isTravelCVVEligible', () => { const mockTravelCardList = { - // eslint-disable-next-line @typescript-eslint/naming-convention - '1234': { - cardID: 1234, - state: 3, - nameValuePairs: { - isVirtual: true, - isTravelCard: true, + workspaceCards: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1234': { + cardID: 1234, + state: 3, + nameValuePairs: { + isVirtual: true, + isTravelCard: true, + }, }, }, - } as unknown as CardList; + } as unknown as Record; const mockNonTravelCardList = { - // eslint-disable-next-line @typescript-eslint/naming-convention - '5678': { - cardID: 5678, - state: 3, - nameValuePairs: { - isVirtual: true, - isTravelCard: false, + workspaceCards: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '5678': { + cardID: 5678, + state: 3, + bank: 'Expensify Card', + nameValuePairs: { + isVirtual: true, + isTravelCard: false, + }, }, }, - } as unknown as CardList; - - const mockBetasWithTravelInvoicing: Beta[] = [CONST.BETAS.TRAVEL_INVOICING as Beta]; - const mockBetasWithoutTravelInvoicing: Beta[] = []; + } as unknown as Record; - it('Should return false when betas is undefined', () => { - const result = isTravelCVVEligible(undefined, mockTravelCardList); + it('Should return false when beta is false', () => { + const result = isTravelCVVEligible(false, mockTravelCardList); expect(result).toBe(false); }); it('Should return false when cardList is undefined', () => { - const result = isTravelCVVEligible(mockBetasWithTravelInvoicing, undefined); + const result = isTravelCVVEligible(true, undefined); expect(result).toBe(false); }); it('Should return false when no travel card exists', () => { - const result = isTravelCVVEligible(mockBetasWithTravelInvoicing, mockNonTravelCardList); + const result = isTravelCVVEligible(true, mockNonTravelCardList); expect(result).toBe(false); }); - it('Should return false when beta is missing', () => { - const result = isTravelCVVEligible(mockBetasWithoutTravelInvoicing, mockTravelCardList); + it('Should return false when beta is false even with travel card', () => { + const result = isTravelCVVEligible(false, mockTravelCardList); expect(result).toBe(false); }); - it('Should return true when beta is present and travel card exists', () => { - const result = isTravelCVVEligible(mockBetasWithTravelInvoicing, mockTravelCardList); + it('Should return true when beta is true and travel card exists', () => { + const result = isTravelCVVEligible(true, mockTravelCardList); expect(result).toBe(true); }); - it('Should return true when testing is enabled and beta is present even if no travel card exists', () => { + it('Should return true when testing is enabled and beta is true even if no travel card exists', () => { isDevelopmentSpy.mockReturnValue(true); - const result = isTravelCVVEligible(mockBetasWithTravelInvoicing, mockNonTravelCardList); + const result = isTravelCVVEligible(true, mockNonTravelCardList); expect(result).toBe(true); }); }); From 2aadfb21f1a9097fab116ed73a521d5e17c26d73 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 30 Jan 2026 16:03:10 -0800 Subject: [PATCH 08/15] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 7a4b4741a0a57..348c533982b69 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 7a4b4741a0a57f0de3f6d3124d377093c96df18d +Subproject commit 348c533982b69feaf057706c750a86a4ba1282cc From d90c259649365c4d8df8f644bb43a80eb27db2a3 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 30 Jan 2026 18:34:24 -0800 Subject: [PATCH 09/15] chore: eslint --- src/App.tsx | 2 +- src/CONST/index.ts | 2 +- .../Wallet/TravelCVVPage/TravelCVVContextProvider.tsx | 4 ++-- src/pages/settings/Wallet/TravelCVVPage/types.ts | 2 +- src/pages/settings/Wallet/WalletPage/index.tsx | 2 +- .../WorkspaceTravelInvoicingSettlementFrequencyPage.tsx | 5 ++--- tests/ui/WorkspaceTravelInvoicingSectionTest.tsx | 8 ++++---- tests/unit/TravelInvoicingUtilsTest.ts | 4 ++-- 8 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index a98c443783e4e..0e53a9acec80e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,6 @@ import {LogBox, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; -import TravelCVVContextProvider from '@pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider'; import '../wdyr'; import {ActionSheetAwareScrollViewProvider} from './components/ActionSheetAwareScrollView'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; @@ -54,6 +53,7 @@ import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; import './libs/HybridApp'; import {AttachmentModalContextProvider} from './pages/media/AttachmentModalScreen/AttachmentModalContext'; import ExpensifyCardContextProvider from './pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardContextProvider'; +import TravelCVVContextProvider from './pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider'; import './setup/backgroundLocationTrackingTask'; import './setup/backgroundTask'; import './setup/fraudProtection'; diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 55eaf64d919f1..55e68eba46644 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7933,7 +7933,7 @@ const CONST = { * The Travel Invoicing feed type constant. * This feed is used for Travel Invoicing cards which are separate from regular Expensify Cards. */ - PROGRAM_TRAVEL_US: 'TRAVEL_US', + PROGRAM_TRAVEL_US: 'PROGRAM_TRAVEL_US', }, LAST_PAYMENT_METHOD: { LAST_USED: 'lastUsed', diff --git a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx index 62b1d55b0463a..e7087b223be75 100644 --- a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx +++ b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx @@ -1,7 +1,7 @@ import type {PropsWithChildren} from 'react'; import React, {createContext, useContext, useState} from 'react'; -import {Errors} from '@src/types/onyx/OnyxCommon'; -import {TravelCVVActionsContextType, TravelCVVStateContextType} from './types'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type {TravelCVVActionsContextType, TravelCVVStateContextType} from './types'; const defaultActionsContext: TravelCVVActionsContextType = { setCvv: () => {}, diff --git a/src/pages/settings/Wallet/TravelCVVPage/types.ts b/src/pages/settings/Wallet/TravelCVVPage/types.ts index 2ddd02fd13a74..c35494d0930b3 100644 --- a/src/pages/settings/Wallet/TravelCVVPage/types.ts +++ b/src/pages/settings/Wallet/TravelCVVPage/types.ts @@ -1,4 +1,4 @@ -import {Errors} from '@src/types/onyx/OnyxCommon'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; type TravelCVVStateContextType = { cvv: string | null; diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index 05015707f3eb0..990100494eb6f 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -40,6 +40,7 @@ import {formatPaymentMethods, getPaymentMethodDescription} from '@libs/PaymentUt import {getDescriptionForPolicyDomainCard, hasEligibleActiveAdminFromWorkspaces} from '@libs/PolicyUtils'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import PaymentMethodList from '@pages/settings/Wallet/PaymentMethodList'; +import WalletTravelCVVSection from '@pages/settings/Wallet/TravelCVVPage/WalletTravelCVVSection'; import {deletePaymentBankAccount, openPersonalBankAccountSetupView, setPersonalBankAccountContinueKYCOnSuccess} from '@userActions/BankAccounts'; import {deletePersonalCard} from '@userActions/Card'; import {close as closeModal} from '@userActions/Modal'; @@ -50,7 +51,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; -import WalletTravelCVVSection from '../TravelCVVPage/WalletTravelCVVSection'; import type {CardPressHandlerParams, PaymentMethodPressHandlerParams} from './types'; import useWalletSectionIllustration from './useWalletSectionIllustration'; diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSettlementFrequencyPage.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSettlementFrequencyPage.tsx index 65bff193d94de..06763e2ef81ea 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSettlementFrequencyPage.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSettlementFrequencyPage.tsx @@ -14,9 +14,8 @@ import {updateTravelInvoiceSettlementFrequency} from '@libs/actions/TravelInvoic import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getTravelSettlementFrequency} from '@libs/TravelInvoicingUtils'; +import {getTravelInvoicingCardSettingsKey, getTravelSettlementFrequency} from '@libs/TravelInvoicingUtils'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; type WorkspaceTravelInvoicingSettlementFrequencyPageProps = PlatformStackScreenProps; @@ -30,7 +29,7 @@ function WorkspaceTravelInvoicingSettlementFrequencyPage({route}: WorkspaceTrave const {translate} = useLocalize(); const policyID = route.params?.policyID; const workspaceAccountID = useWorkspaceAccountID(policyID); - const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${CONST.TRAVEL.PROGRAM_TRAVEL_US}` as const, {canBeMissing: true}); + const [cardSettings] = useOnyx(getTravelInvoicingCardSettingsKey(workspaceAccountID), {canBeMissing: true}); const currentFrequency = getTravelSettlementFrequency(cardSettings); const frequencies = [CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY]; diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx index dbd2db9b22c6a..85271540671c3 100644 --- a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -5,10 +5,10 @@ import Onyx from 'react-native-onyx'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {getTravelInvoicingCardSettingsKey} from '@libs/TravelInvoicingUtils'; import WorkspaceTravelInvoicingSection from '@pages/workspace/travel/WorkspaceTravelInvoicingSection'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OnyxKey} from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; import createRandomPolicy from '../utils/collections/policies'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -108,7 +108,7 @@ describe('WorkspaceTravelInvoicingSection', () => { it('should show BookOrManageYourTrip when paymentBankAccountID is not set', async () => { // Given Travel Invoicing card settings exist but without paymentBankAccountID - const travelInvoicingKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${WORKSPACE_ACCOUNT_ID}_TRAVEL_US` as OnyxKey; + const travelInvoicingKey = getTravelInvoicingCardSettingsKey(WORKSPACE_ACCOUNT_ID); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy); @@ -130,7 +130,7 @@ describe('WorkspaceTravelInvoicingSection', () => { }); describe('When Travel Invoicing is configured', () => { - const travelInvoicingKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${WORKSPACE_ACCOUNT_ID}_TRAVEL_US` as OnyxKey; + const travelInvoicingKey = getTravelInvoicingCardSettingsKey(WORKSPACE_ACCOUNT_ID); const bankAccountKey = ONYXKEYS.BANK_ACCOUNT_LIST; it('should render the section title when card settings are properly configured', async () => { @@ -291,7 +291,7 @@ describe('WorkspaceTravelInvoicingSection', () => { paymentBankAccountID: 12345, remainingLimit: 50000, currentBalance: 10000, - monthlySettlementDate: '2023-10-01', + monthlySettlementDate: new Date(), }); await Onyx.merge(bankAccountKey, { 12345: { diff --git a/tests/unit/TravelInvoicingUtilsTest.ts b/tests/unit/TravelInvoicingUtilsTest.ts index 7dde9b9b88e0e..9336e18072ccb 100644 --- a/tests/unit/TravelInvoicingUtilsTest.ts +++ b/tests/unit/TravelInvoicingUtilsTest.ts @@ -32,8 +32,8 @@ describe('TravelInvoicingUtils', () => { jest.clearAllMocks(); }); describe('PROGRAM_TRAVEL_US constant', () => { - it('Should be defined as TRAVEL_US', () => { - expect(CONST.TRAVEL.PROGRAM_TRAVEL_US).toBe('TRAVEL_US'); + it('Should be defined as PROGRAM_TRAVEL_US', () => { + expect(CONST.TRAVEL.PROGRAM_TRAVEL_US).toBe('PROGRAM_TRAVEL_US'); }); }); From bad99b1325d035ec4f7e50c594c0bb8ab9341113 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 4 Feb 2026 13:20:59 -0800 Subject: [PATCH 10/15] fix: travel card basis and copy change --- src/languages/de.ts | 3 +-- src/languages/en.ts | 3 +-- src/languages/es.ts | 3 +-- src/languages/fr.ts | 3 +-- src/languages/it.ts | 3 +-- src/languages/ja.ts | 3 +-- src/languages/nl.ts | 3 +-- src/languages/pl.ts | 3 +-- src/languages/pt-BR.ts | 3 +-- src/languages/zh-hans.ts | 7 +------ src/libs/TravelInvoicingUtils.ts | 4 ++-- .../Wallet/TravelCVVPage/TravelCVVPage.tsx | 3 +-- tests/unit/TravelInvoicingUtilsTest.ts | 18 +++++++++--------- 13 files changed, 22 insertions(+), 37 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index a801dd910956b..0e317299e896d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2212,8 +2212,7 @@ const translations: TranslationDeepObject = { travelCVV: { title: 'Reise-CVV', subtitle: 'Verwenden Sie dies bei der Buchung von Reisen', - description: 'Diese Karte fasst Ausgaben wie Flugtickets, Bahnfahrten, Mietwagen und manchmal auch Hotels in einem einzigen Konto zusammen.', - instructions: 'Du wirst nach den letzten 4 Ziffern gefragt. Du kannst sie unten über den Button anzeigen.', + description: 'Verwende diese Karte für deine Expensify Travel-Buchungen. Sie wird beim Bezahlen als “Travel Card” angezeigt.', }, }, cardPage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 78d96395ab955..181ddc5b504e8 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2248,8 +2248,7 @@ const translations = { travelCVV: { title: 'Travel CVV', subtitle: 'Use this when booking travel', - description: 'This card consolidates expenses like airfare, rail, car rentals, and sometimes hotels into a single account rather.', - instructions: "You'll be asked for the last 4 digits. You can reveal them below using the button.", + description: "Use this card for your Expensify Travel bookings. It'll show as “Travel Card” at checkout.", }, }, cardPage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 0737ed644879d..03130dd93cefe 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1977,8 +1977,7 @@ const translations: TranslationDeepObject = { travelCVV: { title: 'CVV de viaje', subtitle: 'Úsalo al reservar viajes', - description: 'Esta tarjeta consolida gastos como pasajes de avión, tren, alquiler de coches, y a veces, hoteles en una sola cuenta.', - instructions: 'Se te pedirá los últimos 4 dígitos. Puedes revelarlos abajo usando el botón.', + description: 'Usa esta tarjeta para tus reservas de Expensify Travel. Aparecerá como “Travel Card” al pagar.', }, }, cardPage: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 330303425b9e4..852e7a592b1e9 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2220,8 +2220,7 @@ const translations: TranslationDeepObject = { travelCVV: { title: 'Cryptogramme visuel de la carte de voyage (CVV)', subtitle: 'À utiliser lors de la réservation de voyages', - description: 'Cette carte regroupe les dépenses comme les billets d’avion, le train, les locations de voiture et parfois les hôtels dans un seul compte.', - instructions: 'On vous demandera les 4 derniers chiffres. Vous pouvez les afficher ci-dessous en utilisant le bouton.', + description: 'Utilisez cette carte pour vos réservations Expensify Travel. Elle apparaîtra comme “Travel Card” lors du paiement.', }, }, cardPage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index a3c6f40e9eae9..9bccc868853c0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2208,8 +2208,7 @@ const translations: TranslationDeepObject = { travelCVV: { title: 'CVV di viaggio', subtitle: 'Usa questo quando prenoti viaggi', - description: 'Questa carta consolida spese come voli aerei, treni, noleggi auto e talvolta hotel in un unico conto.', - instructions: 'Ti verranno richieste le ultime 4 cifre. Puoi visualizzarle qui sotto usando il pulsante.', + description: 'Usa questa carta per le tue prenotazioni con Expensify Travel. Verrà visualizzata come “Travel Card” al momento del pagamento.', }, }, cardPage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 9f338a567d1b6..c9c3c8f6b15ba 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2199,8 +2199,7 @@ const translations: TranslationDeepObject = { travelCVV: { title: 'トラベルCVV', subtitle: '出張を予約するときにこれを使用してください', - description: 'このカードは、航空運賃、鉄道、レンタカー、場合によってはホテルなどの経費を、1つの口座にまとめて集約します。', - instructions: '最後の4桁の入力を求められます。下のボタンを使って表示できます。', + description: 'このカードをExpensify Travelでの予約に使用してください。チェックアウト時には「Travel Card」と表示されます。', }, }, cardPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5ee9466cec1e0..78e98ab366473 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2209,8 +2209,7 @@ const translations: TranslationDeepObject = { travelCVV: { title: 'Reis-CVV', subtitle: 'Gebruik dit bij het boeken van reizen', - description: 'Deze kaart bundelt uitgaven zoals vliegtickets, treinreizen, autoverhuur en soms hotels in één enkele rekening.', - instructions: 'Je wordt gevraagd om de laatste 4 cijfers. Je kunt ze hieronder weergeven met de knop.', + description: 'Gebruik deze kaart voor je Expensify Travel-boekingen. Hij wordt weergegeven als “Travel Card” bij het afrekenen.', }, }, cardPage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a3d9ee87529f2..0552c9734d70a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2205,8 +2205,7 @@ const translations: TranslationDeepObject = { travelCVV: { title: 'CVV podróży', subtitle: 'Użyj tego podczas rezerwacji podróży', - description: 'Ta karta konsoliduje wydatki, takie jak przeloty, kolej, wynajem samochodów, a czasem także hotele, na jednym koncie.', - instructions: 'Zostaniesz poproszony o podanie ostatnich 4 cyfr. Możesz je wyświetlić poniżej za pomocą przycisku.', + description: 'Użyj tej karty do rezerwacji w Expensify Travel. Podczas płatności będzie wyświetlana jako “Travel Card”.', }, }, cardPage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 690cc96e0a9c6..28a9971e2e345 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2205,8 +2205,7 @@ const translations: TranslationDeepObject = { travelCVV: { title: 'CVV de viagem', subtitle: 'Use isto ao reservar viagens', - description: 'Este cartão consolida despesas como passagens aéreas, trem, aluguel de carros e, às vezes, hotéis em uma única conta.', - instructions: 'Será solicitado que você informe os últimos 4 dígitos. Você pode revelá-los abaixo usando o botão.', + description: 'Use este cartão para suas reservas no Expensify Travel. Ele aparecerá como “Travel Card” no checkout.', }, }, cardPage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 9bdcb1791c845..0d2e98b92a6c3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2169,12 +2169,7 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} 将失去对此企业银行账户的访问权限。我们仍会完成任何正在处理中的付款。`, reachOutForHelp: '它正在与 Expensify Card 一起使用。若需要取消共享,请联系 Concierge。', unshareErrorModalTitle: '无法取消共享银行账户', - travelCVV: { - title: '旅行 CVV', - subtitle: '预订差旅时使用此选项', - description: '此卡将机票、火车、租车,有时还包括酒店等费用合并到一个账户中。', - instructions: '系统将要求您提供最后 4 位数字。您可以使用下面的按钮查看它们。', - }, + travelCVV: {title: '旅行 CVV', subtitle: '预订差旅时使用此选项', description: '使用此卡预订 Expensify Travel 行程。结账时它会显示为“Travel Card”。'}, }, cardPage: { expensifyCard: 'Expensify 卡', diff --git a/src/libs/TravelInvoicingUtils.ts b/src/libs/TravelInvoicingUtils.ts index b208fa1b549f3..ab05614e7307d 100644 --- a/src/libs/TravelInvoicingUtils.ts +++ b/src/libs/TravelInvoicingUtils.ts @@ -112,7 +112,7 @@ function getTravelInvoicingCardSettingsKey(workspaceAccountID: number): `${typeo /** * Gets the user's Travel Invoicing card from the card list. - * Returns the first card with isTravelCard NVP set to true. + * Returns the first card with feedCountry set to PROGRAM_TRAVEL_US. */ function getTravelInvoicingCard(cardList: Record | undefined) { if (!cardList) { @@ -125,7 +125,7 @@ function getTravelInvoicingCard(cardList: Record !!workspaceCards) .flatMap((workspaceCards) => Object.values(workspaceCards)) .filter((card): card is Card => typeof card !== 'string' && typeof card?.cardID === 'number'); - const travelCard = allCards.find((card) => card.nameValuePairs?.isTravelCard); + const travelCard = allCards.find((card) => card.nameValuePairs?.feedCountry === CONST.TRAVEL.PROGRAM_TRAVEL_US); // If no travel card is found and testing is enabled, return the first available card if (!travelCard && isTravelCVVTestingEnabled()) { return allCards.find((card) => card.bank === CONST.EXPENSIFY_CARD.BANK); diff --git a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx index c00aa45b0f6cc..882085d15cc4b 100644 --- a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx +++ b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx @@ -83,9 +83,8 @@ function TravelCVVPage() { /> - + {translate('walletPage.travelCVV.description')} - {translate('walletPage.travelCVV.instructions')} { state: 3, nameValuePairs: { isVirtual: true, - isTravelCard: false, + feedCountry: 'OTHER_COUNTRY', }, }, }, @@ -246,7 +246,7 @@ describe('TravelInvoicingUtils', () => { expect(result).toBeUndefined(); }); - it('Should return the travel card when isTravelCard is true', () => { + it('Should return the travel card when feedCountry is PROGRAM_TRAVEL_US', () => { const cardList = { workspaceCards: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -255,7 +255,7 @@ describe('TravelInvoicingUtils', () => { state: 3, nameValuePairs: { isVirtual: true, - isTravelCard: true, + feedCountry: CONST.TRAVEL.PROGRAM_TRAVEL_US, }, }, }, @@ -274,7 +274,7 @@ describe('TravelInvoicingUtils', () => { state: 3, nameValuePairs: { isVirtual: true, - isTravelCard: false, + feedCountry: 'OTHER_COUNTRY', }, }, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -283,14 +283,14 @@ describe('TravelInvoicingUtils', () => { state: 3, nameValuePairs: { isVirtual: true, - isTravelCard: true, + feedCountry: CONST.TRAVEL.PROGRAM_TRAVEL_US, }, }, }, } as unknown as Record; const result = getTravelInvoicingCard(cardList); expect(result).toBeDefined(); - expect(result?.nameValuePairs?.isTravelCard).toBe(true); + expect(result?.nameValuePairs?.feedCountry).toBe(CONST.TRAVEL.PROGRAM_TRAVEL_US); }); it('Should fallback to first available card when testing is enabled and no travel card exists', () => { isDevelopmentSpy.mockReturnValue(true); @@ -304,7 +304,7 @@ describe('TravelInvoicingUtils', () => { bank: 'Expensify Card', nameValuePairs: { isVirtual: true, - isTravelCard: false, + feedCountry: 'OTHER_COUNTRY', }, }, }, @@ -325,7 +325,7 @@ describe('TravelInvoicingUtils', () => { state: 3, nameValuePairs: { isVirtual: true, - isTravelCard: true, + feedCountry: CONST.TRAVEL.PROGRAM_TRAVEL_US, }, }, }, @@ -340,7 +340,7 @@ describe('TravelInvoicingUtils', () => { bank: 'Expensify Card', nameValuePairs: { isVirtual: true, - isTravelCard: false, + feedCountry: 'OTHER_COUNTRY', }, }, }, From e0fe4934b4295407df5f4e7a99744b60a5ab4639 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 7 Feb 2026 15:36:26 -0800 Subject: [PATCH 11/15] chore: post-merge updates --- .../WorkspaceTravelInvoicingSection.tsx | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index f886d03162d51..08cea47e29dd7 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -76,7 +76,13 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec // Settlement account display - show empty if no account is selected const settlementAccountNumber = hasSettlementAccount && settlementAccount?.last4 ? `${CONST.MASKED_PAN_PREFIX}${getLastFourDigits(settlementAccount?.last4 ?? '')}` : ''; // Get any errors from the settlement account update - const hasSettlementAccountError = Object.keys(cardSettings?.errors ?? {}).length > 0; + // Get errors for specific fields + const settlementAccountErrorKey = 'paymentBankAccountID'; + const settlementFrequencyErrorKey = 'monthlySettlementDate'; + const hasSettlementAccountError = !!cardSettings?.errorFields?.[settlementAccountErrorKey]; + const hasSettlementFrequencyError = !!cardSettings?.errorFields?.[settlementFrequencyErrorKey]; + const settlementAccountErrors = hasSettlementAccountError ? cardSettings?.errorFields?.[settlementAccountErrorKey] : null; + const settlementFrequencyErrors = hasSettlementFrequencyError ? cardSettings?.errorFields?.[settlementFrequencyErrorKey] : null; // Bank account eligibility for toggle handler const isSetupUnfinished = hasInProgressUSDVBBA(reimbursementAccount?.achData); @@ -125,9 +131,6 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec switchAccessibilityLabel: translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.subtitle'), isActive: hasSettlementAccount, onToggle: handleToggle, - // pendingAction: policy?.pendingFields?.autoReporting ?? policy?.pendingFields?.autoReportingFrequency, - // errors: getLatestErrorField(policy ?? {}, CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING), - // onCloseError: () => clearPolicyErrorField(route.params.policyID, CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING), subMenuItems: ( <> @@ -139,7 +142,6 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec titleStyle={[styles.textNormalThemeText, styles.headerAnonymousFooter]} descriptionTextStyle={styles.textLabelSupportingNormal} interactive={false} - // brickRoadIndicator={hasDelayedSubmissionError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> clearTravelInvoicingSettlementAccountErrors(workspaceAccountID, cardSettings?.previousPaymentBankAccountID ?? null)} errorRowStyles={styles.mh2half} @@ -181,16 +182,24 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec brickRoadIndicator={hasSettlementAccountError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> - {}} - wrapperStyle={[styles.sectionMenuItemTopDescription]} - titleStyle={styles.textNormalThemeText} - descriptionTextStyle={styles.textLabelSupportingNormal} - shouldShowRightIcon - // brickRoadIndicator={hasDelayedSubmissionError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - /> + clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID, cardSettings?.previousMonthlySettlementDate)} + errorRowStyles={styles.mh2half} + errorRowTextStyles={styles.mr3} + > + Navigation.navigate(ROUTES.WORKSPACE_TRAVEL_SETTINGS_FREQUENCY.getRoute(policyID))} + wrapperStyle={[styles.sectionMenuItemTopDescription]} + titleStyle={styles.textNormalThemeText} + descriptionTextStyle={styles.textLabelSupportingNormal} + shouldShowRightIcon + brickRoadIndicator={hasSettlementFrequencyError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> + ), }, From 181ba1c9d51c952c4da50f2f113d68f9b9f82c6f Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 7 Feb 2026 15:37:28 -0800 Subject: [PATCH 12/15] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 1e1c2da1546ba..68c675aff235a 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 1e1c2da1546ba1cf8d13d3b112978b14fef607fb +Subproject commit 68c675aff235ac3c81f04a20a1e8fe019502bebd From 900501b567cd19b1bd1ca27e7dcc03418cf9ba4d Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sun, 15 Feb 2026 23:07:46 -0500 Subject: [PATCH 13/15] fix: clear travel CVV from context on page unmount --- .../TravelCVVContextProvider.tsx | 44 ++++++------------- .../Wallet/TravelCVVPage/TravelCVVPage.tsx | 10 +++-- .../TravelCVVVerifyAccountPage.tsx | 5 +-- .../settings/Wallet/TravelCVVPage/types.ts | 7 +-- 4 files changed, 25 insertions(+), 41 deletions(-) diff --git a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx index e7087b223be75..e676a8059bf72 100644 --- a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx +++ b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider.tsx @@ -1,21 +1,18 @@ import type {PropsWithChildren} from 'react'; import React, {createContext, useContext, useState} from 'react'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {TravelCVVActionsContextType, TravelCVVStateContextType} from './types'; +import type TravelCVVContextType from './types'; -const defaultActionsContext: TravelCVVActionsContextType = { +const defaultContext: TravelCVVContextType = { + cvv: null, + isLoading: false, + validateError: {}, setCvv: () => {}, setIsLoading: () => {}, setValidateError: () => {}, }; -const TravelCVVStateContext = createContext({ - cvv: null, - isLoading: false, - validateError: {}, -}); - -const TravelCVVActionsContext = createContext(defaultActionsContext); +const TravelCVVContext = createContext(defaultContext); /** * Context to display revealed travel card CVV data and pass it between screens. @@ -28,34 +25,21 @@ function TravelCVVContextProvider({children}: PropsWithChildren) { // Because of the React Compiler we don't need to memoize it manually // eslint-disable-next-line react/jsx-no-constructed-context-values - const actionsContextValue: TravelCVVActionsContextType = { - setCvv, - setIsLoading, - setValidateError, - }; - - // Because of the React Compiler we don't need to memoize it manually - // eslint-disable-next-line react/jsx-no-constructed-context-values - const stateContextValue: TravelCVVStateContextType = { + const contextValue: TravelCVVContextType = { cvv, isLoading, validateError, + setCvv, + setIsLoading, + setValidateError, }; - return ( - - {children} - - ); -} - -function useTravelCVVState(): TravelCVVStateContextType { - return useContext(TravelCVVStateContext); + return {children}; } -function useTravelCVVActions(): TravelCVVActionsContextType { - return useContext(TravelCVVActionsContext); +function useTravelCVV(): TravelCVVContextType { + return useContext(TravelCVVContext); } +export {useTravelCVV}; export default TravelCVVContextProvider; -export {useTravelCVVState, useTravelCVVActions}; diff --git a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx index 882085d15cc4b..c18a971380387 100644 --- a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx +++ b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useContext} from 'react'; +import React, {useCallback, useContext, useEffect} from 'react'; import {View} from 'react-native'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import Button from '@components/Button'; @@ -21,7 +21,7 @@ import {shouldShowMissingDetailsPage} from '@libs/PersonalDetailsUtils'; import {getTravelInvoicingCard} from '@libs/TravelInvoicingUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {useTravelCVVState} from './TravelCVVContextProvider'; +import {useTravelCVV} from './TravelCVVContextProvider'; /** * TravelCVVPage - Displays the Travel CVV reveal interface. @@ -40,7 +40,11 @@ function TravelCVVPage() { const {isAccountLocked, showLockedAccountModal} = useContext(LockedAccountContext); // Get CVV from context - shared with TravelCVVVerifyAccountPage - const {cvv} = useTravelCVVState(); + const {cvv, setCvv} = useTravelCVV(); + + // Clear CVV when the page unmounts (e.g. backdrop close) so it doesn't + // remain visible the next time the page is opened + useEffect(() => () => setCvv(null), [setCvv]); const travelCard = getTravelInvoicingCard(cardList); const isSignedInAsDelegate = !!account?.delegatedAccess?.delegate || false; diff --git a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage.tsx b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage.tsx index d0ac8a75488f4..307c61a30ce5e 100644 --- a/src/pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage.tsx +++ b/src/pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage.tsx @@ -10,7 +10,7 @@ import {getTravelInvoicingCard} from '@libs/TravelInvoicingUtils'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {useTravelCVVActions, useTravelCVVState} from './TravelCVVContextProvider'; +import {useTravelCVV} from './TravelCVVContextProvider'; /** * TravelCVVVerifyAccountPage - Handles magic code verification for Travel CVV reveal. @@ -22,8 +22,7 @@ function TravelCVVVerifyAccountPage() { const [cardList] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {canBeMissing: true}); // Get state and actions from context - const {isLoading, validateError} = useTravelCVVState(); - const {setCvv, setIsLoading, setValidateError} = useTravelCVVActions(); + const {isLoading, validateError, setCvv, setIsLoading, setValidateError} = useTravelCVV(); const primaryLogin = account?.primaryLogin ?? ''; const travelCard = getTravelInvoicingCard(cardList); diff --git a/src/pages/settings/Wallet/TravelCVVPage/types.ts b/src/pages/settings/Wallet/TravelCVVPage/types.ts index c35494d0930b3..b2b7130caff8d 100644 --- a/src/pages/settings/Wallet/TravelCVVPage/types.ts +++ b/src/pages/settings/Wallet/TravelCVVPage/types.ts @@ -1,15 +1,12 @@ import type {Errors} from '@src/types/onyx/OnyxCommon'; -type TravelCVVStateContextType = { +type TravelCVVContextType = { cvv: string | null; isLoading: boolean; validateError: Errors; -}; - -type TravelCVVActionsContextType = { setCvv: React.Dispatch>; setIsLoading: React.Dispatch>; setValidateError: React.Dispatch>; }; -export type {TravelCVVStateContextType, TravelCVVActionsContextType}; +export default TravelCVVContextType; From 66dac6839f77905b672af71da0406b77190961c5 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sun, 15 Feb 2026 23:13:52 -0500 Subject: [PATCH 14/15] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 0f7f1d822f922..9f18fcad20c99 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 0f7f1d822f922c8d3962cfbfcd3eeb2e1868a9b1 +Subproject commit 9f18fcad20c99de19e1511bfd6b3eed765391f10 From 19f8f3ec2257c78da5a8e72d3f8bbbb91df62db1 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 16 Feb 2026 14:57:23 -0800 Subject: [PATCH 15/15] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 9f18fcad20c99..db0883a40063c 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 9f18fcad20c99de19e1511bfd6b3eed765391f10 +Subproject commit db0883a40063c1097b8fc5da68b6305e14dee223