diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d673456d2bfc..f86ab0d91d35 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/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/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/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; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index a8844655049c..7d0621708beb 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/CardUtils.ts b/src/libs/CardUtils.ts index 4130d4efb9e7..02faa7e33567 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1403,6 +1403,104 @@ 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; + * 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; +} + +/** + * 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 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 (e.g. `expensify.com (GBP)`). + */ +function getExpensifyCardProgramLabelSuffix(cardSettings: OnyxEntry, programKey: CardProgramKey): string { + const programSettings = getCardSettings(cardSettings, programKey); + const currency = getExpensifyCardProgramCurrency(programKey, programSettings?.country, programSettings?.currency); + return `(${currency})`; +} + +/** + * 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 +1533,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]; @@ -1484,6 +1591,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()); @@ -1898,30 +2025,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); } /** @@ -2065,8 +2169,16 @@ export { hasIssuedExpensifyCard, isExpensifyCardFullySetUp, getCardSettings, + getCardSettingsForSelectedProgram, getCardProgramKey, + getConfiguredExpensifyCardProgramKeys, + getProgramKeyForCard, + isCardInProgram, + filterCardsListByProgram, + getExpensifyCardProgramLabelSuffix, + getSelectableCardProgramKey, getLinkedPolicyIDsFromExpensifyCardSettings, + getLinkedPolicyIDsForExpensifyCardProgram, getPreferredPolicyFromExpensifyCardSettings, getDomainNameFromExpensifyCardSettings, getDomainByFundID, diff --git a/src/libs/ExpensifyCardFeedSelectorUtils.ts b/src/libs/ExpensifyCardFeedSelectorUtils.ts index afd2c617a252..c04bed7fcfc4 100644 --- a/src/libs/ExpensifyCardFeedSelectorUtils.ts +++ b/src/libs/ExpensifyCardFeedSelectorUtils.ts @@ -7,10 +7,14 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {Str} from 'expensify-common'; +import type {CardProgramKey} from './CardUtils'; + import { + getConfiguredExpensifyCardProgramKeys, getDomainByFundID, getDomainNameFromExpensifyCardSettings, getFundIdFromSettingsKey, + getLinkedPolicyIDsForExpensifyCardProgram, getLinkedPolicyIDsFromExpensifyCardSettings, getPreferredPolicyFromExpensifyCardSettings, isPolicyIDInLinkedExpensifyCardPolicyList, @@ -21,6 +25,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. */ @@ -76,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( @@ -95,7 +109,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/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 36dd807d3927..217f80dffc92 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, 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/libs/actions/Card.ts b/src/libs/actions/Card.ts index b6c743c2c5cc..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, @@ -1681,18 +1682,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) { diff --git a/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx b/src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitPage.tsx index cd6c0ed0cd13..f22a4d142068 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/DynamicWorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx index 79cbd3fe8a35..7a0f985dda7f 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,9 +119,12 @@ 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; 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/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 184d969d17e3..83eb7288c25c 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 {getConfiguredExpensifyCardProgramKeys, getExpensifyCardProgramLabelSuffix} 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,22 @@ 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 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, - text: getExpensifyCardFeedDescription(entry.settings, policies, domains, entry.fundID, cardList), - keyForList: entry.fundID.toString(), - isSelected: entry.fundID === lastSelectedExpensifyCardFeedID, + programKey: entry.programKey, + text: labelSuffix ? `${description} ${labelSuffix}` : 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, @@ -168,20 +182,20 @@ 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); + updateSelectedExpensifyCardFeed(feed.value, policyID, feed.programKey); goBack(); }) .catch((error: TranslationPaths) => { @@ -194,7 +208,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF const selectFeed = (feed: ExpensifyFeedListItem) => { resetCardFlowState(); - updateSelectedExpensifyCardFeed(feed.value, policyID); + updateSelectedExpensifyCardFeed(feed.value, policyID, feed.programKey); goBack(); }; @@ -257,7 +271,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF onSelectRow={selectFeed} data={primaryListData} alternateNumberOfSupportedLines={2} - initiallyFocusedItemKey={lastSelectedExpensifyCardFeedID.toString()} + initiallyFocusedItemKey={getFeedRowKey(lastSelectedExpensifyCardFeedID, selectedProgramKey)} addBottomSafeAreaPadding listFooterContent={issueNewCardAndOtherFeedsFooter} onDismissError={onDismissError} diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 1b16917410bd..4a715b9f2708 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, 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,8 @@ 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 feedSupportingText = getExpensifyCardFeedDescription(cardSettings, allPolicies, domains, fundID, cardList); const {allFeeds: allAdminExpensifyCardFeeds} = useExpensifyCardFeedsForFeedSelector(policyID); const shouldShowSelector = allAdminExpensifyCardFeeds.length >= 1; const {isDelegateAccessRestricted} = useDelegateNoAccessState(); @@ -108,7 +113,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 +321,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) { ); diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardVerifyWorkAccountPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardVerifyWorkAccountPage.tsx index b4b19df14016..35a370fe9108 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 ( { 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(() => { diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 99443c289441..996fa383ca2c 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -33,6 +33,7 @@ import { getCompanyCardDescription, getCompanyCardFeed, getCompanyFeeds, + getConfiguredExpensifyCardProgramKeys, getConnectionBankAccountsForReconciliation, getCSVFeedType, getCustomFeedNameFromFeeds, @@ -42,6 +43,7 @@ import { getDisplayableThirdPartyCards, getEligibleBankAccountsForCard, getEligibleBankAccountsForUkEuCard, + getExpensifyCardProgramLabelSuffix, getFeedNameForDisplay, getFeedType, getFilteredCardList, @@ -49,6 +51,8 @@ import { getOriginalCompanyFeeds, getPlaidInstitutionIconUrl, getPlaidInstitutionId, + getProgramKeyForCard, + getSelectableCardProgramKey, getSelectedFeed, getTranslationKeyForCardStatus, getYearFromExpirationDateString, @@ -62,7 +66,9 @@ import { isCSVUploadFeed, isCustomFeed as isCustomFeedCardUtils, isDirectFeed as isDirectFeedCardUtils, + filterCardsListByProgram, isActiveCard, + isCardInProgram, isExpensifyCard, isExpensifyCardFullySetUp, isExpiredCard, @@ -4614,3 +4620,122 @@ describe('getCompanyCardCustomName', () => { expect(getCompanyCardCustomName('9999', sharedCardCustomNames, customCardNames)).toBeUndefined(); }); }); + +describe('multi-program Expensify Card helpers', () => { + 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', () => { + 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 = createMock({ + [CONST.COUNTRY.US]: {paymentBankAccountID: 1}, + [CONST.COUNTRY.GB]: {paymentBankAccountID: 2}, + }); + expect(getConfiguredExpensifyCardProgramKeys(settings)).toEqual([CONST.COUNTRY.US, CONST.COUNTRY.GB]); + }); + + it('ignores program blocks without a settlement bank account', () => { + const settings = createMock({ + [CONST.COUNTRY.US]: {paymentBankAccountID: 1}, + [CONST.COUNTRY.GB]: {isEnabled: true}, + }); + expect(getConfiguredExpensifyCardProgramKeys(settings)).toEqual([CONST.COUNTRY.US]); + }); + + it('returns an empty list for flat/legacy settings with no nested program', () => { + const settings = createMock({domainName: 'legacy.com', isEnabled: true}); + expect(getConfiguredExpensifyCardProgramKeys(settings)).toEqual([]); + }); + }); + + describe('filterCardsListByProgram', () => { + 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); + 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); + }); + }); + + 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 the currency for the USD (US) program', () => { + expect(getExpensifyCardProgramLabelSuffix(usAndGbSettings, CONST.COUNTRY.US)).toBe('(USD)'); + }); + + 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', () => { + 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('(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)'); + }); + }); +}); diff --git a/tests/unit/ExpensifyCardFeedSelectorUtilsTest.ts b/tests/unit/ExpensifyCardFeedSelectorUtilsTest.ts index d3107188fd46..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'; @@ -175,6 +177,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', () => { @@ -213,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([]); + }); +});