From d34668044603c0978051ad35e64470e6fc0e6020 Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Mon, 13 Jul 2026 19:54:25 -0300 Subject: [PATCH 01/19] Add multi-program Expensify Card foundation to CardUtils and Onyx A domain can be provisioned with more than one Expensify Card program (US and GB) under a single fundID-keyed settings NVP and cards list. Add the helpers and the selected-program Onyx key needed to distinguish them, with no behavior change yet: - getConfiguredExpensifyCardProgramKeys, getProgramKeyForCard, isCardInProgram, filterCardsListByProgram, getExpensifyCardProgramCountrySuffix, getSelectableCardProgramKey, getCardSettingsForSelectedProgram - LAST_SELECTED_EXPENSIFY_CARD_PROGRAM collection key + type - updateSelectedExpensifyCardFeed accepts an optional programKey --- src/ONYXKEYS.ts | 4 ++ src/libs/CardUtils.ts | 85 ++++++++++++++++++++++++++++++++++++++++ src/libs/actions/Card.ts | 18 +++++++-- 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 4949274983be..8f6bb4deb2af 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -923,6 +923,9 @@ const ONYXKEYS = { /** Currently displaying Expensify Card feed */ LAST_SELECTED_EXPENSIFY_CARD_FEED: 'lastSelectedExpensifyCardFeed_', + /** Currently displaying Expensify Card program within the selected feed (US/GB), for domains provisioned with more than one program */ + LAST_SELECTED_EXPENSIFY_CARD_PROGRAM: 'lastSelectedExpensifyCardProgram_', + /** Whether the bank account chosen for Expensify Card in on verification waitlist */ NVP_EXPENSIFY_ON_CARD_WAITLIST: 'nvp_expensify_onCardWaitlist_', @@ -1454,6 +1457,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.TRAVEL_INVOICING_RECONCILIATION_BANK_ACCOUNT_ID]: string; [ONYXKEYS.COLLECTION.LAST_SELECTED_FEED]: OnyxTypes.CompanyCardFeedWithDomainID; [ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED]: OnyxTypes.FundID; + [ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_PROGRAM]: string; [ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST]: OnyxTypes.CardOnWaitlist; [ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; [ONYXKEYS.COLLECTION.SAML_METADATA]: OnyxTypes.SamlMetadata; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 4130d4efb9e7..9c09d51325f8 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1403,6 +1403,75 @@ function getCardProgramKey(cardSettings: OnyxEntry): Card }); } +/** + * A single settings NVP can hold more than one provisioned Expensify Card program (e.g. a domain with both a US and a GB feed). + * The user-selectable programs are US and GB; CURRENT is deprecated and TRAVEL_US is backend-managed, so neither is offered as its own row. + * Returns the configured program keys in display order (US before GB). + */ +function getConfiguredExpensifyCardProgramKeys(cardSettings: OnyxEntry): CardProgramKey[] { + if (!cardSettings) { + return []; + } + + const selectableProgramKeys: CardProgramKey[] = [CONST.COUNTRY.US, CONST.COUNTRY.GB]; + return selectableProgramKeys.filter((key) => { + const nested = cardSettings[key]; + return nested != null && typeof nested === 'object' && !Array.isArray(nested) && nested.paymentBankAccountID != null; + }); +} + +/** + * Maps a card to the program it belongs to. Cards on the UK/EU program carry `feedCountry === 'GB'`; every other card + * (US program, or pre-2024 cards that omit `feedCountry`/use the deprecated `CURRENT` value) is treated as US. + */ +function getProgramKeyForCard(card: Card | undefined): CardProgramKey { + return card?.nameValuePairs?.feedCountry === CONST.COUNTRY.GB ? CONST.COUNTRY.GB : CONST.COUNTRY.US; +} + +/** Whether a card belongs to the given program, so a single `cards_{fundID}_Expensify Card` list can be split per program. */ +function isCardInProgram(card: Card | undefined, programKey: CardProgramKey): boolean { + return getProgramKeyForCard(card) === programKey; +} + +/** + * A single `cards_{fundID}_Expensify Card` Onyx list holds every card for the feed regardless of program (the backend keys + * Expensify Card settings/cards by fundID, not by country). This keeps only the cards belonging to `programKey`, preserving the + * `cardList` meta entry. When no program is given, the list is returned unchanged. + */ +function filterCardsListByProgram(cardsList: WorkspaceCardsList | undefined, programKey: CardProgramKey | undefined): WorkspaceCardsList | undefined { + if (!cardsList || !programKey) { + return cardsList; + } + + const result: WorkspaceCardsList = {}; + if (cardsList.cardList) { + result.cardList = cardsList.cardList; + } + forEachAssignedCard(cardsList, (card) => { + if (!isCardInProgram(card, programKey)) { + return; + } + result[card.cardID.toString()] = card; + }); + return result; +} + +/** + * Country suffix appended to a feed's label when a domain has more than one program, so the rows are distinguishable + * (e.g. `deptagency.com` for US and `deptagency.com GB` for GB). US programs stay unsuffixed so single-feed domains look unchanged. + */ +function getExpensifyCardProgramCountrySuffix(programKey: CardProgramKey): string { + return programKey === CONST.COUNTRY.GB ? CONST.COUNTRY.GB : ''; +} + +/** + * Narrows a stored program value (e.g. the `lastSelectedExpensifyCardProgram` Onyx string) to a selectable program key. + * Only US and GB are user-selectable; anything else (including undefined) falls back to US so single-program feeds are unaffected. + */ +function getSelectableCardProgramKey(programKey: string | undefined): CardProgramKey { + return programKey === CONST.COUNTRY.GB ? CONST.COUNTRY.GB : CONST.COUNTRY.US; +} + function getCardSettings(cardSettings: OnyxEntry, programKey?: CardProgramKey): NestedExpensifyCardSettings | undefined { if (!cardSettings) { return undefined; @@ -1435,6 +1504,15 @@ function getCardSettings(cardSettings: OnyxEntry, program ); } +/** + * Resolves the settings for the display page's currently-selected program, falling back to auto-detect when that program + * is not nested on the NVP. This keeps legacy domains the backend still sends flat (or single-program feeds that default to + * US) working, without changing `getCardSettings`, whose strict-`programKey` behavior other callers (e.g. currency resolution) rely on. + */ +function getCardSettingsForSelectedProgram(cardSettings: OnyxEntry, programKey: CardProgramKey | undefined): NestedExpensifyCardSettings | undefined { + return getCardSettings(cardSettings, programKey) ?? getCardSettings(cardSettings); +} + /** Backend may nest linkedPolicyIDs under each program block (not only on the settings root). */ const NESTED_EXPENSIFY_CARD_PROGRAM_KEYS: readonly CardProgramKey[] = [CONST.COUNTRY.US, CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT, CONST.COUNTRY.GB, CONST.TRAVEL.PROGRAM_TRAVEL_US]; @@ -2065,7 +2143,14 @@ export { hasIssuedExpensifyCard, isExpensifyCardFullySetUp, getCardSettings, + getCardSettingsForSelectedProgram, getCardProgramKey, + getConfiguredExpensifyCardProgramKeys, + getProgramKeyForCard, + isCardInProgram, + filterCardsListByProgram, + getExpensifyCardProgramCountrySuffix, + getSelectableCardProgramKey, getLinkedPolicyIDsFromExpensifyCardSettings, getPreferredPolicyFromExpensifyCardSettings, getDomainNameFromExpensifyCardSettings, diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index b6c743c2c5cc..729cd517af38 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -1681,18 +1681,30 @@ function updateSelectedFeed(feed: CompanyCardFeedWithDomainID, policyID: string ]); } -function updateSelectedExpensifyCardFeed(feed: number, policyID: string | undefined) { +function updateSelectedExpensifyCardFeed(feed: number, policyID: string | undefined, programKey?: CardProgramKey) { if (!policyID) { return; } - Onyx.update([ + const updates: Parameters[0] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED}${policyID}`, value: feed, }, - ]); + ]; + + // A single feed (fundID) can hold more than one program (US/GB). Persist which one is selected so the + // card list, currency and details resolve to the right program. Callers that only change the feed leave it untouched. + if (programKey) { + updates.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_PROGRAM}${policyID}`, + value: programKey, + }); + } + + Onyx.update(updates); } function queueExpensifyCardForBilling(feedCountry: string, domainAccountID: number) { From 906160289836d850a2c35de7203371cdf6620076 Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Mon, 13 Jul 2026 19:54:34 -0300 Subject: [PATCH 02/19] Add useSelectedExpensifyCardProgram hook Resolves the selected program (US/GB) for a policy's feed: the stored value when present, otherwise the feed's first configured program so a GB-only feed shows its cards on first load. Pairs with useDefaultFundID to identify which program to display. --- src/hooks/useSelectedExpensifyCardProgram.ts | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/hooks/useSelectedExpensifyCardProgram.ts diff --git a/src/hooks/useSelectedExpensifyCardProgram.ts b/src/hooks/useSelectedExpensifyCardProgram.ts new file mode 100644 index 000000000000..fa341a331eb1 --- /dev/null +++ b/src/hooks/useSelectedExpensifyCardProgram.ts @@ -0,0 +1,28 @@ +import type {CardProgramKey} from '@libs/CardUtils'; +import {getConfiguredExpensifyCardProgramKeys, getSelectableCardProgramKey} from '@libs/CardUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import useOnyx from './useOnyx'; + +/** + * Hook to get the currently selected Expensify Card program (US/GB) for a given policyID and feed (fundID). + * A single feed holds both programs' cards in one Onyx list, so this pairs with `useDefaultFundID` to identify + * which program's cards/currency/settings to display. + * + * When nothing is stored yet, it falls back to the feed's first configured program (US before GB) so a feed that only + * has a GB program still shows its cards on first load, while a US (or single-program) feed keeps behaving exactly as before. + */ +function useSelectedExpensifyCardProgram(policyID: string | undefined, fundID: number | undefined): CardProgramKey { + const [lastSelectedExpensifyCardProgram] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_PROGRAM}${policyID}`); + const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${fundID}`); + + if (lastSelectedExpensifyCardProgram) { + return getSelectableCardProgramKey(lastSelectedExpensifyCardProgram); + } + + return getConfiguredExpensifyCardProgramKeys(cardSettings).at(0) ?? CONST.COUNTRY.US; +} + +export default useSelectedExpensifyCardProgram; From f403835db9dbd5690295fcd21404a1d53c78000b Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Mon, 13 Jul 2026 19:54:44 -0300 Subject: [PATCH 03/19] Show one feed row per program when a domain has multiple programs getAdminExpensifyCardFeedEntries now emits one entry per configured program (each tagged with its programKey) instead of one per fundID. The feed selector renders a row per program, disambiguated by a country suffix on non-US rows (e.g. "deptagency.com" vs "deptagency.com GB"), and persists the selected program alongside the feed. --- src/libs/ExpensifyCardFeedSelectorUtils.ts | 15 ++++++++++- ...WorkspaceExpensifyCardFeedSelectorPage.tsx | 25 ++++++++++++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/libs/ExpensifyCardFeedSelectorUtils.ts b/src/libs/ExpensifyCardFeedSelectorUtils.ts index afd2c617a252..faad96784ba7 100644 --- a/src/libs/ExpensifyCardFeedSelectorUtils.ts +++ b/src/libs/ExpensifyCardFeedSelectorUtils.ts @@ -7,7 +7,10 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {Str} from 'expensify-common'; +import type {CardProgramKey} from './CardUtils'; + import { + getConfiguredExpensifyCardProgramKeys, getDomainByFundID, getDomainNameFromExpensifyCardSettings, getFundIdFromSettingsKey, @@ -21,6 +24,12 @@ type ExpensifyCardFeedEntry = { settingsKey: string; fundID: number; settings: ExpensifyCardSettings; + + /** + * The program (US/GB) this entry represents. A single settings NVP can hold more than one provisioned program, + * in which case each program gets its own entry (and its own selector row) that shares the same `fundID`. + */ + programKey: CardProgramKey; }; /** A feed qualifies only when its settings NVP has a US or GB program block with a configured settlement bank account. */ @@ -95,7 +104,11 @@ function getAdminExpensifyCardFeedEntries( if (!isExpensifyCardFeedVisibleToAdmin(settings, policies, fundID, domains, currentUserAccountID)) { return []; } - return [{settingsKey, fundID, settings}]; + + // A domain provisioned with more than one program (e.g. both US and GB) yields one entry per program so each + // renders as its own selector row. `getConfiguredExpensifyCardProgramKeys` only returns US/GB, both of which pass + // the `hasConfiguredExpensifyCardFeed` gate above, so there is always at least one program here. + return getConfiguredExpensifyCardProgramKeys(settings).map((programKey) => ({settingsKey, fundID, settings, programKey})); }); } diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx index 184d969d17e3..1dac322e653c 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx @@ -19,9 +19,12 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import usePrimaryContactMethod from '@hooks/usePrimaryContactMethod'; +import useSelectedExpensifyCardProgram from '@hooks/useSelectedExpensifyCardProgram'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearIssueNewCardFlow, clearIssueNewCardFormData, setIssueNewCardStepAndData, updateSelectedExpensifyCardFeed} from '@libs/actions/Card'; +import type {CardProgramKey} from '@libs/CardUtils'; +import {getExpensifyCardProgramCountrySuffix} from '@libs/CardUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import type {ExpensifyCardFeedEntry} from '@libs/ExpensifyCardFeedSelectorUtils'; import {getExpensifyCardFeedDescription} from '@libs/ExpensifyCardFeedSelectorUtils'; @@ -53,6 +56,7 @@ import {View} from 'react-native'; type ExpensifyFeedListItem = ListItem & { value: number; + programKey: CardProgramKey; }; type WorkspaceExpensifyCardFeedSelectorPageProps = PlatformStackScreenProps; @@ -74,6 +78,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF const primaryContactMethod = usePrimaryContactMethod(); const defaultFundID = useDefaultFundID(policyID); const lastSelectedExpensifyCardFeedID = lastSelectedExpensifyCardFeed ?? defaultFundID; + const selectedProgramKey = useSelectedExpensifyCardProgram(policyID, lastSelectedExpensifyCardFeedID); const [feedWithError, setFeedWithError] = useState<{fundID?: number; error?: Errors} | undefined>(undefined); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); @@ -132,13 +137,21 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_BANK_ACCOUNT.getRoute(policyID)); }; + // A feed provisioned with more than one program renders one row per program (see getAdminExpensifyCardFeedEntries), + // so the currently-selected row is identified by both the feed (fundID) and the program. + const getFeedRowKey = (fundID: number, programKey: CardProgramKey) => `${fundID}_${programKey}`; + const toListItem = (entry: ExpensifyCardFeedEntry, isOtherWorkspaceSection: boolean): ExpensifyFeedListItem => { const isFeedPendingDelete = entry.settings.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const description = getExpensifyCardFeedDescription(entry.settings, policies, domains, entry.fundID, cardList); + const countrySuffix = getExpensifyCardProgramCountrySuffix(entry.programKey); + const rowKey = getFeedRowKey(entry.fundID, entry.programKey); return { value: entry.fundID, - text: getExpensifyCardFeedDescription(entry.settings, policies, domains, entry.fundID, cardList), - keyForList: entry.fundID.toString(), - isSelected: entry.fundID === lastSelectedExpensifyCardFeedID, + programKey: entry.programKey, + text: countrySuffix ? `${description} ${countrySuffix}` : description, + keyForList: rowKey, + isSelected: entry.fundID === lastSelectedExpensifyCardFeedID && entry.programKey === selectedProgramKey, isDisabled: isFeedPendingDelete || (isOtherWorkspaceSection && isOffline), pendingAction: entry.settings.pendingAction, errors: feedWithError?.fundID === entry.fundID ? feedWithError.error : undefined, @@ -181,7 +194,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF linkCardFeedToPolicy(feed.value, policyID, CONST.COMPANY_CARD.LINK_FEED_TYPE.EXPENSIFY_CARD) .then(() => { - updateSelectedExpensifyCardFeed(feed.value, policyID); + updateSelectedExpensifyCardFeed(feed.value, policyID, feed.programKey); goBack(); }) .catch((error: TranslationPaths) => { @@ -194,7 +207,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF const selectFeed = (feed: ExpensifyFeedListItem) => { resetCardFlowState(); - updateSelectedExpensifyCardFeed(feed.value, policyID); + updateSelectedExpensifyCardFeed(feed.value, policyID, feed.programKey); goBack(); }; @@ -257,7 +270,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF onSelectRow={selectFeed} data={primaryListData} alternateNumberOfSupportedLines={2} - initiallyFocusedItemKey={lastSelectedExpensifyCardFeedID.toString()} + initiallyFocusedItemKey={getFeedRowKey(lastSelectedExpensifyCardFeedID, selectedProgramKey)} addBottomSafeAreaPadding listFooterContent={issueNewCardAndOtherFeedsFooter} onDismissError={onDismissError} From 42da5d26f6d2b0cbc825cee411983e0a4b2b755a Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Mon, 13 Jul 2026 19:54:56 -0300 Subject: [PATCH 04/19] Filter cards and currency by selected program on the card list A single cards_{fundID}_Expensify Card list holds both programs' cards, so filter it by the selected program's feedCountry and resolve the currency, settings and header suffix for that program. useCurrencyForExpensifyCard gains a programKey so it reads the selected program's currency rather than the collapsed, US-first value. --- src/hooks/useCurrencyForExpensifyCard.ts | 14 ++++++++++++-- .../WorkspaceExpensifyCardListPage.tsx | 18 +++++++++++++----- .../WorkspaceExpensifyCardPage.tsx | 14 ++++++++++---- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/hooks/useCurrencyForExpensifyCard.ts b/src/hooks/useCurrencyForExpensifyCard.ts index 723ee1d9582f..b7a182eeb08e 100644 --- a/src/hooks/useCurrencyForExpensifyCard.ts +++ b/src/hooks/useCurrencyForExpensifyCard.ts @@ -1,4 +1,5 @@ -import {getCardOrFeedCurrency} from '@libs/CardUtils'; +import type {CardProgramKey} from '@libs/CardUtils'; +import {getCardOrFeedCurrency, getCardSettings} from '@libs/CardUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -7,14 +8,23 @@ import useExpensifyCardUkEuSupported from './useExpensifyCardUkEuSupported'; import useOnyx from './useOnyx'; import usePolicy from './usePolicy'; -export default function useCurrencyForExpensifyCard({policyID, fundID}: {policyID?: string; fundID?: number}) { +// `programKey` is required (not optional) so callers can't silently fall back to the collapsed, US-first currency on a +// feed that also holds a GB program. Pass the selected program (`useSelectedExpensifyCardProgram`) for feed-level currency, +// or the card's own program (`getProgramKeyForCard`) for a specific card. +export default function useCurrencyForExpensifyCard({policyID, fundID, programKey}: {policyID?: string; fundID?: number; programKey: CardProgramKey}) { const policy = usePolicy(policyID); const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policyID); const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${fundID}`); // The selected feed can belong to a different policy/domain than the one being viewed // (e.g. a US feed linked to a GBP policy), so derive the currency from the feed itself. + // A feed can also hold more than one program (US/GB) — when a program is given, read that program's currency + // so, for example, the US row shows USD even when a GB program is also provisioned on the same feed. if (fundID && cardSettings) { + const programSettings = getCardSettings(cardSettings, programKey); + if (programSettings?.currency) { + return programSettings.currency; + } return getCardOrFeedCurrency(undefined, cardSettings); } diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 1b16917410bd..b34d1761103e 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -31,7 +31,8 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import {clearIssueNewCardFormData, exportExpensifyCardListToCSV, setIssueNewCardStepAndData} from '@libs/actions/Card'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {clearDeletePaymentMethodError} from '@libs/actions/PaymentMethods'; -import {getCardsByCardholderName, getCardSettings, isCurrencySupportedForECards} from '@libs/CardUtils'; +import type {CardProgramKey} from '@libs/CardUtils'; +import {getCardsByCardholderName, getCardSettingsForSelectedProgram, getExpensifyCardProgramCountrySuffix, isCurrencySupportedForECards} from '@libs/CardUtils'; import {getExpensifyCardFeedDescription} from '@libs/ExpensifyCardFeedSelectorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -65,9 +66,12 @@ type WorkspaceExpensifyCardListPageProps = { /** Fund ID */ fundID: number; + + /** The selected program (US/GB) within the feed, used to resolve currency when a feed holds more than one program */ + programKey: CardProgramKey; }; -function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExpensifyCardListPageProps) { +function WorkspaceExpensifyCardListPage({route, cardsList, fundID, programKey}: WorkspaceExpensifyCardListPageProps) { const icons = useMemoizedLazyExpensifyIcons(['Export', 'Gear', 'Plus']); const {shouldUseNarrowLayout, isMediumScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const {translate} = useLocalize(); @@ -83,7 +87,11 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [domains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); - const settings = getCardSettings(cardSettings); + const settings = getCardSettingsForSelectedProgram(cardSettings, programKey); + const feedDescription = getExpensifyCardFeedDescription(cardSettings, allPolicies, domains, fundID, cardList); + const feedCountrySuffix = getExpensifyCardProgramCountrySuffix(programKey); + // Distinguish the two rows of a multi-program feed in the header, matching the selector labels (e.g. `deptagency.com GB`). + const feedSupportingText = feedCountrySuffix ? `${feedDescription} ${feedCountrySuffix}` : feedDescription; const {allFeeds: allAdminExpensifyCardFeeds} = useExpensifyCardFeedsForFeedSelector(policyID); const shouldShowSelector = allAdminExpensifyCardFeeds.length >= 1; const {isDelegateAccessRestricted} = useDelegateNoAccessState(); @@ -108,7 +116,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp /> ); - const settlementCurrency = useCurrencyForExpensifyCard({policyID, fundID}); + const settlementCurrency = useCurrencyForExpensifyCard({policyID, fundID, programKey}); const shouldShowEuUkDisclaimer = isCurrencySupportedForECards(settlementCurrency); const allCards = useMemo(() => { const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList)); @@ -316,7 +324,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp onFeedSelect={() => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_SELECT_FEED.path))} CardFeedIcon={cardFeedIcon} feedName={translate('workspace.common.expensifyCard')} - supportingText={getExpensifyCardFeedDescription(cardSettings, allPolicies, domains, fundID, cardList)} + supportingText={feedSupportingText} /> {isBankAccountVerified && (canWriteExpensifyCard || secondaryActions.length > 0 || !isCardListEmpty) && getHeaderButtons()} diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPage.tsx index 4987c03425f4..6d741eb5c4b8 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPage.tsx @@ -4,10 +4,11 @@ import useDefaultFundID from '@hooks/useDefaultFundID'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useSelectedExpensifyCardProgram from '@hooks/useSelectedExpensifyCardProgram'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {updateSelectedExpensifyCardFeed} from '@libs/actions/Card'; -import {filterInactiveCardsForWorkspace, getCardSettings} from '@libs/CardUtils'; +import {filterCardsListByProgram, filterInactiveCardsForWorkspace, getCardSettingsForSelectedProgram} from '@libs/CardUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; @@ -20,7 +21,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import WorkspaceExpensifyCardListPage from './WorkspaceExpensifyCardListPage'; import WorkspaceExpensifyCardPageEmptyState from './WorkspaceExpensifyCardPageEmptyState'; @@ -32,10 +33,14 @@ function WorkspaceExpensifyCardPage({route}: WorkspaceExpensifyCardPageProps) { const policy = usePolicy(policyID); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.expensifyCard'); const defaultFundID = useDefaultFundID(policyID); + const selectedProgramKey = useSelectedExpensifyCardProgram(policyID, defaultFundID); const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`); - const settings = getCardSettings(cardSettings); - const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCardsForWorkspace}); + const settings = getCardSettingsForSelectedProgram(cardSettings, selectedProgramKey); + const [allProgramsCardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCardsForWorkspace}); + + // A single feed holds both programs' cards in one Onyx list, so keep only the selected program's cards. + const cardsList = useMemo(() => filterCardsListByProgram(allProgramsCardsList, selectedProgramKey), [allProgramsCardsList, selectedProgramKey]); const fetchExpensifyCards = useCallback(() => { updateSelectedExpensifyCardFeed(defaultFundID, policyID); @@ -70,6 +75,7 @@ function WorkspaceExpensifyCardPage({route}: WorkspaceExpensifyCardPageProps) { ); From 2fb883a3bd75abfd4161b654f7cff60efd66347d Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Mon, 13 Jul 2026 19:55:06 -0300 Subject: [PATCH 05/19] Show country and per-card currency on the card details page Add a read-only Country row and resolve the card's currency from its own feedCountry, so US and GB cards on the same feed show their correct country and currency. --- .../DynamicWorkspaceExpensifyCardDetailsPage.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx index 79cbd3fe8a35..a2aa6b84ec7e 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx @@ -31,7 +31,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {openPolicyExpensifyCardsPage} from '@libs/actions/Policy/Policy'; import navigateToCardTransactions from '@libs/CardNavigationUtils'; -import {getAllCardsForWorkspace, getCardFeedTextColor, getCardHintText, getTranslationKeyForLimitType, isCardFrozen, maskCard} from '@libs/CardUtils'; +import {getAllCardsForWorkspace, getCardFeedTextColor, getCardHintText, getProgramKeyForCard, getTranslationKeyForLimitType, isCardFrozen, maskCard} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -119,12 +119,18 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen const workspaceCard = workspaceCards?.[cardID]; const card = workspaceCard ?? cardFromCardList; + // Resolve currency from the card's own program (feedCountry). A feed can hold both a US and a GB program, so the + // selected feed's default program is not necessarily this card's program. const currency = useCurrencyForExpensifyCard({ policyID, fundID: defaultFundID, + programKey: getProgramKeyForCard(card), }); const cardholder = personalDetails?.[card?.accountID ?? CONST.DEFAULT_NUMBER_ID]; const isVirtual = !!card?.nameValuePairs?.isVirtual; + // Surface the card's issuing country so cards on different programs (e.g. US vs GB) of the same feed are distinguishable. + const cardCountryCode = card?.nameValuePairs?.country; + const cardCountryName = cardCountryCode ? (CONST.ALL_COUNTRIES as Record)[cardCountryCode] : undefined; const formattedAvailableSpendAmount = convertToDisplayString(card?.availableSpend, currency); const formattedLimit = convertToDisplayString(card?.nameValuePairs?.unapprovedExpenseLimit, currency); const displayName = temporaryGetDisplayNameOrDefault({ @@ -378,6 +384,13 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen interactive={false} titleStyle={styles.walletCardNumber} /> + {!!cardCountryName && ( + + )} {spendRulesSummary.length > 0 && ( Date: Mon, 13 Jul 2026 19:55:14 -0300 Subject: [PATCH 06/19] Use the correct program currency in the issue-card and edit-limit flows The issue-card flow captured currency without a program, so a GB feed showed USD through the limit and confirmation steps. Pass the selected program in AssigneeStep, and resolve currency from the card's own program on the edit-limit and limit-type pages. --- .../expensifyCard/DynamicExpensifyCardLimitPage.tsx | 7 ++++--- .../expensifyCard/DynamicExpensifyCardLimitTypePage.tsx | 5 +++-- .../workspace/expensifyCard/issueNew/AssigneeStep.tsx | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx index ef07eb33c0a5..f4f71e0d0ad2 100644 --- a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx @@ -17,7 +17,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateExpensifyCardLimit} from '@libs/actions/Card'; -import {filterInactiveCardsForWorkspace} from '@libs/CardUtils'; +import {filterInactiveCardsForWorkspace, getProgramKeyForCard} from '@libs/CardUtils'; import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; @@ -49,11 +49,12 @@ function DynamicExpensifyCardLimitPage({route}: DynamicExpensifyCardLimitPagePro const defaultFundID = useDefaultFundID(policyID); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.EXPENSIFY_CARD_LIMIT.path); - const currency = useCurrencyForExpensifyCard({policyID, fundID: defaultFundID}); - const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCardsForWorkspace}); const card = cardsList?.[cardID]; + // Resolve currency from the card's own program (feedCountry) so a GB card shows GBP/EUR even when the feed also has a US program. + const currency = useCurrencyForExpensifyCard({policyID, fundID: defaultFundID, programKey: getProgramKeyForCard(card)}); + // Keep a ref to the latest card so the confirmation callback recomputes spend from current data, // not the stale card captured when the form was submitted (the card can refresh while the modal is open). const cardRef = useRef(card); diff --git a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx index 9092770ac6a1..6d8c0d55d74b 100644 --- a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx @@ -22,7 +22,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateExpensifyCardLimitType} from '@libs/actions/Card'; import {openPolicyEditCardLimitTypePage} from '@libs/actions/Policy/Policy'; -import {filterInactiveCardsForWorkspace, getDefaultExpensifyCardLimitType} from '@libs/CardUtils'; +import {filterInactiveCardsForWorkspace, getDefaultExpensifyCardLimitType, getProgramKeyForCard} from '@libs/CardUtils'; import DateUtils from '@libs/DateUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getApprovalWorkflow} from '@libs/PolicyUtils'; @@ -74,7 +74,8 @@ function DynamicExpensifyCardLimitTypePage({route}: WorkspaceEditCardLimitTypePa const [typeSelected, setTypeSelected] = useState(initialLimitType); const [expirationToggle, setExpirationToggle] = useState(!!card?.nameValuePairs?.validFrom); - const currency = useCurrencyForExpensifyCard({policyID, fundID: defaultFundID}); + // Resolve currency from the card's own program (feedCountry) so a GB card shows GBP/EUR even when the feed also has a US program. + const currency = useCurrencyForExpensifyCard({policyID, fundID: defaultFundID, programKey: getProgramKeyForCard(card)}); const personalDetails = usePersonalDetails(); const assigneePersonalDetails = personalDetails?.[card?.accountID ?? CONST.DEFAULT_NUMBER_ID]; const assigneeTimeZone = assigneePersonalDetails?.timezone?.selected; diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index d4309609ca58..bb4b2e3c3a2c 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -11,6 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePersonalDetailSearchSelector from '@hooks/usePersonalDetailSearchSelector'; +import useSelectedExpensifyCardProgram from '@hooks/useSelectedExpensifyCardProgram'; import useThemeStyles from '@hooks/useThemeStyles'; import {searchUserInServer} from '@libs/actions/Report'; @@ -86,7 +87,8 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr }); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const defaultFundID = useDefaultFundID(policyID); - const currency = useCurrencyForExpensifyCard({policyID, fundID: defaultFundID}); + const selectedProgramKey = useSelectedExpensifyCardProgram(policyID, defaultFundID); + const currency = useCurrencyForExpensifyCard({policyID, fundID: defaultFundID, programKey: selectedProgramKey}); const isEditing = issueNewCard?.isEditing; const submit = (assignee: ListItem) => { From 1f8511db872264d5520c8751cb958295d669922d Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Mon, 13 Jul 2026 19:55:24 -0300 Subject: [PATCH 07/19] Require a program for currency and fix Settings/settlement program collapse Make programKey required on useCurrencyForExpensifyCard so a caller can no longer silently fall back to the collapsed, US-first currency. Fixing the remaining callers surfaced that the settlement label, Settings page and settlement account/frequency pages all resolved the program via getCardProgramKey (US-first): they showed US settings on a GB feed, and the settlement account/frequency edits wrote to the US program even when editing the GB feed. Use the selected program for both display and the update actions. --- .../DynamicWorkspaceSettlementAccountPage.tsx | 7 ++++--- .../workspace/expensifyCard/WorkspaceCardSettingsPage.tsx | 7 ++++--- .../workspace/expensifyCard/WorkspaceCardsListLabel.tsx | 8 +++++--- .../expensifyCard/WorkspaceSettlementFrequencyPage.tsx | 7 ++++--- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx index 8e411f8fe2f5..7c0b1b0e8e2c 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx @@ -11,12 +11,13 @@ import useEnvironment from '@hooks/useEnvironment'; import useExpensifyCardUkEuSupported from '@hooks/useExpensifyCardUkEuSupported'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useSelectedExpensifyCardProgram from '@hooks/useSelectedExpensifyCardProgram'; import useThemeStyles from '@hooks/useThemeStyles'; import {getRouteParamForConnection} from '@libs/AccountingUtils'; import {openPolicyAccountingPage} from '@libs/actions/PolicyConnections'; import {getLastFourDigits} from '@libs/BankAccountUtils'; -import {getCardProgramKey, getCardSettings, getEligibleBankAccountsForCard, getEligibleBankAccountsForUkEuCard} from '@libs/CardUtils'; +import {getCardSettingsForSelectedProgram, getEligibleBankAccountsForCard, getEligibleBankAccountsForUkEuCard} from '@libs/CardUtils'; import Log from '@libs/Log'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDomainNameForPolicy} from '@libs/PolicyUtils'; @@ -56,8 +57,8 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [bankAccountsList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`); - const programKey = getCardProgramKey(cardSettings); - const settings = getCardSettings(cardSettings, programKey); + const programKey = useSelectedExpensifyCardProgram(policyID, defaultFundID); + const settings = getCardSettingsForSelectedProgram(cardSettings, programKey); const [continuousReconciliation] = useOnyx(`${ONYXKEYS.COLLECTION.EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION}${defaultFundID}`, { selector: isExpensifyCardContinuousReconciliationEnabledSelector, }); diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx index 5683d4260b29..f7db57b940dc 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardSettingsPage.tsx @@ -9,11 +9,12 @@ import useDefaultFundID from '@hooks/useDefaultFundID'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrivateSubscription from '@hooks/usePrivateSubscription'; +import useSelectedExpensifyCardProgram from '@hooks/useSelectedExpensifyCardProgram'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearCashbackToBillError, toggleCashbackToBill} from '@libs/actions/Card'; import {getLastFourDigits} from '@libs/BankAccountUtils'; -import {getCardProgramKey, getCardSettings} from '@libs/CardUtils'; +import {getCardSettingsForSelectedProgram} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {isSubscriptionTypeOfInvoicing} from '@libs/SubscriptionUtils'; @@ -39,11 +40,11 @@ function WorkspaceCardSettingsPage({route}: WorkspaceCardSettingsPageProps) { const {translate} = useLocalize(); const policyID = route.params?.policyID; const defaultFundID = useDefaultFundID(policyID); + const programKey = useSelectedExpensifyCardProgram(policyID, defaultFundID); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`); - const settings = getCardSettings(cardSettings); - const programKey = getCardProgramKey(cardSettings); + const settings = getCardSettingsForSelectedProgram(cardSettings, programKey); const privateSubscription = usePrivateSubscription(); const isUSProgram = programKey === CONST.COUNTRY.US || programKey === CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT; const shouldShowCashbackToggle = isUSProgram && !isSubscriptionTypeOfInvoicing(privateSubscription?.type); diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx index 58e006ca26b1..5da43ae8811d 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx @@ -12,11 +12,12 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSelectedExpensifyCardProgram from '@hooks/useSelectedExpensifyCardProgram'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {getCardSettings} from '@libs/CardUtils'; +import {getCardSettingsForSelectedProgram} from '@libs/CardUtils'; import getClickedTargetLocation from '@libs/getClickedTargetLocation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -72,10 +73,11 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr const anchorRef = useRef(null); const defaultFundID = useDefaultFundID(policyID); + const selectedProgramKey = useSelectedExpensifyCardProgram(policyID, defaultFundID); - const settlementCurrency = useCurrencyForExpensifyCard({policyID, fundID: defaultFundID}); + const settlementCurrency = useCurrencyForExpensifyCard({policyID, fundID: defaultFundID, programKey: selectedProgramKey}); const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`); - const settings = getCardSettings(cardSettings); + const settings = getCardSettingsForSelectedProgram(cardSettings, selectedProgramKey); const [cardManualBilling] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_MANUAL_BILLING}${defaultFundID}`); const icons = useMemoizedLazyExpensifyIcons(['Info']); const paymentBankAccountID = settings?.paymentBankAccountID; diff --git a/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx index 948d977e4d19..20c698f63bb2 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage.tsx @@ -7,10 +7,11 @@ import Text from '@components/Text'; import useDefaultFundID from '@hooks/useDefaultFundID'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useSelectedExpensifyCardProgram from '@hooks/useSelectedExpensifyCardProgram'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateSettlementFrequency as updateSettlementFrequencyUtil} from '@libs/actions/Card'; -import {getCardProgramKey, getCardSettings} from '@libs/CardUtils'; +import {getCardSettingsForSelectedProgram} from '@libs/CardUtils'; import Log from '@libs/Log'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -37,8 +38,8 @@ function WorkspaceSettlementFrequencyPage({route}: WorkspaceSettlementFrequencyP const defaultFundID = useDefaultFundID(policyID); const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`); - const programKey = getCardProgramKey(cardSettings); - const settings = getCardSettings(cardSettings, programKey); + const programKey = useSelectedExpensifyCardProgram(policyID, defaultFundID); + const settings = getCardSettingsForSelectedProgram(cardSettings, programKey); const shouldShowMonthlyOption = settings?.isMonthlySettlementAllowed ?? false; const selectedFrequency = settings?.monthlySettlementDate ? CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY : CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY; From 49b1fd0a7c6c26fa14f66ef5d5ec2b8b4997a3a4 Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Mon, 13 Jul 2026 19:55:33 -0300 Subject: [PATCH 08/19] Add unit tests for multi-program helpers and feed splitting Cover the new CardUtils helpers (program key resolution, card-to-program mapping, per-program card filtering, configured-program detection) and the feed selector splitting a US+GB feed into one entry per program. --- tests/unit/CardUtilsTest.ts | 87 +++++++++++++++++++ .../ExpensifyCardFeedSelectorUtilsTest.ts | 25 ++++++ 2 files changed, 112 insertions(+) diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 99443c289441..18201ed8d21e 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -33,6 +33,7 @@ import { getCompanyCardDescription, getCompanyCardFeed, getCompanyFeeds, + getConfiguredExpensifyCardProgramKeys, getConnectionBankAccountsForReconciliation, getCSVFeedType, getCustomFeedNameFromFeeds, @@ -49,6 +50,8 @@ import { getOriginalCompanyFeeds, getPlaidInstitutionIconUrl, getPlaidInstitutionId, + getProgramKeyForCard, + getSelectableCardProgramKey, getSelectedFeed, getTranslationKeyForCardStatus, getYearFromExpirationDateString, @@ -62,7 +65,9 @@ import { isCSVUploadFeed, isCustomFeed as isCustomFeedCardUtils, isDirectFeed as isDirectFeedCardUtils, + filterCardsListByProgram, isActiveCard, + isCardInProgram, isExpensifyCard, isExpensifyCardFullySetUp, isExpiredCard, @@ -4614,3 +4619,85 @@ describe('getCompanyCardCustomName', () => { expect(getCompanyCardCustomName('9999', sharedCardCustomNames, customCardNames)).toBeUndefined(); }); }); + +describe('multi-program Expensify Card helpers', () => { + const usCard = {cardID: 1, bank: CONST.EXPENSIFY_CARD.BANK, nameValuePairs: {feedCountry: CONST.COUNTRY.US}} as unknown as Card; + const gbCard = {cardID: 2, bank: CONST.EXPENSIFY_CARD.BANK, nameValuePairs: {feedCountry: CONST.COUNTRY.GB}} as unknown as Card; + const legacyCard = {cardID: 3, bank: CONST.EXPENSIFY_CARD.BANK, nameValuePairs: {}} as unknown as Card; + + describe('getSelectableCardProgramKey', () => { + it('returns GB when GB is stored', () => { + expect(getSelectableCardProgramKey(CONST.COUNTRY.GB)).toBe(CONST.COUNTRY.GB); + }); + + it('falls back to US for undefined or unsupported values', () => { + expect(getSelectableCardProgramKey(undefined)).toBe(CONST.COUNTRY.US); + expect(getSelectableCardProgramKey('CURRENT')).toBe(CONST.COUNTRY.US); + }); + }); + + describe('getProgramKeyForCard', () => { + it('maps GB cards to GB', () => { + expect(getProgramKeyForCard(gbCard)).toBe(CONST.COUNTRY.GB); + }); + + it('treats US, legacy (no feedCountry), and missing cards as US', () => { + expect(getProgramKeyForCard(usCard)).toBe(CONST.COUNTRY.US); + expect(getProgramKeyForCard(legacyCard)).toBe(CONST.COUNTRY.US); + expect(getProgramKeyForCard(undefined)).toBe(CONST.COUNTRY.US); + }); + }); + + describe('isCardInProgram', () => { + it('matches a card to its own program only', () => { + expect(isCardInProgram(gbCard, CONST.COUNTRY.GB)).toBe(true); + expect(isCardInProgram(gbCard, CONST.COUNTRY.US)).toBe(false); + expect(isCardInProgram(legacyCard, CONST.COUNTRY.US)).toBe(true); + }); + }); + + describe('getConfiguredExpensifyCardProgramKeys', () => { + it('returns both programs (US before GB) when both have a settlement bank account', () => { + const settings = { + [CONST.COUNTRY.US]: {paymentBankAccountID: 1}, + [CONST.COUNTRY.GB]: {paymentBankAccountID: 2}, + } as unknown as ExpensifyCardSettings; + expect(getConfiguredExpensifyCardProgramKeys(settings)).toEqual([CONST.COUNTRY.US, CONST.COUNTRY.GB]); + }); + + it('ignores program blocks without a settlement bank account', () => { + const settings = { + [CONST.COUNTRY.US]: {paymentBankAccountID: 1}, + [CONST.COUNTRY.GB]: {isEnabled: true}, + } as unknown as ExpensifyCardSettings; + expect(getConfiguredExpensifyCardProgramKeys(settings)).toEqual([CONST.COUNTRY.US]); + }); + + it('returns an empty list for flat/legacy settings with no nested program', () => { + const settings = {domainName: 'legacy.com', isEnabled: true} as unknown as ExpensifyCardSettings; + expect(getConfiguredExpensifyCardProgramKeys(settings)).toEqual([]); + }); + }); + + describe('filterCardsListByProgram', () => { + const cardsList = { + '1': usCard, + '2': gbCard, + '3': legacyCard, + cardList: {someUnassigned: 'XXXX1234'}, + } as unknown as WorkspaceCardsList; + + it('keeps only the selected program cards and preserves the cardList meta entry', () => { + const gbOnly = filterCardsListByProgram(cardsList, CONST.COUNTRY.GB); + expect(Object.keys(gbOnly ?? {}).sort()).toEqual(['2', 'cardList']); + + const usOnly = filterCardsListByProgram(cardsList, CONST.COUNTRY.US); + // US program keeps the explicit US card and the legacy card (no feedCountry). + expect(Object.keys(usOnly ?? {}).sort()).toEqual(['1', '3', 'cardList']); + }); + + it('returns the list unchanged when no program is given', () => { + expect(filterCardsListByProgram(cardsList, undefined)).toBe(cardsList); + }); + }); +}); diff --git a/tests/unit/ExpensifyCardFeedSelectorUtilsTest.ts b/tests/unit/ExpensifyCardFeedSelectorUtilsTest.ts index d3107188fd46..357f82aa3395 100644 --- a/tests/unit/ExpensifyCardFeedSelectorUtilsTest.ts +++ b/tests/unit/ExpensifyCardFeedSelectorUtilsTest.ts @@ -175,6 +175,31 @@ describe('getAdminExpensifyCardFeedEntries', () => { expect(entries).toHaveLength(1); expect(entries.at(0)?.fundID).toBe(feedFundID); + expect(entries.at(0)?.programKey).toBe(CONST.COUNTRY.GB); + }); + + it('tags a single-program feed entry with its program key', () => { + const entries = getAdminExpensifyCardFeedEntries(cardSettingsCollection, adminPolicyForFund, {}, currentUserAccountID); + + expect(entries).toHaveLength(1); + expect(entries.at(0)?.programKey).toBe(CONST.COUNTRY.US); + }); + + it('splits a feed with both US and GB programs into one entry per program (US before GB)', () => { + const multiProgramSettings: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${feedFundID}`]: { + [CONST.COUNTRY.US]: {paymentBankAccountID: 23242}, + [CONST.COUNTRY.GB]: {paymentBankAccountID: 55667}, + hasOnceLoaded: true, + }, + }; + + const entries = getAdminExpensifyCardFeedEntries(multiProgramSettings, adminPolicyForFund, {}, currentUserAccountID); + + expect(entries).toHaveLength(2); + // Both rows share the same feed (fundID) but differ by program. + expect(entries.every((entry) => entry.fundID === feedFundID)).toBe(true); + expect(entries.map((entry) => entry.programKey)).toEqual([CONST.COUNTRY.US, CONST.COUNTRY.GB]); }); it('hides a feed when the user is neither a domain admin nor a workspace admin for the fund', () => { From 0a1f6fa513c663cffe7c4ee1bfa75b44665e4520 Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Mon, 13 Jul 2026 20:56:51 -0300 Subject: [PATCH 09/19] Send the selected program as feedCountry when issuing a card The issue-card flow hardcoded feedCountry as '' or 'US', so selecting the GB feed still issued into US. Pass the selected program's country to CreateExpensifyCard from both the extended-access and magic-code paths. This fixes routing for the physical-card command (CreateExpensifyCard). Virtual cards go through CreateAdminIssuedVirtualCard, which does not yet carry feedCountry end to end (App type omits it, Web-Expensify does not forward it, and the Auth command auto-detects the program) - that routing is a separate backend follow-up. --- .../expensifyCard/issueNew/ConfirmationStep.tsx | 9 +++++++-- .../issueNew/IssueNewCardConfirmMagicCodePage.tsx | 10 +++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx index 88bf2ca14785..1ab5f117f75d 100644 --- a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx @@ -11,6 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import useSelectedExpensifyCardProgram from '@hooks/useSelectedExpensifyCardProgram'; import useThemeStyles from '@hooks/useThemeStyles'; import AccountUtils from '@libs/AccountUtils'; @@ -52,6 +53,7 @@ function ConfirmationStep({policyID, stepNames, startStepIndex}: ConfirmationSte const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const defaultFundID = useDefaultFundID(policyID); + const selectedProgramKey = useSelectedExpensifyCardProgram(policyID, defaultFundID); const {isBetaEnabled} = usePermissions(); const {cardRules} = useExpensifyCardRules(policyID); @@ -106,12 +108,15 @@ function ConfirmationStep({policyID, stepNames, startStepIndex}: ConfirmationSte if (AccountUtils.hasValidateCodeExtendedAccess(account)) { // Attempt to issue directly without magic code when user has extended access // If this fails, the effect above will redirect to the magic code page - issueExpensifyCard(defaultFundID, policyID, isBetaEnabled(CONST.BETAS.EXPENSIFY_CARD_EU_UK) ? '' : CONST.COUNTRY.US, '', assigneeTimeZone, data); + // A feed can hold both a US and a GB program, so pass the selected program's country to route the card to the + // right one. Without the EU/UK beta only US exists, so keep sending US explicitly. + const feedCountry = isBetaEnabled(CONST.BETAS.EXPENSIFY_CARD_EU_UK) ? selectedProgramKey : CONST.COUNTRY.US; + issueExpensifyCard(defaultFundID, policyID, feedCountry, '', assigneeTimeZone, data); } else { // Navigate to magic code page Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_MAGIC_CODE.path)); } - }, [policyID, data, account, defaultFundID, isBetaEnabled, assigneeTimeZone]); + }, [policyID, data, account, defaultFundID, isBetaEnabled, selectedProgramKey, assigneeTimeZone]); const errorMessage = getLatestErrorMessage(issueNewCard) || (shouldDisableSubmitButton ? translate('workspace.card.issueNewCard.disabledApprovalForSmartLimitError') : ''); diff --git a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmMagicCodePage.tsx b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmMagicCodePage.tsx index ff8652696727..b660e07f7c51 100644 --- a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmMagicCodePage.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardConfirmMagicCodePage.tsx @@ -7,6 +7,7 @@ import useInitial from '@hooks/useInitial'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import useSelectedExpensifyCardProgram from '@hooks/useSelectedExpensifyCardProgram'; import {clearIssueNewCardError, clearIssueNewCardFlow, issueExpensifyCard} from '@libs/actions/Card'; import {requestValidateCodeAction} from '@libs/actions/User'; @@ -38,6 +39,7 @@ function IssueNewCardConfirmMagicCodePage({route}: IssueNewCardConfirmMagicCodeP const data = issueNewCard?.data; const isSuccessful = issueNewCard?.isSuccessful; const defaultFundID = useDefaultFundID(policyID); + const selectedProgramKey = useSelectedExpensifyCardProgram(policyID, defaultFundID); const {isBetaEnabled} = usePermissions(); const firstAssigneeEmail = useInitial(issueNewCard?.data?.assigneeEmail); const shouldUseBackToParam = !firstAssigneeEmail || firstAssigneeEmail === issueNewCard?.data?.assigneeEmail; @@ -62,10 +64,12 @@ function IssueNewCardConfirmMagicCodePage({route}: IssueNewCardConfirmMagicCodeP const handleSubmit = useCallback( (validateCode: string) => { - // NOTE: For Expensify Card UK/EU, the backend will automatically detect the correct feedCountry to use - issueExpensifyCard(defaultFundID, policyID, isBetaEnabled(CONST.BETAS.EXPENSIFY_CARD_EU_UK) ? '' : CONST.COUNTRY.US, validateCode, assigneeTimeZone, data); + // A feed can hold both a US and a GB program, so pass the selected program's country to route the card to the + // right one. Without the EU/UK beta only US exists, so keep sending US explicitly. + const feedCountry = isBetaEnabled(CONST.BETAS.EXPENSIFY_CARD_EU_UK) ? selectedProgramKey : CONST.COUNTRY.US; + issueExpensifyCard(defaultFundID, policyID, feedCountry, validateCode, assigneeTimeZone, data); }, - [isBetaEnabled, data, defaultFundID, policyID, assigneeTimeZone], + [isBetaEnabled, selectedProgramKey, data, defaultFundID, policyID, assigneeTimeZone], ); const handleClose = useCallback(() => { From c62114f9d41e0bab3c909dd1565f6c4735e034ae Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Tue, 14 Jul 2026 10:30:43 -0300 Subject: [PATCH 10/19] Issue virtual cards into the selected program CREATE_ADMIN_ISSUED_VIRTUAL_CARD omitted feedCountry, so on a domain with more than one program (e.g. both US and GB) an admin-issued virtual card selected for the GB feed was created on the default US program. Allow feedCountry on the command params and pass the selected program in the virtual-card branch of issueExpensifyCard, matching the physical-card branch. The backend uses it to route the card to the correct program (companion Web-Expensify and Auth PRs). --- src/libs/API/types.ts | 2 +- src/libs/actions/Card.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 7f9799218775..9790bbed2312 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1223,7 +1223,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CONFIGURE_EXPENSIFY_CARDS_FOR_POLICY]: Parameters.ConfigureExpensifyCardsForPolicyParams; [WRITE_COMMANDS.SET_EXPENSIFY_CARD_RULE]: Parameters.SetExpensifyCardRuleParams; [WRITE_COMMANDS.CREATE_EXPENSIFY_CARD]: Omit; - [WRITE_COMMANDS.CREATE_ADMIN_ISSUED_VIRTUAL_CARD]: Omit; + [WRITE_COMMANDS.CREATE_ADMIN_ISSUED_VIRTUAL_CARD]: Parameters.CreateExpensifyCardParams; [WRITE_COMMANDS.QUEUE_EXPENSIFY_CARD_FOR_BILLING]: Parameters.QueueExpensifyCardForBillingParams; [WRITE_COMMANDS.ADD_DELEGATE]: Parameters.AddDelegateParams; [WRITE_COMMANDS.UPDATE_DELEGATE_ROLE]: Parameters.UpdateDelegateRoleParams; diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 729cd517af38..212bfcfe8d99 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -1543,6 +1543,7 @@ function issueExpensifyCard( WRITE_COMMANDS.CREATE_ADMIN_ISSUED_VIRTUAL_CARD, { ...parameters, + feedCountry, policyID, validFrom: validFrom ? DateUtils.normalizeDateToStartOfDay(validFrom, timeZone) : undefined, validThru: validThru ? DateUtils.normalizeDateToEndOfDay(validThru, timeZone) : undefined, From e722f319cdf32d784386d8304feb3a004a8c9857 Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Tue, 14 Jul 2026 10:30:55 -0300 Subject: [PATCH 11/19] Pass the selected program when linking an Expensify Card feed to a policy Linking an Expensify Card feed to a policy always sent feedCountry=US (linkCardFeedToPolicy's default), so linking the GB feed of a multi-program domain failed with '404 Feed not found'. Pass the selected program from the feed selector, and thread it through the work-email/verify detour via a new optional feedCountry route param so the program survives email validation before linking. --- src/ROUTES.ts | 6 ++++-- src/libs/Navigation/types.ts | 2 ++ .../WorkspaceExpensifyCardAddWorkEmailPage.tsx | 13 +++++++------ .../WorkspaceExpensifyCardFeedSelectorPage.tsx | 6 +++--- .../WorkspaceExpensifyCardVerifyWorkAccountPage.tsx | 9 +++++---- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f7b9df0bae9d..75354f25f6ed 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2943,11 +2943,13 @@ const ROUTES = { }, WORKSPACE_EXPENSIFY_CARD_ADD_WORK_EMAIL: { route: 'workspaces/:policyID/expensify-card/:fundID/work-email', - getRoute: (policyID: string, fundID: number) => `workspaces/${policyID}/expensify-card/${encodeURIComponent(fundID)}/work-email` as const, + getRoute: (policyID: string, fundID: number, feedCountry?: string) => + `workspaces/${policyID}/expensify-card/${encodeURIComponent(fundID)}/work-email${feedCountry ? `?feedCountry=${feedCountry}` : ''}` as const, }, WORKSPACE_EXPENSIFY_CARD_VERIFY_WORK_EMAIL: { route: 'workspaces/:policyID/expensify-card/:fundID/verify-work-email', - getRoute: (policyID: string, fundID: number) => `workspaces/${policyID}/expensify-card/${encodeURIComponent(fundID)}/verify-work-email` as const, + getRoute: (policyID: string, fundID: number, feedCountry?: string) => + `workspaces/${policyID}/expensify-card/${encodeURIComponent(fundID)}/verify-work-email${feedCountry ? `?feedCountry=${feedCountry}` : ''}` as const, }, WORKSPACE_EXPENSIFY_CARD_SETTINGS_FREQUENCY: { route: 'workspaces/:policyID/expensify-card/settings/frequency', diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 6303a525a220..992433c4e55e 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1348,6 +1348,7 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.EXPENSIFY_CARD_ADD_WORK_EMAIL]: { policyID: string; fundID: number; + feedCountry?: string; }; [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_SPEND_RULE_SELECTION]: { policyID: string; @@ -1371,6 +1372,7 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.EXPENSIFY_CARD_VERIFY_WORK_EMAIL]: { policyID: string; fundID: number; + feedCountry?: string; }; [SCREENS.WORKSPACE.TRAVEL_SETTINGS_ACCOUNT]: { policyID: string; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardAddWorkEmailPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardAddWorkEmailPage.tsx index 914a3518c63c..67a51fd48ca3 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardAddWorkEmailPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardAddWorkEmailPage.tsx @@ -16,6 +16,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateSelectedExpensifyCardFeed} from '@libs/actions/Card'; import {setContactMethodAsDefault} from '@libs/actions/User'; +import {getSelectableCardProgramKey} from '@libs/CardUtils'; import {addErrorMessage, getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils'; import Log from '@libs/Log'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -45,7 +46,7 @@ import React, {useEffect, useState} from 'react'; type WorkspaceExpensifyCardAddWorkEmailPageProps = PlatformStackScreenProps; function WorkspaceExpensifyCardAddWorkEmailPage({route}: WorkspaceExpensifyCardAddWorkEmailPageProps) { - const {policyID, fundID} = route.params; + const {policyID, fundID, feedCountry} = route.params; const primaryContactMethod = usePrimaryContactMethod(); const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -68,14 +69,14 @@ function WorkspaceExpensifyCardAddWorkEmailPage({route}: WorkspaceExpensifyCardA if (existingLoginKey) { if (!isExistingLoginValidated) { setEmail(submittedEmail); - Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_VERIFY_WORK_EMAIL.getRoute(policyID, fundID)); + Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_VERIFY_WORK_EMAIL.getRoute(policyID, fundID, feedCountry)); return; } setContactMethodAsDefault(currentUserPersonalDetails, existingLoginKey, formatPhoneNumber, undefined, true, ''); setLoading(true); - linkCardFeedToPolicy(Number(fundID), policyID, CONST.COMPANY_CARD.LINK_FEED_TYPE.EXPENSIFY_CARD) + linkCardFeedToPolicy(Number(fundID), policyID, CONST.COMPANY_CARD.LINK_FEED_TYPE.EXPENSIFY_CARD, feedCountry) .then(() => { - updateSelectedExpensifyCardFeed(Number(fundID), policyID); + updateSelectedExpensifyCardFeed(Number(fundID), policyID, getSelectableCardProgramKey(feedCountry)); Navigation.closeRHPFlow(); }) .catch((error: TranslationPaths) => { @@ -96,8 +97,8 @@ function WorkspaceExpensifyCardAddWorkEmailPage({route}: WorkspaceExpensifyCardA if (!email || !primaryContactMethod || primaryContactMethod.toLowerCase() !== email.toLowerCase() || isWorkEmailValidated) { return; } - Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_VERIFY_WORK_EMAIL.getRoute(policyID, fundID)); - }, [primaryContactMethod, email, policyID, fundID, isWorkEmailValidated]); + Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_VERIFY_WORK_EMAIL.getRoute(policyID, fundID, feedCountry)); + }, [primaryContactMethod, email, policyID, fundID, feedCountry, isWorkEmailValidated]); const validate = (values: FormOnyxValues): Errors => { const errors = {}; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx index 1dac322e653c..f43c6cc589a2 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx @@ -181,18 +181,18 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF resetCardFlowState(); const isUserFromPublicDomain = isEmailPublicDomain(primaryContactMethod); if (!isUserValidated || isUserFromPublicDomain) { - Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_ADD_WORK_EMAIL.getRoute(policyID, feed.value)); + Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_ADD_WORK_EMAIL.getRoute(policyID, feed.value, feed.programKey)); return; } const primaryLoginKey = primaryContactMethod ? Object.keys(loginList ?? {}).find((login) => login.toLowerCase() === primaryContactMethod.toLowerCase()) : undefined; const isPrimaryContactValidated = primaryLoginKey ? !!loginList?.[primaryLoginKey]?.validatedDate : !primaryContactMethod; if (!isPrimaryContactValidated) { - Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_VERIFY_WORK_EMAIL.getRoute(policyID, feed.value)); + Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_VERIFY_WORK_EMAIL.getRoute(policyID, feed.value, feed.programKey)); return; } - linkCardFeedToPolicy(feed.value, policyID, CONST.COMPANY_CARD.LINK_FEED_TYPE.EXPENSIFY_CARD) + linkCardFeedToPolicy(feed.value, policyID, CONST.COMPANY_CARD.LINK_FEED_TYPE.EXPENSIFY_CARD, feed.programKey) .then(() => { updateSelectedExpensifyCardFeed(feed.value, policyID, feed.programKey); goBack(); diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardVerifyWorkAccountPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardVerifyWorkAccountPage.tsx index 65ab379f3be4..bf1e67f12551 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardVerifyWorkAccountPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardVerifyWorkAccountPage.tsx @@ -5,6 +5,7 @@ import useOnyx from '@hooks/useOnyx'; import usePrimaryContactMethod from '@hooks/usePrimaryContactMethod'; import {updateSelectedExpensifyCardFeed} from '@libs/actions/Card'; +import {getSelectableCardProgramKey} from '@libs/CardUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {expensifyLoginsSelector} from '@libs/UserUtils'; @@ -31,7 +32,7 @@ type WorkspaceExpensifyCardVerifyWorkAccountPageProps = PlatformStackScreenProps type WorkspaceExpensifyCardVerifyWorkAccountPageContentProps = Pick; function WorkspaceExpensifyCardVerifyWorkAccountPageContent({route}: WorkspaceExpensifyCardVerifyWorkAccountPageContentProps) { - const {policyID, fundID} = route.params; + const {policyID, fundID, feedCountry} = route.params; const {translate} = useLocalize(); const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); const workEmail = usePrimaryContactMethod(); @@ -59,9 +60,9 @@ function WorkspaceExpensifyCardVerifyWorkAccountPageContent({route}: WorkspaceEx if (!isWorkEmailValidated) { return; } - linkCardFeedToPolicy(Number(fundID), policyID, CONST.COMPANY_CARD.LINK_FEED_TYPE.EXPENSIFY_CARD) + linkCardFeedToPolicy(Number(fundID), policyID, CONST.COMPANY_CARD.LINK_FEED_TYPE.EXPENSIFY_CARD, feedCountry) .then(() => { - updateSelectedExpensifyCardFeed(Number(fundID), policyID); + updateSelectedExpensifyCardFeed(Number(fundID), policyID, getSelectableCardProgramKey(feedCountry)); Navigation.closeRHPFlow(); }) .catch((error: TranslationPaths) => { @@ -69,7 +70,7 @@ function WorkspaceExpensifyCardVerifyWorkAccountPageContent({route}: WorkspaceEx error: getMicroSecondOnyxErrorWithTranslationKey(error), }); }); - }, [fundID, isWorkEmailValidated, policyID]); + }, [feedCountry, fundID, isWorkEmailValidated, policyID]); return ( Date: Tue, 14 Jul 2026 11:30:40 -0300 Subject: [PATCH 12/19] Add lastSelectedExpensifyCardProgram to Onyx export safe keys ExportOnyxStateTest requires every ONYXKEYS collection key to be classified into an export bucket. The new LAST_SELECTED_EXPENSIFY_CARD_PROGRAM key holds a non-sensitive selected-program value, so list it under safeOnyxKeys next to LAST_SELECTED_EXPENSIFY_CARD_FEED. --- src/libs/ExportOnyxState/common.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index ace445635726..84ac09ef0612 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -144,6 +144,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.COLLECTION.EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION, ONYXKEYS.COLLECTION.EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION_PENDING_ACTION, ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED, + ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_PROGRAM, ONYXKEYS.COLLECTION.LAST_SELECTED_FEED, ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME, ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED, From 7bbc69ea19e36a17bd5a793093d23a205c06f2c9 Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Tue, 14 Jul 2026 11:32:50 -0300 Subject: [PATCH 13/19] Adjust comments --- src/libs/CardUtils.ts | 2 +- .../workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 9c09d51325f8..7374523f1e3b 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1458,7 +1458,7 @@ function filterCardsListByProgram(cardsList: WorkspaceCardsList | undefined, pro /** * Country suffix appended to a feed's label when a domain has more than one program, so the rows are distinguishable - * (e.g. `deptagency.com` for US and `deptagency.com GB` for GB). US programs stay unsuffixed so single-feed domains look unchanged. + * (e.g. `expensify.com` for US and `expensify.com GB` for GB). US programs stay unsuffixed so single-feed domains look unchanged. */ function getExpensifyCardProgramCountrySuffix(programKey: CardProgramKey): string { return programKey === CONST.COUNTRY.GB ? CONST.COUNTRY.GB : ''; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index b34d1761103e..2fcd416bd67b 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -90,7 +90,6 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID, programKey}: const settings = getCardSettingsForSelectedProgram(cardSettings, programKey); const feedDescription = getExpensifyCardFeedDescription(cardSettings, allPolicies, domains, fundID, cardList); const feedCountrySuffix = getExpensifyCardProgramCountrySuffix(programKey); - // Distinguish the two rows of a multi-program feed in the header, matching the selector labels (e.g. `deptagency.com GB`). const feedSupportingText = feedCountrySuffix ? `${feedDescription} ${feedCountrySuffix}` : feedDescription; const {allFeeds: allAdminExpensifyCardFeeds} = useExpensifyCardFeedsForFeedSelector(policyID); const shouldShowSelector = allAdminExpensifyCardFeeds.length >= 1; From e62f03e056c498a3232714a7c9c129f11585a22d Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Tue, 14 Jul 2026 11:42:10 -0300 Subject: [PATCH 14/19] Avoid new unsafe type assertions in multi-program CardUtils tests The multi-program helper tests added several 'as unknown as' casts, pushing CardUtilsTest over the eslint-seatbelt baseline for no-unsafe-type-assertion. Build the Card and ExpensifyCardSettings mocks with createMock() and set the WorkspaceCardsList cardList meta entry after construction, so the tests add no net unsafe assertions. --- src/libs/CardUtils.ts | 2 +- tests/unit/CardUtilsTest.ts | 26 ++++++++++++-------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 7374523f1e3b..d2acf619acfa 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1458,7 +1458,7 @@ function filterCardsListByProgram(cardsList: WorkspaceCardsList | undefined, pro /** * Country suffix appended to a feed's label when a domain has more than one program, so the rows are distinguishable - * (e.g. `expensify.com` for US and `expensify.com GB` for GB). US programs stay unsuffixed so single-feed domains look unchanged. + * (e.g. `expensify.com` for US and `expensify.com GB` for GB). US programs stay without a suffix so single-feed domains look unchanged. */ function getExpensifyCardProgramCountrySuffix(programKey: CardProgramKey): string { return programKey === CONST.COUNTRY.GB ? CONST.COUNTRY.GB : ''; diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 18201ed8d21e..3986b2a60bdc 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -4621,9 +4621,9 @@ describe('getCompanyCardCustomName', () => { }); describe('multi-program Expensify Card helpers', () => { - const usCard = {cardID: 1, bank: CONST.EXPENSIFY_CARD.BANK, nameValuePairs: {feedCountry: CONST.COUNTRY.US}} as unknown as Card; - const gbCard = {cardID: 2, bank: CONST.EXPENSIFY_CARD.BANK, nameValuePairs: {feedCountry: CONST.COUNTRY.GB}} as unknown as Card; - const legacyCard = {cardID: 3, bank: CONST.EXPENSIFY_CARD.BANK, nameValuePairs: {}} as unknown as Card; + const usCard = createMock({cardID: 1, bank: CONST.EXPENSIFY_CARD.BANK, nameValuePairs: {feedCountry: CONST.COUNTRY.US}}); + const gbCard = createMock({cardID: 2, bank: CONST.EXPENSIFY_CARD.BANK, nameValuePairs: {feedCountry: CONST.COUNTRY.GB}}); + const legacyCard = createMock({cardID: 3, bank: CONST.EXPENSIFY_CARD.BANK, nameValuePairs: {}}); describe('getSelectableCardProgramKey', () => { it('returns GB when GB is stored', () => { @@ -4658,34 +4658,32 @@ describe('multi-program Expensify Card helpers', () => { describe('getConfiguredExpensifyCardProgramKeys', () => { it('returns both programs (US before GB) when both have a settlement bank account', () => { - const settings = { + const settings = createMock({ [CONST.COUNTRY.US]: {paymentBankAccountID: 1}, [CONST.COUNTRY.GB]: {paymentBankAccountID: 2}, - } as unknown as ExpensifyCardSettings; + }); expect(getConfiguredExpensifyCardProgramKeys(settings)).toEqual([CONST.COUNTRY.US, CONST.COUNTRY.GB]); }); it('ignores program blocks without a settlement bank account', () => { - const settings = { + const settings = createMock({ [CONST.COUNTRY.US]: {paymentBankAccountID: 1}, [CONST.COUNTRY.GB]: {isEnabled: true}, - } as unknown as ExpensifyCardSettings; + }); expect(getConfiguredExpensifyCardProgramKeys(settings)).toEqual([CONST.COUNTRY.US]); }); it('returns an empty list for flat/legacy settings with no nested program', () => { - const settings = {domainName: 'legacy.com', isEnabled: true} as unknown as ExpensifyCardSettings; + const settings = createMock({domainName: 'legacy.com', isEnabled: true}); expect(getConfiguredExpensifyCardProgramKeys(settings)).toEqual([]); }); }); describe('filterCardsListByProgram', () => { - const cardsList = { - '1': usCard, - '2': gbCard, - '3': legacyCard, - cardList: {someUnassigned: 'XXXX1234'}, - } as unknown as WorkspaceCardsList; + const cardsList: WorkspaceCardsList = {'1': usCard, '2': gbCard, '3': legacyCard}; + // Set the `cardList` meta entry separately: WorkspaceCardsList's string index signature is `Card`, so including it in + // the object literal would fail typechecking even though the runtime shape is correct. + cardsList.cardList = {someUnassigned: 'XXXX1234'}; it('keeps only the selected program cards and preserves the cardList meta entry', () => { const gbOnly = filterCardsListByProgram(cardsList, CONST.COUNTRY.GB); From a551f890ec731829038c37f71250c1d395a11075 Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Tue, 14 Jul 2026 17:33:12 -0300 Subject: [PATCH 15/19] Show currency/country qualifier on multi-program feed rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a domain is provisioned with more than one Expensify Card program (e.g. both US and GB), each program's selector row now shows a parenthetical qualifier so the rows are distinguishable: - US shows (USD) — country omitted since USD is eligible in one country - GB shows (COUNTRY - CURRENCY), e.g. (GB - GBP) A single-program domain shows just the domain, and the workspace card list header shows just the domain. Currency is derived per program via the new getExpensifyCardProgramCurrency helper (extracted from getCardOrFeedCurrency), so US rows show (USD) even when the settings NVP carries no explicit currency. --- src/libs/CardUtils.ts | 69 +++++++++++-------- ...WorkspaceExpensifyCardFeedSelectorPage.tsx | 7 +- .../WorkspaceExpensifyCardListPage.tsx | 6 +- tests/unit/CardUtilsTest.ts | 39 +++++++++++ 4 files changed, 85 insertions(+), 36 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index d2acf619acfa..43bcff92cd98 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1457,11 +1457,45 @@ function filterCardsListByProgram(cardsList: WorkspaceCardsList | undefined, pro } /** - * Country suffix appended to a feed's label when a domain has more than one program, so the rows are distinguishable - * (e.g. `expensify.com` for US and `expensify.com GB` for GB). US programs stay without a suffix so single-feed domains look unchanged. + * Resolves the settlement currency for an Expensify Card program. Prefers an explicit `currency` (from the card or the + * program's settings NVP), then falls back to the program itself: the US (and deprecated CURRENT) program is always USD, + * the GB program is GBP for the UK/Gibraltar/unset country and EUR elsewhere. Defaults to USD when the program is unknown. */ -function getExpensifyCardProgramCountrySuffix(programKey: CardProgramKey): string { - return programKey === CONST.COUNTRY.GB ? CONST.COUNTRY.GB : ''; +function getExpensifyCardProgramCurrency(programKey: CardProgramKey | undefined, country: string | undefined, explicitCurrency: string | undefined): string { + if (explicitCurrency) { + return explicitCurrency; + } + + if (programKey === CONST.COUNTRY.US || programKey === CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT) { + return CONST.CURRENCY.USD; + } + + if (programKey === CONST.COUNTRY.GB) { + // Only Gibraltar and UK use GBP. If country is not set at all, also assume GBP. + if (!country || country === CONST.COUNTRY.GB || country === CONST.COUNTRY.GI) { + return CONST.CURRENCY.GBP; + } + + // All other countries on this program use EUR + return CONST.CURRENCY.EUR; + } + + return CONST.CURRENCY.USD; +} + +/** + * Parenthetical qualifier appended to a feed's label so that, when a domain is provisioned with more than one program + * (e.g. both US and GB), each program's row is distinguishable by its settlement currency (and country). + */ +function getExpensifyCardProgramLabelSuffix(cardSettings: OnyxEntry, programKey: CardProgramKey): string { + const programSettings = getCardSettings(cardSettings, programKey); + const country = programSettings?.country; + const currency = getExpensifyCardProgramCurrency(programKey, country, programSettings?.currency); + if (currency === CONST.CURRENCY.USD) { + return `(${currency})`; + } + + return country ? `(${country} - ${currency})` : `(${currency})`; } /** @@ -1976,30 +2010,7 @@ function getCardOrFeedCurrency(card?: OnyxEntry, cardSettings?: OnyxEntry< // If not, attempt to get currency from the card settings. const programKey = card?.nameValuePairs?.feedCountry as CardProgramKey | undefined; const settings = getCardSettings(cardSettings, programKey); - if (settings?.currency) { - return settings.currency; - } - - // Fall back to the program and country to try to determine the correct currency. - // US programs are always USD - if (programKey === CONST.COUNTRY.US || programKey === CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT) { - return CONST.CURRENCY.USD; - } - - // For UK/EU cards, determine currency by country - const country = card?.nameValuePairs?.country; - if (programKey === CONST.COUNTRY.GB) { - // Only Gibraltar and UK use GBP. If country is not set at all, also assume GBP. - if (!country || country === CONST.COUNTRY.GB || country === CONST.COUNTRY.GI) { - return CONST.CURRENCY.GBP; - } - - // All other countries on this program use EUR - return CONST.CURRENCY.EUR; - } - - // Finally if all else fails, default to USD - return CONST.CURRENCY.USD; + return getExpensifyCardProgramCurrency(programKey, card?.nameValuePairs?.country, settings?.currency); } /** @@ -2149,7 +2160,7 @@ export { getProgramKeyForCard, isCardInProgram, filterCardsListByProgram, - getExpensifyCardProgramCountrySuffix, + getExpensifyCardProgramLabelSuffix, getSelectableCardProgramKey, getLinkedPolicyIDsFromExpensifyCardSettings, getPreferredPolicyFromExpensifyCardSettings, diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx index f43c6cc589a2..83eb7288c25c 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx @@ -24,7 +24,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearIssueNewCardFlow, clearIssueNewCardFormData, setIssueNewCardStepAndData, updateSelectedExpensifyCardFeed} from '@libs/actions/Card'; import type {CardProgramKey} from '@libs/CardUtils'; -import {getExpensifyCardProgramCountrySuffix} from '@libs/CardUtils'; +import {getConfiguredExpensifyCardProgramKeys, getExpensifyCardProgramLabelSuffix} from '@libs/CardUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import type {ExpensifyCardFeedEntry} from '@libs/ExpensifyCardFeedSelectorUtils'; import {getExpensifyCardFeedDescription} from '@libs/ExpensifyCardFeedSelectorUtils'; @@ -144,12 +144,13 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF const toListItem = (entry: ExpensifyCardFeedEntry, isOtherWorkspaceSection: boolean): ExpensifyFeedListItem => { const isFeedPendingDelete = entry.settings.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const description = getExpensifyCardFeedDescription(entry.settings, policies, domains, entry.fundID, cardList); - const countrySuffix = getExpensifyCardProgramCountrySuffix(entry.programKey); + const hasMultiplePrograms = getConfiguredExpensifyCardProgramKeys(entry.settings).length > 1; + const labelSuffix = hasMultiplePrograms ? getExpensifyCardProgramLabelSuffix(entry.settings, entry.programKey) : ''; const rowKey = getFeedRowKey(entry.fundID, entry.programKey); return { value: entry.fundID, programKey: entry.programKey, - text: countrySuffix ? `${description} ${countrySuffix}` : description, + text: labelSuffix ? `${description} ${labelSuffix}` : description, keyForList: rowKey, isSelected: entry.fundID === lastSelectedExpensifyCardFeedID && entry.programKey === selectedProgramKey, isDisabled: isFeedPendingDelete || (isOtherWorkspaceSection && isOffline), diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 2fcd416bd67b..4a715b9f2708 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -32,7 +32,7 @@ import {clearIssueNewCardFormData, exportExpensifyCardListToCSV, setIssueNewCard import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {clearDeletePaymentMethodError} from '@libs/actions/PaymentMethods'; import type {CardProgramKey} from '@libs/CardUtils'; -import {getCardsByCardholderName, getCardSettingsForSelectedProgram, getExpensifyCardProgramCountrySuffix, isCurrencySupportedForECards} from '@libs/CardUtils'; +import {getCardsByCardholderName, getCardSettingsForSelectedProgram, isCurrencySupportedForECards} from '@libs/CardUtils'; import {getExpensifyCardFeedDescription} from '@libs/ExpensifyCardFeedSelectorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -88,9 +88,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID, programKey}: const [domains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const settings = getCardSettingsForSelectedProgram(cardSettings, programKey); - const feedDescription = getExpensifyCardFeedDescription(cardSettings, allPolicies, domains, fundID, cardList); - const feedCountrySuffix = getExpensifyCardProgramCountrySuffix(programKey); - const feedSupportingText = feedCountrySuffix ? `${feedDescription} ${feedCountrySuffix}` : feedDescription; + const feedSupportingText = getExpensifyCardFeedDescription(cardSettings, allPolicies, domains, fundID, cardList); const {allFeeds: allAdminExpensifyCardFeeds} = useExpensifyCardFeedsForFeedSelector(policyID); const shouldShowSelector = allAdminExpensifyCardFeeds.length >= 1; const {isDelegateAccessRestricted} = useDelegateNoAccessState(); diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 3986b2a60bdc..b3660f9b6cbf 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -43,6 +43,7 @@ import { getDisplayableThirdPartyCards, getEligibleBankAccountsForCard, getEligibleBankAccountsForUkEuCard, + getExpensifyCardProgramLabelSuffix, getFeedNameForDisplay, getFeedType, getFilteredCardList, @@ -4698,4 +4699,42 @@ describe('multi-program Expensify Card helpers', () => { expect(filterCardsListByProgram(cardsList, undefined)).toBe(cardsList); }); }); + + describe('getExpensifyCardProgramLabelSuffix', () => { + const usAndGbSettings = createMock({ + [CONST.COUNTRY.US]: {paymentBankAccountID: 1, currency: CONST.CURRENCY.USD, country: CONST.COUNTRY.US}, + [CONST.COUNTRY.GB]: {paymentBankAccountID: 2, currency: CONST.CURRENCY.GBP, country: CONST.COUNTRY.GB}, + }); + + it('shows currency without country for the USD (US) program', () => { + expect(getExpensifyCardProgramLabelSuffix(usAndGbSettings, CONST.COUNTRY.US)).toBe('(USD)'); + }); + + it('shows the program country and currency for non-USD programs', () => { + expect(getExpensifyCardProgramLabelSuffix(usAndGbSettings, CONST.COUNTRY.GB)).toBe('(GB - GBP)'); + }); + + it('falls back to currency only when the program has no country', () => { + const noCountry = createMock({ + [CONST.COUNTRY.GB]: {paymentBankAccountID: 2, currency: CONST.CURRENCY.GBP}, + }); + expect(getExpensifyCardProgramLabelSuffix(noCountry, CONST.COUNTRY.GB)).toBe('(GBP)'); + }); + + it('shows (USD) for the US program even when settings carry no explicit currency', () => { + const noCurrency = createMock({ + [CONST.COUNTRY.US]: {paymentBankAccountID: 1}, + [CONST.COUNTRY.GB]: {paymentBankAccountID: 2}, + }); + expect(getExpensifyCardProgramLabelSuffix(noCurrency, CONST.COUNTRY.US)).toBe('(USD)'); + }); + + it('derives GBP for the GB program (country GB) when settings carry no explicit currency', () => { + const noCurrency = createMock({ + [CONST.COUNTRY.US]: {paymentBankAccountID: 1}, + [CONST.COUNTRY.GB]: {paymentBankAccountID: 2, country: CONST.COUNTRY.GB}, + }); + expect(getExpensifyCardProgramLabelSuffix(noCurrency, CONST.COUNTRY.GB)).toBe('(GB - GBP)'); + }); + }); }); From 7cf9246a27135d74c15166377d2b0b510df78c4e Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Tue, 14 Jul 2026 17:42:05 -0300 Subject: [PATCH 16/19] Partition multi-program feeds by per-program linked workspaces A domain provisioned with both US and GB programs shares one settings NVP, and isFeedPrimaryForPolicy read the merged linkedPolicyIDs across the root and every program block. So linking a workspace to only the US program made both the US and GB rows appear as available (primary) for that workspace. Add getLinkedPolicyIDsForExpensifyCardProgram, which resolves the linked workspaces for a single program (root-level links still apply, for legacy/single-program feeds). isFeedPrimaryForPolicy now checks the entry's own program, so an unlinked program stays under "From other workspaces". --- src/libs/CardUtils.ts | 21 +++++++++ src/libs/ExpensifyCardFeedSelectorUtils.ts | 9 +++- .../ExpensifyCardFeedSelectorUtilsTest.ts | 43 ++++++++++++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 43bcff92cd98..d38fa186aa62 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1596,6 +1596,26 @@ function getLinkedPolicyIDsFromExpensifyCardSettings(settings: ExpensifyCardSett return dedupePolicyIDsCaseInsensitive(ids); } +/** + * Linked workspace IDs for a single program on the feed. A domain provisioned with more than one program (e.g. US and GB) + * keeps a separate `linkedPolicyIDs` on each program's nested block, so a workspace linked to only the US program must not + * appear linked to the GB program. Root-level `linkedPolicyIDs` still apply to the program because legacy/single-program + * feeds store the links flat on the root rather than nested. + */ +function getLinkedPolicyIDsForExpensifyCardProgram(settings: ExpensifyCardSettings | OnyxEntry, programKey: CardProgramKey): string[] | undefined { + if (!settings) { + return undefined; + } + const ids: string[] = [ + ...collectLinkedPolicyIDsFromBase(settings as ExpensifyCardSettingsBase), + ...collectLinkedPolicyIDsFromBase(getNestedExpensifyCardProgramSettings(settings, programKey)), + ]; + if (ids.length === 0) { + return undefined; + } + return dedupePolicyIDsCaseInsensitive(ids); +} + /** True if `policyID` is in the linked list (case-insensitive). */ function isPolicyIDInLinkedExpensifyCardPolicyList(linkedPolicyIDs: string[] | undefined, policyID: string): boolean { return !!linkedPolicyIDs?.some((id) => id.toUpperCase() === policyID.toUpperCase()); @@ -2163,6 +2183,7 @@ export { getExpensifyCardProgramLabelSuffix, getSelectableCardProgramKey, getLinkedPolicyIDsFromExpensifyCardSettings, + getLinkedPolicyIDsForExpensifyCardProgram, getPreferredPolicyFromExpensifyCardSettings, getDomainNameFromExpensifyCardSettings, getDomainByFundID, diff --git a/src/libs/ExpensifyCardFeedSelectorUtils.ts b/src/libs/ExpensifyCardFeedSelectorUtils.ts index faad96784ba7..c04bed7fcfc4 100644 --- a/src/libs/ExpensifyCardFeedSelectorUtils.ts +++ b/src/libs/ExpensifyCardFeedSelectorUtils.ts @@ -14,6 +14,7 @@ import { getDomainByFundID, getDomainNameFromExpensifyCardSettings, getFundIdFromSettingsKey, + getLinkedPolicyIDsForExpensifyCardProgram, getLinkedPolicyIDsFromExpensifyCardSettings, getPreferredPolicyFromExpensifyCardSettings, isPolicyIDInLinkedExpensifyCardPolicyList, @@ -85,9 +86,13 @@ function isExpensifyCardFeedVisibleToAdmin( ); } -/** A feed shows as available for a policy when that policy is in the feed's `linkedPolicyIDs`; otherwise it shows under "From other workspaces". */ +/** + * A feed shows as available for a policy when that policy is in the linked list for the entry's own program; otherwise it + * shows under "From other workspaces". A domain with more than one program links each program independently, so linking a + * policy to the US program must not make the GB program show as available for that policy (and vice versa). + */ function isFeedPrimaryForPolicy(entry: ExpensifyCardFeedEntry, policyID: string): boolean { - return isPolicyIDInLinkedExpensifyCardPolicyList(getLinkedPolicyIDsFromExpensifyCardSettings(entry.settings), policyID); + return isPolicyIDInLinkedExpensifyCardPolicyList(getLinkedPolicyIDsForExpensifyCardProgram(entry.settings, entry.programKey), policyID); } function getAdminExpensifyCardFeedEntries( diff --git a/tests/unit/ExpensifyCardFeedSelectorUtilsTest.ts b/tests/unit/ExpensifyCardFeedSelectorUtilsTest.ts index 357f82aa3395..10ed1e191305 100644 --- a/tests/unit/ExpensifyCardFeedSelectorUtilsTest.ts +++ b/tests/unit/ExpensifyCardFeedSelectorUtilsTest.ts @@ -1,4 +1,6 @@ -import {getAdminExpensifyCardFeedEntries, getExpensifyCardFeedDescription} from '@libs/ExpensifyCardFeedSelectorUtils'; +import type {CardProgramKey} from '@libs/CardUtils'; +import {getAdminExpensifyCardFeedEntries, getExpensifyCardFeedDescription, partitionExpensifyCardFeedsForSelector} from '@libs/ExpensifyCardFeedSelectorUtils'; +import type {ExpensifyCardFeedEntry} from '@libs/ExpensifyCardFeedSelectorUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -238,3 +240,42 @@ describe('getAdminExpensifyCardFeedEntries', () => { expect(entries).toHaveLength(2); }); }); + +describe('partitionExpensifyCardFeedsForSelector', () => { + const targetPolicyID = 'WS1'; + + function createEntry(programKey: CardProgramKey, settings: ExpensifyCardSettings): ExpensifyCardFeedEntry { + return {settingsKey: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${fundID}`, fundID, settings, programKey}; + } + + it('links each program of a shared feed independently by the program-nested linkedPolicyIDs', () => { + // A single domain provisions both US and GB; only the US program is linked to WS1. + const settings: ExpensifyCardSettings = { + hasOnceLoaded: true, + [CONST.COUNTRY.US]: {paymentBankAccountID: 23242, linkedPolicyIDs: [targetPolicyID]}, + [CONST.COUNTRY.GB]: {paymentBankAccountID: 55667}, + }; + const usEntry = createEntry(CONST.COUNTRY.US, settings); + const gbEntry = createEntry(CONST.COUNTRY.GB, settings); + + const {primary, other} = partitionExpensifyCardFeedsForSelector([usEntry, gbEntry], targetPolicyID); + + // Only the linked US program is primary; the GB program stays under "From other workspaces". + expect(primary).toEqual([usEntry]); + expect(other).toEqual([gbEntry]); + }); + + it('treats a root-level linkedPolicyIDs as applying to the program (legacy/single-program feeds)', () => { + const settings: ExpensifyCardSettings = { + hasOnceLoaded: true, + linkedPolicyIDs: [targetPolicyID], + [CONST.COUNTRY.US]: {paymentBankAccountID: 23242}, + }; + const usEntry = createEntry(CONST.COUNTRY.US, settings); + + const {primary, other} = partitionExpensifyCardFeedsForSelector([usEntry], targetPolicyID); + + expect(primary).toEqual([usEntry]); + expect(other).toEqual([]); + }); +}); From 7c406c5e4a4df1ffc2f775eda75e084c298bc0dd Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Wed, 15 Jul 2026 00:51:47 -0300 Subject: [PATCH 17/19] Drop country from the multi-program feed label; keep only currency The parenthetical qualifier on multi-program feed rows now shows just the settlement currency (e.g. expensify.com (GBP)), not the country. Country is still used to derive the currency for the GB program (GBP vs EUR) but is no longer part of the label. --- src/libs/CardUtils.ts | 11 +++-------- tests/unit/CardUtilsTest.ts | 23 ++++++++++++----------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index d38fa186aa62..78bfde7e5e77 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1485,17 +1485,12 @@ function getExpensifyCardProgramCurrency(programKey: CardProgramKey | undefined, /** * Parenthetical qualifier appended to a feed's label so that, when a domain is provisioned with more than one program - * (e.g. both US and GB), each program's row is distinguishable by its settlement currency (and country). + * (e.g. both US and GB), each program's row is distinguishable by its settlement currency (e.g. `expensify.com (GBP)`). */ function getExpensifyCardProgramLabelSuffix(cardSettings: OnyxEntry, programKey: CardProgramKey): string { const programSettings = getCardSettings(cardSettings, programKey); - const country = programSettings?.country; - const currency = getExpensifyCardProgramCurrency(programKey, country, programSettings?.currency); - if (currency === CONST.CURRENCY.USD) { - return `(${currency})`; - } - - return country ? `(${country} - ${currency})` : `(${currency})`; + const currency = getExpensifyCardProgramCurrency(programKey, programSettings?.country, programSettings?.currency); + return `(${currency})`; } /** diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index b3660f9b6cbf..996fa383ca2c 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -4706,19 +4706,12 @@ describe('multi-program Expensify Card helpers', () => { [CONST.COUNTRY.GB]: {paymentBankAccountID: 2, currency: CONST.CURRENCY.GBP, country: CONST.COUNTRY.GB}, }); - it('shows currency without country for the USD (US) program', () => { + it('shows the currency for the USD (US) program', () => { expect(getExpensifyCardProgramLabelSuffix(usAndGbSettings, CONST.COUNTRY.US)).toBe('(USD)'); }); - it('shows the program country and currency for non-USD programs', () => { - expect(getExpensifyCardProgramLabelSuffix(usAndGbSettings, CONST.COUNTRY.GB)).toBe('(GB - GBP)'); - }); - - it('falls back to currency only when the program has no country', () => { - const noCountry = createMock({ - [CONST.COUNTRY.GB]: {paymentBankAccountID: 2, currency: CONST.CURRENCY.GBP}, - }); - expect(getExpensifyCardProgramLabelSuffix(noCountry, CONST.COUNTRY.GB)).toBe('(GBP)'); + it('shows the currency for non-USD programs', () => { + expect(getExpensifyCardProgramLabelSuffix(usAndGbSettings, CONST.COUNTRY.GB)).toBe('(GBP)'); }); it('shows (USD) for the US program even when settings carry no explicit currency', () => { @@ -4734,7 +4727,15 @@ describe('multi-program Expensify Card helpers', () => { [CONST.COUNTRY.US]: {paymentBankAccountID: 1}, [CONST.COUNTRY.GB]: {paymentBankAccountID: 2, country: CONST.COUNTRY.GB}, }); - expect(getExpensifyCardProgramLabelSuffix(noCurrency, CONST.COUNTRY.GB)).toBe('(GB - GBP)'); + expect(getExpensifyCardProgramLabelSuffix(noCurrency, CONST.COUNTRY.GB)).toBe('(GBP)'); + }); + + it('derives EUR for the GB program in a non-GBP country when settings carry no explicit currency', () => { + const noCurrency = createMock({ + [CONST.COUNTRY.US]: {paymentBankAccountID: 1}, + [CONST.COUNTRY.GB]: {paymentBankAccountID: 2, country: CONST.COUNTRY.IE}, + }); + expect(getExpensifyCardProgramLabelSuffix(noCurrency, CONST.COUNTRY.GB)).toBe('(EUR)'); }); }); }); From d0265b115f24cf9d07ac69843dd4253ad12cb185 Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Wed, 15 Jul 2026 00:55:34 -0300 Subject: [PATCH 18/19] Adjust comment --- src/libs/CardUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 78bfde7e5e77..02faa7e33567 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1405,7 +1405,7 @@ function getCardProgramKey(cardSettings: OnyxEntry): Card /** * A single settings NVP can hold more than one provisioned Expensify Card program (e.g. a domain with both a US and a GB feed). - * The user-selectable programs are US and GB; CURRENT is deprecated and TRAVEL_US is backend-managed, so neither is offered as its own row. + * The user-selectable programs are US and GB; * Returns the configured program keys in display order (US before GB). */ function getConfiguredExpensifyCardProgramKeys(cardSettings: OnyxEntry): CardProgramKey[] { From 662c42d54e9fc93e174d76b0f48cd94dc6b1dabd Mon Sep 17 00:00:00 2001 From: Jairo Calmon Date: Wed, 15 Jul 2026 01:18:21 -0300 Subject: [PATCH 19/19] Revert country row from the card details page --- .../DynamicWorkspaceExpensifyCardDetailsPage.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx index a2aa6b84ec7e..7a0f985dda7f 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx @@ -128,9 +128,6 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen }); const cardholder = personalDetails?.[card?.accountID ?? CONST.DEFAULT_NUMBER_ID]; const isVirtual = !!card?.nameValuePairs?.isVirtual; - // Surface the card's issuing country so cards on different programs (e.g. US vs GB) of the same feed are distinguishable. - const cardCountryCode = card?.nameValuePairs?.country; - const cardCountryName = cardCountryCode ? (CONST.ALL_COUNTRIES as Record)[cardCountryCode] : undefined; const formattedAvailableSpendAmount = convertToDisplayString(card?.availableSpend, currency); const formattedLimit = convertToDisplayString(card?.nameValuePairs?.unapprovedExpenseLimit, currency); const displayName = temporaryGetDisplayNameOrDefault({ @@ -384,13 +381,6 @@ function DynamicWorkspaceExpensifyCardDetailsPage({route}: DynamicWorkspaceExpen interactive={false} titleStyle={styles.walletCardNumber} /> - {!!cardCountryName && ( - - )} {spendRulesSummary.length > 0 && (