Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down Expand Up @@ -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<string, string[]>;
[ONYXKEYS.CARD_LIST]: OnyxTypes.CardList;
[ONYXKEYS.WALLET_STATEMENT]: OnyxTypes.WalletStatement;
[ONYXKEYS.TRAVEL_INVOICE_STATEMENT]: OnyxTypes.TravelInvoiceStatement;
Expand Down
12 changes: 7 additions & 5 deletions src/components/SubStepForms/CountryFullStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 =
Expand All @@ -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<string>('');
const selectedCountry = shouldAllowChange ? userSelectedCountry || countryDefaultValue : currencyMappedToCountry;
Expand Down
15 changes: 13 additions & 2 deletions src/libs/CardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,17 +533,27 @@ function getConnectionBankAccountsForReconciliation(connections: OnyxEntry<Parti
}
}

function getEligibleBankAccountsForUkEuCard(bankAccountsList: OnyxEntry<BankAccountList>, 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<Record<string, string[]>>, currency?: string): readonly string[] {
const byCurrency: Record<string, readonly string[]> = supportedCountriesByCurrency ?? CONST.EXPENSIFY_CARD_SUPPORTED_COUNTRIES_BY_CURRENCY;
return byCurrency[currency ?? ''] ?? [];
}

function getEligibleBankAccountsForUkEuCard(bankAccountsList: OnyxEntry<BankAccountList>, supportedCountriesByCurrency: OnyxEntry<Record<string, string[]>>, 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),
);
}

Expand Down Expand Up @@ -2100,6 +2110,7 @@ export {
getCardFeedWithDomainID,
splitCardFeedWithDomainID,
getEligibleBankAccountsForUkEuCard,
getSupportedCardCountriesForCurrency,
getConnectionBankAccountsForReconciliation,
isPersonalCard,
COMPANY_CARD_FEED_ICON_NAMES,
Expand Down
1 change: 1 addition & 0 deletions src/libs/ExportOnyxState/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ const safeOnyxKeys = new Set<string>([
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, string[]>>,
currency: string | undefined,
localeCompare: LocaleContextProps['localeCompare'],
): Record<string, string> {
const supportedCountrySet = new Set<string>(getSupportedCardCountriesForCurrency(supportedCountriesByCurrency, currency));
return Object.fromEntries(europeanCountries.filter(([code]) => supportedCountrySet.has(code)).sort(([, nameA], [, nameB]) => localeCompare(nameA, nameB)));
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down
46 changes: 45 additions & 1 deletion tests/unit/CardUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/getAvailableCardCountryOptionsTest.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
Loading