diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8b6c6c0e4b65..06fabc97e7cf 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5707,7 +5707,11 @@ const CONST = { SE: 'Sweden', }, - EXPENSIFY_UK_EU_SUPPORTED_COUNTRIES: ['BE', 'DK', 'ES', 'FI', 'IE', 'LT', 'LU', 'LV', 'NL', 'PL', 'SE', 'GB', 'GI'], + // Hard-coded fallback for the Expensify Card supported countries keyed by settlement currency + EXPENSIFY_CARD_SUPPORTED_COUNTRIES_BY_CURRENCY: { + GBP: ['GB', 'GI'], + EUR: ['BE', 'DK', 'ES', 'FI', 'IE', 'LT', 'LU', 'LV', 'NL', 'PL', 'SE'], + }, EU_REGISTRATION_NUMBER_REGEX: { AT: /^FN\d{6}[a-z]?$/i, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 532d700759fd..f81c53dc6fed 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -388,6 +388,9 @@ const ONYXKEYS = { /** The user's payment and P2P cards */ FUND_LIST: 'fundList', + /** Authoritative country codes where the Expensify Card is supported, keyed by settlement currency, sent by the backend */ + CARD_SUPPORTED_COUNTRIES: 'cardSupportedCountries', + /** The user's cash card and imported cards (including the Expensify Card) */ CARD_LIST: 'cardList', @@ -1580,6 +1583,7 @@ type OnyxValuesMapping = { [ONYXKEYS.WALLET_TERMS]: OnyxTypes.WalletTerms; [ONYXKEYS.BANK_ACCOUNT_LIST]: OnyxTypes.BankAccountList; [ONYXKEYS.FUND_LIST]: OnyxTypes.FundList; + [ONYXKEYS.CARD_SUPPORTED_COUNTRIES]: Record; [ONYXKEYS.CARD_LIST]: OnyxTypes.CardList; [ONYXKEYS.WALLET_STATEMENT]: OnyxTypes.WalletStatement; [ONYXKEYS.TRAVEL_INVOICE_STATEMENT]: OnyxTypes.TravelInvoiceStatement; diff --git a/src/components/SubStepForms/CountryFullStep.tsx b/src/components/SubStepForms/CountryFullStep.tsx index 30388d80f817..3d53fbc51f24 100644 --- a/src/components/SubStepForms/CountryFullStep.tsx +++ b/src/components/SubStepForms/CountryFullStep.tsx @@ -19,7 +19,7 @@ import {getFieldRequiredErrors} from '@libs/ValidationUtils'; import Navigation from '@navigation/Navigation'; -import getAvailableEuCountries from '@pages/ReimbursementAccount/utils/getAvailableEuCountries'; +import getAvailableCardCountryOptions from '@pages/ReimbursementAccount/utils/getAvailableCardCountryOptions'; import {clearErrors, setDraftValues} from '@userActions/FormActions'; import {setIsComingFromGlobalReimbursementsFlow} from '@userActions/Policy/Policy'; @@ -52,13 +52,14 @@ type CountryFullStepProps = { }; function CountryFullStep({onBackButtonPress, stepNames, onSubmit, policyID, isComingFromExpensifyCard}: CountryFullStepProps) { - const {translate} = useLocalize(); + const {translate, localeCompare} = useLocalize(); const styles = useThemeStyles(); const {environmentURL} = useEnvironment(); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [supportedCountriesByCurrency] = useOnyx(ONYXKEYS.CARD_SUPPORTED_COUNTRIES); const [showNoPolicyError, setShowNoPolicyError] = useState(false); const currency = @@ -67,12 +68,13 @@ function CountryFullStep({onBackButtonPress, stepNames, onSubmit, policyID, isCo reimbursementAccount?.achData?.currency ?? CONST.BBA_COUNTRY_CURRENCY_MAP[reimbursementAccount?.achData?.country ?? '']; - const shouldAllowChange = currency === CONST.CURRENCY.EUR && !reimbursementAccount?.achData?.accountNumber; + const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policyID) && isComingFromExpensifyCard; + // GBP maps 1:1 to GB outside the Expensify Card flow, so only unlock the country picker for GBP during card onboarding, where GI is also valid + const shouldAllowChange = (currency === CONST.CURRENCY.EUR || (currency === CONST.CURRENCY.GBP && isUkEuCurrencySupported)) && !reimbursementAccount?.achData?.accountNumber; const defaultCountries = shouldAllowChange ? CONST.ALL_EUROPEAN_UNION_COUNTRIES : CONST.ALL_COUNTRIES; const countryDefaultValue = reimbursementAccountDraft?.[COUNTRY] ?? reimbursementAccount?.achData?.[COUNTRY] ?? ''; const currencyMappedToCountry = mapCurrencyToCountry(currency) || countryDefaultValue; - const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policyID) && isComingFromExpensifyCard; - const countriesSupportedForExpensifyCard = getAvailableEuCountries(); + const countriesSupportedForExpensifyCard = getAvailableCardCountryOptions(supportedCountriesByCurrency, currency, localeCompare); const [userSelectedCountry, setUserSelectedCountry] = useState(''); const selectedCountry = shouldAllowChange ? userSelectedCountry || countryDefaultValue : currencyMappedToCountry; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 4130d4efb9e7..a1cce79deae2 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -533,17 +533,27 @@ function getConnectionBankAccountsForReconciliation(connections: OnyxEntry, outputCurrency?: string) { +/** + * Resolves the Expensify Card supported countries for a settlement currency, falling back to the hard-coded + * list until the backend supplies it via Onyx. + */ +function getSupportedCardCountriesForCurrency(supportedCountriesByCurrency: OnyxEntry>, currency?: string): readonly string[] { + const byCurrency: Record = supportedCountriesByCurrency ?? CONST.EXPENSIFY_CARD_SUPPORTED_COUNTRIES_BY_CURRENCY; + return byCurrency[currency ?? ''] ?? []; +} + +function getEligibleBankAccountsForUkEuCard(bankAccountsList: OnyxEntry, supportedCountriesByCurrency: OnyxEntry>, outputCurrency?: string) { if (!bankAccountsList || isEmptyObject(bankAccountsList)) { return []; } + const supportedCountries = getSupportedCardCountriesForCurrency(supportedCountriesByCurrency, outputCurrency); return Object.values(bankAccountsList).filter( (bankAccount) => bankAccount?.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && bankAccount?.accountData?.allowDebit && !isBankAccountPartiallySetup(bankAccount?.accountData?.state) && bankAccount?.bankCurrency === outputCurrency && - (CONST.EXPENSIFY_UK_EU_SUPPORTED_COUNTRIES as unknown as string).includes(bankAccount?.bankCountry), + supportedCountries.includes(bankAccount?.bankCountry), ); } @@ -2100,6 +2110,7 @@ export { getCardFeedWithDomainID, splitCardFeedWithDomainID, getEligibleBankAccountsForUkEuCard, + getSupportedCardCountriesForCurrency, getConnectionBankAccountsForReconciliation, isPersonalCard, COMPANY_CARD_FEED_ICON_NAMES, diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 5e63da804d0b..be80fb1db935 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -134,6 +134,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.BETAS, ONYXKEYS.BETA_CONFIGURATION, ONYXKEYS.CACHED_PDF_PATHS, + ONYXKEYS.CARD_SUPPORTED_COUNTRIES, ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST, ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS, ONYXKEYS.COLLECTION.DOMAIN_HIGHLIGHT_ITEMS, diff --git a/src/pages/ReimbursementAccount/utils/getAvailableCardCountryOptions.ts b/src/pages/ReimbursementAccount/utils/getAvailableCardCountryOptions.ts new file mode 100644 index 000000000000..c7da5c8bd7b8 --- /dev/null +++ b/src/pages/ReimbursementAccount/utils/getAvailableCardCountryOptions.ts @@ -0,0 +1,18 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import {getSupportedCardCountriesForCurrency} from '@libs/CardUtils'; + +import CONST from '@src/CONST'; + +import type {OnyxEntry} from 'react-native-onyx'; + +const europeanCountries = Object.entries(CONST.EUROPEAN_UNION_COUNTRIES_WITH_GB); + +export default function getAvailableCardCountryOptions( + supportedCountriesByCurrency: OnyxEntry>, + currency: string | undefined, + localeCompare: LocaleContextProps['localeCompare'], +): Record { + const supportedCountrySet = new Set(getSupportedCardCountriesForCurrency(supportedCountriesByCurrency, currency)); + return Object.fromEntries(europeanCountries.filter(([code]) => supportedCountrySet.has(code)).sort(([, nameA], [, nameB]) => localeCompare(nameA, nameB))); +} diff --git a/src/pages/ReimbursementAccount/utils/getAvailableEuCountries.ts b/src/pages/ReimbursementAccount/utils/getAvailableEuCountries.ts deleted file mode 100644 index b59938b49a8a..000000000000 --- a/src/pages/ReimbursementAccount/utils/getAvailableEuCountries.ts +++ /dev/null @@ -1,8 +0,0 @@ -import CONST from '@src/CONST'; - -const ukEuSupportedCountrySet = new Set(CONST.EXPENSIFY_UK_EU_SUPPORTED_COUNTRIES); -const europeanCountries = Object.entries(CONST.EUROPEAN_UNION_COUNTRIES_WITH_GB); - -export default function getAvailableEuCountries(): Record { - return Object.fromEntries(europeanCountries.filter(([code]) => ukEuSupportedCountrySet.has(code))); -} diff --git a/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx b/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx index 8e411f8fe2f5..5bca13f57d82 100644 --- a/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx +++ b/src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx @@ -55,6 +55,7 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [bankAccountsList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [supportedCountriesByCurrency] = useOnyx(ONYXKEYS.CARD_SUPPORTED_COUNTRIES); const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`); const programKey = getCardProgramKey(cardSettings); const settings = getCardSettings(cardSettings, programKey); @@ -71,7 +72,7 @@ function DynamicWorkspaceSettlementAccountPage({route}: WorkspaceSettlementAccou const getEligibleBankAccounts = () => { if (isUkEuCurrencySupported) { - return getEligibleBankAccountsForUkEuCard(bankAccountsList, policy?.outputCurrency); + return getEligibleBankAccountsForUkEuCard(bankAccountsList, supportedCountriesByCurrency, policy?.outputCurrency); } return getEligibleBankAccountsForCard(bankAccountsList); }; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx index 6fff669f76b8..6e0aea653066 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx @@ -45,6 +45,7 @@ function WorkspaceExpensifyCardBankAccounts({route}: WorkspaceExpensifyCardBankA const {translate} = useLocalize(); const styles = useThemeStyles(); const [bankAccountsList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [supportedCountriesByCurrency] = useOnyx(ONYXKEYS.CARD_SUPPORTED_COUNTRIES); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const policyID = route?.params?.policyID; @@ -92,7 +93,7 @@ function WorkspaceExpensifyCardBankAccounts({route}: WorkspaceExpensifyCardBankA } const eligibleBankAccounts = isUkEuCurrencySupported - ? getEligibleBankAccountsForUkEuCard(bankAccountsList, policy?.outputCurrency) + ? getEligibleBankAccountsForUkEuCard(bankAccountsList, supportedCountriesByCurrency, policy?.outputCurrency) : getEligibleBankAccountsForCard(bankAccountsList); return eligibleBankAccounts.map((bankAccount) => { diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx index 34a325046cb8..9580bb5c2385 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx @@ -55,6 +55,7 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif const theme = useTheme(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [supportedCountriesByCurrency] = useOnyx(ONYXKEYS.CARD_SUPPORTED_COUNTRIES); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const {showConfirmModal, closeModal} = useConfirmModal(); const {windowHeight} = useWindowDimensions(); @@ -80,7 +81,9 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif const {allFeeds} = useExpensifyCardFeedsForFeedSelector(policy?.id); const hasAccessibleFeeds = allFeeds.length > 0; - const eligibleBankAccounts = isUkEuCurrencySupported ? getEligibleBankAccountsForUkEuCard(bankAccountList, policy?.outputCurrency) : getEligibleBankAccountsForCard(bankAccountList); + const eligibleBankAccounts = isUkEuCurrencySupported + ? getEligibleBankAccountsForUkEuCard(bankAccountList, supportedCountriesByCurrency, policy?.outputCurrency) + : getEligibleBankAccountsForCard(bankAccountList); const shouldStartBankAccountSetup = !eligibleBankAccounts.length || isSetupUnfinished; const canStartBankAccountSetup = canEditWorkspaceSettings(policy, currentUserLogin); const shouldDisableCTA = !canWriteExpensifyCard || (!hasAccessibleFeeds && shouldStartBankAccountSetup && !canStartBankAccountSetup); diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 99443c289441..3369130a1c2f 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -4416,10 +4416,54 @@ describe('getEligibleBankAccountsForUkEuCard', () => { bankCountry: 'GB', }, }; - const result = getEligibleBankAccountsForUkEuCard(bankAccounts, 'GBP'); + const result = getEligibleBankAccountsForUkEuCard(bankAccounts, {GBP: ['GB', 'GI']}, 'GBP'); expect(result).toHaveLength(1); expect(result.at(0)?.accountData?.state).toBe(CONST.BANK_ACCOUNT.STATE.OPEN); }); + + it('uses only the supported countries for the workspace settlement currency', () => { + const bankAccounts: BankAccountList = { + '1': { + accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, allowDebit: true, state: CONST.BANK_ACCOUNT.STATE.OPEN}, + bankCurrency: 'EUR', + bankCountry: 'BE', + }, + '2': { + accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, allowDebit: true, state: CONST.BANK_ACCOUNT.STATE.OPEN}, + bankCurrency: 'EUR', + bankCountry: 'GB', + }, + }; + const supportedCountriesByCurrency = {GBP: ['GB', 'GI'], EUR: ['BE', 'DK']}; + const result = getEligibleBankAccountsForUkEuCard(bankAccounts, supportedCountriesByCurrency, 'EUR'); + expect(result).toHaveLength(1); + expect(result.at(0)?.bankCountry).toBe('BE'); + }); + + it('returns no accounts when the settlement currency has no supported-country entry', () => { + const bankAccounts: BankAccountList = { + '1': { + accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, allowDebit: true, state: CONST.BANK_ACCOUNT.STATE.OPEN}, + bankCurrency: 'GBP', + bankCountry: 'GB', + }, + }; + const result = getEligibleBankAccountsForUkEuCard(bankAccounts, {EUR: ['BE', 'DK']}, 'GBP'); + expect(result).toHaveLength(0); + }); + + it('falls back to the hard-coded supported countries when the backend list is unavailable', () => { + const bankAccounts: BankAccountList = { + '1': { + accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, allowDebit: true, state: CONST.BANK_ACCOUNT.STATE.OPEN}, + bankCurrency: 'GBP', + bankCountry: 'GB', + }, + }; + const result = getEligibleBankAccountsForUkEuCard(bankAccounts, undefined, 'GBP'); + expect(result).toHaveLength(1); + expect(result.at(0)?.bankCountry).toBe('GB'); + }); }); describe('getConnectionBankAccountsForReconciliation', () => { diff --git a/tests/unit/getAvailableCardCountryOptionsTest.ts b/tests/unit/getAvailableCardCountryOptionsTest.ts new file mode 100644 index 000000000000..207231203e4e --- /dev/null +++ b/tests/unit/getAvailableCardCountryOptionsTest.ts @@ -0,0 +1,20 @@ +import getAvailableCardCountryOptions from '@pages/ReimbursementAccount/utils/getAvailableCardCountryOptions'; + +const localeCompare = (a: string, b: string) => a.localeCompare(b); + +describe('getAvailableEuCountries', () => { + it('sorts supported countries by localized name, not by country code', () => { + // LT/LU/LV are alphabetical by code but Latvia/Lithuania/Luxembourg by name + const result = getAvailableCardCountryOptions({EUR: ['LV', 'LT', 'LU', 'BE']}, 'EUR', localeCompare); + expect(Object.values(result)).toEqual(['Belgium', 'Latvia', 'Lithuania', 'Luxembourg']); + }); + + it('falls back to the hard-coded supported countries (sorted by name) when the backend list is unavailable', () => { + const result = getAvailableCardCountryOptions(undefined, 'EUR', localeCompare); + expect(Object.values(result)).toEqual(['Belgium', 'Denmark', 'Finland', 'Ireland', 'Latvia', 'Lithuania', 'Luxembourg', 'Netherlands', 'Poland', 'Spain', 'Sweden']); + }); + + it('returns no countries for an unsupported settlement currency', () => { + expect(getAvailableCardCountryOptions({EUR: ['BE']}, 'USD', localeCompare)).toEqual({}); + }); +});