diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 6a888a09b60b..82a5d36101fe 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -211,9 +211,8 @@ function MoneyRequestConfirmationList({ } const defaultRate = defaultMileageRate?.customUnitRateID ?? ''; - const lastSelectedRate = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? defaultRate; - const rateID = lastSelectedRate; - IOU.setCustomUnitRateID(transactionID, rateID); + const lastSelectedRateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? defaultRate; + IOU.setCustomUnitRateID(transactionID, lastSelectedRateID); }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, transactionID, isDistanceRequest]); const mileageRate = DistanceRequestUtils.getRate({transaction, policy, policyDraft}); @@ -278,6 +277,18 @@ function MoneyRequestConfirmationList({ const [didConfirm, setDidConfirm] = useState(isConfirmed); const [didConfirmSplit, setDidConfirmSplit] = useState(false); + // Clear the form error if it's set to one among the list passed as an argument + const clearFormErrors = useCallback( + (errors: string[]) => { + if (!errors.includes(formError)) { + return; + } + + setFormError(''); + }, + [formError, setFormError], + ); + const shouldDisplayFieldError: boolean = useMemo(() => { if (!isEditingSplitBill) { return false; @@ -305,6 +316,32 @@ function MoneyRequestConfirmationList({ const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0); + useEffect(() => { + // We want this effect to run only when the transaction is moving from Self DM to a workspace chat + if (!isDistanceRequest || !isMovingTransactionFromTrackExpense || !isPolicyExpenseChat) { + return; + } + + const errorKey = 'iou.error.invalidRate'; + const policyRates = DistanceRequestUtils.getMileageRates(policy); + + // If the selected rate belongs to the policy, clear the error + if (Object.keys(policyRates).includes(customUnitRateID)) { + clearFormErrors([errorKey]); + return; + } + + // If there is a distance rate in the policy that matches the rate and unit of the currently selected mileage rate, select it automatically + const matchingRate = Object.values(policyRates).find((policyRate) => policyRate.rate === mileageRate.rate && policyRate.unit === mileageRate.unit); + if (matchingRate?.customUnitRateID) { + IOU.setCustomUnitRateID(transactionID, matchingRate.customUnitRateID); + return; + } + + // If none of the above conditions are met, display the rate error + setFormError(errorKey); + }, [isDistanceRequest, isPolicyExpenseChat, transactionID, mileageRate, customUnitRateID, policy, isMovingTransactionFromTrackExpense, setFormError, clearFormErrors]); + useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); @@ -315,7 +352,7 @@ function MoneyRequestConfirmationList({ return; } // reset the form error whenever the screen gains or loses focus - setFormError(''); + clearFormErrors(['iou.error.genericSmartscanFailureMessage', 'iou.receiptScanningFailed']); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run if it's just setFormError that changes }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); @@ -470,8 +507,8 @@ function MoneyRequestConfirmationList({ return; } - setFormError(''); - }, [isFocused, transaction, isTypeSplit, transaction?.splitShares, currentUserPersonalDetails.accountID, iouAmount, iouCurrencyCode, setFormError, translate]); + clearFormErrors(['iou.error.invalidSplit', 'iou.error.invalidSplitParticipants', 'iou.error.invalidSplitYourself']); + }, [isFocused, transaction, isTypeSplit, transaction?.splitShares, currentUserPersonalDetails.accountID, iouAmount, iouCurrencyCode, setFormError, translate, clearFormErrors]); useEffect(() => { if (!isTypeSplit || !transaction?.splitShares) { @@ -638,7 +675,9 @@ function MoneyRequestConfirmationList({ }, [isTypeSplit, translate, payeePersonalDetails, getSplitSectionHeader, splitParticipants, selectedParticipants]); useEffect(() => { - if (!isDistanceRequest || isMovingTransactionFromTrackExpense) { + if (!isDistanceRequest || (isMovingTransactionFromTrackExpense && !isPolicyExpenseChat)) { + // We don't want to recalculate the distance merchant when moving a transaction from Track Expense to a 1:1 chat, because the distance rate will be the same default P2P rate. + // When moving to a policy chat (e.g. sharing with an accountant), we should recalculate the distance merchant with the policy's rate. return; } @@ -661,6 +700,7 @@ function MoneyRequestConfirmationList({ translate, toLocaleDigit, isDistanceRequest, + isPolicyExpenseChat, transaction, transactionID, action, diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 340be8a6c3e1..c391564a6d3d 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -259,6 +259,7 @@ function MoneyRequestConfirmationListFooter({ const taxRateTitle = TransactionUtils.getTaxName(policy, transaction); // Determine if the merchant error should be displayed const shouldDisplayMerchantError = isMerchantRequired && (shouldDisplayFieldError || formError === 'iou.error.invalidMerchant') && isMerchantEmpty; + const shouldDisplayDistanceRateError = formError === 'iou.error.invalidRate'; // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") const shouldShowReceiptEmptyState = iouType === CONST.IOU.TYPE.SUBMIT && PolicyUtils.isPaidGroupPolicy(policy); const { @@ -369,6 +370,7 @@ function MoneyRequestConfirmationListFooter({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + brickRoadIndicator={shouldDisplayDistanceRateError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} disabled={didConfirm} interactive={!!rate && !isReadOnly && isPolicyExpenseChat} /> diff --git a/src/languages/en.ts b/src/languages/en.ts index 7088d9df8a51..e392d858e458 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -98,6 +98,7 @@ import type { MarkedReimbursedParams, MarkReimbursedFromIntegrationParams, MissingPropertyParams, + MovedFromSelfDMParams, NoLongerHaveAccessParams, NotAllowedExtensionParams, NotYouParams, @@ -979,6 +980,7 @@ const translations = { threadExpenseReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} ${comment ? `for ${comment}` : 'expense'}`, threadTrackReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Tracking ${formattedAmount} ${comment ? `for ${comment}` : ''}`, threadPaySomeoneReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`, + movedFromSelfDM: ({workspaceName, reportName}: MovedFromSelfDMParams) => `moved expense from self DM to ${workspaceName ?? `chat with ${reportName}`}`, tagSelection: 'Select a tag to better organize your spend.', categorySelection: 'Select a category to better organize your spend.', error: { @@ -1008,6 +1010,7 @@ const translations = { splitExpenseMultipleParticipantsErrorMessage: 'An expense cannot be split between a workspace and other members. Please update your selection.', invalidMerchant: 'Please enter a correct merchant.', atLeastOneAttendee: 'At least one attendee must be selected', + invalidRate: 'Rate not valid for this workspace. Please select an available rate from the workspace.', }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `started settling up. Payment is on hold until ${submitterDisplayName} enables their wallet.`, enableWallet: 'Enable wallet', diff --git a/src/languages/es.ts b/src/languages/es.ts index 47e11bf716ac..e9676ed2e8c0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -97,6 +97,7 @@ import type { MarkedReimbursedParams, MarkReimbursedFromIntegrationParams, MissingPropertyParams, + MovedFromSelfDMParams, NoLongerHaveAccessParams, NotAllowedExtensionParams, NotYouParams, @@ -977,6 +978,7 @@ const translations = { threadExpenseReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${comment ? `${formattedAmount} para ${comment}` : `Gasto de ${formattedAmount}`}`, threadTrackReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Seguimiento ${formattedAmount} ${comment ? `para ${comment}` : ''}`, threadPaySomeoneReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`, + movedFromSelfDM: ({workspaceName, reportName}: MovedFromSelfDMParams) => `movió el gasto desde su propio mensaje directo a ${workspaceName ?? `un chat con ${reportName}`}`, tagSelection: 'Selecciona una etiqueta para organizar mejor tus gastos.', categorySelection: 'Selecciona una categoría para organizar mejor tus gastos.', error: { @@ -1006,6 +1008,7 @@ const translations = { splitExpenseMultipleParticipantsErrorMessage: 'Solo puedes dividir un gasto entre un único espacio de trabajo o con miembros individuales. Por favor, actualiza tu selección.', invalidMerchant: 'Por favor, introduce un comerciante correcto.', atLeastOneAttendee: 'Debe seleccionarse al menos un asistente', + invalidRate: 'Tasa no válida para este espacio de trabajo. Por favor, selecciona una tasa disponible en el espacio de trabajo.', }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su billetera`, enableWallet: 'Habilitar billetera', diff --git a/src/languages/params.ts b/src/languages/params.ts index 3088b99e753b..9ac9cd585f4a 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -165,6 +165,8 @@ type ThreadRequestReportNameParams = {formattedAmount: string; comment: string}; type ThreadSentMoneyReportNameParams = {formattedAmount: string; comment: string}; +type MovedFromSelfDMParams = {workspaceName?: string; reportName?: string}; + type SizeExceededParams = {maxUploadSizeInMB: number}; type ResolutionConstraintsParams = {minHeightInPx: number; minWidthInPx: number; maxHeightInPx: number; maxWidthInPx: number}; @@ -667,7 +669,7 @@ export type { LoggedInAsParams, ManagerApprovedAmountParams, ManagerApprovedParams, - SignUpNewFaceCodeParams, + MovedFromSelfDMParams, NoLongerHaveAccessParams, NotAllowedExtensionParams, NotYouParams, @@ -701,6 +703,7 @@ export type { SetTheRequestParams, SettleExpensifyCardParams, SettledAfterAddedBankAccountParams, + SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, StepCounterParams, diff --git a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts index 78eb0adecc5e..7b322189838a 100644 --- a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts +++ b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts @@ -20,6 +20,12 @@ type CategorizeTrackedExpenseParams = { taxCode: string; taxAmount: number; billable?: boolean; + waypoints?: string; + customUnitRateID?: string; + policyExpenseChatReportID?: string; + policyExpenseCreatedReportActionID?: string; + adminsChatReportID?: string; + adminsCreatedReportActionID?: string; }; export default CategorizeTrackedExpenseParams; diff --git a/src/libs/API/parameters/ShareTrackedExpenseParams.ts b/src/libs/API/parameters/ShareTrackedExpenseParams.ts index cee4bc40d9ac..d44a7af5a444 100644 --- a/src/libs/API/parameters/ShareTrackedExpenseParams.ts +++ b/src/libs/API/parameters/ShareTrackedExpenseParams.ts @@ -20,6 +20,12 @@ type ShareTrackedExpenseParams = { taxCode: string; taxAmount: number; billable?: boolean; + waypoints?: string; + customUnitRateID?: string; + policyExpenseChatReportID?: string; + policyExpenseCreatedReportActionID?: string; + adminsChatReportID?: string; + adminsCreatedReportActionID?: string; }; export default ShareTrackedExpenseParams; diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 2ad25f77c249..24998db0e5cd 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -1,15 +1,17 @@ +import isEmpty from 'lodash/isEmpty'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PolicyTagLists, ReportAction} from '@src/types/onyx'; +import type {PolicyTagLists, Report, ReportAction} from '@src/types/onyx'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import * as Localize from './Localize'; import Log from './Log'; import * as PolicyUtils from './PolicyUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; -import * as ReportConnection from './ReportConnection'; +// eslint-disable-next-line import/no-cycle +import {buildReportNameFromParticipantNames, getPolicyExpenseChatName, getPolicyName, getRootParentReport, isPolicyExpenseChat} from './ReportUtils'; import * as TransactionUtils from './TransactionUtils'; let allPolicyTags: OnyxCollection = {}; @@ -25,6 +27,13 @@ Onyx.connect({ }, }); +let allReports: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => (allReports = value), +}); + /** * Utility to get message based on boolean literal value. */ @@ -126,6 +135,20 @@ function getForDistanceRequest(newMerchant: string, oldMerchant: string, newAmou }); } +function getForExpenseMovedFromSelfDM(destinationReportID: string) { + const destinationReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${destinationReportID}`]; + const rootParentReport = getRootParentReport(destinationReport); + + // The "Move report" flow only supports moving expenses to a policy expense chat or a 1:1 DM. + const reportName = isPolicyExpenseChat(rootParentReport) ? getPolicyExpenseChatName(rootParentReport) : buildReportNameFromParticipantNames({report: rootParentReport}); + const policyName = getPolicyName(rootParentReport, true); + + return Localize.translateLocal('iou.movedFromSelfDM', { + reportName, + workspaceName: !isEmpty(policyName) ? policyName : undefined, + }); +} + /** * Get the report action message when expense has been modified. * @@ -136,8 +159,13 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr if (!ReportActionsUtils.isModifiedExpenseAction(reportAction)) { return ''; } + const reportActionOriginalMessage = ReportActionsUtils.getOriginalMessage(reportAction); - const policyID = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.policyID ?? '-1'; + const policyID = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.policyID ?? '-1'; + + if (reportActionOriginalMessage?.movedToReportID) { + return getForExpenseMovedFromSelfDM(reportActionOriginalMessage.movedToReportID); + } const removalFragments: string[] = []; const setFragments: string[] = []; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a304ee800131..0e75159b20d4 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -73,6 +73,7 @@ import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Localize from './Localize'; import Log from './Log'; import {isEmailPublicDomain} from './LoginUtils'; +// eslint-disable-next-line import/no-cycle import ModifiedExpenseMessage from './ModifiedExpenseMessage'; import linkingConfig from './Navigation/linkingConfig'; import Navigation from './Navigation/Navigation'; @@ -3915,6 +3916,21 @@ const reportNameCache = new Map): string => `${report?.reportID}-${report?.lastVisibleActionCreated}-${report?.reportName}`; +/** + * Get the title for a report using only participant names. This may be used for 1:1 DMs and other non-categorized chats. + */ +function buildReportNameFromParticipantNames({report, personalDetails}: {report: OnyxEntry; personalDetails?: Partial}) { + const participantsWithoutCurrentUser: number[] = []; + Object.keys(report?.participants ?? {}).forEach((accountID) => { + const accID = Number(accountID); + if (accID !== currentUserAccountID && participantsWithoutCurrentUser.length < 5) { + participantsWithoutCurrentUser.push(accID); + } + }); + const isMultipleParticipantReport = participantsWithoutCurrentUser.length > 1; + return participantsWithoutCurrentUser.map((accountID) => getDisplayNameForParticipant(accountID, isMultipleParticipantReport, true, false, personalDetails)).join(', '); +} + /** * Get the title for a report. */ @@ -4074,16 +4090,7 @@ function getReportName( } // Not a room or PolicyExpenseChat, generate title from first 5 other participants - const participantsWithoutCurrentUser: number[] = []; - Object.keys(report?.participants ?? {}).forEach((accountID) => { - const accID = Number(accountID); - if (accID !== currentUserAccountID && participantsWithoutCurrentUser.length < 5) { - participantsWithoutCurrentUser.push(accID); - } - }); - const isMultipleParticipantReport = participantsWithoutCurrentUser.length > 1; - const participantNames = participantsWithoutCurrentUser.map((accountID) => getDisplayNameForParticipant(accountID, isMultipleParticipantReport, true, false, personalDetails)).join(', '); - formattedName = participantNames; + formattedName = buildReportNameFromParticipantNames({report, personalDetails}); if (reportID) { reportNameCache.set(cacheKey, {lastVisibleActionCreated: report?.lastVisibleActionCreated ?? '', reportName: formattedName}); @@ -8519,6 +8526,7 @@ export { buildOptimisticWorkspaceChats, buildOptimisticCardAssignedReportAction, buildParticipantsFromAccountIDs, + buildReportNameFromParticipantNames, buildTransactionThread, canAccessReport, isReportNotFound, @@ -8606,6 +8614,7 @@ export { getPersonalDetailsForAccountID, getPolicyDescriptionText, getPolicyExpenseChat, + getPolicyExpenseChatName, getPolicyName, getPolicyType, getReimbursementDeQueuedActionMessage, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 6c804ba8d256..183706250d0a 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -8,6 +8,7 @@ import ReceiptGeneric from '@assets/images/receipt-generic.png'; import * as API from '@libs/API'; import type { ApproveMoneyRequestParams, + CategorizeTrackedExpenseParams as CategorizeTrackedExpenseApiParams, CompleteSplitBillParams, CreateDistanceRequestParams, CreateWorkspaceParams, @@ -21,6 +22,7 @@ import type { SendInvoiceParams, SendMoneyParams, SetNameValuePairParams, + ShareTrackedExpenseParams as ShareTrackedExpenseApiParams, SplitBillParams, StartSplitBillParams, SubmitReportParams, @@ -125,6 +127,8 @@ type CategorizeTrackedExpenseTransactionParams = { tag?: string; billable?: boolean; receipt?: Receipt; + waypoints?: string; + customUnitRateID?: string; }; type CategorizeTrackedExpensePolicyParams = { policyID: string; @@ -587,7 +591,31 @@ function setMoneyRequestReceipt(transactionID: string, source: string, filename: * Set custom unit rateID for the transaction draft */ function setCustomUnitRateID(transactionID: string, customUnitRateID: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {customUnit: {customUnitRateID}}}); + const isFakeP2PRate = customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID; + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + comment: { + customUnit: { + customUnitRateID, + ...(!isFakeP2PRate && {defaultP2PRate: null}), + }, + }, + }); +} + +/** + * Revert custom unit of the draft transaction to the original transaction's value + */ +function resetDraftTransactionsCustomUnit(transactionID: string) { + const originalTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!originalTransaction) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + comment: { + customUnit: originalTransaction.comment?.customUnit ?? {}, + }, + }); } /** Set the distance rate of a new transaction */ @@ -3586,8 +3614,8 @@ function categorizeTrackedExpense(trackedExpenseParams: CategorizeTrackedExpense optimisticData?.push(...moveTransactionOptimisticData); successData?.push(...moveTransactionSuccessData); failureData?.push(...moveTransactionFailureData); - const parameters = { - onyxData, + + const parameters: CategorizeTrackedExpenseApiParams = { ...reportInformation, ...policyParams, ...transactionParams, @@ -3630,6 +3658,8 @@ function shareTrackedExpense( taxAmount = 0, billable?: boolean, receipt?: Receipt, + waypoints?: string, + customUnitRateID?: string, createdWorkspaceParams?: CreateWorkspaceParams, ) { const {optimisticData, successData, failureData} = onyxData ?? {}; @@ -3653,7 +3683,7 @@ function shareTrackedExpense( successData?.push(...moveTransactionSuccessData); failureData?.push(...moveTransactionFailureData); - const parameters = { + const parameters: ShareTrackedExpenseApiParams = { policyID, transactionID, moneyRequestPreviewReportActionID, @@ -3673,6 +3703,8 @@ function shareTrackedExpense( taxAmount, billable, receipt, + waypoints, + customUnitRateID, policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, @@ -3910,6 +3942,7 @@ function trackExpense( // Pass an open receipt so the distance expense will show a map with the route optimistically const trackedReceipt = validWaypoints ? {source: ReceiptGeneric as ReceiptSource, state: CONST.IOU.RECEIPT_STATE.OPEN} : receipt; + const sanitizedWaypoints = validWaypoints ? JSON.stringify(sanitizeRecentWaypoints(validWaypoints)) : undefined; const { createdWorkspaceParams, @@ -3964,7 +3997,7 @@ function trackExpense( if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) { return; } - const transactionParams = { + const transactionParams: CategorizeTrackedExpenseTransactionParams = { transactionID: transaction?.transactionID ?? '-1', amount, currency, @@ -3977,12 +4010,14 @@ function trackExpense( tag, billable, receipt: trackedReceipt, + waypoints: sanitizedWaypoints, + customUnitRateID, }; - const policyParams = { + const policyParams: CategorizeTrackedExpensePolicyParams = { policyID: chatReport?.policyID ?? '-1', isDraftPolicy, }; - const reportInformation = { + const reportInformation: CategorizeTrackedExpenseReportInformation = { moneyRequestPreviewReportActionID: iouAction?.reportActionID ?? '-1', moneyRequestReportID: iouReport?.reportID ?? '-1', moneyRequestCreatedReportActionID: createdIOUReportActionID ?? '-1', @@ -3992,7 +4027,7 @@ function trackExpense( transactionThreadReportID: transactionThreadReportID ?? '-1', reportPreviewReportActionID: reportPreviewAction?.reportActionID ?? '-1', }; - const trackedExpenseParams = { + const trackedExpenseParams: CategorizeTrackedExpenseParams = { onyxData, reportInformation, transactionParams, @@ -4030,6 +4065,8 @@ function trackExpense( taxAmount, billable, trackedReceipt, + sanitizedWaypoints, + customUnitRateID, createdWorkspaceParams, ); break; @@ -4059,7 +4096,7 @@ function trackExpense( receiptGpsPoints: gpsPoints ? JSON.stringify(gpsPoints) : undefined, transactionThreadReportID: transactionThreadReportID ?? '-1', createdReportActionIDForThread: createdReportActionIDForThread ?? '-1', - waypoints: validWaypoints ? JSON.stringify(sanitizeRecentWaypoints(validWaypoints)) : undefined, + waypoints: sanitizedWaypoints, customUnitRateID, }; if (actionableWhisperReportActionIDParam) { @@ -8738,6 +8775,7 @@ export { replaceReceipt, requestMoney, resetSplitShares, + resetDraftTransactionsCustomUnit, savePreferredPaymentMethod, sendInvoice, sendMoneyElsewhere, diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 989277bb5fc1..39634eb061b9 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -135,6 +135,19 @@ function IOURequestStepConfirmation({ useFetchRoute(transaction, transaction?.comment?.waypoints, action, IOUUtils.shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT); + useEffect( + // This effect runs on the component unmount. It resets the custom unit rate ID of the transaction if it's moving from Track Expense. + // This is needed to revert the rate back to the original FAKE_P2P_RATE_ID when changing the destination workspace. + () => () => { + if (!isMovingTransactionFromTrackExpense) { + return; + } + + IOU.resetDraftTransactionsCustomUnit(transactionID); + }, + [isMovingTransactionFromTrackExpense, transactionID], + ); + useEffect(() => { const policyExpenseChat = participants?.find((participant) => participant.isPolicyExpenseChat); if (policyExpenseChat?.policyID && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index fb2484ea414f..df0504a25c01 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -44,6 +44,7 @@ function IOURequestStepParticipants({ const numberOfParticipants = useRef(participants?.length ?? 0); const iouRequestType = TransactionUtils.getRequestType(transaction); const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT; + const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); const headerTitle = useMemo(() => { if (action === CONST.IOU.ACTION.CATEGORIZE) { return translate('iou.categorize'); @@ -75,23 +76,27 @@ function IOURequestStepParticipants({ // the image ceases to exist. The best way for the user to recover from this is to start over from the start of the expense process. // skip this in case user is moving the transaction as the receipt path will be valid in that case useEffect(() => { - if (IOUUtils.isMovingTransactionFromTrackExpense(action)) { + if (isMovingTransactionFromTrackExpense) { return; } IOU.navigateToStartStepIfScanFileCannotBeRead(receiptFilename ?? '', receiptPath ?? '', () => {}, iouRequestType, iouType, transactionID, reportID, receiptType ?? ''); - }, [receiptType, receiptPath, receiptFilename, iouRequestType, iouType, transactionID, reportID, action]); + }, [receiptType, receiptPath, receiptFilename, iouRequestType, iouType, transactionID, reportID, isMovingTransactionFromTrackExpense]); const addParticipant = useCallback( (val: Participant[]) => { HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS); const firstParticipantReportID = val.at(0)?.reportID ?? ''; - const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID); const isInvoice = iouType === CONST.IOU.TYPE.INVOICE && ReportUtils.isInvoiceRoomWithID(firstParticipantReportID); numberOfParticipants.current = val.length; - IOU.setMoneyRequestParticipants(transactionID, val); - IOU.setCustomUnitRateID(transactionID, rateID); + + if (!isMovingTransactionFromTrackExpense) { + // When moving the transaction, keep the original rate and let the user manually change it to the one they want from the workspace. + // Otherwise, select the default one automatically. + const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID); + IOU.setCustomUnitRateID(transactionID, rateID); + } // When multiple participants are selected, the reportID is generated at the end of the confirmation step. // So we are resetting selectedReportID ref to the reportID coming from params. @@ -103,7 +108,7 @@ function IOURequestStepParticipants({ // When a participant is selected, the reportID needs to be saved because that's the reportID that will be used in the confirmation step. selectedReportID.current = firstParticipantReportID || reportID; }, - [iouType, reportID, transactionID], + [iouType, reportID, transactionID, isMovingTransactionFromTrackExpense], ); const goToNextStep = useCallback(() => {