Skip to content
5 changes: 5 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9603,6 +9603,7 @@ const CONST = {
CHANGE_POLICY_TRAINING_MODAL: 'changePolicyModal',
AGENTS_RULES_BANNER: 'agentsRulesBanner',
WISE_GLOBAL_REIMBURSEMENTS_WALLET_BANNER: 'wiseGlobalReimbursementsWalletBanner',
WISE_GLOBAL_REIMBURSEMENTS_WORKSPACE_BANNER: 'wiseGlobalReimbursementsWorkspaceBanner',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol sob this needs auth change too

SMART_BANNER_HEIGHT: 152,

NAVIGATION_TESTS: {
Expand Down Expand Up @@ -9849,6 +9850,10 @@ const CONST = {
CTA: 'WiseGlobalReimbursementsWalletBanner-CTA',
DISMISS: 'WiseGlobalReimbursementsWalletBanner-Dismiss',
},
WISE_GLOBAL_REIMBURSEMENTS_WORKSPACE_BANNER: {
CTA: 'WiseGlobalReimbursementsWorkspaceBanner-CTA',
DISMISS: 'WiseGlobalReimbursementsWorkspaceBanner-Dismiss',
},
PRODUCT_MARKETING_WINDOW: {
CTA: 'ProductMarketingWindow-CTA',
DISMISS: 'ProductMarketingWindow-Dismiss',
Expand Down
9 changes: 5 additions & 4 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ const DYNAMIC_ROUTES = {
}),
queryParams: ['bankCountry', 'bankCurrency'],
},
WISE_GLOBAL_REIMBURSEMENTS_INTRO: {
path: 'global-reimbursements/:bankAccountID',
entryScreens: [SCREENS.SETTINGS.WALLET.ROOT, SCREENS.WORKSPACE.WORKFLOWS],
getRoute: (bankAccountID: number | undefined) => `global-reimbursements/${bankAccountID}` as const,
},
BANK_ACCOUNT_VERIFY_ACCOUNT: {
path: 'verify-bank-account',
entryScreens: [SCREENS.REIMBURSEMENT_ACCOUNT],
Expand Down Expand Up @@ -2644,10 +2649,6 @@ 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,
Expand Down
2 changes: 1 addition & 1 deletion src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +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',
DYNAMIC_WISE_GLOBAL_REIMBURSEMENTS_INTRO: 'Dynamic_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',
Expand Down
63 changes: 63 additions & 0 deletions src/components/WiseGlobalReimbursementsBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';

import BillingBanner from '@pages/settings/Subscription/CardSection/BillingBanner/BillingBanner';

import CONST from '@src/CONST';

import React from 'react';
import {View} from 'react-native';

import Button from './Button';

type WiseGlobalReimbursementsBannerProps = {
/** Opens the global reimbursements intro page */
onGetStartedPress: () => void;

/** Hides the banner for good */
onDismissPress: () => void;

/** Sentry labels of the "Get started" and close buttons */
sentryLabels: {CTA: string; DISMISS: string};
};

/** The "Pay people worldwide" banner promoting Wise global reimbursements. Callers decide when it shows and what its buttons do */
function WiseGlobalReimbursementsBanner({onGetStartedPress, onDismissPress, sentryLabels}: WiseGlobalReimbursementsBannerProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout();
const illustrations = useMemoizedLazyIllustrations(['EarthWithCoins']);
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']);

const shouldDisplayButtonInSeparateLine = shouldUseNarrowLayout && !isInLandscapeMode;

return (
<BillingBanner
icon={illustrations.EarthWithCoins}
title={translate('walletPage.wiseGlobalReimbursementsBanner.title')}
subtitle={translate('walletPage.wiseGlobalReimbursementsBanner.subtitle')}
subtitleStyle={[styles.mt1, styles.textLabel]}
style={styles.borderRadiusComponentLarge}
rightIcon={expensifyIcons.Close}
onRightIconPress={onDismissPress}
rightIconAccessibilityLabel={translate('common.dismiss')}
rightIconTooltipText={translate('common.close')}
rightIconSentryLabel={sentryLabels.DISMISS}
rightComponent={
<View style={shouldDisplayButtonInSeparateLine ? [styles.flex0, styles.flexBasis100, styles.maxWidth100Percentage, styles.justifyContentCenter] : undefined}>
<Button
variant={CONST.BUTTON_VARIANT.SUCCESS}
onPress={onGetStartedPress}
sentryLabel={sentryLabels.CTA}
>
<Button.Text>{translate('walletPage.wiseGlobalReimbursementsIntro.getStarted')}</Button.Text>
</Button>
</View>
}
/>
);
}

export default WiseGlobalReimbursementsBanner;
42 changes: 34 additions & 8 deletions src/libs/BankAccountUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,15 @@ function isFilterableBankAccount(bankAccount: OnyxEntry<OnyxTypes.BankAccount>):

/**
* A verified (OPEN) USD business bank account that is not already set up for global reimbursements can start the Wise flow.
* Pass the record's top-level `bankCurrency` when available, since the nested `additionalData.currency` is optional.
*/
function isBankAccountEligibleForGlobalReimbursements(accountData: AccountData | undefined): boolean {
function isBankAccountEligibleForGlobalReimbursements(accountData: AccountData | undefined, bankCurrency?: string): boolean {
return (
accountData?.additionalData?.currency === CONST.CURRENCY.USD &&
accountData.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS &&
!accountData.additionalData.verifications?.corpay &&
// `||` not `??`: bankCurrency can be an empty string, which should fall through to additionalData.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
(bankCurrency || accountData?.additionalData?.currency) === CONST.CURRENCY.USD &&
accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS &&
!accountData.additionalData?.verifications?.corpay &&
accountData.state === CONST.BANK_ACCOUNT.STATE.OPEN
);
}
Expand All @@ -153,22 +156,21 @@ function isBankAccountEligibleForGlobalReimbursements(accountData: AccountData |
*/
function getWiseGlobalReimbursementsBannerBankAccountID(bankAccountList: OnyxEntry<OnyxTypes.BankAccountList>): number | undefined {
const newest = Object.values(bankAccountList ?? {}).reduce<AccountData | undefined>((latest, bankAccount) => {
// Accounts deleted offline stay in the list until the request syncs, so they should not be offered
// Accounts deleted offline stay in the list until the request syncs
if (bankAccount.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
return latest;
}

const accountData = bankAccount.accountData;
if (!accountData?.bankAccountID || !isBankAccountEligibleForGlobalReimbursements(accountData)) {
if (!accountData?.bankAccountID || !isBankAccountEligibleForGlobalReimbursements(accountData, bankAccount.bankCurrency)) {
return latest;
}

if (!latest) {
return accountData;
}

// `created` is a DB timestamp, so plain string comparison orders it chronologically.
// Accounts created in the same second fall back to the ID, which grows with each new account
// `created` is a DB timestamp, so string comparison orders accounts chronologically. When two accounts share the same second, the higher bank account ID wins.
const created = accountData.created ?? '';
const latestCreated = latest.created ?? '';
const isNewer = created > latestCreated || (created === latestCreated && accountData.bankAccountID > (latest.bankAccountID ?? CONST.DEFAULT_NUMBER_ID));
Expand All @@ -179,6 +181,29 @@ function getWiseGlobalReimbursementsBannerBankAccountID(bankAccountList: OnyxEnt
return newest?.bankAccountID;
}

/**
* Picks the bank account the Workspace Workflows global reimbursements banner points to: the workspace's connected account, when it is eligible.
*/
function getWorkspaceWiseGlobalReimbursementsBannerBankAccountID(policy: OnyxEntry<OnyxTypes.Policy>, bankAccountList: OnyxEntry<OnyxTypes.BankAccountList>): number | undefined {
// Only "Reimburse via Expensify" pays out of the connected account, so the other choices have nothing to enable
if (policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES) {
return undefined;
}

const bankAccountID = policy.achAccount?.bankAccountID;
if (!bankAccountID) {
return undefined;
}

// Admins without a copy of the account can't see its currency, type or verifications, so the banner stays hidden for them
const bankAccount = bankAccountList?.[bankAccountID];
if (bankAccount?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isBankAccountEligibleForGlobalReimbursements(bankAccount?.accountData, bankAccount?.bankCurrency)) {
return undefined;
}

return bankAccountID;
}

function doesPolicyHavePartiallySetupBankAccount(bankAccountList: OnyxEntry<OnyxTypes.BankAccountList>, policyID: string) {
if (!bankAccountList) {
return false;
Expand Down Expand Up @@ -345,6 +370,7 @@ export {
isFilterableBankAccount,
isBankAccountEligibleForGlobalReimbursements,
getWiseGlobalReimbursementsBannerBankAccountID,
getWorkspaceWiseGlobalReimbursementsBannerBankAccountID,
getDefaultCompanyWebsite,
getBankAccountState,
hasBankAccountAllowDebit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP
require<ReactComponentModule>('../../../../pages/settings/Wallet/EnableGlobalReimbursements/EnableGlobalReimbursementsSignPage').default,
[SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: () => require<ReactComponentModule>('../../../../pages/settings/Wallet/ShareBankAccount/ShareBankAccount').default,
[SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME]: () => require<ReactComponentModule>('../../../../pages/settings/Wallet/EditBankAccountNicknamePage').default,
[SCREENS.SETTINGS.WALLET.WISE_GLOBAL_REIMBURSEMENTS_INTRO]: () => require<ReactComponentModule>('../../../../pages/settings/Wallet/WiseGlobalReimbursementsIntroPage').default,
[SCREENS.SETTINGS.WALLET.DYNAMIC_WISE_GLOBAL_REIMBURSEMENTS_INTRO]: () => require<ReactComponentModule>('../../../../pages/settings/Wallet/WiseGlobalReimbursementsIntroPage').default,
[SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: () => require<ReactComponentModule>('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVPage').default,
[SCREENS.SETTINGS.WALLET.TRAVEL_CVV_VERIFY_ACCOUNT]: () => require<ReactComponentModule>('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage').default,
[SCREENS.SETTINGS.WALLET.PERSONAL_CARD_FIX_CONNECTION]: () => require<ReactComponentModule>('@pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage').default,
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/linkingConfig/OldRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ const oldRoutes: Record<string, string> = {
'/workspaces/*/expensify-card/*': '/workspaces/$1/expensify-card/card-details/$2',
'/workspace/confirmation/currency': '/workspaces/workspace/confirmation/currency',
'/workspace/confirmation': '/workspaces/workspace/confirmation',
'/settings/wallet/*/global-reimbursements': '/settings/wallet/global-reimbursements/$1',
'/settings/wallet/enable-payments/plaid*': '/settings/wallet/enable-payments/add-bank-account/plaid$1',
'/settings/wallet/enable-payments/bank-account-confirmation*': '/settings/wallet/enable-payments/add-bank-account/confirmation$1',
'/settings/wallet/enable-payments/legal-name*': '/settings/wallet/enable-payments/personal-info/legal-name$1',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ const SETTINGS_TO_RHP: Partial<Record<keyof SettingsSplitNavigatorParamList, str
SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_SIGN,
SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT,
SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME,
SCREENS.SETTINGS.WALLET.WISE_GLOBAL_REIMBURSEMENTS_INTRO,
SCREENS.SETTINGS.WALLET.CARD_ACTIVATE,
SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD,
SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD_CONFIRM_VALIDATE_CODE,
Expand Down
5 changes: 1 addition & 4 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,7 @@ const config: LinkingOptions<RootNavigatorParamList>['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.DYNAMIC_WISE_GLOBAL_REIMBURSEMENTS_INTRO]: DYNAMIC_ROUTES.WISE_GLOBAL_REIMBURSEMENTS_INTRO.path,
[SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME]: {
path: ROUTES.SETTINGS_WALLET_EDIT_BANK_ACCOUNT_NICKNAME.route,
exact: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ type AccountSettingsParamList = {
[SCREENS.SETTINGS.WALLET.SHARE_BANK_ACCOUNT]: {
bankAccountID: string;
};
[SCREENS.SETTINGS.WALLET.WISE_GLOBAL_REIMBURSEMENTS_INTRO]: {
[SCREENS.SETTINGS.WALLET.DYNAMIC_WISE_GLOBAL_REIMBURSEMENTS_INTRO]: {
bankAccountID: string;
};
[SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME]: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Icon from '@components/Icon';
import {PressableWithoutFeedback} from '@components/Pressable';
import Text from '@components/Text';
import Tooltip from '@components/Tooltip';

import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
Expand Down Expand Up @@ -39,6 +40,9 @@ type BillingBannerProps = {
onRightIconPress?: () => void;
rightIconAccessibilityLabel?: string;

/** Text of the tooltip shown on hover of the right icon button. No tooltip is shown when omitted. */
rightIconTooltipText?: string;

/** Sentry label for the right icon button. Defaults to `CONST.SENTRY_LABEL.BILLING_BANNER.RIGHT_ICON`. */
rightIconSentryLabel?: string;

Expand All @@ -57,6 +61,7 @@ function BillingBanner({
rightIcon,
onRightIconPress,
rightIconAccessibilityLabel,
rightIconTooltipText,
rightIconSentryLabel,
rightComponent,
}: BillingBannerProps) {
Expand All @@ -67,20 +72,25 @@ function BillingBanner({

const rightIconComponent = useMemo(() => {
if (rightIcon) {
return onRightIconPress && rightIconAccessibilityLabel ? (
<PressableWithoutFeedback
onPress={onRightIconPress}
style={[styles.touchableButtonImage, styles.threeDotsMenuIconWidth]}
role={CONST.ROLE.BUTTON}
accessibilityLabel={rightIconAccessibilityLabel}
sentryLabel={rightIconSentryLabel ?? CONST.SENTRY_LABEL.BILLING_BANNER.RIGHT_ICON}
>
<Icon
src={rightIcon}
fill={theme.icon}
/>
</PressableWithoutFeedback>
) : (
if (onRightIconPress && rightIconAccessibilityLabel) {
const rightIconButton = (
<PressableWithoutFeedback
onPress={onRightIconPress}
style={[styles.touchableButtonImage, styles.threeDotsMenuIconWidth]}
role={CONST.ROLE.BUTTON}
accessibilityLabel={rightIconAccessibilityLabel}
sentryLabel={rightIconSentryLabel ?? CONST.SENTRY_LABEL.BILLING_BANNER.RIGHT_ICON}
>
<Icon
src={rightIcon}
fill={theme.icon}
/>
</PressableWithoutFeedback>
);
return rightIconTooltipText ? <Tooltip text={rightIconTooltipText}>{rightIconButton}</Tooltip> : rightIconButton;
}

return (
<Icon
src={rightIcon}
fill={theme.icon}
Expand All @@ -101,6 +111,7 @@ function BillingBanner({
onRightIconPress,
rightIcon,
rightIconAccessibilityLabel,
rightIconTooltipText,
rightIconSentryLabel,
styles.touchableButtonImage,
styles.threeDotsMenuIconWidth,
Expand Down
Loading
Loading