From adb2075f03ee102749cbd65bab759938b5a74093 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 15 Jun 2026 14:31:35 +0200 Subject: [PATCH 1/3] perf: extract submitAmount handler and cache submit-only Onyx state --- .../iou/request/step/AmountSubmission.ts | 531 +++++++++++++++++- .../iou/request/step/IOURequestStepAmount.tsx | 459 ++------------- tests/unit/AmountSubmissionTest.ts | 301 +++++++++- 3 files changed, 886 insertions(+), 405 deletions(-) diff --git a/src/pages/iou/request/step/AmountSubmission.ts b/src/pages/iou/request/step/AmountSubmission.ts index 41e8424ea080..51c18f297745 100644 --- a/src/pages/iou/request/step/AmountSubmission.ts +++ b/src/pages/iou/request/step/AmountSubmission.ts @@ -1,13 +1,56 @@ +import {hasSeenTourSelector} from '@selectors/Onboarding'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import {isParticipantP2P} from '@libs/IOUUtils'; -import {getMoneyRequestParticipantsFromReport} from '@userActions/IOU/MoneyRequest'; +import {convertToBackendAmount} from '@libs/CurrencyUtils'; +import { + calculateDefaultReimbursable, + getExistingTransactionID, + isMovingTransactionFromTrackExpense, + isParticipantP2P, + navigateToConfirmationPage, + navigateToParticipantPage, + resolveOptimisticChatReportID, +} from '@libs/IOUUtils'; +import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; +import type {WriteOverrides} from '@libs/Navigation/helpers/submitWithDismissFirst'; +import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; +import Navigation from '@libs/Navigation/Navigation'; +import {rand64} from '@libs/NumberUtils'; +import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; +import Permissions from '@libs/Permissions'; +import {getPolicyExpenseChat, getTransactionDetails, isSelfDM, shouldEnableNegative} from '@libs/ReportUtils'; +import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy'; +import {calculateTaxAmount, getAmount, getCurrency, getDefaultTaxCode, getIsFromGlobalCreate, getTaxValue, hasReceipt} from '@libs/TransactionUtils'; +import { + getMoneyRequestParticipantsFromReport, + setMoneyRequestAmount, + setMoneyRequestParticipantsFromReport, + setMoneyRequestTaxAmount, + setMoneyRequestTaxRate, +} from '@userActions/IOU/MoneyRequest'; +import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney'; +import {resetSplitShares, setDraftSplitTransaction, setSplitShares} from '@userActions/IOU/Split'; +import {requestMoney, trackExpense} from '@userActions/IOU/TrackExpense'; +import {updateMoneyRequestAmountAndCurrency} from '@userActions/IOU/UpdateMoneyRequest'; +import {setTransactionReport} from '@userActions/Transaction'; +import CONST from '@src/CONST'; +import type {IOUAction, IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; +import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; -// `allReports` and `allReportDrafts` are only consumed by submit-time helpers in this module, -// never during render. Onyx.connectWithoutView is appropriate. If React components need these -// values, use useOnyx instead. +// The values below are only consumed by submit-time helpers in this module, never during render. +// Onyx.connectWithoutView is appropriate. If React components need these values, use useOnyx instead. + +let allPersonalDetails: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (value) => (allPersonalDetails = value), +}); let allReports: OnyxCollection; Onyx.connectWithoutView({ @@ -23,6 +66,109 @@ Onyx.connectWithoutView({ callback: (value) => (allReportDrafts = value), }); +let quickAction: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + callback: (value) => (quickAction = value), +}); + +let introSelected: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_INTRO_SELECTED, + callback: (value) => (introSelected = value), +}); + +let onboarding: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_ONBOARDING, + callback: (value) => (onboarding = value), +}); + +let betas: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.BETAS, + callback: (value) => (betas = value), +}); + +let betaConfiguration: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.BETA_CONFIGURATION, + callback: (value) => (betaConfiguration = value), +}); + +let amountOwed: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED, + callback: (value) => (amountOwed = value), +}); + +let ownerBillingGracePeriodEnd: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END, + callback: (value) => (ownerBillingGracePeriodEnd = value), +}); + +let policyRecentlyUsedCurrencies: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.RECENTLY_USED_CURRENCIES, + callback: (value) => (policyRecentlyUsedCurrencies = value), +}); + +let recentWaypoints: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_RECENT_WAYPOINTS, + callback: (value) => (recentWaypoints = value), +}); + +let conciergeReportID: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.CONCIERGE_REPORT_ID, + callback: (value) => (conciergeReportID = value), +}); + +let reportAttributesDerivedValue: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, + callback: (value) => (reportAttributesDerivedValue = value), +}); + +type SubmitAmountArgs = { + report: OnyxEntry; + transaction: OnyxEntry; + splitDraftTransaction: OnyxEntry; + policy: OnyxEntry; + selectedCurrency: string; + decimals: number; + iouType: IOUType; + transactionID: string; + reportID: string; + action: IOUAction; + backTo: Route | undefined; + backToReport: string | undefined; + shouldKeepUserInput: boolean; + shouldSkipConfirmation: boolean; + isReportArchived: boolean; + currentUserPersonalDetails: OnyxTypes.PersonalDetails; + delegateAccountID: number | undefined; + selfDMReport: OnyxEntry; + defaultExpensePolicy: OnyxEntry; + personalPolicy: OnyxEntry>; + navigateBack: () => void; + amount: string; + paymentMethod?: PaymentMethodType; + + // Submit-time collection data — passed in by the screen until follow-up PRs cache these at module scope. + transactionDrafts: OnyxCollection; + transactionViolations: OnyxCollection; + storedTransaction: OnyxEntry; + parentReportNextStep: OnyxEntry; + policyCategories: OnyxEntry; + userBillingGracePeriodEnds: OnyxCollection; + allReportNVPs: OnyxCollection; + duplicateTransactions: OnyxCollection; + duplicateTransactionViolations: OnyxCollection; +}; + /** * Look up a report by ID across the cached `COLLECTION.REPORT` and `COLLECTION.REPORT_DRAFT` * collections. Returns the report-draft entry when no concrete report exists for the ID. @@ -54,4 +200,377 @@ function getIsP2PForAmount({chatReportForP2P, currentUserAccountID}: GetIsP2PFor return isParticipantP2P(firstParticipant); } -export {getIsP2PForAmount, getReportOrReportDraftForAmount}; +/** + * Submission orchestration for `IOURequestStepAmount`. Verbatim port of the previous inline + * `saveAmountAndCurrency` + `navigateToNextPage` handlers. All submit-only Onyx values are read + * from the module-scoped `connectWithoutView` caches above. + * + * Branches: + * - Non-edit path → delegate to nested `navigateToNextPage` (skip-confirm PAY / SEND / TRACK / + * REQUEST / SUBMIT / SPLIT, or routing to confirmation / participant / default-workspace). + * - Edit path → fast-exit if amount/currency unchanged; otherwise `setDraftSplitTransaction` + * for split bills, else `updateMoneyRequestAmountAndCurrency` with duplicate-tx propagation. + */ +function submitAmount({ + report, + transaction, + splitDraftTransaction, + policy, + selectedCurrency, + decimals, + iouType, + transactionID, + reportID, + action, + backTo, + backToReport, + shouldKeepUserInput, + shouldSkipConfirmation, + isReportArchived, + currentUserPersonalDetails, + delegateAccountID, + selfDMReport, + defaultExpensePolicy, + personalPolicy, + navigateBack, + amount, + paymentMethod, + transactionDrafts, + transactionViolations, + storedTransaction, + parentReportNextStep, + policyCategories, + userBillingGracePeriodEnds, + allReportNVPs, + duplicateTransactions, + duplicateTransactionViolations, +}: SubmitAmountArgs): void { + const isEditing = action === CONST.IOU.ACTION.EDIT; + const isCreateAction = action === CONST.IOU.ACTION.CREATE; + const isSubmitAction = action === CONST.IOU.ACTION.SUBMIT; + const isSubmitType = iouType === CONST.IOU.TYPE.SUBMIT; + const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; + const isEditingSplitBill = isEditing && isSplitBill; + const currentTransaction = isEditingSplitBill && !isEmptyObject(splitDraftTransaction) ? splitDraftTransaction : transaction; + const allowNegative = shouldEnableNegative(report, policy, iouType, transaction?.participants); + const disableOppositeConversion = isCreateAction || (isSubmitType && isSubmitAction); + const currentUserAccountIDParam = currentUserPersonalDetails.accountID; + const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; + const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); + const isASAPSubmitBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, betas, betaConfiguration); + + const navigateToNextPage = () => { + const amountInSmallestCurrencyUnits = convertToBackendAmount(Number.parseFloat(amount)); + + setMoneyRequestAmount(transactionID, amountInSmallestCurrencyUnits, selectedCurrency || CONST.CURRENCY.USD, shouldKeepUserInput, hasReceipt(transaction)); + + if (isMovingTransactionFromTrackExpense(action)) { + const taxCode = selectedCurrency !== policy?.outputCurrency ? policy?.taxRates?.foreignTaxDefault : policy?.taxRates?.defaultExternalID; + if (taxCode) { + setMoneyRequestTaxRate(transactionID, taxCode); + const taxPercentage = getTaxValue(policy, transaction, taxCode) ?? ''; + const taxAmount = convertToBackendAmount(calculateTaxAmount(taxPercentage, amountInSmallestCurrencyUnits, decimals)); + setMoneyRequestTaxAmount(transactionID, taxAmount); + } + } + + if (backTo) { + Navigation.goBack(backTo); + return; + } + + // If a reportID exists in the report object, it's because either: + // - The user started this flow from using the + button in the composer inside a report. + // - The user started this flow from using the global create menu by selecting the Track expense option. + // In this case, the participants can be automatically assigned from the report and the user can skip + // the participants step and go straight to the confirm step. + // If the user is started this flow using the Create expense option (combined submit/track flow), they + // should be redirected to the participants page. + if (report?.reportID && !isReportArchived && iouType !== CONST.IOU.TYPE.CREATE) { + const selectedParticipants = getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID); + const reportAttributesReports = reportAttributesDerivedValue?.reports; + const participants = selectedParticipants.map((participant) => { + const participantAccountID = participant?.accountID ?? CONST.DEFAULT_NUMBER_ID; + const privateIsArchived = !!allReportNVPs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`]?.private_isArchived; + return participantAccountID + ? getParticipantsOption(participant, allPersonalDetails) + : getReportOption( + participant, + privateIsArchived, + policy, + allPersonalDetails, + conciergeReportID, + reportAttributesReports, + allReportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${participant.reportID}`], + ); + }); + const backendAmount = convertToBackendAmount(Number.parseFloat(amount)); + + if (shouldSkipConfirmation) { + const participant = participants.at(0); + const defaultReimbursable = calculateDefaultReimbursable({ + iouType, + policy, + policyForMovingExpenses: policy, + participant, + transactionReportID: report?.reportID, + }); + if (iouType === CONST.IOU.TYPE.PAY || iouType === CONST.IOU.TYPE.SEND) { + const {optimisticChatReportID, chatReportID} = resolveOptimisticChatReportID( + [participants.at(0)?.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserAccountIDParam], + report, + ); + const sendMoneyParams = { + report, + quickAction, + amount: backendAmount, + currency: selectedCurrency, + comment: '', + currentUserAccountID: currentUserAccountIDParam, + recipient: participants.at(0) ?? {}, + optimisticChatReportID, + shouldStartTracking: false, + }; + + const executeSendMoneyWrite = (overrides?: {shouldDeferForSearch?: boolean}) => { + const mergedParams = {...sendMoneyParams, ...overrides}; + if (paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + sendMoneyWithWallet(mergedParams); + } else { + sendMoneyElsewhere(mergedParams); + } + }; + + submitWithDismissFirst({ + executeWrite: () => executeSendMoneyWrite({shouldDeferForSearch: false}), + destinationReportID: chatReportID, + telemetryContext: { + scenario: CONST.TELEMETRY.SUBMIT_EXPENSE_SCENARIO.SEND_MONEY, + iouType: CONST.IOU.TYPE.PAY, + requestType: CONST.IOU.TYPE.PAY, + isFromGlobalCreate: isEmptyObject(report) || !report?.reportID, + hasReceipt: false, + }, + }); + return; + } + const optimisticTransactionID = rand64(); + const {optimisticChatReportID} = resolveOptimisticChatReportID([participants.at(0)?.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserAccountIDParam], report); + if (iouType !== CONST.IOU.TYPE.SUBMIT && iouType !== CONST.IOU.TYPE.REQUEST && iouType !== CONST.IOU.TYPE.TRACK) { + return; + } + const isTrackExpenseSubmit = iouType === CONST.IOU.TYPE.TRACK; + const draftTransactionIDsList = Object.keys(transactionDrafts ?? {}); + const isSelfTourViewed = hasSeenTourSelector(onboarding) ?? false; + const executeExpenseWrite = (overrides: WriteOverrides) => { + if (isTrackExpenseSubmit) { + trackExpense({ + report, + isDraftPolicy: false, + participantParams: { + payeeEmail: currentUserEmailParam, + payeeAccountID: currentUserAccountIDParam, + participant: participants.at(0) ?? {}, + }, + transactionParams: { + amount: backendAmount, + currency: selectedCurrency ?? CONST.CURRENCY.USD, + created: transaction?.created, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + reimbursable: defaultReimbursable, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + }, + isASAPSubmitBetaEnabled, + currentUser: {accountID: currentUserAccountIDParam, email: currentUserEmailParam}, + currentUserLocalCurrency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, + introSelected, + quickAction, + recentWaypoints, + betas, + draftTransactionIDs: draftTransactionIDsList, + isSelfTourViewed, + optimisticChatReportID, + optimisticTransactionID, + }); + } else { + const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; + requestMoney({ + report, + betas, + participantParams: { + participant: participants.at(0) ?? {}, + payeeEmail: currentUserEmailParam, + payeeAccountID: currentUserAccountIDParam, + }, + transactionParams: { + amount: backendAmount, + currency: selectedCurrency, + created: transaction?.created ?? '', + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + attendees: transaction?.comment?.attendees, + reimbursable: defaultReimbursable, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + }, + shouldGenerateTransactionThreadReport: false, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam, + currentUserEmailParam, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + existingTransactionDraft, + existingTransaction: storedTransaction, + draftTransactionIDs: draftTransactionIDsList, + isSelfTourViewed, + personalDetails: allPersonalDetails, + optimisticChatReportID, + optimisticTransactionID, + }); + } + cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { + report, + action, + draftTransactionIDs: draftTransactionIDsList, + transactionID: existingTransactionID ?? optimisticTransactionID, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + backToReport, + optimisticChatReportID, + linkedTrackedExpenseReportAction: transaction?.linkedTrackedExpenseReportAction, + }); + }; + submitWithDismissFirst({ + executeWrite: executeExpenseWrite, + destinationReportID: isTrackExpenseSubmit ? (report?.reportID ?? selfDMReport?.reportID) : report?.reportID, + telemetryContext: { + scenario: isTrackExpenseSubmit ? CONST.TELEMETRY.SUBMIT_EXPENSE_SCENARIO.TRACK_EXPENSE : CONST.TELEMETRY.SUBMIT_EXPENSE_SCENARIO.REQUEST_MONEY_MANUAL, + iouType, + requestType: CONST.IOU.REQUEST_TYPE.MANUAL, + isFromGlobalCreate: isEmptyObject(report) || !report?.reportID, + hasReceipt: false, + }, + }); + return; + } + if (isSplitBill && !report.isOwnPolicyExpenseChat && report.participants) { + const participantAccountIDs = Object.keys(report.participants).map((accountID) => Number(accountID)); + setSplitShares(transaction, amountInSmallestCurrencyUnits, selectedCurrency || CONST.CURRENCY.USD, participantAccountIDs, currentUserAccountIDParam); + } + setMoneyRequestParticipantsFromReport(transactionID, report, currentUserPersonalDetails.accountID).then(() => { + navigateToConfirmationPage(iouType, transactionID, reportID, backToReport); + }); + return; + } + + // Starting from global + menu means no participant context exists yet, + // so we need to handle participant selection based on available workspace settings + if (shouldUseDefaultExpensePolicy(iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, currentUserAccountIDParam)) { + const shouldAutoReport = !!defaultExpensePolicy?.autoReporting || !!personalPolicy?.autoReporting; + const targetReport = shouldAutoReport ? getPolicyExpenseChat(currentUserAccountIDParam, defaultExpensePolicy?.id) : selfDMReport; + const transactionReportID = isSelfDM(targetReport) ? CONST.REPORT.UNREPORTED_REPORT_ID : targetReport?.reportID; + const iouTypeTrackOrSubmit = transactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; + const isReturningFromConfirmationPage = !!transaction?.participants?.length; + + const resetToDefaultWorkspace = () => { + setTransactionReport(transactionID, {reportID: transactionReportID}, true); + setMoneyRequestParticipantsFromReport(transactionID, targetReport, currentUserPersonalDetails.accountID).then(() => { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouTypeTrackOrSubmit, transactionID, targetReport?.reportID)); + }); + }; + + if (isReturningFromConfirmationPage) { + const isP2PChat = isParticipantP2P(transaction?.participants?.at(0)); + const isNegativeAmount = convertToBackendAmount(Number.parseFloat(amount)) < 0; + + // P2P chats don't support negative amounts, so reset to default workspace when amount is negative. + if (isP2PChat && isNegativeAmount) { + resetToDefaultWorkspace(); + return; + } + + // Preserve user's participant selection to avoid forcing them back to default workspace. + const iouReportID = transaction?.reportID; + const transactionAssociatedReport = getReportOrReportDraftForAmount(transaction?.reportID); + const selectedReport = iouReportID === CONST.REPORT.UNREPORTED_REPORT_ID ? selfDMReport : transactionAssociatedReport; + const navigationIOUType = isSelfDM(selectedReport) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; + const chatReportID = selectedReport?.chatReportID ?? selectedReport?.reportID; + + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, navigationIOUType, transactionID, chatReportID)); + }); + } else { + resetToDefaultWorkspace(); + } + } else { + Navigation.setNavigationActionToMicrotaskQueue(() => { + navigateToParticipantPage(iouType, transactionID, reportID); + }); + } + }; + + const newAmount = convertToBackendAmount(Number.parseFloat(amount)); + + if (!isEditing) { + // Edits to the amount from the splits page should reset the split shares. + if (transaction?.splitShares) { + resetSplitShares(transaction, newAmount, selectedCurrency, currentUserAccountIDParam, true); + } + navigateToNextPage(); + return; + } + + // If the value hasn't changed, don't request to save changes on the server and just close the modal + const transactionCurrency = getCurrency(currentTransaction); + if (newAmount === getAmount(currentTransaction, false, false, allowNegative, disableOppositeConversion) && selectedCurrency === transactionCurrency) { + navigateBack(); + return; + } + + // If currency has changed, then we get the default tax rate based on currency, otherwise we use the current tax rate selected in transaction, if we have it. + const transactionTaxCode = getTransactionDetails(currentTransaction)?.taxCode; + const defaultTaxCode = getDefaultTaxCode(policy, currentTransaction, selectedCurrency) ?? ''; + const taxCode = (selectedCurrency !== transactionCurrency ? defaultTaxCode : transactionTaxCode) ?? defaultTaxCode; + const taxPercentage = getTaxValue(policy, currentTransaction, taxCode) ?? ''; + const taxAmount = convertToBackendAmount(calculateTaxAmount(taxPercentage, newAmount, decimals)); + + if (isSplitBill) { + setDraftSplitTransaction(transactionID, splitDraftTransaction, {amount: newAmount, currency: selectedCurrency, taxCode, taxAmount}); + navigateBack(); + return; + } + + // Reset split shares for non-split-bill edits (split-bill share recalculation is handled by the confirmation list). + if (transaction?.splitShares) { + resetSplitShares(transaction, newAmount, selectedCurrency, currentUserAccountIDParam, false); + } + + // `parentReport` is read from the module-scope REPORT cache (introduced in PR 3); the rest + // of the edit-branch collection data (`parentReportNextStep`, `policyCategories`, + // `duplicateTransactions`, `duplicateTransactionViolations`) comes in via args until the + // follow-up PR caches `NEXT_STEP`, `POLICY_CATEGORIES`, `TRANSACTION`, and + // `TRANSACTION_VIOLATIONS` at module scope. + const parentReport = report?.parentReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`] : undefined; + + updateMoneyRequestAmountAndCurrency({ + transactionID, + transactionThreadReport: report, + parentReport, + parentReportNextStep, + transactions: duplicateTransactions, + transactionViolations: duplicateTransactionViolations, + currency: selectedCurrency, + amount: newAmount, + taxAmount, + policy, + taxCode, + taxValue: taxPercentage, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + delegateAccountID, + }); + navigateBack(); +} + +export {submitAmount, getIsP2PForAmount, getReportOrReportDraftForAmount}; diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 9b974a9b63db..4486ebe3858a 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -1,10 +1,8 @@ import {useFocusEffect} from '@react-navigation/native'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; -import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {Keyboard} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {BetasContext} from '@components/OnyxListItemProvider'; import isTextInputFocused from '@components/TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -14,79 +12,32 @@ import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; -import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; -import {requestMoney} from '@libs/actions/IOU/TrackExpense'; -import {setTransactionReport} from '@libs/actions/Transaction'; -import {convertToBackendAmount} from '@libs/CurrencyUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import { - calculateDefaultReimbursable, - getExistingTransactionID, - isMovingTransactionFromTrackExpense, - isParticipantP2P, - navigateToConfirmationPage, - navigateToParticipantPage, - resolveOptimisticChatReportID, -} from '@libs/IOUUtils'; -import cleanupAfterSkipConfirmSubmit from '@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit'; -import {submitWithDismissFirst} from '@libs/Navigation/helpers/submitWithDismissFirst'; -import type {WriteOverrides} from '@libs/Navigation/helpers/submitWithDismissFirst'; +import {getExistingTransactionID} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {rand64} from '@libs/NumberUtils'; -import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; -import {getPolicyExpenseChat, getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM, shouldEnableNegative} from '@libs/ReportUtils'; -import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy'; -import { - calculateTaxAmount, - getAmount, - getCurrency, - getDefaultTaxCode, - getIsFromGlobalCreate, - getRequestType, - getTaxValue, - hasReceipt, - isDistanceRequest, - isExpenseUnreported, -} from '@libs/TransactionUtils'; +import {getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, shouldEnableNegative} from '@libs/ReportUtils'; +import {getRequestType, isDistanceRequest, isExpenseUnreported} from '@libs/TransactionUtils'; import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; -import { - getMoneyRequestParticipantsFromReport, - setMoneyRequestAmount, - setMoneyRequestParticipantsFromReport, - setMoneyRequestTaxAmount, - setMoneyRequestTaxRate, -} from '@userActions/IOU/MoneyRequest'; -import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney'; -import {resetSplitShares, setDraftSplitTransaction, setSplitShares} from '@userActions/IOU/Split'; -import {trackExpense} from '@userActions/IOU/TrackExpense'; -import {updateMoneyRequestAmountAndCurrency} from '@userActions/IOU/UpdateMoneyRequest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {SelectedTabRequest} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type Transaction from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {getIsP2PForAmount, getReportOrReportDraftForAmount} from './AmountSubmission'; +import {getIsP2PForAmount, submitAmount} from './AmountSubmission'; import IOURequestStepCurrencyModal from './IOURequestStepCurrencyModal'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; import withWritableReportOrNotFound from './withWritableReportOrNotFound'; -type AmountParams = { - amount: string; - paymentMethod?: PaymentMethodType; -}; - type IOURequestStepAmountProps = WithWritableReportOrNotFoundProps & { /** The transaction object being modified in Onyx */ transaction: OnyxEntry; @@ -105,13 +56,11 @@ function IOURequestStepAmount({ }: IOURequestStepAmountProps) { const {translate} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); - const {isBetaEnabled} = usePermissions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); const textInput = useRef(null); const focusTimeoutRef = useRef(null); - const isSaveButtonPressed = useRef(false); const iouRequestType = getRequestType(transaction); const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK; const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); @@ -124,28 +73,38 @@ function IOURequestStepAmount({ const iouOrExpenseReport = useReportOrReportDraft(report?.chatReportID); const actualChatReportID = iouOrExpenseReport && isMoneyRequestReport(iouOrExpenseReport) ? iouOrExpenseReport.chatReportID : undefined; const actualChatReport = useReportOrReportDraft(actualChatReportID); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [parentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); - const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); - const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); - const betas = useContext(BetasContext); const defaultExpensePolicy = useDefaultExpensePolicy(); const personalPolicy = usePersonalPolicy(); - const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [allReportNVPs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); + const [storedTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(existingTransactionID)}`); + const isEditing = action === CONST.IOU.ACTION.EDIT; const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(isEditing && transactionID ? [transactionID] : []); - const reportIDToCheck = isMoneyRequestReport(report) ? report?.chatReportID : report?.reportID; - const [reportDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportIDToCheck}`); - const reportAttributesDerived = useReportAttributes(); - const [allReportNVPs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + + // When editing, the `report` is the transaction thread which only has the current user as participant. + // To correctly determine if this is a P2P expense, we need to traverse to the actual chat report + // (e.g., the 1:1 DM) via the IOU/expense report's chatReportID. + const chatReportForP2PCheck = useMemo(() => { + if (!isEditing) { + return report; + } + // When editing, report is the transaction thread. We need to get the actual chat report. + // Transaction thread's chatReportID points to the IOU/expense report, + // and the IOU/expense report's chatReportID points to the actual chat. + if (iouOrExpenseReport && isMoneyRequestReport(iouOrExpenseReport) && iouOrExpenseReport.chatReportID) { + return actualChatReport; + } + // Fallback to the passed report if we can't traverse + return report; + }, [isEditing, report, iouOrExpenseReport, actualChatReport]); const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; const isCreateAction = action === CONST.IOU.ACTION.CREATE; const isSubmitAction = action === CONST.IOU.ACTION.SUBMIT; @@ -162,14 +121,6 @@ function IOURequestStepAmount({ const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction); const isUnreportedDistanceExpense = isEditing && isDistanceRequest(transaction) && isExpenseUnreported(transaction); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); - const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); - const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - - const currentUserAccountIDParam = currentUserPersonalDetails.accountID; - const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - // For quick button actions, we'll skip the confirmation page unless the report is archived or this is a workspace request, as // the user will have to add a merchant. const shouldSkipConfirmation: boolean = useMemo(() => { @@ -180,25 +131,6 @@ function IOURequestStepAmount({ return !(isReportArchived || isPolicyExpenseChat(report)); }, [report, isSplitBill, skipConfirmation, isReportArchived]); - // When editing, the `report` is the transaction thread which only has the current user as participant. - // To correctly determine if this is a P2P expense, we need to traverse to the actual chat report - // (e.g., the 1:1 DM) via the IOU/expense report's chatReportID. - const chatReportForP2PCheck = useMemo(() => { - if (!isEditing) { - return report; - } - - // When editing, report is the transaction thread. We need to get the actual chat report. - // Transaction thread's chatReportID points to the IOU/expense report, - // and the IOU/expense report's chatReportID points to the actual chat. - if (iouOrExpenseReport && isMoneyRequestReport(iouOrExpenseReport) && iouOrExpenseReport.chatReportID) { - return actualChatReport; - } - - // Fallback to the passed report if we can't traverse - return report; - }, [isEditing, report, iouOrExpenseReport, actualChatReport]); - useFocusEffect( useCallback(() => { if (isCurrencyPickerVisible) { @@ -219,312 +151,45 @@ function IOURequestStepAmount({ setSelectedCurrency(originalCurrency); }, [originalCurrency]); - const navigateBack = () => { + const navigateBack = useCallback(() => { Navigation.goBack(backTo); - }; + }, [backTo]); - const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); - const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); - // Use the stored transaction instead of the draft to preserve existing values, especially for distance requests while create a new request. - const [storedTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(existingTransactionID)}`); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - - const navigateToNextPage = ({amount, paymentMethod}: AmountParams) => { - isSaveButtonPressed.current = true; - const amountInSmallestCurrencyUnits = convertToBackendAmount(Number.parseFloat(amount)); - - setMoneyRequestAmount(transactionID, amountInSmallestCurrencyUnits, selectedCurrency || CONST.CURRENCY.USD, shouldKeepUserInput, hasReceipt(transaction)); - - if (isMovingTransactionFromTrackExpense(action)) { - const taxCode = selectedCurrency !== policy?.outputCurrency ? policy?.taxRates?.foreignTaxDefault : policy?.taxRates?.defaultExternalID; - if (taxCode) { - setMoneyRequestTaxRate(transactionID, taxCode); - const taxPercentage = getTaxValue(policy, transaction, taxCode) ?? ''; - const taxAmount = convertToBackendAmount(calculateTaxAmount(taxPercentage, amountInSmallestCurrencyUnits, decimals)); - setMoneyRequestTaxAmount(transactionID, taxAmount); - } - } - - if (backTo) { - Navigation.goBack(backTo); - return; - } - - // If a reportID exists in the report object, it's because either: - // - The user started this flow from using the + button in the composer inside a report. - // - The user started this flow from using the global create menu by selecting the Track expense option. - // In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight - // to the confirm step. - // If the user is started this flow using the Create expense option (combined submit/track flow), they should be redirected to the participants page. - if (report?.reportID && !isReportArchived && iouType !== CONST.IOU.TYPE.CREATE) { - const selectedParticipants = getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID); - const participants = selectedParticipants.map((participant) => { - const participantAccountID = participant?.accountID ?? CONST.DEFAULT_NUMBER_ID; - const privateIsArchived = !!allReportNVPs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`]?.private_isArchived; - return participantAccountID - ? getParticipantsOption(participant, personalDetails) - : getReportOption(participant, privateIsArchived, policy, personalDetails, conciergeReportID, reportAttributesDerived, reportDraft); - }); - const backendAmount = convertToBackendAmount(Number.parseFloat(amount)); - - if (shouldSkipConfirmation) { - const participant = participants.at(0); - const defaultReimbursable = calculateDefaultReimbursable({ - iouType, - policy, - policyForMovingExpenses: policy, - participant, - transactionReportID: report?.reportID, - }); - if (iouType === CONST.IOU.TYPE.PAY || iouType === CONST.IOU.TYPE.SEND) { - const {optimisticChatReportID, chatReportID} = resolveOptimisticChatReportID( - [participants.at(0)?.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserAccountIDParam], - report, - ); - const sendMoneyParams = { - report, - quickAction, - amount: backendAmount, - currency: selectedCurrency, - comment: '', - currentUserAccountID: currentUserAccountIDParam, - recipient: participants.at(0) ?? {}, - optimisticChatReportID, - shouldStartTracking: false, - }; - - const executeSendMoneyWrite = (overrides?: {shouldDeferForSearch?: boolean}) => { - const mergedParams = {...sendMoneyParams, ...overrides}; - if (paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - sendMoneyWithWallet(mergedParams); - } else { - sendMoneyElsewhere(mergedParams); - } - }; - - submitWithDismissFirst({ - executeWrite: () => executeSendMoneyWrite({shouldDeferForSearch: false}), - destinationReportID: chatReportID, - telemetryContext: { - scenario: CONST.TELEMETRY.SUBMIT_EXPENSE_SCENARIO.SEND_MONEY, - iouType: CONST.IOU.TYPE.PAY, - requestType: CONST.IOU.TYPE.PAY, - isFromGlobalCreate: isEmptyObject(report) || !report?.reportID, - hasReceipt: false, - }, - }); - return; - } - const optimisticTransactionID = rand64(); - const {optimisticChatReportID} = resolveOptimisticChatReportID([participants.at(0)?.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserAccountIDParam], report); - if (iouType !== CONST.IOU.TYPE.SUBMIT && iouType !== CONST.IOU.TYPE.REQUEST && iouType !== CONST.IOU.TYPE.TRACK) { - return; - } - const isTrackExpenseSubmit = iouType === CONST.IOU.TYPE.TRACK; - const executeExpenseWrite = (overrides: WriteOverrides) => { - if (isTrackExpenseSubmit) { - trackExpense({ - report, - isDraftPolicy: false, - participantParams: { - payeeEmail: currentUserEmailParam, - payeeAccountID: currentUserAccountIDParam, - participant: participants.at(0) ?? {}, - }, - transactionParams: { - amount: backendAmount, - currency: selectedCurrency ?? CONST.CURRENCY.USD, - created: transaction?.created, - merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - reimbursable: defaultReimbursable, - isFromGlobalCreate: getIsFromGlobalCreate(transaction), - }, - isASAPSubmitBetaEnabled, - currentUser: {accountID: currentUserAccountIDParam, email: currentUserEmailParam}, - introSelected, - quickAction, - recentWaypoints, - betas, - draftTransactionIDs, - isSelfTourViewed, - optimisticChatReportID, - optimisticTransactionID, - currentUserLocalCurrency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, - }); - } else { - const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; - requestMoney({ - report, - betas, - participantParams: { - participant: participants.at(0) ?? {}, - payeeEmail: currentUserEmailParam, - payeeAccountID: currentUserAccountIDParam, - }, - transactionParams: { - amount: backendAmount, - currency: selectedCurrency, - created: transaction?.created ?? '', - merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - attendees: transaction?.comment?.attendees, - reimbursable: defaultReimbursable, - isFromGlobalCreate: getIsFromGlobalCreate(transaction), - }, - shouldGenerateTransactionThreadReport: false, - isASAPSubmitBetaEnabled, - currentUserAccountIDParam, - currentUserEmailParam, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], - existingTransactionDraft, - existingTransaction: storedTransaction, - draftTransactionIDs, - isSelfTourViewed, - personalDetails, - optimisticChatReportID, - optimisticTransactionID, - }); - } - cleanupAfterSkipConfirmSubmit(overrides.shouldHandleNavigation, { - report, - action, - draftTransactionIDs, - transactionID: existingTransactionID ?? optimisticTransactionID, - isFromGlobalCreate: getIsFromGlobalCreate(transaction), - backToReport, - optimisticChatReportID, - linkedTrackedExpenseReportAction: transaction?.linkedTrackedExpenseReportAction, - }); - }; - submitWithDismissFirst({ - executeWrite: executeExpenseWrite, - destinationReportID: isTrackExpenseSubmit ? (report?.reportID ?? selfDMReport?.reportID) : report?.reportID, - telemetryContext: { - scenario: isTrackExpenseSubmit ? CONST.TELEMETRY.SUBMIT_EXPENSE_SCENARIO.TRACK_EXPENSE : CONST.TELEMETRY.SUBMIT_EXPENSE_SCENARIO.REQUEST_MONEY_MANUAL, - iouType, - requestType: CONST.IOU.REQUEST_TYPE.MANUAL, - isFromGlobalCreate: isEmptyObject(report) || !report?.reportID, - hasReceipt: false, - }, - }); - return; - } - if (isSplitBill && !report.isOwnPolicyExpenseChat && report.participants) { - const participantAccountIDs = Object.keys(report.participants).map((accountID) => Number(accountID)); - setSplitShares(transaction, amountInSmallestCurrencyUnits, selectedCurrency || CONST.CURRENCY.USD, participantAccountIDs, currentUserAccountIDParam); - } - setMoneyRequestParticipantsFromReport(transactionID, report, currentUserPersonalDetails.accountID).then(() => { - navigateToConfirmationPage(iouType, transactionID, reportID, backToReport); - }); - return; - } - - // Starting from global + menu means no participant context exists yet, - // so we need to handle participant selection based on available workspace settings - if (shouldUseDefaultExpensePolicy(iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, currentUserAccountIDParam)) { - const shouldAutoReport = !!defaultExpensePolicy?.autoReporting || !!personalPolicy?.autoReporting; - const targetReport = shouldAutoReport ? getPolicyExpenseChat(currentUserAccountIDParam, defaultExpensePolicy?.id) : selfDMReport; - const transactionReportID = isSelfDM(targetReport) ? CONST.REPORT.UNREPORTED_REPORT_ID : targetReport?.reportID; - const iouTypeTrackOrSubmit = transactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; - const isReturningFromConfirmationPage = !!transaction?.participants?.length; - - const resetToDefaultWorkspace = () => { - setTransactionReport(transactionID, {reportID: transactionReportID}, true); - setMoneyRequestParticipantsFromReport(transactionID, targetReport, currentUserPersonalDetails.accountID).then(() => { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouTypeTrackOrSubmit, transactionID, targetReport?.reportID)); - }); - }; - - if (isReturningFromConfirmationPage) { - const firstParticipant = transaction?.participants?.at(0); - const isP2PChat = isParticipantP2P(firstParticipant); - const isNegativeAmount = convertToBackendAmount(Number.parseFloat(amount)) < 0; - - // P2P chats don't support negative amounts, so reset to default workspace when amount is negative. - if (isP2PChat && isNegativeAmount) { - resetToDefaultWorkspace(); - return; - } - - // Preserve user's participant selection to avoid forcing them back to default workspace. - const iouReportID = transaction?.reportID; - const transactionAssociatedReport = getReportOrReportDraftForAmount(transaction?.reportID); - const selectedReport = iouReportID === CONST.REPORT.UNREPORTED_REPORT_ID ? selfDMReport : transactionAssociatedReport; - const navigationIOUType = isSelfDM(selectedReport) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; - const chatReportID = selectedReport?.chatReportID ?? selectedReport?.reportID; - - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, navigationIOUType, transactionID, chatReportID)); - }); - } else { - resetToDefaultWorkspace(); - } - } else { - Navigation.setNavigationActionToMicrotaskQueue(() => { - navigateToParticipantPage(iouType, transactionID, reportID); - }); - } - }; - - const saveAmountAndCurrency = ({amount, paymentMethod}: AmountParams) => { - const newAmount = convertToBackendAmount(Number.parseFloat(amount)); - - if (!isEditing) { - // Edits to the amount from the splits page should reset the split shares. - if (transaction?.splitShares) { - resetSplitShares(transaction, newAmount, selectedCurrency, currentUserAccountIDParam, true); - } - navigateToNextPage({amount, paymentMethod}); - return; - } - - // If the value hasn't changed, don't request to save changes on the server and just close the modal - const transactionCurrency = getCurrency(currentTransaction); - if (newAmount === getAmount(currentTransaction, false, false, allowNegative, disableOppositeConversion) && selectedCurrency === transactionCurrency) { - navigateBack(); - return; - } - - // If currency has changed, then we get the default tax rate based on currency, otherwise we use the current tax rate selected in transaction, if we have it. - const transactionTaxCode = getTransactionDetails(currentTransaction)?.taxCode; - const defaultTaxCode = getDefaultTaxCode(policy, currentTransaction, selectedCurrency) ?? ''; - const taxCode = (selectedCurrency !== transactionCurrency ? defaultTaxCode : transactionTaxCode) ?? defaultTaxCode; - const taxPercentage = getTaxValue(policy, currentTransaction, taxCode) ?? ''; - const taxAmount = convertToBackendAmount(calculateTaxAmount(taxPercentage, newAmount, decimals)); - - if (isSplitBill) { - setDraftSplitTransaction(transactionID, splitDraftTransaction, {amount: newAmount, currency: selectedCurrency, taxCode, taxAmount}); - navigateBack(); - return; - } - - // Reset split shares for non-split-bill edits (split-bill share recalculation is handled by the confirmation list). - if (transaction?.splitShares) { - resetSplitShares(transaction, newAmount, selectedCurrency, currentUserAccountIDParam, false); - } - - updateMoneyRequestAmountAndCurrency({ + const handleSubmit = ({amount, paymentMethod}: {amount: string; paymentMethod?: PaymentMethodType}) => { + submitAmount({ + report, + transaction, + splitDraftTransaction, + policy, + selectedCurrency, + decimals, + iouType, transactionID, - transactionThreadReport: report, - parentReport, + reportID, + action, + backTo, + backToReport, + shouldKeepUserInput, + shouldSkipConfirmation, + isReportArchived, + currentUserPersonalDetails, + delegateAccountID, + selfDMReport, + defaultExpensePolicy, + personalPolicy, + navigateBack, + amount, + paymentMethod, + transactionDrafts, + transactionViolations, + storedTransaction, parentReportNextStep, - transactions: duplicateTransactions, - transactionViolations: duplicateTransactionViolations, - currency: selectedCurrency, - amount: newAmount, - taxAmount, - policy, - taxCode, - taxValue: taxPercentage, policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], - delegateAccountID, + userBillingGracePeriodEnds, + allReportNVPs, + duplicateTransactions, + duplicateTransactionViolations, }); - navigateBack(); }; const hideCurrencyPicker = () => { @@ -572,7 +237,7 @@ function IOURequestStepAmount({ }} shouldKeepUserInput={transaction?.shouldShowOriginalAmount} onCurrencyButtonPress={showCurrencyPicker} - onSubmitButtonPress={saveAmountAndCurrency} + onSubmitButtonPress={handleSubmit} allowFlippingAmount={!isSplitBill && allowNegative} selectedTab={iouRequestType as SelectedTabRequest} chatReportID={reportID} diff --git a/tests/unit/AmountSubmissionTest.ts b/tests/unit/AmountSubmissionTest.ts index 132643bf711d..c0cefb245491 100644 --- a/tests/unit/AmountSubmissionTest.ts +++ b/tests/unit/AmountSubmissionTest.ts @@ -1,8 +1,9 @@ import Onyx from 'react-native-onyx'; -import {getIsP2PForAmount, getReportOrReportDraftForAmount} from '@pages/iou/request/step/AmountSubmission'; +import type {OnyxEntry} from 'react-native-onyx'; +import {getIsP2PForAmount, getReportOrReportDraftForAmount, submitAmount} from '@pages/iou/request/step/AmountSubmission'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; +import type {PersonalDetails, Policy, Report, Transaction} from '@src/types/onyx'; import {createRandomReport} from '../utils/collections/reports'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -12,8 +13,77 @@ const OTHER_USER_ACCOUNT_ID = 10; jest.mock('@src/libs/Navigation/Navigation', () => ({ navigate: jest.fn(), goBack: jest.fn(), + setNavigationActionToMicrotaskQueue: jest.fn((cb: () => void) => cb()), + dismissModal: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), navigationRef: { getCurrentRoute: jest.fn(() => undefined), + getRootState: jest.fn(() => ({})), + }, +})); + +// Run the executeWrite callback synchronously so we can assert on what the handler ultimately invokes. +jest.mock('@libs/Navigation/helpers/submitWithDismissFirst', () => ({ + submitWithDismissFirst: jest.fn(({executeWrite}: {executeWrite: (overrides: {shouldHandleNavigation: boolean}) => void}) => { + executeWrite({shouldHandleNavigation: false}); + }), +})); + +jest.mock('@libs/Navigation/helpers/cleanupAfterSkipConfirmSubmit', () => jest.fn()); + +const mockSendMoneyElsewhere = jest.fn(); +const mockSendMoneyWithWallet = jest.fn(); +jest.mock('@userActions/IOU/SendMoney', () => ({ + sendMoneyElsewhere: (...args: unknown[]): void => { + mockSendMoneyElsewhere(...args); + }, + sendMoneyWithWallet: (...args: unknown[]): void => { + mockSendMoneyWithWallet(...args); + }, +})); + +const mockTrackExpense = jest.fn(); +const mockRequestMoney = jest.fn(); +jest.mock('@userActions/IOU/TrackExpense', () => ({ + trackExpense: (...args: unknown[]): void => { + mockTrackExpense(...args); + }, + requestMoney: (...args: unknown[]): void => { + mockRequestMoney(...args); + }, +})); + +const mockSetDraftSplitTransaction = jest.fn(); +jest.mock('@userActions/IOU/Split', () => ({ + setDraftSplitTransaction: (...args: unknown[]): void => { + mockSetDraftSplitTransaction(...args); + }, + setSplitShares: jest.fn(), + resetSplitShares: jest.fn(), +})); + +const mockUpdateMoneyRequestAmountAndCurrency = jest.fn(); +jest.mock('@userActions/IOU/UpdateMoneyRequest', () => ({ + updateMoneyRequestAmountAndCurrency: (...args: unknown[]): void => { + mockUpdateMoneyRequestAmountAndCurrency(...args); + }, +})); + +jest.mock('@userActions/IOU/MoneyRequest', () => { + const actual = jest.requireActual>('@userActions/IOU/MoneyRequest'); + return { + ...actual, + setMoneyRequestAmount: jest.fn(), + setMoneyRequestParticipantsFromReport: jest.fn(() => Promise.resolve()), + setMoneyRequestTaxAmount: jest.fn(), + setMoneyRequestTaxRate: jest.fn(), + }; +}); + +const mockSetTransactionReport = jest.fn(); +jest.mock('@userActions/Transaction', () => ({ + setTransactionReport: (...args: unknown[]): void => { + mockSetTransactionReport(...args); }, })); @@ -119,4 +189,231 @@ describe('AmountSubmission', () => { expect(result).toBe(false); }); }); + + describe('submitAmount', () => { + const buildBaseArgs = (overrides: Partial[0]> = {}): Parameters[0] => { + const baseReport: Report = { + ...createRandomReport(100, undefined), + reportID: 'report-100', + participants: { + [CURRENT_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [OTHER_USER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; + const currentUserPersonalDetails: PersonalDetails = { + accountID: CURRENT_USER_ACCOUNT_ID, + login: 'me@test.com', + localCurrencyCode: CONST.CURRENCY.USD, + }; + return { + report: baseReport, + transaction: undefined, + splitDraftTransaction: undefined, + policy: undefined, + selectedCurrency: CONST.CURRENCY.USD, + decimals: 2, + iouType: CONST.IOU.TYPE.SUBMIT, + transactionID: 'tx-1', + reportID: 'report-100', + action: CONST.IOU.ACTION.CREATE, + backTo: undefined, + backToReport: undefined, + shouldKeepUserInput: false, + shouldSkipConfirmation: false, + isReportArchived: false, + currentUserPersonalDetails, + delegateAccountID: undefined, + selfDMReport: undefined, + defaultExpensePolicy: undefined, + personalPolicy: undefined, + navigateBack: jest.fn(), + amount: '10', + paymentMethod: undefined, + transactionDrafts: {}, + transactionViolations: {}, + storedTransaction: undefined, + parentReportNextStep: undefined, + policyCategories: undefined, + userBillingGracePeriodEnds: {}, + allReportNVPs: {}, + duplicateTransactions: {}, + duplicateTransactionViolations: {}, + ...overrides, + }; + }; + + beforeEach(() => { + mockSendMoneyElsewhere.mockClear(); + mockSendMoneyWithWallet.mockClear(); + mockTrackExpense.mockClear(); + mockRequestMoney.mockClear(); + mockSetDraftSplitTransaction.mockClear(); + mockUpdateMoneyRequestAmountAndCurrency.mockClear(); + mockSetTransactionReport.mockClear(); + }); + + it('calls sendMoneyElsewhere on non-edit + skip-confirm + PAY (non-wallet)', () => { + submitAmount( + buildBaseArgs({ + iouType: CONST.IOU.TYPE.PAY, + shouldSkipConfirmation: true, + paymentMethod: undefined, + }), + ); + + expect(mockSendMoneyElsewhere).toHaveBeenCalledTimes(1); + expect(mockSendMoneyWithWallet).not.toHaveBeenCalled(); + }); + + it('calls sendMoneyWithWallet on non-edit + skip-confirm + PAY + EXPENSIFY payment method', () => { + submitAmount( + buildBaseArgs({ + iouType: CONST.IOU.TYPE.PAY, + shouldSkipConfirmation: true, + paymentMethod: CONST.IOU.PAYMENT_TYPE.EXPENSIFY, + }), + ); + + expect(mockSendMoneyWithWallet).toHaveBeenCalledTimes(1); + expect(mockSendMoneyElsewhere).not.toHaveBeenCalled(); + }); + + it('calls trackExpense on non-edit + skip-confirm + TRACK', () => { + submitAmount( + buildBaseArgs({ + iouType: CONST.IOU.TYPE.TRACK, + shouldSkipConfirmation: true, + }), + ); + + expect(mockTrackExpense).toHaveBeenCalledTimes(1); + }); + + it('calls requestMoney on non-edit + skip-confirm + SUBMIT', () => { + submitAmount( + buildBaseArgs({ + iouType: CONST.IOU.TYPE.SUBMIT, + shouldSkipConfirmation: true, + }), + ); + + expect(mockRequestMoney).toHaveBeenCalledTimes(1); + }); + + it('calls requestMoney on non-edit + skip-confirm + REQUEST', () => { + submitAmount( + buildBaseArgs({ + iouType: CONST.IOU.TYPE.REQUEST, + shouldSkipConfirmation: true, + }), + ); + + expect(mockRequestMoney).toHaveBeenCalledTimes(1); + }); + + it('calls setTransactionReport when CREATE iouType + no report context + default expense policy applies', () => { + const defaultExpensePolicy: OnyxEntry = { + id: 'policy-1', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + name: 'Test Workspace', + owner: 'me@test.com', + outputCurrency: CONST.CURRENCY.USD, + isPolicyExpenseChatEnabled: true, + autoReporting: false, + } as Policy; + + submitAmount( + buildBaseArgs({ + iouType: CONST.IOU.TYPE.CREATE, + report: undefined, + transaction: undefined, + defaultExpensePolicy, + }), + ); + + expect(mockSetTransactionReport).toHaveBeenCalledTimes(1); + }); + + it('navigateBack is called on edit when amount and currency are unchanged', () => { + const navigateBack = jest.fn(); + const tx: Transaction = { + transactionID: 'tx-1', + amount: -1000, + currency: CONST.CURRENCY.USD, + created: '2024-01-01', + merchant: 'Test', + reportID: 'report-100', + comment: {}, + }; + + submitAmount( + buildBaseArgs({ + action: CONST.IOU.ACTION.EDIT, + transaction: tx, + selectedCurrency: CONST.CURRENCY.USD, + amount: '10', + navigateBack, + }), + ); + + expect(navigateBack).toHaveBeenCalledTimes(1); + expect(mockUpdateMoneyRequestAmountAndCurrency).not.toHaveBeenCalled(); + }); + + it('calls setDraftSplitTransaction on edit + split bill', () => { + const splitDraft: Transaction = { + transactionID: 'split-1', + amount: -1000, + currency: CONST.CURRENCY.USD, + created: '2024-01-01', + merchant: 'Test', + reportID: 'report-100', + comment: {}, + }; + + submitAmount( + buildBaseArgs({ + action: CONST.IOU.ACTION.EDIT, + iouType: CONST.IOU.TYPE.SPLIT, + transaction: splitDraft, + splitDraftTransaction: splitDraft, + selectedCurrency: CONST.CURRENCY.USD, + amount: '25', + }), + ); + + expect(mockSetDraftSplitTransaction).toHaveBeenCalledTimes(1); + expect(mockUpdateMoneyRequestAmountAndCurrency).not.toHaveBeenCalled(); + }); + + it('calls updateMoneyRequestAmountAndCurrency on edit + non-split when amount changes', () => { + const tx: Transaction = { + transactionID: 'tx-1', + amount: -1000, + currency: CONST.CURRENCY.USD, + created: '2024-01-01', + merchant: 'Test', + reportID: 'report-100', + comment: {}, + }; + + submitAmount( + buildBaseArgs({ + action: CONST.IOU.ACTION.EDIT, + transaction: tx, + selectedCurrency: CONST.CURRENCY.USD, + amount: '99', + }), + ); + + expect(mockUpdateMoneyRequestAmountAndCurrency).toHaveBeenCalledTimes(1); + expect(mockUpdateMoneyRequestAmountAndCurrency).toHaveBeenCalledWith( + expect.objectContaining({ + transactionID: 'tx-1', + currency: CONST.CURRENCY.USD, + }), + ); + }); + }); }); From b75911facdc47fab82d1ec48d57de81d3dc179af Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 15 Jun 2026 15:54:24 +0200 Subject: [PATCH 2/3] drop redundant useCallback --- src/pages/iou/request/step/IOURequestStepAmount.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 4486ebe3858a..099fab54250d 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -151,9 +151,9 @@ function IOURequestStepAmount({ setSelectedCurrency(originalCurrency); }, [originalCurrency]); - const navigateBack = useCallback(() => { + const navigateBack = () => { Navigation.goBack(backTo); - }, [backTo]); + }; const handleSubmit = ({amount, paymentMethod}: {amount: string; paymentMethod?: PaymentMethodType}) => { submitAmount({ From 7f07d18435bdae4d63eeeb741c48e48a44ce3464 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 17 Jun 2026 09:54:26 +0200 Subject: [PATCH 3/3] fix: preserve reportDraft lookup --- src/pages/iou/request/step/AmountSubmission.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/pages/iou/request/step/AmountSubmission.ts b/src/pages/iou/request/step/AmountSubmission.ts index 51c18f297745..f90a446aee11 100644 --- a/src/pages/iou/request/step/AmountSubmission.ts +++ b/src/pages/iou/request/step/AmountSubmission.ts @@ -18,7 +18,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import Permissions from '@libs/Permissions'; -import {getPolicyExpenseChat, getTransactionDetails, isSelfDM, shouldEnableNegative} from '@libs/ReportUtils'; +import {getPolicyExpenseChat, getTransactionDetails, isMoneyRequestReport, isSelfDM, shouldEnableNegative} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy'; import {calculateTaxAmount, getAmount, getCurrency, getDefaultTaxCode, getIsFromGlobalCreate, getTaxValue, hasReceipt} from '@libs/TransactionUtils'; import { @@ -289,20 +289,14 @@ function submitAmount({ if (report?.reportID && !isReportArchived && iouType !== CONST.IOU.TYPE.CREATE) { const selectedParticipants = getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID); const reportAttributesReports = reportAttributesDerivedValue?.reports; + const reportIDToCheck = isMoneyRequestReport(report) ? report?.chatReportID : report?.reportID; + const reportDraft = allReportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${reportIDToCheck}`]; const participants = selectedParticipants.map((participant) => { const participantAccountID = participant?.accountID ?? CONST.DEFAULT_NUMBER_ID; const privateIsArchived = !!allReportNVPs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`]?.private_isArchived; return participantAccountID ? getParticipantsOption(participant, allPersonalDetails) - : getReportOption( - participant, - privateIsArchived, - policy, - allPersonalDetails, - conciergeReportID, - reportAttributesReports, - allReportDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${participant.reportID}`], - ); + : getReportOption(participant, privateIsArchived, policy, allPersonalDetails, conciergeReportID, reportAttributesReports, reportDraft); }); const backendAmount = convertToBackendAmount(Number.parseFloat(amount));