diff --git a/assets/images/product-illustrations/earth-with-coins.svg b/assets/images/product-illustrations/earth-with-coins.svg new file mode 100644 index 000000000000..eb9c97faaf63 --- /dev/null +++ b/assets/images/product-illustrations/earth-with-coins.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 09a15d5f8084..51eba403a336 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2636,6 +2636,10 @@ const ROUTES = { route: 'settings/wallet/:bankAccountID/share-bank-account', getRoute: (bankAccountID: number | undefined) => `settings/wallet/${bankAccountID}/share-bank-account` as const, }, + SETTINGS_WALLET_WISE_GLOBAL_REIMBURSEMENTS_INTRO: { + route: 'settings/wallet/:bankAccountID/global-reimbursements', + getRoute: (bankAccountID: number | undefined) => `settings/wallet/${bankAccountID}/global-reimbursements` as const, + }, SETTINGS_WALLET_EDIT_BANK_ACCOUNT_NICKNAME: { route: 'settings/wallet/:bankAccountID/edit-nickname', getRoute: (bankAccountID: number | undefined) => `settings/wallet/${bankAccountID}/edit-nickname` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1a9e45eccb1e..0f66fb4e8512 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -208,6 +208,7 @@ const SCREENS = { DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: 'Dynamic_Settings_Wallet_Enable_Global_Reimbursements_Sign', SHARE_BANK_ACCOUNT: 'Settings_Wallet_Share_Bank_Account', EDIT_BANK_ACCOUNT_NICKNAME: 'Settings_Wallet_Edit_Bank_Account_Nickname', + WISE_GLOBAL_REIMBURSEMENTS_INTRO: 'Settings_Wallet_Wise_Global_Reimbursements_Intro', TRAVEL_CVV: 'Settings_Wallet_Travel_CVV', TRAVEL_CVV_VERIFY_ACCOUNT: 'Settings_Wallet_Travel_CVV_VerifyAccount', PERSONAL_CARD_DETAILS: 'Settings_Wallet_Personal_Card_Details', diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx index 0d1bdafffcd7..204fbb6f62d6 100644 --- a/src/components/ConfirmationPage.tsx +++ b/src/components/ConfirmationPage.tsx @@ -127,6 +127,7 @@ function ConfirmationPage({ ); @@ -150,11 +151,17 @@ function ConfirmationPage({ ); })()} - {heading} + + {heading} + {!!descriptionComponent && descriptionComponent} {!!description && {description}} {cta ? {cta} : null} diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index 2bafe8d3dac4..8ccb44d73fe1 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -62,6 +62,7 @@ import BigVault from '@assets/images/product-illustrations/big-vault.svg'; import BrokenCompanyCardBankConnection from '@assets/images/product-illustrations/broken-humpty-dumpty.svg'; import BrokenMagnifyingGlass from '@assets/images/product-illustrations/broken-magnifying-glass.svg'; import DjBoothReferenceHands from '@assets/images/product-illustrations/dj-booth-reference-hands.svg'; +import EarthWithCoins from '@assets/images/product-illustrations/earth-with-coins.svg'; import EarthWithControls from '@assets/images/product-illustrations/earth-with-controls.svg'; import EmptyStateDomainExists from '@assets/images/product-illustrations/empty-state-domain-exists.svg'; import EmptyStateExpenses from '@assets/images/product-illustrations/emptystate__expenses.svg'; @@ -300,6 +301,7 @@ const Illustrations = { HoldExpense, ReceiptFairy, EmptyStateTravel, + EarthWithCoins, EarthWithControls, EmptyStateDomainExists, Fireworks, diff --git a/src/languages/de.ts b/src/languages/de.ts index 8fe7783c19d1..95710fccbaf2 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2828,6 +2828,7 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Dieses Konto erfordert Ihre Aufmerksamkeit', unlock: 'Entsperren', }, + wiseGlobalReimbursementsIntro: {title: 'Globale Rückerstattungen einrichten?', subtitle: 'Zahlen Sie Mitglieder weltweit in ihrer lokalen Währung.', getStarted: "Los geht's"}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Karte zu ${walletName} hinzufügen`, diff --git a/src/languages/el.ts b/src/languages/el.ts index aa8734fa1717..322bae595055 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -2884,6 +2884,7 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Αυτός ο λογαριασμός χρειάζεται προσοχή', unlock: 'Ξεκλείδωμα', }, + wiseGlobalReimbursementsIntro: {title: 'Να ρυθμιστούν οι παγκόσμιες αποζημιώσεις;', subtitle: 'Πληρώστε μέλη παγκοσμίως στο τοπικό τους νόμισμα.', getStarted: 'Ξεκινήστε'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Προσθήκη κάρτας στο ${walletName}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 78ad056cea58..e11f453b5db7 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2900,6 +2900,11 @@ const translations = { editNickname: 'Edit nickname', nickname: 'Nickname', editNicknameInstruction: 'Give the bank account a nickname that sets it apart from others.', + wiseGlobalReimbursementsIntro: { + title: 'Set up global reimbursements?', + subtitle: 'Pay members worldwide in their local currency.', + getStarted: 'Get started', + }, deleteCard: 'Delete card', deleteCardConfirmation: 'All unsubmitted card transactions, including those on open reports, will be removed. Are you sure you want to delete this card? You cannot undo this action.', diff --git a/src/languages/es.ts b/src/languages/es.ts index f7643a27f7f3..954331664658 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2738,6 +2738,7 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Esta cuenta requiere atención', unlock: 'Desbloquear', }, + wiseGlobalReimbursementsIntro: {title: '¿Configurar reembolsos globales?', subtitle: 'Paga a miembros de todo el mundo en su moneda local.', getStarted: 'Empieza'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Añadir tarjeta a ${walletName}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 5b11ce2e90ce..a35a8506e5f9 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2833,6 +2833,11 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Ce compte nécessite une attention particulière', unlock: 'Déverrouiller', }, + wiseGlobalReimbursementsIntro: { + title: 'Configurer les remboursements globaux ?', + subtitle: 'Payez des membres partout dans le monde dans leur devise locale.', + getStarted: 'Commencer', + }, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Ajouter la carte à ${walletName}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index b00d76854404..88ce439f43b8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2817,6 +2817,7 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Questo conto richiede attenzione', unlock: 'Sblocca', }, + wiseGlobalReimbursementsIntro: {title: 'Configurare i rimborsi globali?', subtitle: 'Paga i membri in tutto il mondo nella loro valuta locale.', getStarted: 'Inizia'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Aggiungi carta a ${walletName}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index eef159b8d2fe..afe40800db2b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2796,6 +2796,7 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'このアカウントには対応が必要です', unlock: 'ロック解除', }, + wiseGlobalReimbursementsIntro: {title: 'グローバル払い戻しを設定しますか?', subtitle: '世界中のメンバーに現地通貨で支払えます。', getStarted: '使い始める'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `${walletName} にカードを追加`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 34c05ae78383..5cfa1a7d0489 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2821,6 +2821,7 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Deze account vereist aandacht', unlock: 'Ontgrendelen', }, + wiseGlobalReimbursementsIntro: {title: 'Wereldwijde terugbetalingen instellen?', subtitle: 'Betaal leden wereldwijd in hun eigen lokale valuta.', getStarted: 'Aan de slag'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Kaart toevoegen aan ${walletName}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index f128ab320992..791df2822ef4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2855,6 +2855,7 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'To konto wymaga uwagi', unlock: 'Odblokuj', }, + wiseGlobalReimbursementsIntro: {title: 'Skonfigurować globalne zwroty kosztów?', subtitle: 'Płać członkom na całym świecie w ich lokalnej walucie.', getStarted: 'Zacznij'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Dodaj kartę do ${walletName}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c031b98782af..14b8e80954f6 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2812,6 +2812,7 @@ const translations: TranslationDeepObject = { accountRequiresAttention: 'Esta conta precisa de atenção', unlock: 'Desbloquear', }, + wiseGlobalReimbursementsIntro: {title: 'Configurar reembolsos globais?', subtitle: 'Pague membros no mundo todo na moeda local deles.', getStarted: 'Começar'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `Adicionar cartão à ${walletName}`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 074df21d443d..989b0710d02e 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2722,6 +2722,7 @@ const translations: TranslationDeepObject = { accountRequiresAttention: '此账户需要处理', unlock: '解锁', }, + wiseGlobalReimbursementsIntro: {title: '设置全球报销?', subtitle: '按当地货币向全球成员付款。', getStarted: '开始使用'}, }, addCardToDigitalWallet: { title: ({walletName}: {walletName: string}) => `将卡添加到 ${walletName}`, diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index f34ad0e810ce..0e9860c0f036 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -136,6 +136,18 @@ function isFilterableBankAccount(bankAccount: OnyxEntry): return bankAccount?.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && !isBankAccountPartiallySetup(bankAccount?.accountData?.state); } +/** + * A verified (OPEN) USD business bank account that is not already set up for global reimbursements can start the Wise flow. + */ +function isEligibleForWiseGlobalReimbursements(accountData: AccountData | undefined): boolean { + return ( + accountData?.additionalData?.currency === CONST.CURRENCY.USD && + accountData.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && + !accountData.additionalData.verifications?.corpay && + accountData.state === CONST.BANK_ACCOUNT.STATE.OPEN + ); +} + function doesPolicyHavePartiallySetupBankAccount(bankAccountList: OnyxEntry, policyID: string) { if (!bankAccountList) { return false; @@ -300,6 +312,7 @@ function showUnlockAlreadyRequestedModal(showConfirmModal: ReturnType('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage').default, [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/ShareBankAccount/ShareBankAccount').default, [SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME]: () => require('../../../../pages/settings/Wallet/EditBankAccountNicknamePage').default, + [SCREENS.SETTINGS.WALLET.WISE_GLOBAL_REIMBURSEMENTS_INTRO]: () => require('../../../../pages/settings/Wallet/WiseGlobalReimbursementsIntroPage').default, [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: () => require('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVPage').default, [SCREENS.SETTINGS.WALLET.TRAVEL_CVV_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage').default, [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_FIX_CONNECTION]: () => require('@pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index cd258c3841e1..551cf527dfd4 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -52,6 +52,7 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_WALLET_SHARE_BANK_ACCOUNT.route, exact: true, }, + [SCREENS.SETTINGS.WALLET.WISE_GLOBAL_REIMBURSEMENTS_INTRO]: { + path: ROUTES.SETTINGS_WALLET_WISE_GLOBAL_REIMBURSEMENTS_INTRO.route, + exact: true, + }, [SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME]: { path: ROUTES.SETTINGS_WALLET_EDIT_BANK_ACCOUNT_NICKNAME.route, exact: true, diff --git a/src/libs/Navigation/paramLists/AccountSettingsParamList.ts b/src/libs/Navigation/paramLists/AccountSettingsParamList.ts index 8a67ac1c0638..9be30b8905ee 100644 --- a/src/libs/Navigation/paramLists/AccountSettingsParamList.ts +++ b/src/libs/Navigation/paramLists/AccountSettingsParamList.ts @@ -184,6 +184,9 @@ type AccountSettingsParamList = { [SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: { bankAccountID: string; }; + [SCREENS.SETTINGS.WALLET.WISE_GLOBAL_REIMBURSEMENTS_INTRO]: { + bankAccountID: string; + }; [SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME]: { bankAccountID: string; }; diff --git a/src/pages/settings/Wallet/WiseGlobalReimbursementsIntroPage.tsx b/src/pages/settings/Wallet/WiseGlobalReimbursementsIntroPage.tsx new file mode 100644 index 000000000000..a98061f9a6c5 --- /dev/null +++ b/src/pages/settings/Wallet/WiseGlobalReimbursementsIntroPage.tsx @@ -0,0 +1,90 @@ +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import ConfirmationPage from '@components/ConfirmationPage'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import Header from '@components/Header'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {isEligibleForWiseGlobalReimbursements} from '@libs/BankAccountUtils'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +import Navigation from '@navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import React, {useEffect} from 'react'; + +type WiseGlobalReimbursementsIntroPageProps = PlatformStackScreenProps; + +function WiseGlobalReimbursementsIntroPage({route}: WiseGlobalReimbursementsIntroPageProps) { + const bankAccountID = Number(route.params.bankAccountID); + const [bankAccountList, bankAccountListMetadata] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [isLoadingPaymentMethods = true] = useOnyx(ONYXKEYS.IS_LOADING_PAYMENT_METHODS); + const bankAccount = bankAccountList?.[bankAccountID]; + + const {translate} = useLocalize(); + const {isBetaEnabledOrUnknown} = usePermissions(); + const isWiseReimbursementsBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.WISE_REIMBURSEMENTS); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const illustrations = useMemoizedLazyIllustrations(['EarthWithCoins']); + + const goBackToWallet = () => Navigation.goBack(ROUTES.SETTINGS_WALLET); + + // Close the page instead of showing "not found" when the user lacks the beta. Wait for betas to load first + useEffect(() => { + if (isWiseReimbursementsBetaEnabled !== false) { + return; + } + goBackToWallet(); + }, [isWiseReimbursementsBetaEnabled, goBackToWallet]); + + // On a cold direct link the account list can be empty until the payment-method fetch finishes, so wait for it before judging eligibility + const isWaitingForBankAccount = !bankAccount && !!isLoadingPaymentMethods; + + if (isLoadingOnyxValue(bankAccountListMetadata) || isWaitingForBankAccount || !isWiseReimbursementsBetaEnabled) { + return ; + } + + return ( + + +
+ + +
+ {}} + /> +
+
+ ); +} + +export default WiseGlobalReimbursementsIntroPage; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 45764d769358..4c4566d71bd1 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -378,6 +378,7 @@ export default { productMarketingWindowOffset: 20, productMarketingWindowOffsetNarrow: 12, productMarketingWindowHorizontalOffsetNarrow: 16, + wiseGlobalReimbursementsIntroIllustrationSize: 250, holdEducationModalWidth: 400, changePolicyEducationModalWidth: 400, wideConfirmModalWidth: 400, diff --git a/tests/unit/BankAccountUtilsTest.ts b/tests/unit/BankAccountUtilsTest.ts index 23036f167f60..c9e16023cec4 100644 --- a/tests/unit/BankAccountUtilsTest.ts +++ b/tests/unit/BankAccountUtilsTest.ts @@ -9,6 +9,7 @@ import { hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo, isBankAccountPartiallySetup, + isEligibleForWiseGlobalReimbursements, isPersonalBankAccountMissingInfo, isUserAddressVerificationRequired, isUserDOBVerificationRequired, @@ -222,6 +223,66 @@ describe('BankAccountUtils', () => { }); }); + describe('isEligibleForWiseGlobalReimbursements', () => { + const eligibleAccountData: AccountData = { + type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: {currency: CONST.CURRENCY.USD}, + }; + + it('returns true for a verified USD business account', () => { + // Given a verified USD business bank account + // When checking whether it can start the Wise global reimbursements flow + // Then it is eligible, since that is the only kind of account Wise onboarding supports + expect(isEligibleForWiseGlobalReimbursements(eligibleAccountData)).toBe(true); + }); + + it('returns false when the account data is missing', () => { + // Given no account data, e.g. an unknown bankAccountID in the URL + // When checking eligibility + // Then it is not eligible, so the intro page shows not-found instead of crashing + expect(isEligibleForWiseGlobalReimbursements(undefined)).toBe(false); + }); + + it('returns false for a personal account', () => { + // Given a personal account that is otherwise eligible + // When checking eligibility + // Then it is not eligible, because global reimbursements are paid from business accounts only + expect(isEligibleForWiseGlobalReimbursements({...eligibleAccountData, type: CONST.BANK_ACCOUNT.TYPE.PERSONAL})).toBe(false); + }); + + it('returns false for a non-USD account', () => { + // Given a business account in a currency other than USD + // When checking eligibility + // Then it is not eligible, because Release 1 only supports U.S. accounts + expect(isEligibleForWiseGlobalReimbursements({...eligibleAccountData, additionalData: {currency: CONST.CURRENCY.EUR}})).toBe(false); + }); + + it('returns false when the currency is missing', () => { + // Given a business account without a currency + // When checking eligibility + // Then it is not eligible, matching the existing Corpay condition that requires an explicit USD currency + expect(isEligibleForWiseGlobalReimbursements({...eligibleAccountData, additionalData: {}})).toBe(false); + }); + + it('returns false when the account is already set up with Corpay', () => { + // Given a verified USD business account that already has a Corpay verification + // When checking eligibility + // Then it is not eligible, because global reimbursements are already enabled for it, matching the Wallet menu condition + expect(isEligibleForWiseGlobalReimbursements({...eligibleAccountData, additionalData: {currency: CONST.CURRENCY.USD, verifications: {corpay: {}}}})).toBe(false); + }); + + it.each([CONST.BANK_ACCOUNT.STATE.SETUP, CONST.BANK_ACCOUNT.STATE.PENDING, CONST.BANK_ACCOUNT.STATE.VERIFYING, CONST.BANK_ACCOUNT.STATE.LOCKED])( + 'returns false for an account in the %s state', + (state) => { + // Given a USD business account that is not verified yet or is locked + // When checking eligibility + // Then it is not eligible, because only OPEN accounts can send reimbursements + expect(isEligibleForWiseGlobalReimbursements({...eligibleAccountData, state})).toBe(false); + }, + ); + }); + describe('getLastFourDigits', () => { it.each([ {input: '123456789012', expected: '9012', description: 'long account number'},