From 1d645d662046b16a8cd559fc8ce85fd415d7d2e1 Mon Sep 17 00:00:00 2001 From: Getabalew Date: Fri, 16 May 2025 20:36:09 +0300 Subject: [PATCH 01/37] Merge remote-tracking branch 'exfy/main' into feat-standardize-pay-button --- src/CONST.ts | 6 +- src/components/Button/index.tsx | 14 +- .../ButtonWithDropdownMenu/index.tsx | 20 +- .../ButtonWithDropdownMenu/types.ts | 8 + src/components/KYCWall/BaseKYCWall.tsx | 57 ++- src/components/KYCWall/types.ts | 5 +- src/components/MoneyReportHeader.tsx | 2 +- src/components/PopoverMenu.tsx | 11 +- src/components/ProcessMoneyReportHoldMenu.tsx | 10 +- .../ReportActionItem/ReportPreview.tsx | 4 +- .../SelectionList/Search/ActionCell.tsx | 45 +- .../Search/TransactionListItemRow.tsx | 2 + src/components/SelectionList/types.ts | 6 + src/components/SettlementButton/index.tsx | 429 ++++++++++++++---- src/components/SettlementButton/types.ts | 8 +- src/languages/en.ts | 22 +- src/languages/es.ts | 22 +- src/languages/params.ts | 9 + src/libs/DebugUtils.ts | 4 + src/libs/IOUUtils.ts | 18 +- src/libs/MoneyRequestReportUtils.ts | 7 +- src/libs/ReportActionsUtils.ts | 2 +- src/libs/ReportUtils.ts | 62 ++- src/libs/SearchUIUtils.ts | 13 + src/libs/actions/BankAccounts.ts | 70 ++- src/libs/actions/IOU.ts | 47 +- src/libs/actions/Policy/Policy.ts | 28 ++ .../resetUSDBankAccount.ts | 234 +++++----- src/libs/actions/Report.ts | 8 +- src/libs/actions/Search.ts | 27 +- .../home/report/ReportActionItemMessage.tsx | 6 +- .../TransactionPreviewContent.stories.tsx | 3 +- src/styles/index.ts | 6 + src/styles/utils/index.ts | 13 + src/types/onyx/LastPaymentMethod.ts | 20 +- src/types/onyx/OriginalMessage.ts | 6 + src/types/onyx/Report.ts | 2 +- src/types/onyx/ReportAction.ts | 6 + src/types/onyx/SearchResults.ts | 3 + tests/actions/IOUTest.ts | 12 +- tests/unit/Search/SearchUIUtilsTest.ts | 6 + 41 files changed, 983 insertions(+), 300 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 2c28d8e11bc8..db6a40634153 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -7062,9 +7062,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', diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 34ab3e22f3ec..fd7c9cf5754f 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -310,7 +310,19 @@ function Button( const textComponent = secondLineText ? ( {primaryText} - {secondLineText} + + {secondLineText} + ) : ( primaryText diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index f183a5775d29..4502667a581c 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -47,6 +47,8 @@ function ButtonWithDropdownMenu({ shouldShowSelectedItemCheck = false, testID, secondLineText = '', + shouldPopoverUseScrollView = false, + containerStyles, }: ButtonWithDropdownMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -66,6 +68,10 @@ function ButtonWithDropdownMenu({ const isButtonSizeLarge = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE; const nullCheckRef = (ref: MutableRefObject) => ref ?? null; + useEffect(() => { + setSelectedItemIndex(defaultSelectedIndex); + }, [defaultSelectedIndex]); + useEffect(() => { if (!dropdownAnchor.current) { return; @@ -222,13 +228,25 @@ function ButtonWithDropdownMenu({ anchorRef={nullCheckRef(dropdownAnchor)} withoutOverlay anchorAlignment={anchorAlignment} + shouldUseModalPaddingStyle={false} 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 && typeof item.shouldUpdateSelectedIndex === 'boolean') { + return; + } + setSelectedItemIndex(index); }, shouldCallAfterModalHide: true, diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 19a8e3f32266..2ca35c881a93 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -35,6 +35,8 @@ type DropdownOption = { titleStyle?: ViewStyle; shouldCloseModalOnSelect?: boolean; displayInDefaultIconColor?: boolean; + /** Whether the selected index should be updated when the option is selected even if we have onSelected callback */ + shouldUpdateSelectedIndex?: boolean; subMenuItems?: PopoverMenuItem[]; }; @@ -125,6 +127,12 @@ type ButtonWithDropdownMenuProps = { /** The second line text displays under the first line */ secondLineText?: string; + + /** Whether the popover content should be scrollable */ + shouldPopoverUseScrollView?: boolean; + + /** Container style to be applied to the popover of the dropdown menu */ + containerStyles?: StyleProp; }; export type { diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 68e43f43538e..efe19d75109e 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -5,18 +5,20 @@ import type {EmitterSubscription, GestureResponderEvent, View} from 'react-nativ import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu'; 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 {isExpenseReport as isExpenseReportReportUtils, isIOUReport} from '@libs/ReportUtils'; +import {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 {Policy} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import viewRef from '@src/types/utils/viewRef'; import type {AnchorPosition, DomRect, KYCWallProps, PaymentMethod} from './types'; @@ -45,11 +47,11 @@ function KYCWall({ source, shouldShowPersonalBankAccountOption = false, }: KYCWallProps) { - const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET); - const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS); - const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); - const [bankAccountList = {}] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET, {canBeMissing: true}); + const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS, {canBeMissing: true}); + const [fundList] = useOnyx(ONYXKEYS.FUND_LIST, {canBeMissing: true}); + const [bankAccountList = {}] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true}); + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {canBeMissing: true}); const anchorRef = useRef(null); const transferBalanceButtonRef = useRef(null); @@ -100,16 +102,41 @@ 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(); } 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) ?? {}; + savePreferredPaymentMethod(iouReport.policyID, policy.id, CONST.LAST_PAYMENT_METHOD.IOU); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(newPolicyExpenseChatReportID)); + } else { + moveIOUReportToPolicy(iouReport.reportID, policy.id, true); + savePreferredPaymentMethod(iouReport.policyID, policy.id, CONST.LAST_PAYMENT_METHOD.IOU); + 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) { + savePreferredPaymentMethod(iouReport.policyID, policyID, CONST.LAST_PAYMENT_METHOD.IOU); + } completePaymentOnboarding(CONST.PAYMENT_SELECTED.BBA, adminsChatReportID, policyID); if (workspaceChatReportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(workspaceChatReportID, reportPreviewReportActionID)); @@ -117,7 +144,6 @@ function KYCWall({ // Navigate to the bank account set up flow for this specific policy Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute(policyID)); - return; } Navigation.navigate(addBankAccountRoute); @@ -133,7 +159,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; /** @@ -170,6 +196,13 @@ function KYCWall({ const clickedElementLocation = getClickedTargetLocation(targetElement as HTMLDivElement); const position = getAnchorPosition(clickedElementLocation); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (paymentMethod || policy) { + setShouldShowAddPaymentMenu(false); + selectPaymentMethod(paymentMethod, policy); + return; + } + setPositionAddPaymentMenu(position); setShouldShowAddPaymentMenu(true); diff --git a/src/components/KYCWall/types.ts b/src/components/KYCWall/types.ts index 568f2a15903f..94c2e1832925 100644 --- a/src/components/KYCWall/types.ts +++ b/src/components/KYCWall/types.ts @@ -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'; @@ -63,6 +63,9 @@ type KYCWallProps = { /** Children to build the KYC */ children: (continueAction: (event: GestureResponderEvent | KeyboardEvent | undefined, method?: PaymentMethodType) => void, anchorRef: RefObject) => void; + + /** The policy used for payment */ + policy?: Policy; }; export type {AnchorPosition, KYCWallProps, PaymentMethod, DomRect, PaymentMethodType, Source}; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index c681897c4a57..10dea2071b79 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -307,7 +307,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea payInvoice(type, chatReport, moneyRequestReport, payAsBusiness, methodID, paymentMethod); } else { startAnimation(); - payMoneyRequest(type, chatReport, moneyRequestReport, true); + payMoneyRequest(type, chatReport, moneyRequestReport, undefined, true); } }, [chatReport, isAnyTransactionOnHold, isDelegateAccessRestricted, isInvoiceReport, moneyRequestReport, startAnimation], diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 902ded4b71f5..354abd09e10e 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -308,13 +308,10 @@ function PopoverMenu({ } setFocusedIndex(menuIndex); }} - wrapperStyle={StyleUtils.getItemBackgroundColorStyle( - !!item.isSelected, - focusedIndex === menuIndex, - item.disabled ?? false, - theme.activeComponentBG, - theme.hoverComponentBG, - )} + wrapperStyle={[ + StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, focusedIndex === menuIndex, item.disabled ?? false, theme.activeComponentBG, theme.hoverComponentBG), + shouldUseScrollView && StyleUtils.getOptionMargin(menuIndex, currentMenuItems.length - 1), + ]} shouldRemoveHoverBackground={item.isSelected} titleStyle={StyleSheet.flatten([styles.flex1, item.titleStyle])} // Spread other props dynamically diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index ba320a594135..0a155fa4c41b 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -4,7 +4,7 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import Navigation from '@libs/Navigation/Navigation'; import {isLinkedTransactionHeld} from '@libs/ReportActionsUtils'; -import * as IOU from '@userActions/IOU'; +import {approveMoneyRequest, payMoneyRequest} from '@userActions/IOU'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; @@ -69,15 +69,15 @@ function ProcessMoneyReportHoldMenu({ if (startAnimation) { startAnimation(); } - IOU.approveMoneyRequest(moneyRequestReport, full); - if (!full && isLinkedTransactionHeld(Navigation.getTopmostReportActionId() ?? '-1', moneyRequestReport?.reportID ?? '')) { - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(moneyRequestReport?.reportID ?? '')); + approveMoneyRequest(moneyRequestReport, full); + if (!full && isLinkedTransactionHeld(Navigation.getTopmostReportActionId(), moneyRequestReport?.reportID)) { + Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(moneyRequestReport?.reportID)); } } else if (chatReport && paymentType) { if (startAnimation) { startAnimation(); } - IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport, full); + payMoneyRequest(paymentType, chatReport, moneyRequestReport, undefined, full); } onClose(); }; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 8abb8b93a58f..e3637279dd99 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -264,7 +264,7 @@ function ReportPreview({ const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const confirmPayment = useCallback( - (type: PaymentMethodType | undefined, payAsBusiness?: boolean, methodID?: number, paymentMethod?: PaymentMethod) => { + (type: PaymentMethodType | undefined, payAsBusiness?: boolean, methodID?: number, paymentMethod?: PaymentMethod, usedPolicyID?: string) => { if (!type) { return; } @@ -279,7 +279,7 @@ function ReportPreview({ if (isInvoiceReportUtils(iouReport)) { payInvoice(type, chatReport, iouReport, payAsBusiness, methodID, paymentMethod); } else { - payMoneyRequest(type, chatReport, iouReport); + payMoneyRequest(type, chatReport, iouReport, usedPolicyID); } } }, diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx index c99f4df2e8f3..499abcf935c0 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -1,16 +1,23 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import Badge from '@components/Badge'; import Button from '@components/Button'; import * as Expensicons from '@components/Icon/Expensicons'; +import type {PaymentMethodType} from '@components/KYCWall/types'; +import SettlementButton from '@components/SettlementButton'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {payMoneyRequest} from '@libs/actions/IOU'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type {SearchTransactionAction} from '@src/types/onyx/SearchResults'; const actionTranslationsMap: Record = { @@ -31,6 +38,9 @@ type ActionCellProps = { isChildListItem?: boolean; parentAction?: string; isLoading?: boolean; + policyID?: string; + bankAccountRoute?: Route; + reportID?: string; }; function ActionCell({ @@ -41,6 +51,9 @@ function ActionCell({ isChildListItem = false, parentAction = '', isLoading = false, + policyID = '', + bankAccountRoute = ROUTES.BANK_ACCOUNT as Route, + reportID = '', }: ActionCellProps) { const {translate} = useLocalize(); const theme = useTheme(); @@ -48,8 +61,21 @@ function ActionCell({ const StyleUtils = useStyleUtils(); const {isOffline} = useNetwork(); + const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); const text = isChildListItem ? translate(actionTranslationsMap[CONST.SEARCH.ACTION_TYPES.VIEW]) : translate(actionTranslationsMap[action]); const shouldUseViewAction = action === CONST.SEARCH.ACTION_TYPES.VIEW || (parentAction === CONST.SEARCH.ACTION_TYPES.PAID && action === CONST.SEARCH.ACTION_TYPES.PAID); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`, {canBeMissing: true}); + + const confirmPayment = useCallback( + (type: PaymentMethodType | undefined) => { + if (!type || !chatReport) { + return; + } + + payMoneyRequest(type, chatReport, iouReport); + }, + [chatReport, iouReport], + ); if ((parentAction !== CONST.SEARCH.ACTION_TYPES.PAID && action === CONST.SEARCH.ACTION_TYPES.PAID) || (action === CONST.SEARCH.ACTION_TYPES.DONE && !isChildListItem)) { return ( @@ -94,6 +120,23 @@ function ActionCell({ ) : null; } + if (action === CONST.SEARCH.ACTION_TYPES.PAY) { + return ( + + ); + } + return (