Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6c7a985
revert the revert and apply fixes
getusha Jul 21, 2025
281239f
Merge remote-tracking branch 'exfy/main' into feat-standardize-pay-bu…
getusha Jul 21, 2025
3fea2cb
fix: lint
getusha Jul 21, 2025
cffc5e8
fix: more lint
getusha Jul 21, 2025
8fd601d
fix: stale default payment method after workspace change
getusha Jul 21, 2025
8ebd03f
fix: padding and add doc
getusha Jul 24, 2025
6ecce23
fix: address comments and bugs
getusha Jul 24, 2025
cd2a7d5
Merge remote-tracking branch 'exfy/main' into feat-standardize-pay-bu…
getusha Jul 24, 2025
af68fdf
fix: lint
getusha Jul 24, 2025
4e6aa84
fix: lint
getusha Jul 24, 2025
e8f230c
Merge remote-tracking branch 'exfy/main' into feat-standardize-pay-bu…
getusha Jul 25, 2025
f11ff9a
Merge remote-tracking branch 'exfy/main' into feat-standardize-pay-bu…
getusha Jul 28, 2025
17419c9
update type description
getusha Jul 29, 2025
db6eb4b
address comments
getusha Jul 30, 2025
4b03b9f
fix: show amount on pay button and show correct payment options
getusha Jul 31, 2025
b6354e1
prettier
getusha Jul 31, 2025
e119dcb
Merge remote-tracking branch 'exfy/main' into feat-standardize-pay-bu…
getusha Jul 31, 2025
174ad4f
fix: subtle conflicts
getusha Jul 31, 2025
54d6f93
fix: reduce lint warnings
getusha Jul 31, 2025
3465fe2
fix: remove condition to show amount on mark as paid
getusha Jul 31, 2025
bfe7fb1
fix: lint
getusha Jul 31, 2025
992af70
fix: bba option not working correctly
getusha Jul 31, 2025
5389b13
Merge remote-tracking branch 'exfy/main' into feat-standardize-pay-bu…
getusha Jul 31, 2025
474abb4
fix: clear lastUsed and bankAccountID
getusha Aug 1, 2025
773d038
Merge remote-tracking branch 'exfy/main' into feat-standardize-pay-bu…
getusha Aug 1, 2025
3e89c3a
Merge remote-tracking branch 'exfy/main' into feat-standardize-pay-bu…
getusha Aug 1, 2025
0da978e
fix: lint
getusha Aug 1, 2025
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: 3 additions & 3 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6899,9 +6899,9 @@ const CONST = {
},
LAST_PAYMENT_METHOD: {
LAST_USED: 'lastUsed',
IOU: 'Iou',
EXPENSE: 'Expense',
INVOICE: 'Invoice',
IOU: 'iou',
EXPENSE: 'expense',
INVOICE: 'invoice',
},
SKIPPABLE_COLLECTION_MEMBER_IDS: [String(DEFAULT_NUMBER_ID), '-1', 'undefined', 'null', 'NaN'] as string[],
SETUP_SPECIALIST_LOGIN: 'Setup Specialist',
Expand Down
14 changes: 13 additions & 1 deletion src/components/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,19 @@ function Button(
const textComponent = secondLineText ? (
<View style={[styles.alignItemsCenter, styles.flexColumn, styles.flexShrink1]}>
{primaryText}
<Text style={[isLoading && styles.opacity0, styles.pointerEventsNone, styles.fontWeightNormal, styles.textDoubleDecker]}>{secondLineText}</Text>
<Text

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.

We neglected to allow truncation here, so we fixed it in #70837

style={[
isLoading && styles.opacity0,
styles.pointerEventsNone,
styles.fontWeightNormal,
styles.textDoubleDecker,
!!secondLineText && styles.textExtraSmallSupporting,
styles.textWhite,
styles.textBold,
]}
>
{secondLineText}
</Text>
</View>
) : (
primaryText
Expand Down
45 changes: 37 additions & 8 deletions src/components/ButtonWithDropdownMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import mergeRefs from '@libs/mergeRefs';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {AnchorPosition} from '@src/styles';
import type {ButtonWithDropdownMenuProps} from './types';
Expand Down Expand Up @@ -56,7 +57,10 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
testID,
secondLineText = '',
icon,
shouldPopoverUseScrollView = false,
containerStyles,
shouldUseModalPaddingStyle = true,
shouldUseShortForm = false,
shouldUseOptionIcon = false,
} = props;

Expand All @@ -79,9 +83,14 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
const areAllOptionsDisabled = options.every((option) => option.disabled);
const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(buttonSize);
const isButtonSizeLarge = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE;
const isButtonSizeSmall = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL;
const nullCheckRef = (refParam: RefObject<View | null>) => refParam ?? null;
const shouldShowButtonRightIcon = !!options.at(0)?.shouldShowButtonRightIcon;

useEffect(() => {
setSelectedItemIndex(defaultSelectedIndex);
}, [defaultSelectedIndex]);

const {paddingBottom} = useSafeAreaPaddings(true);

useEffect(() => {
Expand Down Expand Up @@ -153,6 +162,7 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
},
);
const splitButtonWrapperStyle = isSplitButton ? [styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter] : {};
const isTextTooLong = customText && customText?.length > 6;

const handlePress = useCallback(
(event?: GestureResponderEvent | KeyboardEvent) => {
Expand Down Expand Up @@ -186,12 +196,13 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
large={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE}
medium={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
small={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL}
innerStyles={[innerStyleDropButton, !isSplitButton && styles.dropDownButtonCartIconView]}
innerStyles={[innerStyleDropButton, !isSplitButton && styles.dropDownButtonCartIconView, isTextTooLong && shouldUseShortForm && {...styles.pl2, ...styles.pr1}]}
enterKeyEventListenerPriority={enterKeyEventListenerPriority}
iconRight={Expensicons.DownArrow}
shouldShowRightIcon={!isSplitButton}
isSplitButton={isSplitButton}
testID={testID}
textStyles={[isTextTooLong && shouldUseShortForm ? {...styles.textExtraSmall, ...styles.textBold} : {}]}
secondLineText={secondLineText}
icon={icon}
/>
Expand All @@ -207,16 +218,25 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
large={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE}
medium={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
small={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL}
innerStyles={[styles.dropDownButtonCartIconContainerPadding, innerStyleDropButton]}
innerStyles={[styles.dropDownButtonCartIconContainerPadding, innerStyleDropButton, isButtonSizeSmall && styles.dropDownButtonCartIcon]}
enterKeyEventListenerPriority={enterKeyEventListenerPriority}
>
<View style={[styles.dropDownButtonCartIconView, innerStyleDropButton]}>
<View style={[success ? styles.buttonSuccessDivider : styles.buttonDivider]} />
<View style={[isButtonSizeLarge ? styles.dropDownLargeButtonArrowContain : styles.dropDownMediumButtonArrowContain]}>
<View
style={[
isButtonSizeLarge && styles.dropDownLargeButtonArrowContain,
isButtonSizeSmall && shouldUseShortForm ? styles.dropDownSmallButtonArrowContain : styles.dropDownMediumButtonArrowContain,
]}
>
<Icon
medium={isButtonSizeLarge}
small={!isButtonSizeLarge}
small={!isButtonSizeLarge && !shouldUseShortForm}
inline={shouldUseShortForm}
width={shouldUseShortForm ? variables.iconSizeExtraSmall : undefined}
height={shouldUseShortForm ? variables.iconSizeExtraSmall : undefined}
src={Expensicons.DownArrow}
additionalStyles={shouldUseShortForm ? [styles.pRelative, styles.t0] : undefined}
fill={success ? theme.buttonSuccessText : theme.icon}
/>
</View>
Expand Down Expand Up @@ -266,18 +286,27 @@ function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuPr
shouldShowSelectedItemCheck={shouldShowSelectedItemCheck}
// eslint-disable-next-line react-compiler/react-compiler
anchorRef={nullCheckRef(dropdownAnchor)}
withoutOverlay
shouldUseScrollView
scrollContainerStyle={!shouldUseModalPaddingStyle && isSmallScreenWidth && {...styles.pt4, paddingBottom}}
shouldUseModalPaddingStyle={shouldUseModalPaddingStyle}
anchorAlignment={anchorAlignment}
shouldUseModalPaddingStyle={shouldUseModalPaddingStyle}
headerText={menuHeaderText}
shouldUseScrollView={shouldPopoverUseScrollView}
containerStyles={containerStyles}
menuItems={options.map((item, index) => ({
...item,
onSelected: item.onSelected
? () => item.onSelected?.()
? () => {
item.onSelected?.();
if (item.shouldUpdateSelectedIndex) {
setSelectedItemIndex(index);
}
}
: () => {
onOptionSelected?.(item);
if (item.shouldUpdateSelectedIndex === false) {
return;
}

setSelectedItemIndex(index);
},
shouldCallAfterModalHide: true,
Expand Down
11 changes: 11 additions & 0 deletions src/components/ButtonWithDropdownMenu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ type DropdownOption<TValueType> = {
descriptionTextStyle?: StyleProp<TextStyle>;
wrapperStyle?: StyleProp<ViewStyle>;
displayInDefaultIconColor?: boolean;
/** Whether the selected index should be updated when the option is selected even if we have onSelected callback */
shouldUpdateSelectedIndex?: boolean;
subMenuItems?: PopoverMenuItem[];
backButtonText?: string;
avatarSize?: ValueOf<typeof CONST.AVATAR_SIZE>;
Expand Down Expand Up @@ -143,9 +145,18 @@ type ButtonWithDropdownMenuProps<TValueType> = {
/** Icon for main button */
icon?: IconAsset;

/** Whether the popover content should be scrollable */
shouldPopoverUseScrollView?: boolean;

/** Container style to be applied to the popover of the dropdown menu */
containerStyles?: StyleProp<ViewStyle>;

/** Whether to use modal padding style for the popover menu */
shouldUseModalPaddingStyle?: boolean;

/** Whether to use short form for the button */
shouldUseShortForm?: boolean;

/** Whether to display the option icon when only one option is available */
shouldUseOptionIcon?: boolean;
};
Expand Down
70 changes: 60 additions & 10 deletions src/components/KYCWall/BaseKYCWall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@ import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Dimensions} from 'react-native';
import type {EmitterSubscription, GestureResponderEvent, View} from 'react-native';
import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import {openPersonalBankAccountSetupView} from '@libs/actions/BankAccounts';
import {completePaymentOnboarding} from '@libs/actions/IOU';
import {completePaymentOnboarding, savePreferredPaymentMethod} from '@libs/actions/IOU';
import {moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report';
import getClickedTargetLocation from '@libs/getClickedTargetLocation';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import {hasExpensifyPaymentMethod} from '@libs/PaymentUtils';
import {getBankAccountRoute, isExpenseReport as isExpenseReportReportUtils, isIOUReport} from '@libs/ReportUtils';
import {getBankAccountRoute, getPolicyExpenseChat, isExpenseReport as isExpenseReportReportUtils, isIOUReport} from '@libs/ReportUtils';
import {kycWallRef} from '@userActions/PaymentMethods';
import {createWorkspaceFromIOUPayment} from '@userActions/Policy/Policy';
import {setKYCWallSource} from '@userActions/Wallet';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {BankAccountList} from '@src/types/onyx';
import type {BankAccountList, Policy} from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import {getEmptyObject} from '@src/types/utils/EmptyObject';
import viewRef from '@src/types/utils/viewRef';
Expand Down Expand Up @@ -54,6 +56,8 @@ function KYCWall({
const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {canBeMissing: true});
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, {canBeMissing: true});

const {formatPhoneNumber} = useLocalize();

const anchorRef = useRef<HTMLDivElement | View>(null);
const transferBalanceButtonRef = useRef<HTMLDivElement | View | null>(null);

Expand All @@ -64,6 +68,8 @@ function KYCWall({
anchorPositionHorizontal: 0,
});

const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {canBeMissing: true});

const getAnchorPosition = useCallback(
(domRect: DomRect): AnchorPosition => {
if (anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) {
Expand Down Expand Up @@ -103,31 +109,55 @@ function KYCWall({
}, [getAnchorPosition]);

const selectPaymentMethod = useCallback(
(paymentMethod: PaymentMethod) => {
onSelectPaymentMethod(paymentMethod);
(paymentMethod?: PaymentMethod, policy?: Policy) => {
if (paymentMethod) {
onSelectPaymentMethod(paymentMethod);
}

if (paymentMethod === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT) {
openPersonalBankAccountSetupView({shouldSetUpUSBankAccount: isIOUReport(iouReport)});
} else if (paymentMethod === CONST.PAYMENT_METHODS.DEBIT_CARD) {
Navigation.navigate(addDebitCardRoute ?? ROUTES.HOME);
} else if (paymentMethod === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT) {
} else if (paymentMethod === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT || policy) {
if (iouReport && isIOUReport(iouReport)) {
if (policy) {
const policyExpenseChatReportID = getPolicyExpenseChat(iouReport.ownerAccountID, policy.id)?.reportID;
if (!policyExpenseChatReportID) {
const {policyExpenseChatReportID: newPolicyExpenseChatReportID} = moveIOUReportToPolicyAndInviteSubmitter(iouReport.reportID, policy.id, formatPhoneNumber) ?? {};
savePreferredPaymentMethod(iouReport.policyID, policy.id, CONST.LAST_PAYMENT_METHOD.IOU, lastPaymentMethod?.[policy.id]);
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(newPolicyExpenseChatReportID));

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.

{BZ CHECKLIST} there was a regression, the navigation occurs too soon—before the optimistic report from moveIOUReportToPolicyAndInviteSubmitter is fully merged into Onyx, this was fixed using setNavigationActionToMicrotaskQueue

} else {
moveIOUReportToPolicy(iouReport.reportID, policy.id, true);
savePreferredPaymentMethod(iouReport.policyID, policy.id, CONST.LAST_PAYMENT_METHOD.IOU, lastPaymentMethod?.[policy.id]);
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(policyExpenseChatReportID));
}

if (policy?.achAccount) {
return;
}
// Navigate to the bank account set up flow for this specific policy
Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute(policy.id));
return;
}

const {policyID, workspaceChatReportID, reportPreviewReportActionID, adminsChatReportID} = createWorkspaceFromIOUPayment(iouReport) ?? {};
if (policyID && iouReport?.policyID) {
savePreferredPaymentMethod(iouReport.policyID, policyID, CONST.LAST_PAYMENT_METHOD.IOU, lastPaymentMethod?.[iouReport?.policyID]);
}
completePaymentOnboarding(CONST.PAYMENT_SELECTED.BBA, adminsChatReportID, policyID);
if (workspaceChatReportID) {
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(workspaceChatReportID, reportPreviewReportActionID));
}

// Navigate to the bank account set up flow for this specific policy
Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute(policyID));

return;
}
const bankAccountRoute = addBankAccountRoute ?? getBankAccountRoute(chatReport);
Navigation.navigate(bankAccountRoute);
}
},
[addBankAccountRoute, addDebitCardRoute, chatReport, iouReport, onSelectPaymentMethod],
[addBankAccountRoute, addDebitCardRoute, chatReport, iouReport, onSelectPaymentMethod, formatPhoneNumber, lastPaymentMethod],
);

/**
Expand All @@ -137,7 +167,7 @@ function KYCWall({
*
*/
const continueAction = useCallback(
(event?: GestureResponderEvent | KeyboardEvent, iouPaymentType?: PaymentMethodType) => {
(event?: GestureResponderEvent | KeyboardEvent, iouPaymentType?: PaymentMethodType, paymentMethod?: PaymentMethod, policy?: Policy) => {
const currentSource = walletTerms?.source ?? source;

/**
Expand Down Expand Up @@ -171,6 +201,19 @@ function KYCWall({
return;
}

// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (paymentMethod || policy) {
setShouldShowAddPaymentMenu(false);
selectPaymentMethod(paymentMethod, policy);
return;
}

if (iouPaymentType && isExpenseReport) {
setShouldShowAddPaymentMenu(false);
selectPaymentMethod(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT);
return;
}

const clickedElementLocation = getClickedTargetLocation(targetElement as HTMLDivElement);
const position = getAnchorPosition(clickedElementLocation);

Expand All @@ -183,13 +226,20 @@ function KYCWall({
// Ask the user to upgrade to a gold wallet as this means they have not yet gone through our Know Your Customer (KYC) checks
const hasActivatedWallet = userWallet?.tierName && [CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM].some((name) => name === userWallet.tierName);

if (!hasActivatedWallet) {
if (!hasActivatedWallet && !policy) {
Log.info('[KYC Wallet] User does not have active wallet');

Navigation.navigate(enablePaymentsRoute);

return;
}

// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (policy || (paymentMethod && (!hasActivatedWallet || paymentMethod !== CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT))) {
setShouldShowAddPaymentMenu(false);
selectPaymentMethod(paymentMethod, policy);
return;
}
}

Log.info('[KYC Wallet] User has valid payment method and passed KYC checks or did not need them');
Expand Down
5 changes: 4 additions & 1 deletion src/components/KYCWall/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import type CONST from '@src/CONST';
import type {Route} from '@src/ROUTES';
import type {Report} from '@src/types/onyx';
import type {Policy, Report} from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';

Expand Down Expand Up @@ -63,6 +63,9 @@ type KYCWallProps = {

/** Children to build the KYC */
children: (continueAction: (event: GestureResponderEvent | KeyboardEvent | undefined, method?: PaymentMethodType) => void, anchorRef: RefObject<View | null>) => void;

/** The policy used for payment */
policy?: Policy;
};

export type {AnchorPosition, KYCWallProps, PaymentMethod, DomRect, PaymentMethodType, Source};
Loading
Loading