From 526afbe6da41b8ea3f6de471b7b1af429909215a Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 14 Sep 2026 14:17:18 +0430 Subject: [PATCH 01/27] Fix distance preview mismatch with expense details header --- .../TransactionPreviewContent.tsx | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index 76c33eb07c81..d4e4a32490d4 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -11,6 +11,7 @@ import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -25,6 +26,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {calculateAmount} from '@libs/IOUUtils'; import Parser from '@libs/Parser'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; +import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {isMarkAsCashActionForTransaction} from '@libs/ReportPrimaryActionUtils'; @@ -33,7 +35,14 @@ import {canEditMoneyRequest, getTransactionDetails, isPolicyExpenseChat, isRepor import StringUtils from '@libs/StringUtils'; import type {TranslationPathOrText} from '@libs/TransactionPreviewUtils'; import {createTransactionPreviewConditionals, getIOUPayerAndReceiver, getTransactionPreviewTextAndTranslationPaths} from '@libs/TransactionPreviewUtils'; -import {isManagedCardTransaction as isCardTransactionUtils, isGPSDistanceRequest, isMapDistanceRequest, isScanning} from '@libs/TransactionUtils'; +import { + getDisplayTransactionWithoutInvalidCommuterExclusion, + isDistanceRequest, + isManagedCardTransaction as isCardTransactionUtils, + isGPSDistanceRequest, + isMapDistanceRequest, + isScanning, +} from '@libs/TransactionUtils'; import ViolationsUtils, {filterReceiptViolations} from '@libs/Violations/ViolationsUtils'; import {fontScale} from '@styles/typography'; @@ -81,12 +90,26 @@ function TransactionPreviewContent({ const theme = useTheme(); const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); - const {convertToDisplayString, getCurrencyDecimals} = useCurrencyListActions(); + const {convertToDisplayString, getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); const {environmentURL} = useEnvironment(); const isParentPolicyExpenseChat = isPolicyExpenseChat(chatReport); + const customUnitRateID = isDistanceRequest(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; + const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(policy, customUnitRateID); + const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); + const displayTransaction = useMemo( + () => + getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: isParentPolicyExpenseChat, + policy: distanceOriginalPolicy ?? policy, + translate, + getCurrencySymbol, + }), + [transaction, isParentPolicyExpenseChat, distanceOriginalPolicy, policy, translate, getCurrencySymbol], + ); const transactionDetails = useMemo>( - () => getTransactionDetails(transaction, undefined, policy, isParentPolicyExpenseChat) ?? {}, - [transaction, policy, isParentPolicyExpenseChat], + () => getTransactionDetails(displayTransaction, undefined, policy, isParentPolicyExpenseChat) ?? {}, + [displayTransaction, policy, isParentPolicyExpenseChat], ); const {amount, comment: requestComment, merchant, category, currency: requestCurrency} = transactionDetails; const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`); From b585e1802309f34f037a7ffb5b0eb0190c8586e2 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 14 Sep 2026 15:58:33 +0430 Subject: [PATCH 02/27] Sync distance preview accessibility amount with visible display --- .../TransactionPreview/index.tsx | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionItem/TransactionPreview/index.tsx b/src/components/ReportActionItem/TransactionPreview/index.tsx index b1850100660e..482b1cfa1908 100644 --- a/src/components/ReportActionItem/TransactionPreview/index.tsx +++ b/src/components/ReportActionItem/TransactionPreview/index.tsx @@ -3,6 +3,7 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeed import {showContextMenuForReport, useShowContextMenuActions, useShowContextMenuState} from '@components/ShowContextMenuContext'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useTransactionViolations from '@hooks/useTransactionViolations'; @@ -11,10 +12,18 @@ import ControlSelection from '@libs/ControlSelection'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; +import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction as isMoneyRequestActionReportActionsUtils} from '@libs/ReportActionsUtils'; -import {getTransactionDetails} from '@libs/ReportUtils'; +import {getTransactionDetails, isPolicyExpenseChat} from '@libs/ReportUtils'; import {getReviewNavigationRoute} from '@libs/TransactionPreviewUtils'; -import {getExpenseTypeTranslationKey, getOriginalTransactionWithSplitInfo, getTransactionType, removeSettledAndApprovedTransactions} from '@libs/TransactionUtils'; +import { + getDisplayTransactionWithoutInvalidCommuterExclusion, + getExpenseTypeTranslationKey, + getOriginalTransactionWithSplitInfo, + getTransactionType, + isDistanceRequest, + removeSettledAndApprovedTransactions, +} from '@libs/TransactionUtils'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; import type {TransactionDuplicateNavigatorParamList} from '@navigation/types'; @@ -40,7 +49,7 @@ import TransactionPreviewContent from './TransactionPreviewContent'; function TransactionPreview(props: TransactionPreviewProps) { const {translate} = useLocalize(); - const {convertToDisplayString} = useCurrencyListActions(); + const {convertToDisplayString, getCurrencySymbol} = useCurrencyListActions(); const {action, chatReport, reportID, transactionID: transactionIDFromProps, onPreviewPressed, shouldHighlight, reportPreviewAction, contextAction} = props; const chatReportID = chatReport?.reportID; const {anchor: contextMenuAnchorRef, shouldDisplayContextMenu, originalReportID} = useShowContextMenuState(); @@ -67,7 +76,18 @@ function TransactionPreview(props: TransactionPreviewProps) { const sessionAccountID = session?.accountID; const areThereDuplicates = allDuplicateIDs.length > 0 && duplicates.length > 0 && allDuplicateIDs.length === duplicates.length; - const transactionDetails = getTransactionDetails(transaction); + const isParentPolicyExpenseChat = isPolicyExpenseChat(chatReport); + const customUnitRateID = onPreviewPressed && isDistanceRequest(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; + const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(policy, customUnitRateID); + const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); + const displayTransaction = getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: isParentPolicyExpenseChat, + policy: distanceOriginalPolicy ?? policy, + translate, + getCurrencySymbol, + }); + const transactionDetails = getTransactionDetails(displayTransaction, undefined, policy, isParentPolicyExpenseChat); const {amount: requestAmount, currency: requestCurrency} = transactionDetails ?? {}; const contextMenuReportID = contextAction ? chatReportID : reportID; From 205e28a7b40f628e065c847c06ee2295159812eb Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 21 Sep 2026 11:21:05 +0430 Subject: [PATCH 03/27] Deduplicate distance preview normalization and add regression tests --- .../TransactionPreviewContent.tsx | 28 +----- .../TransactionPreview/index.tsx | 13 +-- .../TransactionPreview/types.ts | 3 + .../MoneyRequestReportPreview.stories.tsx | 1 + .../TransactionPreviewContent.stories.tsx | 6 +- tests/ui/TransactionPreviewContentTest.tsx | 86 ++++++++++++++++++- 6 files changed, 101 insertions(+), 36 deletions(-) diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index 5f494e235620..d5041ab99880 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -11,7 +11,6 @@ import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -27,7 +26,6 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {calculateAmount} from '@libs/IOUUtils'; import Parser from '@libs/Parser'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; -import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {isMarkAsCashActionForTransaction} from '@libs/ReportPrimaryActionUtils'; @@ -36,14 +34,7 @@ import {canEditMoneyRequest, getTransactionDetails, isPolicyExpenseChat, isRepor import StringUtils from '@libs/StringUtils'; import type {TranslationPathOrText} from '@libs/TransactionPreviewUtils'; import {createTransactionPreviewConditionals, getTransactionPreviewTextAndTranslationPaths} from '@libs/TransactionPreviewUtils'; -import { - getDisplayTransactionWithoutInvalidCommuterExclusion, - isDistanceRequest, - isManagedCardTransaction as isCardTransactionUtils, - isGPSDistanceRequest, - isMapDistanceRequest, - isScanning, -} from '@libs/TransactionUtils'; +import {isManagedCardTransaction as isCardTransactionUtils, isGPSDistanceRequest, isMapDistanceRequest, isScanning} from '@libs/TransactionUtils'; import ViolationsUtils, {filterReceiptViolations} from '@libs/Violations/ViolationsUtils'; import {fontScale} from '@styles/typography'; @@ -71,6 +62,7 @@ function TransactionPreviewContent({ report, policy, transaction, + displayTransaction, violations, transactionRawAmount, offlineWithFeedbackOnClose, @@ -91,23 +83,9 @@ function TransactionPreviewContent({ const theme = useTheme(); const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); - const {convertToDisplayString, getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); + const {convertToDisplayString, getCurrencyDecimals} = useCurrencyListActions(); const {environmentURL} = useEnvironment(); const isParentPolicyExpenseChat = isPolicyExpenseChat(chatReport); - const customUnitRateID = isDistanceRequest(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; - const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(policy, customUnitRateID); - const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); - const displayTransaction = useMemo( - () => - getDisplayTransactionWithoutInvalidCommuterExclusion({ - transaction, - isPolicyExpenseChat: isParentPolicyExpenseChat, - policy: distanceOriginalPolicy ?? policy, - translate, - getCurrencySymbol, - }), - [transaction, isParentPolicyExpenseChat, distanceOriginalPolicy, policy, translate, getCurrencySymbol], - ); const transactionDetails = useMemo>( () => getTransactionDetails(displayTransaction, undefined, policy, isParentPolicyExpenseChat) ?? {}, [displayTransaction, policy, isParentPolicyExpenseChat], diff --git a/src/components/ReportActionItem/TransactionPreview/index.tsx b/src/components/ReportActionItem/TransactionPreview/index.tsx index 482b1cfa1908..f0bef33f7aac 100644 --- a/src/components/ReportActionItem/TransactionPreview/index.tsx +++ b/src/components/ReportActionItem/TransactionPreview/index.tsx @@ -76,18 +76,19 @@ function TransactionPreview(props: TransactionPreviewProps) { const sessionAccountID = session?.accountID; const areThereDuplicates = allDuplicateIDs.length > 0 && duplicates.length > 0 && allDuplicateIDs.length === duplicates.length; + const transactionPreview = onPreviewPressed ? transaction : (originalTransaction ?? transaction); const isParentPolicyExpenseChat = isPolicyExpenseChat(chatReport); - const customUnitRateID = onPreviewPressed && isDistanceRequest(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; + const customUnitRateID = isDistanceRequest(transactionPreview) ? transactionPreview?.comment?.customUnit?.customUnitRateID : undefined; const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(policy, customUnitRateID); const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); const displayTransaction = getDisplayTransactionWithoutInvalidCommuterExclusion({ - transaction, + transaction: transactionPreview, isPolicyExpenseChat: isParentPolicyExpenseChat, policy: distanceOriginalPolicy ?? policy, translate, getCurrencySymbol, }); - const transactionDetails = getTransactionDetails(displayTransaction, undefined, policy, isParentPolicyExpenseChat); + const transactionDetails = getTransactionDetails(displayTransaction); const {amount: requestAmount, currency: requestCurrency} = transactionDetails ?? {}; const contextMenuReportID = contextAction ? chatReportID : reportID; @@ -108,8 +109,6 @@ function TransactionPreview(props: TransactionPreviewProps) { const navigateToReviewFields = () => Navigation.navigate(getReviewNavigationRoute(Navigation.getActiveRoute(), route.params?.reportID, transaction, duplicates, policy, policyCategories, policyTags ?? {}, report)); - const transactionPreview = transaction; - const {isBillSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); // See description of `transactionRawAmount` prop for more context @@ -138,6 +137,7 @@ function TransactionPreview(props: TransactionPreviewProps) { chatReport={chatReport} personalDetails={personalDetails} transaction={transactionPreview} + displayTransaction={displayTransaction} transactionRawAmount={transactionRawAmount} report={report} policy={policy} @@ -162,7 +162,8 @@ function TransactionPreview(props: TransactionPreviewProps) { isBillSplit={isBillSplit} chatReport={chatReport} personalDetails={personalDetails} - transaction={originalTransaction ?? transaction} + transaction={transactionPreview} + displayTransaction={displayTransaction} transactionRawAmount={transactionRawAmount} report={report} policy={policy} diff --git a/src/components/ReportActionItem/TransactionPreview/types.ts b/src/components/ReportActionItem/TransactionPreview/types.ts index 9bec44e4b950..42b28a70faac 100644 --- a/src/components/ReportActionItem/TransactionPreview/types.ts +++ b/src/components/ReportActionItem/TransactionPreview/types.ts @@ -82,6 +82,9 @@ type TransactionPreviewContentProps = { transaction: OnyxEntry; + /** Transaction normalized for display-only values. */ + displayTransaction: OnyxEntry; + /** The amount of the transaction saved in the database. This is used to deduce who is the sender and who is the receiver of the money request * In case of Splits the property `transaction` is actually an original transaction (for the whole split) and it does not have the data required to deduce who is the sender */ transactionRawAmount: number; diff --git a/src/stories/MoneyRequestReportPreview.stories.tsx b/src/stories/MoneyRequestReportPreview.stories.tsx index 512d635f9034..73e58c019374 100755 --- a/src/stories/MoneyRequestReportPreview.stories.tsx +++ b/src/stories/MoneyRequestReportPreview.stories.tsx @@ -145,6 +145,7 @@ function Template(props: MoneyRequestReportPreviewContentProps, {parameters}: {p report={iouReportR14932} policy={undefined} transaction={item} + displayTransaction={item} transactionRawAmount={item.amount} violations={item.errors ? violationsR14932 : []} offlineWithFeedbackOnClose={() => undefined} diff --git a/src/stories/TransactionPreviewContent.stories.tsx b/src/stories/TransactionPreviewContent.stories.tsx index fce7bd86897e..afa630b72980 100644 --- a/src/stories/TransactionPreviewContent.stories.tsx +++ b/src/stories/TransactionPreviewContent.stories.tsx @@ -48,6 +48,7 @@ const disabledProperties = [ 'walletTermsErrors', 'personalDetails', 'chatReport', + 'displayTransaction', ].reduce>((disabledArgTypes, property) => { // eslint-disable-next-line no-param-reassign disabledArgTypes[property] = { @@ -136,7 +137,10 @@ function Template(props: TransactionPreviewContentProps) { - + diff --git a/tests/ui/TransactionPreviewContentTest.tsx b/tests/ui/TransactionPreviewContentTest.tsx index 64ffdeda82cb..270ad819ea66 100644 --- a/tests/ui/TransactionPreviewContentTest.tsx +++ b/tests/ui/TransactionPreviewContentTest.tsx @@ -94,6 +94,38 @@ const transaction = { comment: {}, } as Transaction; +const commuterDistanceTransaction = { + ...transaction, + amount: 415, + modifiedAmount: 415, + merchant: '5.46 mi @ $0.76 / mi', + modifiedMerchant: '5.46 mi @ $0.76 / mi', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: 6.46, + reimbursableDistance: 5.46, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, +} as Transaction; + +const fullRouteDisplayTransaction = { + ...commuterDistanceTransaction, + amount: 491, + modifiedAmount: undefined, + merchant: '6.46 mi @ $0.76 / mi', + modifiedMerchant: undefined, +} as Transaction; + +const policyExpenseChat = { + reportID: 'policy_expense_chat_tpc_test', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID, +} as Report; + const smartscanFailedViolation: TransactionViolation = { name: CONST.VIOLATIONS.SMARTSCAN_FAILED, type: CONST.VIOLATION_TYPES.VIOLATION, @@ -105,19 +137,30 @@ const personalDetails: PersonalDetailsList = { [approverAccountID]: {accountID: approverAccountID, login: approverEmail, displayName: 'Approver'}, }; -const renderTransactionPreviewContent = () => +const renderTransactionPreviewContent = ({ + transactionToRender = transaction, + displayTransaction = transactionToRender, + chatReport, + violations = [smartscanFailedViolation], +}: { + transactionToRender?: Transaction; + displayTransaction?: Transaction; + chatReport?: Report; + violations?: TransactionViolation[]; +} = {}) => render( {}} containerStyles={[]} @@ -187,4 +230,39 @@ describe('TransactionPreviewContent', () => { expect(screen.getByText('smartscan#false')).toBeOnTheScreen(); }); }); + + it('shows full-route values for a self-DM distance expense with commuter exclusion metadata', async () => { + // Given a removed distance expense whose stored values still include a commuter exclusion + await seedOnyx({[moneyRequestAction.reportActionID]: moneyRequestAction}); + + // When the preview receives the normalized display transaction for the self-DM context + renderTransactionPreviewContent({ + transactionToRender: commuterDistanceTransaction, + displayTransaction: fullRouteDisplayTransaction, + violations: [], + }); + await waitForBatchedUpdatesWithAct(); + + // Then it renders the full-route merchant instead of the commuter-adjusted merchant + expect(screen.getByText('6.46 mi @ $0.76 / mi')).toBeOnTheScreen(); + expect(screen.queryByText('5.46 mi @ $0.76 / mi')).not.toBeOnTheScreen(); + }); + + it('keeps commuter-adjusted values for a distance expense in a policy expense chat', async () => { + // Given a distance expense that still belongs to a policy expense chat + await seedOnyx({[moneyRequestAction.reportActionID]: moneyRequestAction}); + + // When the preview receives the original commuter-adjusted transaction for display + renderTransactionPreviewContent({ + transactionToRender: commuterDistanceTransaction, + displayTransaction: commuterDistanceTransaction, + chatReport: policyExpenseChat, + violations: [], + }); + await waitForBatchedUpdatesWithAct(); + + // Then it preserves the commuter-adjusted merchant instead of showing the full route + expect(screen.getByText('5.46 mi @ $0.76 / mi')).toBeOnTheScreen(); + expect(screen.queryByText('6.46 mi @ $0.76 / mi')).not.toBeOnTheScreen(); + }); }); From ea711700cc74be470c1148fdd8b3b707561c5b13 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 21 Sep 2026 12:19:46 +0430 Subject: [PATCH 04/27] Extract shared distance display transaction hook --- .../ReportActionItem/MoneyRequestView.tsx | 24 ++++----------- .../TransactionPreview/index.tsx | 25 +++------------- src/hooks/useDisplayTransaction.ts | 30 +++++++++++++++++++ 3 files changed, 40 insertions(+), 39 deletions(-) create mode 100644 src/hooks/useDisplayTransaction.ts diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index c55d923ad880..9607e8a24c23 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -24,7 +24,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; -import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; +import useDisplayTransaction from '@hooks/useDisplayTransaction'; import useEnvironment from '@hooks/useEnvironment'; import useHasMultipleSplitChildren from '@hooks/useHasMultipleSplitChildren'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -64,7 +64,6 @@ import Parser from '@libs/Parser'; import { canSubmitPerDiemExpenseFromWorkspace, findVendorByID, - getDistanceRateCustomUnitRate, getLengthOfTag, getPerDiemCustomUnit, getPolicyByCustomUnitID, @@ -103,7 +102,6 @@ import { getCurrency, getDescription, getDetailedExpenseTypeTranslationKey, - getDisplayTransactionWithoutInvalidCommuterExclusion, getDistanceInMeters, getFormattedCreated, getOriginalAmountForDisplay, @@ -247,9 +245,9 @@ function MoneyRequestView({ const isPerDiemRequest = isPerDiemRequestTransactionUtils(transaction); const perDiemOriginalPolicy = getPolicyByCustomUnitID(transaction, policiesWithPerDiem); - const customUnitRateID = isDistanceRequestTransactionUtils(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; - const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(expensePolicy, customUnitRateID); - const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); + const [reportPolicyType] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${parentReport?.policyID}`, {selector: policyTypeSelector}); + // Collect/Control (group) policies are always tied to an expense chat, so a group policy type means this is a policy expense chat. + const isPolicyExpenseChat = isGroupPolicyByType(reportPolicyType); let policy; let policyID; @@ -266,6 +264,8 @@ function MoneyRequestView({ policyID = parentReport?.policyID; } + const {displayTransaction, distanceOriginalPolicy} = useDisplayTransaction(transaction, isPolicyExpenseChat, policy, expensePolicy); + // Use the report's real policy, not `policy` above (swapped to an unrelated workspace for // unreported expenses), else self-DM split editing wrongly redirects to RESTRICTED_ACTION. const restrictedActionPolicyID = useRestrictedActionPolicyID(expensePolicy); @@ -301,10 +301,6 @@ function MoneyRequestView({ const isApproved = isReportApproved({report: moneyRequestReport}); const isInvoice = isInvoiceReport(moneyRequestReport); const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(transactionThreadReport, moneyRequestReport, parentReportAction); - const [reportPolicyType] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport?.policyID}`, {selector: policyTypeSelector}); - // Collect/Control (group) policies are always tied to an expense chat, so a group policy type means this is a policy expense chat. - const isPolicyExpenseChat = isGroupPolicyByType(reportPolicyType); - let iouType: ValueOf; if (isTrackExpense) { iouType = CONST.IOU.TYPE.TRACK; @@ -315,14 +311,6 @@ function MoneyRequestView({ } const allowNegativeAmount = shouldEnableNegative(parentReport, policy, iouType); - const displayTransaction = getDisplayTransactionWithoutInvalidCommuterExclusion({ - transaction, - isPolicyExpenseChat, - policy: distanceOriginalPolicy ?? policy, - translate, - getCurrencySymbol, - }); - const { created: transactionDate, amount: transactionAmount, diff --git a/src/components/ReportActionItem/TransactionPreview/index.tsx b/src/components/ReportActionItem/TransactionPreview/index.tsx index f0bef33f7aac..8a024d465e19 100644 --- a/src/components/ReportActionItem/TransactionPreview/index.tsx +++ b/src/components/ReportActionItem/TransactionPreview/index.tsx @@ -3,7 +3,7 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeed import {showContextMenuForReport, useShowContextMenuActions, useShowContextMenuState} from '@components/ShowContextMenuContext'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; +import useDisplayTransaction from '@hooks/useDisplayTransaction'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useTransactionViolations from '@hooks/useTransactionViolations'; @@ -12,18 +12,10 @@ import ControlSelection from '@libs/ControlSelection'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction as isMoneyRequestActionReportActionsUtils} from '@libs/ReportActionsUtils'; import {getTransactionDetails, isPolicyExpenseChat} from '@libs/ReportUtils'; import {getReviewNavigationRoute} from '@libs/TransactionPreviewUtils'; -import { - getDisplayTransactionWithoutInvalidCommuterExclusion, - getExpenseTypeTranslationKey, - getOriginalTransactionWithSplitInfo, - getTransactionType, - isDistanceRequest, - removeSettledAndApprovedTransactions, -} from '@libs/TransactionUtils'; +import {getExpenseTypeTranslationKey, getOriginalTransactionWithSplitInfo, getTransactionType, removeSettledAndApprovedTransactions} from '@libs/TransactionUtils'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; import type {TransactionDuplicateNavigatorParamList} from '@navigation/types'; @@ -49,7 +41,7 @@ import TransactionPreviewContent from './TransactionPreviewContent'; function TransactionPreview(props: TransactionPreviewProps) { const {translate} = useLocalize(); - const {convertToDisplayString, getCurrencySymbol} = useCurrencyListActions(); + const {convertToDisplayString} = useCurrencyListActions(); const {action, chatReport, reportID, transactionID: transactionIDFromProps, onPreviewPressed, shouldHighlight, reportPreviewAction, contextAction} = props; const chatReportID = chatReport?.reportID; const {anchor: contextMenuAnchorRef, shouldDisplayContextMenu, originalReportID} = useShowContextMenuState(); @@ -78,16 +70,7 @@ function TransactionPreview(props: TransactionPreviewProps) { const transactionPreview = onPreviewPressed ? transaction : (originalTransaction ?? transaction); const isParentPolicyExpenseChat = isPolicyExpenseChat(chatReport); - const customUnitRateID = isDistanceRequest(transactionPreview) ? transactionPreview?.comment?.customUnit?.customUnitRateID : undefined; - const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(policy, customUnitRateID); - const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); - const displayTransaction = getDisplayTransactionWithoutInvalidCommuterExclusion({ - transaction: transactionPreview, - isPolicyExpenseChat: isParentPolicyExpenseChat, - policy: distanceOriginalPolicy ?? policy, - translate, - getCurrencySymbol, - }); + const {displayTransaction} = useDisplayTransaction(transactionPreview, isParentPolicyExpenseChat, policy); const transactionDetails = getTransactionDetails(displayTransaction); const {amount: requestAmount, currency: requestCurrency} = transactionDetails ?? {}; diff --git a/src/hooks/useDisplayTransaction.ts b/src/hooks/useDisplayTransaction.ts new file mode 100644 index 000000000000..68aabca75ecb --- /dev/null +++ b/src/hooks/useDisplayTransaction.ts @@ -0,0 +1,30 @@ +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useLocalize from '@hooks/useLocalize'; + +import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; +import {getDisplayTransactionWithoutInvalidCommuterExclusion, isDistanceRequest} from '@libs/TransactionUtils'; + +import type {Policy, Transaction} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import useDistanceRateOriginalPolicy from './useDistanceRateOriginalPolicy'; + +function useDisplayTransaction(transaction: OnyxEntry, isPolicyExpenseChat: boolean, policy?: OnyxEntry, rateLookupPolicy: OnyxEntry = policy) { + const {translate} = useLocalize(); + const {getCurrencySymbol} = useCurrencyListActions(); + const customUnitRateID = isDistanceRequest(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; + const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(rateLookupPolicy, customUnitRateID); + const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); + const displayTransaction = getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat, + policy: distanceOriginalPolicy ?? policy, + translate, + getCurrencySymbol, + }); + + return {displayTransaction, distanceOriginalPolicy}; +} + +export default useDisplayTransaction; From bd2f262aee9210a7ad95b46eecf7e32287240725 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 21 Sep 2026 14:28:05 +0430 Subject: [PATCH 05/27] Fix display transaction hook imports and report context handling --- src/hooks/useDisplayTransaction.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/hooks/useDisplayTransaction.ts b/src/hooks/useDisplayTransaction.ts index 68aabca75ecb..1cb2f26753fe 100644 --- a/src/hooks/useDisplayTransaction.ts +++ b/src/hooks/useDisplayTransaction.ts @@ -1,6 +1,3 @@ -import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useLocalize from '@hooks/useLocalize'; - import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; import {getDisplayTransactionWithoutInvalidCommuterExclusion, isDistanceRequest} from '@libs/TransactionUtils'; @@ -8,7 +5,9 @@ import type {Policy, Transaction} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {useCurrencyListActions} from './useCurrencyList'; import useDistanceRateOriginalPolicy from './useDistanceRateOriginalPolicy'; +import useLocalize from './useLocalize'; function useDisplayTransaction(transaction: OnyxEntry, isPolicyExpenseChat: boolean, policy?: OnyxEntry, rateLookupPolicy: OnyxEntry = policy) { const {translate} = useLocalize(); From 33ca266d5fe5f3e4d3ece80bedfeca88e670aa20 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 21 Sep 2026 14:47:06 +0430 Subject: [PATCH 06/27] Align distance preview policy context and add regression coverage --- .../TransactionPreview/index.tsx | 5 +- tests/ui/TransactionPreviewContentTest.tsx | 103 +++++++++++++----- 2 files changed, 76 insertions(+), 32 deletions(-) diff --git a/src/components/ReportActionItem/TransactionPreview/index.tsx b/src/components/ReportActionItem/TransactionPreview/index.tsx index 8a024d465e19..2294aab2a76d 100644 --- a/src/components/ReportActionItem/TransactionPreview/index.tsx +++ b/src/components/ReportActionItem/TransactionPreview/index.tsx @@ -12,8 +12,9 @@ import ControlSelection from '@libs/ControlSelection'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; +import {isGroupPolicyByType} from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction as isMoneyRequestActionReportActionsUtils} from '@libs/ReportActionsUtils'; -import {getTransactionDetails, isPolicyExpenseChat} from '@libs/ReportUtils'; +import {getTransactionDetails} from '@libs/ReportUtils'; import {getReviewNavigationRoute} from '@libs/TransactionPreviewUtils'; import {getExpenseTypeTranslationKey, getOriginalTransactionWithSplitInfo, getTransactionType, removeSettledAndApprovedTransactions} from '@libs/TransactionUtils'; @@ -69,7 +70,7 @@ function TransactionPreview(props: TransactionPreviewProps) { const areThereDuplicates = allDuplicateIDs.length > 0 && duplicates.length > 0 && allDuplicateIDs.length === duplicates.length; const transactionPreview = onPreviewPressed ? transaction : (originalTransaction ?? transaction); - const isParentPolicyExpenseChat = isPolicyExpenseChat(chatReport); + const isParentPolicyExpenseChat = isGroupPolicyByType(policy?.type); const {displayTransaction} = useDisplayTransaction(transactionPreview, isParentPolicyExpenseChat, policy); const transactionDetails = getTransactionDetails(displayTransaction); const {amount: requestAmount, currency: requestCurrency} = transactionDetails ?? {}; diff --git a/tests/ui/TransactionPreviewContentTest.tsx b/tests/ui/TransactionPreviewContentTest.tsx index 270ad819ea66..1e7d63dcf55e 100644 --- a/tests/ui/TransactionPreviewContentTest.tsx +++ b/tests/ui/TransactionPreviewContentTest.tsx @@ -2,6 +2,7 @@ import {act, render, screen, waitFor} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import TransactionPreview from '@components/ReportActionItem/TransactionPreview'; import TransactionPreviewContent from '@components/ReportActionItem/TransactionPreview/TransactionPreviewContent'; import CONST from '@src/CONST'; @@ -24,6 +25,14 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ }), })); +jest.mock('@react-navigation/native', () => { + const actualNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + useRoute: () => ({key: 'report', name: 'Report', params: {}}), + }; +}); + // Expose the canEdit translation parameter in the rendered output so canEdit-dependent messages can be asserted. // The message must stay under CONST.REPORT_VIOLATIONS.RBR_MESSAGE_MAX_CHARACTERS_FOR_PREVIEW or the preview // swaps it for the generic "review required" message. @@ -60,6 +69,26 @@ const corporatePolicy = { }, } as Policy; +const distancePolicy = { + ...corporatePolicy, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + rate1: { + customUnitRateID: 'rate1', + currency: CONST.CURRENCY.USD, + rate: 76, + }, + }, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }, +} as Policy; + const expenseReport = { reportID: expenseReportID, type: CONST.REPORT.TYPE.EXPENSE, @@ -112,20 +141,22 @@ const commuterDistanceTransaction = { }, } as Transaction; -const fullRouteDisplayTransaction = { - ...commuterDistanceTransaction, - amount: 491, - modifiedAmount: undefined, - merchant: '6.46 mi @ $0.76 / mi', - modifiedMerchant: undefined, -} as Transaction; - const policyExpenseChat = { reportID: 'policy_expense_chat_tpc_test', chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, policyID, } as Report; +const selfDMReportID = 'self_dm_tpc_test'; +const selfDMReport = { + reportID: selfDMReportID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + participants: { + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, +} as Report; + const smartscanFailedViolation: TransactionViolation = { name: CONST.VIOLATIONS.SMARTSCAN_FAILED, type: CONST.VIOLATION_TYPES.VIOLATION, @@ -176,6 +207,22 @@ const renderTransactionPreviewContent = ({ , ); +const renderTransactionPreview = ({action = moneyRequestAction, chatReport = policyExpenseChat}: {action?: ReportAction; chatReport?: Report} = {}) => + render( + + + , + ); + describe('TransactionPreviewContent', () => { beforeAll(() => { Onyx.init({ @@ -190,14 +237,17 @@ describe('TransactionPreviewContent', () => { }); }); - const seedOnyx = async (reportActions: Record) => { + const seedOnyx = async ( + reportActions: Record, + {report = expenseReport, transactionToSeed = transaction}: {report?: Report; transactionToSeed?: Transaction} = {}, + ) => { await act(async () => { await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID, email: currentUserEmail}); await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, corporatePolicy); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, reportActions); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, distancePolicy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transactionToSeed); }); await waitForBatchedUpdatesWithAct(); }; @@ -233,32 +283,25 @@ describe('TransactionPreviewContent', () => { it('shows full-route values for a self-DM distance expense with commuter exclusion metadata', async () => { // Given a removed distance expense whose stored values still include a commuter exclusion - await seedOnyx({[moneyRequestAction.reportActionID]: moneyRequestAction}); + const selfDMAction = {...moneyRequestAction, reportID: selfDMReportID}; + const selfDMTransaction = {...commuterDistanceTransaction, reportID: selfDMReportID}; + await seedOnyx({[selfDMAction.reportActionID]: selfDMAction}, {report: selfDMReport, transactionToSeed: selfDMTransaction}); - // When the preview receives the normalized display transaction for the self-DM context - renderTransactionPreviewContent({ - transactionToRender: commuterDistanceTransaction, - displayTransaction: fullRouteDisplayTransaction, - violations: [], - }); + // When the transaction preview derives the display context from the personal report policy + renderTransactionPreview({action: selfDMAction, chatReport: selfDMReport}); await waitForBatchedUpdatesWithAct(); // Then it renders the full-route merchant instead of the commuter-adjusted merchant - expect(screen.getByText('6.46 mi @ $0.76 / mi')).toBeOnTheScreen(); + expect(screen.getByText(/6\.46 mi/)).toBeOnTheScreen(); expect(screen.queryByText('5.46 mi @ $0.76 / mi')).not.toBeOnTheScreen(); }); it('keeps commuter-adjusted values for a distance expense in a policy expense chat', async () => { // Given a distance expense that still belongs to a policy expense chat - await seedOnyx({[moneyRequestAction.reportActionID]: moneyRequestAction}); - - // When the preview receives the original commuter-adjusted transaction for display - renderTransactionPreviewContent({ - transactionToRender: commuterDistanceTransaction, - displayTransaction: commuterDistanceTransaction, - chatReport: policyExpenseChat, - violations: [], - }); + await seedOnyx({[moneyRequestAction.reportActionID]: moneyRequestAction}, {transactionToSeed: commuterDistanceTransaction}); + + // When the transaction preview derives the display context from the group policy + renderTransactionPreview(); await waitForBatchedUpdatesWithAct(); // Then it preserves the commuter-adjusted merchant instead of showing the full route From f1afddd9f9c0e6324d96e75ec9a1df27f4a20cd9 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 21 Sep 2026 15:03:08 +0430 Subject: [PATCH 07/27] Align distance preview policy detection and strengthen regression tests --- tests/ui/TransactionPreviewContentTest.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ui/TransactionPreviewContentTest.tsx b/tests/ui/TransactionPreviewContentTest.tsx index 1e7d63dcf55e..6230beb29167 100644 --- a/tests/ui/TransactionPreviewContentTest.tsx +++ b/tests/ui/TransactionPreviewContentTest.tsx @@ -9,6 +9,8 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Policy, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx'; +import type * as ReactNavigation from '@react-navigation/native'; + import React from 'react'; import Onyx from 'react-native-onyx'; @@ -26,7 +28,7 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ })); jest.mock('@react-navigation/native', () => { - const actualNavigation = jest.requireActual('@react-navigation/native'); + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); return { ...actualNavigation, useRoute: () => ({key: 'report', name: 'Report', params: {}}), From 89ff5706b54e2d57aeb116500d02693b64f65cd1 Mon Sep 17 00:00:00 2001 From: Nabi Date: Sat, 10 Oct 2026 20:01:15 +0200 Subject: [PATCH 08/27] Preserve historical rates and manual amounts in self-DM distance displays --- src/libs/TransactionUtils/index.ts | 87 ++++++++++++++- tests/unit/TransactionUtilsTest.ts | 172 +++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 4 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 5d204cf44bd8..97e67f2f4662 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -9,6 +9,7 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import {convertAttendeesToArray, normalizeAttendees} from '@libs/AttendeeUtils'; import {isTravelCardTransaction} from '@libs/CardUtils'; import {isCategoryMissing} from '@libs/CategoryUtils'; +import {getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; import type {MachineDateFormat} from '@libs/DateUtils'; import DateUtils from '@libs/DateUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -26,6 +27,7 @@ import {isInvalidMerchantValue} from '@libs/ValidationUtils'; import type {IOURequestType, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Card, CardList, OnyxInputOrEntry, PersonalDetails, Policy, RecentWaypoint, Report, Transaction} from '@src/types/onyx'; @@ -178,6 +180,41 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ return transaction; } + const modifiedAmount = hasValidModifiedAmount(transaction) ? Number(transaction.modifiedAmount) : undefined; + const storedUnit = customUnit?.distanceUnit; + const reducedDistance = customUnit?.reimbursableDistance; + if ( + storedUnit && + typeof reducedDistance === 'number' && + reducedDistance >= 0 && + reducedDistance < fullDistance && + (!transaction.modifiedCurrency || transaction.modifiedCurrency === transaction.currency) + ) { + const fullMerchantPrefix = `${fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; + const reducedMerchantPrefix = `${reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; + const fullRateText = transaction.merchant?.startsWith(fullMerchantPrefix) ? transaction.merchant.slice(fullMerchantPrefix.length) : undefined; + const reducedRateText = transaction.modifiedMerchant?.startsWith(reducedMerchantPrefix) ? transaction.modifiedMerchant.slice(reducedMerchantPrefix.length) : undefined; + if (fullRateText && fullRateText === reducedRateText && Number.isFinite(transaction.amount) && (modifiedAmount === undefined || Number.isFinite(modifiedAmount))) { + const roundedFullDistance = Number(fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); + const roundedReducedDistance = Number(reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); + if (roundedFullDistance > 0) { + const lowerRate = Math.max(0, (Math.abs(transaction.amount) - 0.5) / roundedFullDistance); + const upperRate = (Math.abs(transaction.amount) + 0.5) / roundedFullDistance; + const calculatedReducedAmount = Math.round(roundedReducedDistance * lowerRate); + const isCalculatedAmount = + modifiedAmount === undefined || (calculatedReducedAmount === Math.round(roundedReducedDistance * upperRate) && calculatedReducedAmount === Math.abs(modifiedAmount)); + const amountToDisplay = isCalculatedAmount ? Math.abs(transaction.amount) : Math.abs(modifiedAmount ?? transaction.amount); + return { + ...transaction, + amount: (modifiedAmount ?? transaction.amount) < 0 ? -amountToDisplay : amountToDisplay, + convertedAmount: undefined, + modifiedAmount: undefined, + modifiedMerchant: undefined, + }; + } + } + } + const mileageRate = DistanceRequestUtils.getRateByCustomUnitRateIDAcrossPolicies({customUnitRateID: customUnit?.customUnitRateID, policy, policies}); const rate = mileageRate?.rate; const unit = customUnit?.distanceUnit ?? mileageRate?.unit; @@ -186,10 +223,52 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ } const fullDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(fullDistance, unit); - const fullDistanceAmount = DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, rate); - const storedAmount = hasValidModifiedAmount(transaction) ? Number(transaction.modifiedAmount) : (transaction.amount ?? 0); - const normalizedAmount = storedAmount < 0 ? -fullDistanceAmount : fullDistanceAmount; + const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionDisplayData(customUnit, unit); + const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData?.reimbursableDistance ?? fullDistance, unit); + const reducedAmount = DistanceRequestUtils.getDistanceRequestAmount(reducedDistanceInMeters, unit, rate); const currency = mileageRate?.currency ?? getCurrency(transaction); + const storedReducedMerchant = transaction.modifiedMerchant ?? transaction.merchant; + const getDisplayCurrencySymbol = (currencyCode: string) => getCurrencySymbol(currencyCode) ?? getLocalizedCurrencySymbol(IntlStore.getCurrentLocale(), currencyCode); + const savedCurrency = getCurrency(transaction); + if (currency !== savedCurrency && getDisplayCurrencySymbol(currency) === getDisplayCurrencySymbol(savedCurrency)) { + return transaction; + } + const merchantAtCurrentRate = getDistanceMerchantForTransaction({ + transaction, + distanceInMeters: fullDistanceInMeters, + unit, + rate, + currency, + translate, + getCurrencySymbol: getDisplayCurrencySymbol, + commuterExclusionData, + }); + const getEnglishCurrencySymbol = (currencyCode: string) => getLocalizedCurrencySymbol(CONST.LOCALES.EN, currencyCode); + const merchantWithEnglishSymbol = getDistanceMerchantForTransaction({ + transaction, + distanceInMeters: fullDistanceInMeters, + unit, + rate, + currency, + translate, + getCurrencySymbol: getEnglishCurrencySymbol, + commuterExclusionData, + }); + const merchantMatchesCurrentRate = storedReducedMerchant === merchantAtCurrentRate || storedReducedMerchant === merchantWithEnglishSymbol; + const storedAmount = modifiedAmount ?? transaction.amount; + if ( + !commuterExclusionData || + !merchantMatchesCurrentRate || + !Number.isFinite(storedAmount) || + (Math.abs(storedAmount) !== reducedAmount && Math.abs(transaction.amount) !== reducedAmount) + ) { + return transaction; + } + + const fullDistanceAmount = DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, rate); + const isManuallyModified = Math.abs(storedAmount) !== reducedAmount; + const normalizedAmount = isManuallyModified ? storedAmount : storedAmount < 0 ? -fullDistanceAmount : fullDistanceAmount; + const merchantCurrencySymbol = storedReducedMerchant === merchantAtCurrentRate ? getDisplayCurrencySymbol : getEnglishCurrencySymbol; const normalizedMerchant = getDistanceMerchantForTransaction({ transaction, distanceInMeters: fullDistanceInMeters, @@ -197,7 +276,7 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ rate, currency, translate, - getCurrencySymbol, + getCurrencySymbol: merchantCurrencySymbol, }); return { diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 4c578eb45c51..ca6f8e0a6399 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1710,6 +1710,178 @@ describe('TransactionUtils', () => { }, }, }; + const historicalTransaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: 1934, + modifiedAmount: -1734, + currency: 'TJS', + modifiedCurrency: '', + merchant: '19.34 mi @ TJS1.00 / mi', + modifiedMerchant: '17.34 mi @ TJS1.00 / mi', + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: 19.34, + reimbursableDistance: 17.34, + commuterExclusion: 2, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + const changedRatePolicy: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: {rate1: {customUnitRateID: 'rate1', currency: 'TJS', rate: 200}}, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }, + }, + }; + + it('uses the saved full-route values after the workspace rate changes', () => { + // Given a personal distance expense saved at TJS 1/mi before its workspace rate changed to TJS 2/mi + // When the display transaction is built from its saved full-route fields + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: historicalTransaction, + isPolicyExpenseChat: false, + policy: changedRatePolicy, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the original rate and full amount remain visible + expect(displayTransaction?.amount).toBe(-1934); + expect(displayTransaction?.merchant).toBe('19.34 mi @ TJS1.00 / mi'); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + }); + + it('preserves a manual amount when restoring the saved full route', () => { + // Given a manually edited amount on a distance expense with full-route base fields + const transaction = {...historicalTransaction, modifiedAmount: -5000}; + + // When the workspace rate has changed but the expense is displayed in a personal report + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: changedRatePolicy, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the edited amount and original route are both retained + expect(displayTransaction?.amount).toBe(-5000); + expect(displayTransaction?.merchant).toBe('19.34 mi @ TJS1.00 / mi'); + }); + + it('does not reprice reduced-only values when the saved rate differs from the workspace rate', () => { + // Given a moved expense whose base fields also contain commuter-reduced values from TJS 1/mi + const transaction = {...historicalTransaction, amount: 1734, merchant: '17.34 mi @ TJS1.00 / mi'}; + + // When its selected rate ID now points to TJS 2/mi + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: changedRatePolicy, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the helper does not invent a new amount from the updated rate + expect(displayTransaction).toBe(transaction); + }); + + it('preserves a manual amount when only reduced values were saved', () => { + // Given a distance expense with a manually edited amount and no saved full-route base fields + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: 415, + modifiedAmount: -5000, + currency: CONST.CURRENCY.USD, + merchant: '5.46 mi @ $0.76 / mi', + modifiedMerchant: '5.46 mi @ $0.76 / mi', + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: 6.46, + reimbursableDistance: 5.46, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + + // When the unchanged workspace rate verifies the saved merchant and base amount + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: policyWithDistanceRate, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the manual amount remains while the merchant returns to the full route + expect(displayTransaction?.amount).toBe(-5000); + expect(displayTransaction?.merchant).toBe('6.46 mi @ $0.76 / mi'); + }); + + it('does not infer a new currency from an unchanged dollar symbol', () => { + // Given a reduced-only USD expense whose selected rate now belongs to CAD + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: 415, + currency: CONST.CURRENCY.USD, + merchant: '5.46 mi @ $0.76 / mi', + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: 6.46, + reimbursableDistance: 5.46, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + const policyWithChangedCurrency: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: {rate1: {customUnitRateID: 'rate1', currency: CONST.CURRENCY.CAD, rate: 76}}, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }, + }, + }; + + // When the rate's symbol cannot prove which currency was originally used + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: policyWithChangedCurrency, + translate, + getCurrencySymbol: () => '$', + }); + + // Then the transaction remains in its saved currency + expect(displayTransaction).toBe(transaction); + }); + + it('keeps commuter values unchanged for a workspace expense after its rate changes', () => { + // Given the same transaction still on its workspace expense report + // When its personal-only display normalization is requested + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: historicalTransaction, + isPolicyExpenseChat: true, + policy: changedRatePolicy, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the workspace commuter exclusion remains untouched + expect(displayTransaction).toBe(historicalTransaction); + }); it('rebuilds full-route amount and merchant for personal distance expenses with commuter metadata', () => { const transaction = generateTransaction({ From 19b66237135535d220f974123c769bb151a5df44 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 12 Oct 2026 14:38:19 +0200 Subject: [PATCH 09/27] Fix nested ternary lint error in distance transaction display --- src/libs/TransactionUtils/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index ea9174bc8ac9..21273a45d50e 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -267,7 +267,8 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const fullDistanceAmount = DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, rate); const isManuallyModified = Math.abs(storedAmount) !== reducedAmount; - const normalizedAmount = isManuallyModified ? storedAmount : storedAmount < 0 ? -fullDistanceAmount : fullDistanceAmount; + const calculatedAmount = storedAmount < 0 ? -fullDistanceAmount : fullDistanceAmount; + const normalizedAmount = isManuallyModified ? storedAmount : calculatedAmount; const merchantCurrencySymbol = storedReducedMerchant === merchantAtCurrentRate ? getDisplayCurrencySymbol : getEnglishCurrencySymbol; const normalizedMerchant = getDistanceMerchantForTransaction({ transaction, From cae6c9b157caaebc2baaf25c94eba42ccb4225c2 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 12 Oct 2026 16:57:07 +0200 Subject: [PATCH 10/27] Revert historical rate and manual amount handling for distance displays --- src/libs/TransactionUtils/index.ts | 88 +-------------- tests/unit/TransactionUtilsTest.ts | 172 ----------------------------- 2 files changed, 4 insertions(+), 256 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 21273a45d50e..8346efd4a970 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -9,7 +9,6 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import {convertAttendeesToArray, normalizeAttendees} from '@libs/AttendeeUtils'; import {isTravelCardTransaction} from '@libs/CardUtils'; import {isCategoryMissing} from '@libs/CategoryUtils'; -import {getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; import type {MachineDateFormat} from '@libs/DateUtils'; import DateUtils from '@libs/DateUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -27,7 +26,6 @@ import {isInvalidMerchantValue} from '@libs/ValidationUtils'; import type {IOURequestType, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Card, CardList, OnyxInputOrEntry, PersonalDetails, Policy, RecentWaypoint, Report, Transaction} from '@src/types/onyx'; @@ -180,41 +178,6 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ return transaction; } - const modifiedAmount = hasValidModifiedAmount(transaction) ? Number(transaction.modifiedAmount) : undefined; - const storedUnit = customUnit?.distanceUnit; - const reducedDistance = customUnit?.reimbursableDistance; - if ( - storedUnit && - typeof reducedDistance === 'number' && - reducedDistance >= 0 && - reducedDistance < fullDistance && - (!transaction.modifiedCurrency || transaction.modifiedCurrency === transaction.currency) - ) { - const fullMerchantPrefix = `${fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; - const reducedMerchantPrefix = `${reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; - const fullRateText = transaction.merchant?.startsWith(fullMerchantPrefix) ? transaction.merchant.slice(fullMerchantPrefix.length) : undefined; - const reducedRateText = transaction.modifiedMerchant?.startsWith(reducedMerchantPrefix) ? transaction.modifiedMerchant.slice(reducedMerchantPrefix.length) : undefined; - if (fullRateText && fullRateText === reducedRateText && Number.isFinite(transaction.amount) && (modifiedAmount === undefined || Number.isFinite(modifiedAmount))) { - const roundedFullDistance = Number(fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); - const roundedReducedDistance = Number(reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); - if (roundedFullDistance > 0) { - const lowerRate = Math.max(0, (Math.abs(transaction.amount) - 0.5) / roundedFullDistance); - const upperRate = (Math.abs(transaction.amount) + 0.5) / roundedFullDistance; - const calculatedReducedAmount = Math.round(roundedReducedDistance * lowerRate); - const isCalculatedAmount = - modifiedAmount === undefined || (calculatedReducedAmount === Math.round(roundedReducedDistance * upperRate) && calculatedReducedAmount === Math.abs(modifiedAmount)); - const amountToDisplay = isCalculatedAmount ? Math.abs(transaction.amount) : Math.abs(modifiedAmount ?? transaction.amount); - return { - ...transaction, - amount: (modifiedAmount ?? transaction.amount) < 0 ? -amountToDisplay : amountToDisplay, - convertedAmount: undefined, - modifiedAmount: undefined, - modifiedMerchant: undefined, - }; - } - } - } - const mileageRate = DistanceRequestUtils.getRateByCustomUnitRateIDAcrossPolicies({customUnitRateID: customUnit?.customUnitRateID, policy, policies}); const rate = mileageRate?.rate; const unit = customUnit?.distanceUnit ?? mileageRate?.unit; @@ -223,53 +186,10 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ } const fullDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(fullDistance, unit); - const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionDisplayData(customUnit, unit); - const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData?.reimbursableDistance ?? fullDistance, unit); - const reducedAmount = DistanceRequestUtils.getDistanceRequestAmount(reducedDistanceInMeters, unit, rate); - const currency = mileageRate?.currency ?? getCurrency(transaction); - const storedReducedMerchant = transaction.modifiedMerchant ?? transaction.merchant; - const getDisplayCurrencySymbol = (currencyCode: string) => getCurrencySymbol(currencyCode) ?? getLocalizedCurrencySymbol(IntlStore.getCurrentLocale(), currencyCode); - const savedCurrency = getCurrency(transaction); - if (currency !== savedCurrency && getDisplayCurrencySymbol(currency) === getDisplayCurrencySymbol(savedCurrency)) { - return transaction; - } - const merchantAtCurrentRate = getDistanceMerchantForTransaction({ - transaction, - distanceInMeters: fullDistanceInMeters, - unit, - rate, - currency, - translate, - getCurrencySymbol: getDisplayCurrencySymbol, - commuterExclusionData, - }); - const getEnglishCurrencySymbol = (currencyCode: string) => getLocalizedCurrencySymbol(CONST.LOCALES.EN, currencyCode); - const merchantWithEnglishSymbol = getDistanceMerchantForTransaction({ - transaction, - distanceInMeters: fullDistanceInMeters, - unit, - rate, - currency, - translate, - getCurrencySymbol: getEnglishCurrencySymbol, - commuterExclusionData, - }); - const merchantMatchesCurrentRate = storedReducedMerchant === merchantAtCurrentRate || storedReducedMerchant === merchantWithEnglishSymbol; - const storedAmount = modifiedAmount ?? transaction.amount; - if ( - !commuterExclusionData || - !merchantMatchesCurrentRate || - !Number.isFinite(storedAmount) || - (Math.abs(storedAmount) !== reducedAmount && Math.abs(transaction.amount) !== reducedAmount) - ) { - return transaction; - } - const fullDistanceAmount = DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, rate); - const isManuallyModified = Math.abs(storedAmount) !== reducedAmount; - const calculatedAmount = storedAmount < 0 ? -fullDistanceAmount : fullDistanceAmount; - const normalizedAmount = isManuallyModified ? storedAmount : calculatedAmount; - const merchantCurrencySymbol = storedReducedMerchant === merchantAtCurrentRate ? getDisplayCurrencySymbol : getEnglishCurrencySymbol; + const storedAmount = hasValidModifiedAmount(transaction) ? Number(transaction.modifiedAmount) : (transaction.amount ?? 0); + const normalizedAmount = storedAmount < 0 ? -fullDistanceAmount : fullDistanceAmount; + const currency = mileageRate?.currency ?? getCurrency(transaction); const normalizedMerchant = getDistanceMerchantForTransaction({ transaction, distanceInMeters: fullDistanceInMeters, @@ -277,7 +197,7 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ rate, currency, translate, - getCurrencySymbol: merchantCurrencySymbol, + getCurrencySymbol, }); return { diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index cc3a60023caf..70ca5c62fb45 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1807,178 +1807,6 @@ describe('TransactionUtils', () => { }, }, }; - const historicalTransaction = generateTransaction({ - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, - amount: 1934, - modifiedAmount: -1734, - currency: 'TJS', - modifiedCurrency: '', - merchant: '19.34 mi @ TJS1.00 / mi', - modifiedMerchant: '17.34 mi @ TJS1.00 / mi', - comment: { - customUnit: { - customUnitRateID: 'rate1', - quantity: 19.34, - reimbursableDistance: 17.34, - commuterExclusion: 2, - distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - }, - }, - }); - const changedRatePolicy: Policy = { - ...policyWithDistanceRate, - customUnits: { - distance: { - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - customUnitID: 'distance', - rates: {rate1: {customUnitRateID: 'rate1', currency: 'TJS', rate: 200}}, - attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, - }, - }, - }; - - it('uses the saved full-route values after the workspace rate changes', () => { - // Given a personal distance expense saved at TJS 1/mi before its workspace rate changed to TJS 2/mi - // When the display transaction is built from its saved full-route fields - const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ - transaction: historicalTransaction, - isPolicyExpenseChat: false, - policy: changedRatePolicy, - translate, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - // Then the original rate and full amount remain visible - expect(displayTransaction?.amount).toBe(-1934); - expect(displayTransaction?.merchant).toBe('19.34 mi @ TJS1.00 / mi'); - expect(displayTransaction?.modifiedAmount).toBeUndefined(); - }); - - it('preserves a manual amount when restoring the saved full route', () => { - // Given a manually edited amount on a distance expense with full-route base fields - const transaction = {...historicalTransaction, modifiedAmount: -5000}; - - // When the workspace rate has changed but the expense is displayed in a personal report - const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ - transaction, - isPolicyExpenseChat: false, - policy: changedRatePolicy, - translate, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - // Then the edited amount and original route are both retained - expect(displayTransaction?.amount).toBe(-5000); - expect(displayTransaction?.merchant).toBe('19.34 mi @ TJS1.00 / mi'); - }); - - it('does not reprice reduced-only values when the saved rate differs from the workspace rate', () => { - // Given a moved expense whose base fields also contain commuter-reduced values from TJS 1/mi - const transaction = {...historicalTransaction, amount: 1734, merchant: '17.34 mi @ TJS1.00 / mi'}; - - // When its selected rate ID now points to TJS 2/mi - const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ - transaction, - isPolicyExpenseChat: false, - policy: changedRatePolicy, - translate, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - // Then the helper does not invent a new amount from the updated rate - expect(displayTransaction).toBe(transaction); - }); - - it('preserves a manual amount when only reduced values were saved', () => { - // Given a distance expense with a manually edited amount and no saved full-route base fields - const transaction = generateTransaction({ - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, - amount: 415, - modifiedAmount: -5000, - currency: CONST.CURRENCY.USD, - merchant: '5.46 mi @ $0.76 / mi', - modifiedMerchant: '5.46 mi @ $0.76 / mi', - comment: { - customUnit: { - customUnitRateID: 'rate1', - quantity: 6.46, - reimbursableDistance: 5.46, - commuterExclusion: 1, - distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - }, - }, - }); - - // When the unchanged workspace rate verifies the saved merchant and base amount - const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ - transaction, - isPolicyExpenseChat: false, - policy: policyWithDistanceRate, - translate, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - // Then the manual amount remains while the merchant returns to the full route - expect(displayTransaction?.amount).toBe(-5000); - expect(displayTransaction?.merchant).toBe('6.46 mi @ $0.76 / mi'); - }); - - it('does not infer a new currency from an unchanged dollar symbol', () => { - // Given a reduced-only USD expense whose selected rate now belongs to CAD - const transaction = generateTransaction({ - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, - amount: 415, - currency: CONST.CURRENCY.USD, - merchant: '5.46 mi @ $0.76 / mi', - comment: { - customUnit: { - customUnitRateID: 'rate1', - quantity: 6.46, - reimbursableDistance: 5.46, - commuterExclusion: 1, - distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - }, - }, - }); - const policyWithChangedCurrency: Policy = { - ...policyWithDistanceRate, - customUnits: { - distance: { - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - customUnitID: 'distance', - rates: {rate1: {customUnitRateID: 'rate1', currency: CONST.CURRENCY.CAD, rate: 76}}, - attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, - }, - }, - }; - - // When the rate's symbol cannot prove which currency was originally used - const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ - transaction, - isPolicyExpenseChat: false, - policy: policyWithChangedCurrency, - translate, - getCurrencySymbol: () => '$', - }); - - // Then the transaction remains in its saved currency - expect(displayTransaction).toBe(transaction); - }); - - it('keeps commuter values unchanged for a workspace expense after its rate changes', () => { - // Given the same transaction still on its workspace expense report - // When its personal-only display normalization is requested - const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ - transaction: historicalTransaction, - isPolicyExpenseChat: true, - policy: changedRatePolicy, - translate, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - // Then the workspace commuter exclusion remains untouched - expect(displayTransaction).toBe(historicalTransaction); - }); it('rebuilds full-route amount and merchant for personal distance expenses with commuter metadata', () => { const transaction = generateTransaction({ From e63871236548e866a8c1ba65a5e5263468ba8a0b Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 13 Oct 2026 17:43:54 +0200 Subject: [PATCH 11/27] Fix distance expense previews to preserve saved rates --- src/libs/TransactionUtils/index.ts | 106 ++++++++++++++++- tests/unit/TransactionUtilsTest.ts | 182 +++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+), 3 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index d2b32f4d9210..eef74ab932d6 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -179,18 +179,118 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ return transaction; } + const modifiedAmount = hasValidModifiedAmount(transaction) ? Number(transaction.modifiedAmount) : undefined; + const storedUnit = customUnit?.distanceUnit; + const reducedDistance = customUnit?.reimbursableDistance; + const storedAmount = modifiedAmount ?? transaction.amount; + const storedMerchant = transaction.modifiedMerchant ?? transaction.merchant; + if (storedUnit && storedMerchant?.startsWith(`${fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `)) { + return { + ...transaction, + amount: storedAmount, + convertedAmount: undefined, + modifiedAmount: undefined, + merchant: storedMerchant, + modifiedMerchant: undefined, + }; + } + + if ( + storedUnit && + typeof reducedDistance === 'number' && + reducedDistance >= 0 && + reducedDistance < fullDistance && + (!transaction.modifiedCurrency || transaction.modifiedCurrency === transaction.currency) + ) { + const fullMerchantPrefix = `${fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; + const reducedMerchantPrefix = `${reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; + const fullRateText = transaction.merchant?.startsWith(fullMerchantPrefix) ? transaction.merchant.slice(fullMerchantPrefix.length) : undefined; + const reducedRateText = transaction.modifiedMerchant?.startsWith(reducedMerchantPrefix) ? transaction.modifiedMerchant.slice(reducedMerchantPrefix.length) : undefined; + if (fullRateText && fullRateText === reducedRateText && Number.isFinite(transaction.amount) && (modifiedAmount === undefined || Number.isFinite(modifiedAmount))) { + const roundedFullDistance = Number(fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); + const roundedReducedDistance = Number(reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); + if (roundedFullDistance > 0) { + const lowerRate = Math.max(0, (Math.abs(transaction.amount) - 0.5) / roundedFullDistance); + const upperRate = (Math.abs(transaction.amount) + 0.5) / roundedFullDistance; + const calculatedReducedAmount = Math.round(roundedReducedDistance * lowerRate); + const isCalculatedAmount = + modifiedAmount === undefined || (calculatedReducedAmount === Math.round(roundedReducedDistance * upperRate) && calculatedReducedAmount === Math.abs(modifiedAmount)); + const amountToDisplay = isCalculatedAmount ? Math.abs(transaction.amount) : Math.abs(modifiedAmount ?? transaction.amount); + return { + ...transaction, + amount: (modifiedAmount ?? transaction.amount) < 0 ? -amountToDisplay : amountToDisplay, + convertedAmount: undefined, + modifiedAmount: undefined, + modifiedMerchant: undefined, + }; + } + } + } + const mileageRate = DistanceRequestUtils.getRateByCustomUnitRateIDAcrossPolicies({customUnitRateID: customUnit?.customUnitRateID, policy, policies}); const rate = mileageRate?.rate; const unit = customUnit?.distanceUnit ?? mileageRate?.unit; - if (!unit || !rate) { + if (!unit) { return transaction; } const fullDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(fullDistance, unit); + const storedReducedMerchant = transaction.modifiedMerchant ?? transaction.merchant; + const storedReducedAmount = modifiedAmount ?? transaction.amount; + const canRestoreSavedReducedValues = + modifiedAmount !== undefined && + !!transaction.modifiedMerchant && + storedUnit && + typeof reducedDistance === 'number' && + reducedDistance > 0 && + reducedDistance < fullDistance && + Number.isFinite(storedReducedAmount) && + storedReducedMerchant?.startsWith(`${reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `); + const restoreSavedReducedValues = () => { + if (!canRestoreSavedReducedValues || !storedReducedMerchant || typeof reducedDistance !== 'number') { + return transaction; + } + + const rateText = storedReducedMerchant.slice(storedReducedMerchant.indexOf(CONST.DISTANCE_MERCHANT_SEPARATOR) + CONST.DISTANCE_MERCHANT_SEPARATOR.length).trim(); + const fullAmount = Math.round((Math.abs(storedReducedAmount ?? 0) * fullDistance) / reducedDistance); + return { + ...transaction, + amount: (storedReducedAmount ?? 0) < 0 ? -fullAmount : fullAmount, + convertedAmount: undefined, + modifiedAmount: undefined, + merchant: `${fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} ${rateText}`, + modifiedMerchant: undefined, + }; + }; + + if (!rate) { + return restoreSavedReducedValues(); + } + const fullDistanceAmount = DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, rate); - const storedAmount = hasValidModifiedAmount(transaction) ? Number(transaction.modifiedAmount) : (transaction.amount ?? 0); - const normalizedAmount = storedAmount < 0 ? -fullDistanceAmount : fullDistanceAmount; + const normalizedAmount = (storedAmount ?? 0) < 0 ? -fullDistanceAmount : fullDistanceAmount; const currency = mileageRate?.currency ?? getCurrency(transaction); + const savedCurrency = getCurrency(transaction); + if (currency !== savedCurrency && getCurrencySymbol(currency) === getCurrencySymbol(savedCurrency)) { + return transaction; + } + + if (canRestoreSavedReducedValues && typeof reducedDistance === 'number') { + const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(reducedDistance, unit); + const merchantAtCurrentRate = getDistanceMerchantForTransaction({ + transaction, + distanceInMeters: reducedDistanceInMeters, + unit, + rate, + currency, + translate, + getCurrencySymbol, + }); + if (storedReducedMerchant !== merchantAtCurrentRate) { + return restoreSavedReducedValues(); + } + } + const normalizedMerchant = getDistanceMerchantForTransaction({ transaction, distanceInMeters: fullDistanceInMeters, diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 670ebd28dd87..b48efb63e6dd 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1808,6 +1808,24 @@ describe('TransactionUtils', () => { }, }; + const historicalTransaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: -12158, + modifiedAmount: -11758, + currency: 'AFN', + merchant: '60.79 mi @ Af2.00 / mi', + modifiedMerchant: '58.79 mi @ Af2.00 / mi', + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: 60.79, + reimbursableDistance: 58.79, + commuterExclusion: 2, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + it('rebuilds full-route amount and merchant for personal distance expenses with commuter metadata', () => { const transaction = generateTransaction({ iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, @@ -1849,6 +1867,170 @@ describe('TransactionUtils', () => { expect(displayTransaction.currency).toBe(CONST.CURRENCY.USD); }); + it('preserves the saved rate when the workspace rate changes', () => { + // Given a personal distance expense saved at Af2 per mile before its workspace rate changed to Af3 + const policyWithChangedRate: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + rate1: { + customUnitRateID: 'rate1', + currency: 'AFN', + rate: 300, + }, + }, + }, + }, + }; + + // When the commuter exclusion is removed for display in a personal report + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: historicalTransaction, + isPolicyExpenseChat: false, + policy: policyWithChangedRate, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the full route uses the saved Af2 rate instead of repricing the expense at Af3 + expect(displayTransaction?.amount).toBe(-12158); + expect(displayTransaction?.merchant).toBe('60.79 mi @ Af2.00 / mi'); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + expect(displayTransaction?.modifiedMerchant).toBeUndefined(); + }); + + it('preserves a selected custom rate after the live custom rate changes', () => { + // Given an expense whose base fields use the old default rate while its modified fields contain the selected custom rate + const transactionAfterSelectingCustomRate = generateTransaction({ + ...historicalTransaction, + amount: -18237, + merchant: '60.79 mi @ Af3.00 / mi', + modifiedAmount: -4468, + modifiedMerchant: '58.79 mi @ Af0.76 / mi', + comment: { + customUnit: { + ...historicalTransaction.comment?.customUnit, + customUnitRateID: 'customRate', + }, + }, + }); + const policyWithChangedCustomRate: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + customRate: { + customUnitRateID: 'customRate', + currency: 'AFN', + rate: 900, + }, + }, + }, + }, + }; + + // When the personal-report display is rebuilt after the live custom rate changes from Af0.76 to Af9 + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: transactionAfterSelectingCustomRate, + isPolicyExpenseChat: false, + policy: policyWithChangedCustomRate, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the full route retains the selected Af0.76 rate instead of using the live Af9 rate + expect(displayTransaction?.amount).toBe(-4620); + expect(displayTransaction?.merchant).toBe('60.79 mi @ Af0.76 / mi'); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + expect(displayTransaction?.modifiedMerchant).toBeUndefined(); + }); + + it('preserves the reassigned default rate when saved fields already contain the full route', () => { + // Given an expense reassigned to the default rate after its custom rate was deleted, leaving full-route values alongside commuter metadata + const transactionReassignedToDefaultRate = generateTransaction({ + ...historicalTransaction, + amount: -18237, + modifiedAmount: -18237, + merchant: '60.79 mi @ Af3.00 / mi', + modifiedMerchant: '60.79 mi @ Af3.00 / mi', + comment: { + customUnit: { + ...historicalTransaction.comment?.customUnit, + customUnitRateID: 'defaultRate', + }, + }, + }); + const policyWithUpdatedDefaultRate: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + defaultRate: { + customUnitRateID: 'defaultRate', + currency: 'AFN', + rate: 100, + }, + }, + }, + }, + }; + + // When the expense returns to the personal report and the live default rate has changed from Af3 to Af1 + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: transactionReassignedToDefaultRate, + isPolicyExpenseChat: false, + policy: policyWithUpdatedDefaultRate, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the already-saved full-route Af3 values remain authoritative + expect(displayTransaction?.amount).toBe(-18237); + expect(displayTransaction?.merchant).toBe('60.79 mi @ Af3.00 / mi'); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + expect(displayTransaction?.modifiedMerchant).toBeUndefined(); + }); + + it('restores the saved full-route values when the workspace rate was deleted', () => { + // Given a personal distance expense whose original workspace rate no longer exists + const policyWithoutRate: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: {}, + }, + }, + }; + + // When the commuter exclusion is removed for display in a personal report + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: historicalTransaction, + isPolicyExpenseChat: false, + policy: policyWithoutRate, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the saved full-route amount and merchant are restored without the missing policy rate + expect(displayTransaction?.amount).toBe(-12158); + expect(displayTransaction?.merchant).toBe('60.79 mi @ Af2.00 / mi'); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + expect(displayTransaction?.modifiedMerchant).toBeUndefined(); + }); + it('returns an ordinary transaction unchanged when no commuter exclusion is applied', () => { const transaction = generateTransaction({ iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, From 599a0600eeb01839de9959563cb3ec47ea4f1c07 Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 14 Oct 2026 10:59:48 +0200 Subject: [PATCH 12/27] Add coverage for matching currency symbols in distance previews --- tests/unit/TransactionUtilsTest.ts | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index f7947a4fcc32..9ef4422f12c3 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1867,6 +1867,56 @@ describe('TransactionUtils', () => { expect(displayTransaction.currency).toBe(CONST.CURRENCY.USD); }); + it('keeps the saved transaction when the rate currency has the same symbol', () => { + // Given a saved CNY distance expense whose current policy rate uses JPY, which shares the same currency symbol + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: 415, + modifiedAmount: 415, + currency: 'CNY', + merchant: '5.46 mi @ ¥0.76 / mi', + modifiedMerchant: '5.46 mi @ ¥0.76 / mi', + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: 6.46, + reimbursableDistance: 5.46, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + const policyWithMatchingCurrencySymbol: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + rate1: { + customUnitRateID: 'rate1', + currency: 'JPY', + rate: 76, + }, + }, + }, + }, + }; + + // When the personal-report display attempts to rebuild the full-route values + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: policyWithMatchingCurrencySymbol, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then it keeps the saved transaction to avoid silently replacing CNY values with JPY values + expect(displayTransaction).toBe(transaction); + }); + it('preserves the saved rate when the workspace rate changes', () => { // Given a personal distance expense saved at Af2 per mile before its workspace rate changed to Af3 const policyWithChangedRate: Policy = { From 529645fd25354a700d8d2ae99765a6c29095de44 Mon Sep 17 00:00:00 2001 From: Nabi Date: Fri, 16 Oct 2026 10:18:04 +0200 Subject: [PATCH 13/27] Clarify distance rate restoration logic --- src/libs/TransactionUtils/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 030b3272fe7b..4f60853c7922 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -184,6 +184,8 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const reducedDistance = customUnit?.reimbursableDistance; const storedAmount = modifiedAmount ?? transaction.amount; const storedMerchant = transaction.modifiedMerchant ?? transaction.merchant; + // A rate edit can leave the preferred merchant describing the full route. Keep its saved rate + // and amount, and only clear the stale commuter fields. if (storedUnit && storedMerchant?.startsWith(`${fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `)) { return { ...transaction, @@ -195,6 +197,8 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ }; } + // Base fields can preserve the full route while modified fields contain the commuter-reduced + // snapshot. Matching rate text confirms both snapshots use the same historical rate. if ( storedUnit && typeof reducedDistance === 'number' && @@ -210,6 +214,10 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const roundedFullDistance = Number(fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); const roundedReducedDistance = Number(reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); if (roundedFullDistance > 0) { + // The stored total is rounded to the nearest minor currency unit, so its unrounded + // value lies within +/-0.5 of that total. Dividing those bounds by distance gives + // the possible historical-rate range; if the whole range reproduces modifiedAmount, + // it was an automatic commuter reduction rather than a manual edit. const lowerRate = Math.max(0, (Math.abs(transaction.amount) - 0.5) / roundedFullDistance); const upperRate = (Math.abs(transaction.amount) + 0.5) / roundedFullDistance; const calculatedReducedAmount = Math.round(roundedReducedDistance * lowerRate); @@ -237,6 +245,8 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const fullDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(fullDistance, unit); const storedReducedMerchant = transaction.modifiedMerchant ?? transaction.merchant; const storedReducedAmount = modifiedAmount ?? transaction.amount; + // Only allow proportional restoration when both modified fields are available and the merchant + // identifies the saved reduced distance; otherwise the full route cannot be reconstructed safely. const canRestoreSavedReducedValues = modifiedAmount !== undefined && !!transaction.modifiedMerchant && @@ -263,6 +273,8 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ }; }; + // When the rate cannot be resolved from policy data, recover from the available reduced snapshot + // instead of leaving commuter values in a personal report. if (!rate) { return restoreSavedReducedValues(); } @@ -271,10 +283,14 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const normalizedAmount = (storedAmount ?? 0) < 0 ? -fullDistanceAmount : fullDistanceAmount; const currency = mileageRate?.currency ?? getCurrency(transaction); const savedCurrency = getCurrency(transaction); + // Identical symbols cannot prove that the expense used the newly resolved currency, so avoid + // a potentially incorrect conversion. if (currency !== savedCurrency && getCurrencySymbol(currency) === getCurrencySymbol(savedCurrency)) { return transaction; } + // Verify the live policy rate against the saved reduced merchant. If they do not match, preserve + // the historical snapshot instead of risking a reprice from changed or differently formatted data. if (canRestoreSavedReducedValues && typeof reducedDistance === 'number') { const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(reducedDistance, unit); const merchantAtCurrentRate = getDistanceMerchantForTransaction({ From 2b82f09ab3e4889e7f4e38552516189ebf830dd2 Mon Sep 17 00:00:00 2001 From: Nabi Date: Fri, 16 Oct 2026 10:38:21 +0200 Subject: [PATCH 14/27] Ensure saved distance restoration flag is boolean --- src/libs/Permissions.ts | 2 +- src/libs/TransactionUtils/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 12a4e8880929..c3d2f3e6b2f3 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -19,7 +19,7 @@ function canApplyBetaOverrides(environment: Environment): boolean { // eslint-disable-next-line rulesdir/no-beta-handler function canUseAllBetas(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.ALL); + return true; } /** diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 4f60853c7922..2fada4ffa9b9 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -250,12 +250,12 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const canRestoreSavedReducedValues = modifiedAmount !== undefined && !!transaction.modifiedMerchant && - storedUnit && + !!storedUnit && typeof reducedDistance === 'number' && reducedDistance > 0 && reducedDistance < fullDistance && Number.isFinite(storedReducedAmount) && - storedReducedMerchant?.startsWith(`${reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `); + !!storedReducedMerchant?.startsWith(`${reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `); const restoreSavedReducedValues = () => { if (!canRestoreSavedReducedValues || !storedReducedMerchant || typeof reducedDistance !== 'number') { return transaction; From 9da497eafc820147188f2d247482ba67441bb198 Mon Sep 17 00:00:00 2001 From: Nabi Date: Fri, 16 Oct 2026 10:47:42 +0200 Subject: [PATCH 15/27] Restore all-betas permission check --- src/libs/Permissions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index c3d2f3e6b2f3..12a4e8880929 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -19,7 +19,7 @@ function canApplyBetaOverrides(environment: Environment): boolean { // eslint-disable-next-line rulesdir/no-beta-handler function canUseAllBetas(betas: OnyxEntry): boolean { - return true; + return !!betas?.includes(CONST.BETAS.ALL); } /** From b51f015163fb863dd485b0faa8a99ab07f2f778d Mon Sep 17 00:00:00 2001 From: Nabi Date: Fri, 16 Oct 2026 18:31:37 +0200 Subject: [PATCH 16/27] Fix optimistic Self DM distance rate calculation --- .../TransactionUtils/getUpdatedTransaction.ts | 38 +++---- src/libs/TransactionUtils/index.ts | 39 +++++++- src/libs/actions/IOU/UpdateMoneyRequest.ts | 1 + .../actions/IOUTest/UpdateMoneyRequestTest.ts | 73 ++++++++++++++ tests/unit/TransactionUtilsTest.ts | 98 +++++++++++++++++++ 5 files changed, 228 insertions(+), 21 deletions(-) diff --git a/src/libs/TransactionUtils/getUpdatedTransaction.ts b/src/libs/TransactionUtils/getUpdatedTransaction.ts index 36c51421678b..cfdf949bfaa9 100644 --- a/src/libs/TransactionUtils/getUpdatedTransaction.ts +++ b/src/libs/TransactionUtils/getUpdatedTransaction.ts @@ -135,6 +135,7 @@ function getUpdatedTransaction({ policy = undefined, policies = undefined, isSplitTransaction = false, + shouldApplyCommuterExclusion = true, personalPolicyOutputCurrency, getCurrencyDecimals, getCurrencySymbol, @@ -146,6 +147,7 @@ function getUpdatedTransaction({ policy?: OnyxEntry; policies?: OnyxCollection; isSplitTransaction?: boolean; + shouldApplyCommuterExclusion?: boolean; personalPolicyOutputCurrency: string | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; @@ -228,15 +230,16 @@ function getUpdatedTransaction({ lodashSet(updatedTransaction, 'comment.customUnit.routeDistanceMeters', distanceInMeters); } - const commuterExclusionTransactionData = hasAppliedCommuterExclusion(updatedTransaction) - ? DistanceRequestUtils.getTransactionCommuterExclusionData({ - transaction: updatedTransaction, - policy, - storedCustomUnit: transaction?.comment?.customUnit, - personalPolicyOutputCurrency, - hasTripChanged: waypointsActuallyChanged, - }) - : undefined; + const commuterExclusionTransactionData = + shouldApplyCommuterExclusion && hasAppliedCommuterExclusion(updatedTransaction) + ? DistanceRequestUtils.getTransactionCommuterExclusionData({ + transaction: updatedTransaction, + policy, + storedCustomUnit: transaction?.comment?.customUnit, + personalPolicyOutputCurrency, + hasTripChanged: waypointsActuallyChanged, + }) + : undefined; if (commuterExclusionTransactionData) { lodashSet(updatedTransaction, 'comment.customUnit', commuterExclusionTransactionData.customUnit); @@ -347,14 +350,15 @@ function getUpdatedTransaction({ // The commuter exclusion is expressed in distance units, so it has to be re-derived against the new // rate's unit and the converted quantity. The amount and merchant then describe the reimbursable // distance, matching what the backend stores for modifiedAmount/modifiedMerchant. - const commuterExclusionTransactionData = hasAppliedCommuterExclusion(updatedTransaction) - ? DistanceRequestUtils.getTransactionCommuterExclusionData({ - transaction: updatedTransaction, - policy, - storedCustomUnit: transaction?.comment?.customUnit, - personalPolicyOutputCurrency, - }) - : undefined; + const commuterExclusionTransactionData = + shouldApplyCommuterExclusion && hasAppliedCommuterExclusion(updatedTransaction) + ? DistanceRequestUtils.getTransactionCommuterExclusionData({ + transaction: updatedTransaction, + policy, + storedCustomUnit: transaction?.comment?.customUnit, + personalPolicyOutputCurrency, + }) + : undefined; if (commuterExclusionTransactionData) { lodashSet(updatedTransaction, 'comment.customUnit', commuterExclusionTransactionData.customUnit); diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 2fada4ffa9b9..0c8acffba9e6 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -184,6 +184,9 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const reducedDistance = customUnit?.reimbursableDistance; const storedAmount = modifiedAmount ?? transaction.amount; const storedMerchant = transaction.modifiedMerchant ?? transaction.merchant; + const mileageRate = DistanceRequestUtils.getRateByCustomUnitRateIDAcrossPolicies({customUnitRateID: customUnit?.customUnitRateID, policy, policies}); + const rate = mileageRate?.rate; + const unit = customUnit?.distanceUnit ?? mileageRate?.unit; // A rate edit can leave the preferred merchant describing the full route. Keep its saved rate // and amount, and only clear the stale commuter fields. if (storedUnit && storedMerchant?.startsWith(`${fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `)) { @@ -221,8 +224,39 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const lowerRate = Math.max(0, (Math.abs(transaction.amount) - 0.5) / roundedFullDistance); const upperRate = (Math.abs(transaction.amount) + 0.5) / roundedFullDistance; const calculatedReducedAmount = Math.round(roundedReducedDistance * lowerRate); - const isCalculatedAmount = + let isCalculatedAmount = modifiedAmount === undefined || (calculatedReducedAmount === Math.round(roundedReducedDistance * upperRate) && calculatedReducedAmount === Math.abs(modifiedAmount)); + if (!isCalculatedAmount && modifiedAmount !== undefined && typeof rate === 'number' && unit === storedUnit) { + const resolvedCurrency = mileageRate?.currency ?? getCurrency(transaction); + const savedCurrency = getCurrency(transaction); + if (resolvedCurrency === savedCurrency) { + const fullDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(roundedFullDistance, unit); + const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(roundedReducedDistance, unit); + const fullMerchantAtResolvedRate = getDistanceMerchantForTransaction({ + transaction, + distanceInMeters: fullDistanceInMeters, + unit, + rate, + currency: resolvedCurrency, + translate, + getCurrencySymbol, + }); + const reducedMerchantAtResolvedRate = getDistanceMerchantForTransaction({ + transaction, + distanceInMeters: reducedDistanceInMeters, + unit, + rate, + currency: resolvedCurrency, + translate, + getCurrencySymbol, + }); + isCalculatedAmount = + transaction.merchant === fullMerchantAtResolvedRate && + transaction.modifiedMerchant === reducedMerchantAtResolvedRate && + Math.abs(transaction.amount) === DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, rate) && + Math.abs(modifiedAmount) === DistanceRequestUtils.getDistanceRequestAmount(reducedDistanceInMeters, unit, rate); + } + } const amountToDisplay = isCalculatedAmount ? Math.abs(transaction.amount) : Math.abs(modifiedAmount ?? transaction.amount); return { ...transaction, @@ -235,9 +269,6 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ } } - const mileageRate = DistanceRequestUtils.getRateByCustomUnitRateIDAcrossPolicies({customUnitRateID: customUnit?.customUnitRateID, policy, policies}); - const rate = mileageRate?.rate; - const unit = customUnit?.distanceUnit ?? mileageRate?.unit; if (!unit) { return transaction; } diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 81152ff8065a..c12f95cd6988 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -2601,6 +2601,7 @@ function getUpdateTrackExpenseParams( transaction, transactionChanges, isFromExpenseReport: false, + shouldApplyCommuterExclusion: false, policy: policyForTransaction, personalPolicyOutputCurrency, getCurrencyDecimals, diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index c3cb07138a8f..f648084ae161 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -2286,6 +2286,79 @@ describe('actions/IOU/UpdateMoneyRequest', () => { }); describe('getUpdateTrackExpenseParams', () => { + it('uses the full distance when optimistically changing a tracked expense rate in Self DM', async () => { + const transactionID = 'track_distance_rate'; + const transactionThreadReportID = 'track_distance_rate_thread'; + const selfDMReportID = 'track_distance_rate_self_dm'; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + amount: 3, + modifiedAmount: 1, + currency: CONST.CURRENCY.USD, + merchant: '1.97 mi @ $0.015 / mi', + modifiedMerchant: '0.97 mi @ $0.015 / mi', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitRateID: 'custom', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 1.97, + commuterExclusion: 1, + reimbursableDistance: 0.97, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }, + }, + }; + const fakeThreadReport = createMock({ + reportID: transactionThreadReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: selfDMReportID, + }); + const fakeSelfDMReport = createRandomReport(2, CONST.REPORT.CHAT_TYPE.SELF_DM); + fakeSelfDMReport.reportID = selfDMReportID; + const fakePolicy: Policy = { + ...createRandomPolicy(1), + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + custom: {customUnitRateID: 'custom', currency: CONST.CURRENCY.USD, rate: 0.02}, + default: {customUnitRateID: 'default', currency: CONST.CURRENCY.USD, rate: 1.5}, + }, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, fakeThreadReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`, fakeSelfDMReport); + await waitForBatchedUpdates(); + + const {onyxData} = getUpdateTrackExpenseParams({ + transactionID, + transaction: fakeTransaction, + transactionThreadReportID, + transactionChanges: {customUnitRateID: 'default'}, + policy: fakePolicy, + delegateAccountID: undefined, + currencyContext: {getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal}, + shouldBuildOptimisticModifiedExpenseReportAction: false, + }); + const optimisticTransactionUpdate = onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + + expect(optimisticTransactionUpdate?.value).toMatchObject({ + amount: -3, + modifiedAmount: -3, + modifiedMerchant: '1.97 mi @ $0.015 / mi', + comment: {customUnit: {customUnitRateID: 'default'}}, + }); + }); + it('preserves full-precision distance in API params (#90561 — mirror of getUpdateMoneyRequestParams)', async () => { // Given a self-DM track expense whose stored quantity is at 2-decimal precision const transactionID = 'track_distance_precision'; diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 9ef4422f12c3..774da5813413 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1024,6 +1024,51 @@ describe('TransactionUtils', () => { }, ); + it('does not apply a saved commuter exclusion when changing a distance rate in Self DM', () => { + const fakePolicy: Policy = { + ...createRandomPolicy(0), + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + custom: {customUnitRateID: 'custom', currency: CONST.CURRENCY.USD, rate: 0.02}, + default: {customUnitRateID: 'default', currency: CONST.CURRENCY.USD, rate: 1.5}, + }, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }, + }, + }; + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + customUnit: { + customUnitRateID: 'custom', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 1.97, + commuterExclusion: 1, + reimbursableDistance: 0.97, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }, + }, + currency: CONST.CURRENCY.USD, + }); + + const updatedTransaction = TransactionUtils.getUpdatedTransaction({ + transaction, + isFromExpenseReport: false, + shouldApplyCommuterExclusion: false, + policy: fakePolicy, + transactionChanges: {customUnitRateID: 'default'}, + personalPolicyOutputCurrency: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + expect(updatedTransaction.modifiedAmount).toBe(3); + expect(updatedTransaction.modifiedMerchant).toBe('1.97 mi @ $0.015 / mi'); + }); + it('does not apply a newly enabled commuter exclusion when a historical manual distance is changed', () => { const fakePolicy: Policy = { ...createRandomPolicy(0), @@ -1867,6 +1912,59 @@ describe('TransactionUtils', () => { expect(displayTransaction.currency).toBe(CONST.CURRENCY.USD); }); + it('rebuilds a rounded full-route amount when a low rate makes historical rate inference ambiguous', () => { + // Given a low-rate expense whose full and commuter-reduced totals both lose precision when rounded to cents + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: 3, + modifiedAmount: 1, + currency: CONST.CURRENCY.USD, + merchant: '1.97 mi @ $0.015 / mi', + modifiedMerchant: '0.97 mi @ $0.015 / mi', + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: 1.97, + reimbursableDistance: 0.97, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + const lowRatePolicy: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + rate1: { + customUnitRateID: 'rate1', + currency: CONST.CURRENCY.USD, + rate: 1.5, + }, + }, + }, + }, + }; + + // When the commuter exclusion is removed for display in a personal report + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: lowRatePolicy, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the exact resolved rate identifies the reduced cent as automatic and restores the rounded full total + expect(displayTransaction?.amount).toBe(3); + expect(displayTransaction?.merchant).toBe('1.97 mi @ $0.015 / mi'); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + expect(displayTransaction?.modifiedMerchant).toBeUndefined(); + }); + it('keeps the saved transaction when the rate currency has the same symbol', () => { // Given a saved CNY distance expense whose current policy rate uses JPY, which shares the same currency symbol const transaction = generateTransaction({ From 44204ea0a8f610ca754d8dcd915b2a3bb989244a Mon Sep 17 00:00:00 2001 From: Nabi Date: Sat, 17 Oct 2026 10:09:59 +0200 Subject: [PATCH 17/27] `Preserve full distance amount across offline rate changes` --- .../TransactionUtils/getUpdatedTransaction.ts | 4 +- tests/unit/TransactionUtilsTest.ts | 73 ++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/libs/TransactionUtils/getUpdatedTransaction.ts b/src/libs/TransactionUtils/getUpdatedTransaction.ts index cfdf949bfaa9..d2568add1404 100644 --- a/src/libs/TransactionUtils/getUpdatedTransaction.ts +++ b/src/libs/TransactionUtils/getUpdatedTransaction.ts @@ -377,7 +377,9 @@ function getUpdatedTransaction({ DistanceRequestUtils.getCommuterExclusionDisplayData(commuterExclusionTransactionData?.customUnit, unit), ); - updatedTransaction.amount = updatedAmount; + if (!commuterExclusionTransactionData) { + updatedTransaction.amount = updatedAmount; + } updatedTransaction.modifiedAmount = updatedAmount; updatedTransaction.modifiedMerchant = updatedMerchant; updatedTransaction.modifiedCurrency = updatedCurrency; diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 774da5813413..b439a661720b 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -15,7 +15,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {Card, Policy, Report, ReviewDuplicates, Transaction} from '../../src/types/onyx'; -import type {Routes} from '../../src/types/onyx/Transaction'; +import type {Routes, TransactionChanges} from '../../src/types/onyx/Transaction'; import type {TransactionViolation} from '../../src/types/onyx/TransactionViolation'; import * as TransactionUtils from '../../src/libs/TransactionUtils'; @@ -1069,6 +1069,77 @@ describe('TransactionUtils', () => { expect(updatedTransaction.modifiedMerchant).toBe('1.97 mi @ $0.015 / mi'); }); + it('preserves the full amount through offline workspace distance and rate edits', () => { + // Given a workspace expense with a one-mile commuter exclusion and separate full/reduced snapshots + const fakePolicy: Policy = { + ...createRandomPolicy(0), + commuterExclusions: { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + fixedDistance: 1, + fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + rateA: {customUnitRateID: 'rateA', currency: CONST.CURRENCY.USD, rate: 100}, + rateB: {customUnitRateID: 'rateB', currency: CONST.CURRENCY.USD, rate: 200}, + }, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }, + }, + }; + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: -1015, + modifiedAmount: -915, + currency: CONST.CURRENCY.USD, + merchant: '10.15 mi @ $1.00 / mi', + modifiedMerchant: '9.15 mi @ $1.00 / mi', + comment: { + customUnit: { + customUnitRateID: 'rateA', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 10.15, + commuterExclusion: 1, + reimbursableDistance: 9.15, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }, + }, + }); + const updateWorkspaceTransaction = (transactionToUpdate: Transaction, transactionChanges: TransactionChanges) => + TransactionUtils.getUpdatedTransaction({ + transaction: transactionToUpdate, + isFromExpenseReport: true, + policy: fakePolicy, + transactionChanges, + personalPolicyOutputCurrency: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // When offline optimistic edits change the distance away and back, then switch rate A to B and back to A + const longerTransaction = updateWorkspaceTransaction(transaction, {distance: 11.15}); + const originalDistanceTransaction = updateWorkspaceTransaction(longerTransaction, {distance: 10.15}); + const rateBTransaction = updateWorkspaceTransaction(originalDistanceTransaction, {customUnitRateID: 'rateB'}); + const restoredRateATransaction = updateWorkspaceTransaction(rateBTransaction, {customUnitRateID: 'rateA'}); + const personalDisplayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: restoredRateATransaction, + isPolicyExpenseChat: false, + policy: fakePolicy, + translate: (path, ...parameters) => translateWithLocale(CONST.LOCALES.EN, path, ...parameters), + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the workspace keeps its reduced amount while Self DM can restore the preserved full snapshot + expect(rateBTransaction.amount).toBe(-1015); + expect(rateBTransaction.modifiedAmount).toBe(-1830); + expect(restoredRateATransaction.modifiedAmount).toBe(-915); + expect(personalDisplayTransaction?.amount).toBe(-1015); + expect(personalDisplayTransaction?.merchant).toBe('10.15 mi @ $1.00 / mi'); + }); + it('does not apply a newly enabled commuter exclusion when a historical manual distance is changed', () => { const fakePolicy: Policy = { ...createRandomPolicy(0), From 999ceec1d745ead1feaa0354b5958ff019070c63 Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 19 Oct 2026 09:12:23 +0200 Subject: [PATCH 18/27] Fix distance expense amount changing when switching languages --- src/libs/TransactionUtils/index.ts | 3 +- tests/unit/TransactionUtilsTest.ts | 60 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 0c8acffba9e6..004f8f4ffc21 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -333,7 +333,8 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ translate, getCurrencySymbol, }); - if (storedReducedMerchant !== merchantAtCurrentRate) { + // Rate values use a locale-dependent decimal separator, while the saved merchant keeps the locale used at edit time. + if (storedReducedMerchant.replaceAll(',', '.') !== merchantAtCurrentRate.replaceAll(',', '.')) { return restoreSavedReducedValues(); } } diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index b439a661720b..250855e1568a 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -2036,6 +2036,66 @@ describe('TransactionUtils', () => { expect(displayTransaction?.modifiedMerchant).toBeUndefined(); }); + it.each([ + {fromLocale: CONST.LOCALES.EN, toLocale: CONST.LOCALES.FR, savedRateText: '$0.50'}, + {fromLocale: CONST.LOCALES.FR, toLocale: CONST.LOCALES.EN, savedRateText: '$0,50'}, + ])('keeps the full amount when switching from $fromLocale to $toLocale after a manual distance edit', ({fromLocale, toLocale, savedRateText}) => { + // Given a saved expense whose manual distance edit left an old base merchant and a rounded commuter amount + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: 541, + modifiedAmount: -2, + currency: CONST.CURRENCY.USD, + modifiedCurrency: CONST.CURRENCY.USD, + merchant: `10.82 mi @ ${savedRateText} / mi`, + modifiedMerchant: `0.03 mi @ ${savedRateText} / mi`, + comment: { + customUnit: { + customUnitRateID: 'custom', + quantity: 1.03, + reimbursableDistance: 0.03, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + const customRatePolicy: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: {custom: {customUnitRateID: 'custom', currency: CONST.CURRENCY.USD, rate: 50}}, + }, + }, + }; + const currentLocale = jest.spyOn(IntlStore, 'getCurrentLocale'); + + // When Self DM renders the saved transaction before and after switching languages + currentLocale.mockReturnValue(fromLocale); + const originalDisplay = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: customRatePolicy, + translate: (path, ...parameters) => translateWithLocale(fromLocale, path, ...parameters), + getCurrencySymbol: () => '$', + }); + currentLocale.mockReturnValue(toLocale); + const translatedDisplay = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: customRatePolicy, + translate: (path, ...parameters) => translateWithLocale(toLocale, path, ...parameters), + getCurrencySymbol: () => '$', + }); + currentLocale.mockRestore(); + + // Then both locales use the selected rate, not a ratio of the already rounded two cents + expect(originalDisplay?.amount).toBe(-52); + expect(translatedDisplay?.amount).toBe(-52); + }); + it('keeps the saved transaction when the rate currency has the same symbol', () => { // Given a saved CNY distance expense whose current policy rate uses JPY, which shares the same currency symbol const transaction = generateTransaction({ From 0706971fa293822501b99872f04c263f8251203f Mon Sep 17 00:00:00 2001 From: Nabi Date: Mon, 19 Oct 2026 12:25:08 +0200 Subject: [PATCH 19/27] Preserve saved distance rates when currency data loads --- src/libs/TransactionUtils/index.ts | 20 +++++++++++- tests/unit/TransactionUtilsTest.ts | 51 +++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 004f8f4ffc21..9e53417adf2d 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -293,7 +293,25 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ } const rateText = storedReducedMerchant.slice(storedReducedMerchant.indexOf(CONST.DISTANCE_MERCHANT_SEPARATOR) + CONST.DISTANCE_MERCHANT_SEPARATOR.length).trim(); - const fullAmount = Math.round((Math.abs(storedReducedAmount ?? 0) * fullDistance) / reducedDistance); + let fullAmount = Math.round((Math.abs(storedReducedAmount ?? 0) * fullDistance) / reducedDistance); + const savedCurrency = getCurrency(transaction); + const currencyPrefix = [getCurrencySymbol(savedCurrency), `${savedCurrency} `].find((prefix) => !!prefix && rateText.startsWith(prefix)); + const unitSuffix = ` / ${storedUnit}`; + if (currencyPrefix && rateText.endsWith(unitSuffix)) { + const numericRateText = rateText.slice(currencyPrefix.length, -unitSuffix.length); + // Distance rate labels have no grouping separators. Verify the entire saved value before using it. + if (/^\d+(?:[.,]\d{1,4})?$/.test(numericRateText)) { + const savedRate = Number(numericRateText.replace(',', '.')) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET; + const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(reducedDistance, unit); + if ( + Number.isFinite(savedRate) && + savedRate > 0 && + DistanceRequestUtils.getDistanceRequestAmount(reducedDistanceInMeters, unit, savedRate) === Math.abs(storedReducedAmount) + ) { + fullAmount = DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, savedRate); + } + } + } return { ...transaction, amount: (storedReducedAmount ?? 0) < 0 ? -fullAmount : fullAmount, diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 250855e1568a..46799bf4e5a0 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -2037,9 +2037,11 @@ describe('TransactionUtils', () => { }); it.each([ - {fromLocale: CONST.LOCALES.EN, toLocale: CONST.LOCALES.FR, savedRateText: '$0.50'}, - {fromLocale: CONST.LOCALES.FR, toLocale: CONST.LOCALES.EN, savedRateText: '$0,50'}, - ])('keeps the full amount when switching from $fromLocale to $toLocale after a manual distance edit', ({fromLocale, toLocale, savedRateText}) => { + {fromLocale: CONST.LOCALES.EN, toLocale: CONST.LOCALES.FR, savedRateText: '$0.50', liveRate: 50}, + {fromLocale: CONST.LOCALES.FR, toLocale: CONST.LOCALES.EN, savedRateText: '$0,50', liveRate: 50}, + {fromLocale: CONST.LOCALES.EN, toLocale: CONST.LOCALES.FR, savedRateText: '$0.50', liveRate: 100}, + {fromLocale: CONST.LOCALES.FR, toLocale: CONST.LOCALES.EN, savedRateText: '$0,50', liveRate: 100}, + ])('keeps the full amount from $fromLocale to $toLocale with live rate $liveRate after a manual distance edit', ({fromLocale, toLocale, savedRateText, liveRate}) => { // Given a saved expense whose manual distance edit left an old base merchant and a rounded commuter amount const transaction = generateTransaction({ iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, @@ -2066,7 +2068,7 @@ describe('TransactionUtils', () => { ...policyWithDistanceRate.customUnits?.distance, name: CONST.CUSTOM_UNITS.NAME_DISTANCE, customUnitID: 'distance', - rates: {custom: {customUnitRateID: 'custom', currency: CONST.CURRENCY.USD, rate: 50}}, + rates: {custom: {customUnitRateID: 'custom', currency: CONST.CURRENCY.USD, rate: liveRate}}, }, }, }; @@ -2094,6 +2096,47 @@ describe('TransactionUtils', () => { // Then both locales use the selected rate, not a ratio of the already rounded two cents expect(originalDisplay?.amount).toBe(-52); expect(translatedDisplay?.amount).toBe(-52); + if (liveRate !== 50) { + expect(originalDisplay?.merchant).toBe(`1.03 mi @ ${savedRateText} / mi`); + expect(translatedDisplay?.merchant).toBe(`1.03 mi @ ${savedRateText} / mi`); + } + }); + + it('preserves the restored amount for a saved ISO currency prefix when currency data loads', () => { + // Given a reduced snapshot saved with an ISO currency prefix and a rounded two-cent amount + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: 541, + modifiedAmount: -2, + currency: CONST.CURRENCY.USD, + merchant: '10.82 mi @ USD 0.50 / mi', + modifiedMerchant: '0.03 mi @ USD 0.50 / mi', + comment: { + customUnit: { + customUnitRateID: 'custom', + quantity: 1.03, + reimbursableDistance: 0.03, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + + // When the same saved transaction renders before and after its currency symbol loads + const displays = [undefined, '$'].map((symbol) => + TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + translate: (path, ...parameters) => translateWithLocale(CONST.LOCALES.EN, path, ...parameters), + getCurrencySymbol: () => symbol, + }), + ); + + // Then loading currency data does not change the restored total or historical rate + for (const display of displays) { + expect(display?.amount).toBe(-52); + expect(display?.merchant).toBe('1.03 mi @ USD 0.50 / mi'); + } }); it('keeps the saved transaction when the rate currency has the same symbol', () => { From a8f39bdd6c3dcd321d6fbb1661ca5c5cb489ffb6 Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 20 Oct 2026 17:47:17 +0200 Subject: [PATCH 20/27] Extract distance merchant parsing and add formatter round-trip tests --- src/libs/TransactionUtils/index.ts | 44 +++++------ .../TransactionUtils/parseDistanceMerchant.ts | 51 +++++++++++++ tests/unit/ParseDistanceMerchantTest.ts | 76 +++++++++++++++++++ 3 files changed, 147 insertions(+), 24 deletions(-) create mode 100644 src/libs/TransactionUtils/parseDistanceMerchant.ts create mode 100644 tests/unit/ParseDistanceMerchantTest.ts diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 9e53417adf2d..a598d32ae426 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -60,6 +60,7 @@ import getDistanceInMeters from './getDistanceInMeters'; import getSelectedRouteKey from './getSelectedRouteKey'; // eslint-disable-next-line import/no-cycle import {getClearedPendingFields, getDistanceMerchantForTransaction, getUpdatedTransaction} from './getUpdatedTransaction'; +import parseDistanceMerchant from './parseDistanceMerchant'; // eslint-disable-next-line import/no-cycle import { calculateTaxAmount, @@ -189,7 +190,7 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const unit = customUnit?.distanceUnit ?? mileageRate?.unit; // A rate edit can leave the preferred merchant describing the full route. Keep its saved rate // and amount, and only clear the stale commuter fields. - if (storedUnit && storedMerchant?.startsWith(`${fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `)) { + if (parseDistanceMerchant({merchant: storedMerchant, distance: fullDistance, unit: storedUnit})) { return { ...transaction, amount: storedAmount, @@ -209,10 +210,8 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ reducedDistance < fullDistance && (!transaction.modifiedCurrency || transaction.modifiedCurrency === transaction.currency) ) { - const fullMerchantPrefix = `${fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; - const reducedMerchantPrefix = `${reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; - const fullRateText = transaction.merchant?.startsWith(fullMerchantPrefix) ? transaction.merchant.slice(fullMerchantPrefix.length) : undefined; - const reducedRateText = transaction.modifiedMerchant?.startsWith(reducedMerchantPrefix) ? transaction.modifiedMerchant.slice(reducedMerchantPrefix.length) : undefined; + const fullRateText = parseDistanceMerchant({merchant: transaction.merchant, distance: fullDistance, unit: storedUnit})?.rateText; + const reducedRateText = parseDistanceMerchant({merchant: transaction.modifiedMerchant, distance: reducedDistance, unit: storedUnit})?.rateText; if (fullRateText && fullRateText === reducedRateText && Number.isFinite(transaction.amount) && (modifiedAmount === undefined || Number.isFinite(modifiedAmount))) { const roundedFullDistance = Number(fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); const roundedReducedDistance = Number(reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); @@ -276,6 +275,14 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const fullDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(fullDistance, unit); const storedReducedMerchant = transaction.modifiedMerchant ?? transaction.merchant; const storedReducedAmount = modifiedAmount ?? transaction.amount; + const savedCurrencyForRate = getCurrency(transaction); + const parsedReducedMerchant = parseDistanceMerchant({ + merchant: storedReducedMerchant, + distance: reducedDistance, + unit: storedUnit, + currency: savedCurrencyForRate, + currencySymbol: getCurrencySymbol(savedCurrencyForRate), + }); // Only allow proportional restoration when both modified fields are available and the merchant // identifies the saved reduced distance; otherwise the full route cannot be reconstructed safely. const canRestoreSavedReducedValues = @@ -286,30 +293,19 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ reducedDistance > 0 && reducedDistance < fullDistance && Number.isFinite(storedReducedAmount) && - !!storedReducedMerchant?.startsWith(`${reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `); + !!parsedReducedMerchant; const restoreSavedReducedValues = () => { - if (!canRestoreSavedReducedValues || !storedReducedMerchant || typeof reducedDistance !== 'number') { + if (!canRestoreSavedReducedValues || !parsedReducedMerchant || typeof reducedDistance !== 'number') { return transaction; } - const rateText = storedReducedMerchant.slice(storedReducedMerchant.indexOf(CONST.DISTANCE_MERCHANT_SEPARATOR) + CONST.DISTANCE_MERCHANT_SEPARATOR.length).trim(); + const rateText = parsedReducedMerchant.rateText.trim(); let fullAmount = Math.round((Math.abs(storedReducedAmount ?? 0) * fullDistance) / reducedDistance); - const savedCurrency = getCurrency(transaction); - const currencyPrefix = [getCurrencySymbol(savedCurrency), `${savedCurrency} `].find((prefix) => !!prefix && rateText.startsWith(prefix)); - const unitSuffix = ` / ${storedUnit}`; - if (currencyPrefix && rateText.endsWith(unitSuffix)) { - const numericRateText = rateText.slice(currencyPrefix.length, -unitSuffix.length); - // Distance rate labels have no grouping separators. Verify the entire saved value before using it. - if (/^\d+(?:[.,]\d{1,4})?$/.test(numericRateText)) { - const savedRate = Number(numericRateText.replace(',', '.')) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET; - const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(reducedDistance, unit); - if ( - Number.isFinite(savedRate) && - savedRate > 0 && - DistanceRequestUtils.getDistanceRequestAmount(reducedDistanceInMeters, unit, savedRate) === Math.abs(storedReducedAmount) - ) { - fullAmount = DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, savedRate); - } + const savedRate = parsedReducedMerchant.rate; + if (savedRate !== undefined) { + const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(reducedDistance, unit); + if (DistanceRequestUtils.getDistanceRequestAmount(reducedDistanceInMeters, unit, savedRate) === Math.abs(storedReducedAmount)) { + fullAmount = DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, savedRate); } } return { diff --git a/src/libs/TransactionUtils/parseDistanceMerchant.ts b/src/libs/TransactionUtils/parseDistanceMerchant.ts new file mode 100644 index 000000000000..e2d172c64801 --- /dev/null +++ b/src/libs/TransactionUtils/parseDistanceMerchant.ts @@ -0,0 +1,51 @@ +import CONST from '@src/CONST'; +import type {Unit} from '@src/types/onyx/Policy'; + +/** + * Compatibility parser for historical distance expenses without a structured rate snapshot. + * Distance, unit, and currency must come from the transaction, not from the label or live policy. + * This mirrors getDistanceMerchantForTransaction's label format; round-trip tests guard that contract. + * An unrecognized rate leaves rate undefined so callers retain their existing fallback behavior. + */ +function parseDistanceMerchant({ + merchant, + distance, + unit, + currency, + currencySymbol, +}: { + merchant: string | null | undefined; + distance: number | null | undefined; + unit: Unit | undefined; + currency?: string; + currencySymbol?: string; +}): {rateText: string; rate: number | undefined} | undefined { + if (typeof distance !== 'number' || !unit) { + return; + } + + const prefix = `${distance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${unit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; + if (!merchant?.startsWith(prefix)) { + return; + } + + const rateText = merchant.slice(prefix.length); + const trimmedRateText = rateText.trim(); + const currencyPrefix = [currencySymbol, currency ? `${currency} ` : undefined].find((candidate) => !!candidate && trimmedRateText.startsWith(candidate)); + const unitSuffix = ` / ${unit}`; + let rate: number | undefined; + if (currencyPrefix && trimmedRateText.endsWith(unitSuffix)) { + const numericRateText = trimmedRateText.slice(currencyPrefix.length, -unitSuffix.length); + // Rate labels have no grouping separators and at most four decimal places. + if (/^\d+(?:[.,]\d{1,4})?$/.test(numericRateText)) { + const parsedRate = Number(numericRateText.replace(',', '.')) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET; + if (Number.isFinite(parsedRate) && parsedRate > 0) { + rate = parsedRate; + } + } + } + + return {rateText, rate}; +} + +export default parseDistanceMerchant; diff --git a/tests/unit/ParseDistanceMerchantTest.ts b/tests/unit/ParseDistanceMerchantTest.ts new file mode 100644 index 000000000000..d8a366dc20cf --- /dev/null +++ b/tests/unit/ParseDistanceMerchantTest.ts @@ -0,0 +1,76 @@ +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import {translate as translateWithLocale} from '@libs/Localize'; +import {getDistanceMerchantForTransaction} from '@libs/TransactionUtils/getUpdatedTransaction'; +import parseDistanceMerchant from '@libs/TransactionUtils/parseDistanceMerchant'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; + +describe('parseDistanceMerchant', () => { + beforeAll(async () => { + await IntlStore.load(CONST.LOCALES.EN); + await IntlStore.load(CONST.LOCALES.FR); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe.each([CONST.LOCALES.EN, CONST.LOCALES.FR])('formatter contract in %s', (locale) => { + it.each([ + {rate: 50, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, symbol: '$'}, + {rate: 1.5, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, symbol: '$'}, + {rate: 123450, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, symbol: '$'}, + {rate: 50, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, symbol: undefined}, + ])('round-trips rate $rate and unit $unit with symbol $symbol', ({rate, unit, symbol}) => { + // Given a label generated by the production formatter, without relying on a policy lookup + jest.spyOn(IntlStore, 'getCurrentLocale').mockReturnValue(locale); + const distance = 1.03; + const merchant = getDistanceMerchantForTransaction({ + transaction: undefined, + distanceInMeters: DistanceRequestUtils.convertToDistanceInMeters(distance, unit), + unit, + rate, + currency: CONST.CURRENCY.USD, + translate: (path, ...parameters) => translateWithLocale(locale, path, ...parameters), + getCurrencySymbol: () => symbol, + }); + + // When the historical label is parsed with the saved structured distance, unit and currency + const parsed = parseDistanceMerchant({merchant, distance, unit, currency: CONST.CURRENCY.USD, currencySymbol: symbol}); + + // Then the exact rate survives the formatter/parser boundary, including fractional and large rates + expect(parsed?.rate).toBeCloseTo(rate, 8); + expect(`${distance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${unit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} ${parsed?.rateText}`).toBe(merchant); + }); + }); + + it.each(['UNKNOWN0.50 / mi', '$1,234.50 / mi', '$0.50 extra / mi', '$0.50 / km'])('does not infer a numeric rate from %s', (rateText) => { + // Given a historical label with an unsupported currency, number format or unit + const merchant = `1.03 mi @ ${rateText}`; + + // When parsing the complete rate value + const parsed = parseDistanceMerchant({ + merchant, + distance: 1.03, + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + currency: CONST.CURRENCY.USD, + currencySymbol: '$', + }); + + // Then the raw snapshot remains available but the caller cannot treat the text as a verified numeric rate + expect(parsed?.rateText).toBe(rateText); + expect(parsed?.rate).toBeUndefined(); + }); + + it('rejects a label that does not match the saved distance', () => { + // Given a stale merchant from before the route was edited + const merchant = '10.82 mi @ $0.50 / mi'; + + // When it is checked against the new structured distance + const parsed = parseDistanceMerchant({merchant, distance: 1.03, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}); + + // Then it cannot be mistaken for a snapshot of the new route + expect(parsed).toBeUndefined(); + }); +}); From 6d1a5d5056c60bd8aec076b302c2e72460503759 Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 20 Oct 2026 17:57:00 +0200 Subject: [PATCH 21/27] Document policy parameters in useDisplayTransaction --- src/hooks/useDisplayTransaction.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/hooks/useDisplayTransaction.ts b/src/hooks/useDisplayTransaction.ts index 1cb2f26753fe..9222ed735217 100644 --- a/src/hooks/useDisplayTransaction.ts +++ b/src/hooks/useDisplayTransaction.ts @@ -9,6 +9,13 @@ import {useCurrencyListActions} from './useCurrencyList'; import useDistanceRateOriginalPolicy from './useDistanceRateOriginalPolicy'; import useLocalize from './useLocalize'; +/** + * Normalizes a transaction for display, keeping commuter-reduced values in policy + * expense chats and restoring full-route values elsewhere when possible. + * + * @param policy Fallback policy used for display normalization when no original policy is found. + * @param rateLookupPolicy Policy checked for the selected rate before searching other policies. Defaults to policy. + */ function useDisplayTransaction(transaction: OnyxEntry, isPolicyExpenseChat: boolean, policy?: OnyxEntry, rateLookupPolicy: OnyxEntry = policy) { const {translate} = useLocalize(); const {getCurrencySymbol} = useCurrencyListActions(); From 06d7c5c7bb5bf0d0ccccdf23f0ea7878af87fcad Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 20 Oct 2026 18:22:04 +0200 Subject: [PATCH 22/27] Add explicit return type to useDisplayTransaction --- src/hooks/useDisplayTransaction.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/hooks/useDisplayTransaction.ts b/src/hooks/useDisplayTransaction.ts index 9222ed735217..65225633f8b0 100644 --- a/src/hooks/useDisplayTransaction.ts +++ b/src/hooks/useDisplayTransaction.ts @@ -9,6 +9,11 @@ import {useCurrencyListActions} from './useCurrencyList'; import useDistanceRateOriginalPolicy from './useDistanceRateOriginalPolicy'; import useLocalize from './useLocalize'; +type UseDisplayTransactionResult = { + displayTransaction: OnyxEntry; + distanceOriginalPolicy: OnyxEntry; +}; + /** * Normalizes a transaction for display, keeping commuter-reduced values in policy * expense chats and restoring full-route values elsewhere when possible. @@ -16,7 +21,12 @@ import useLocalize from './useLocalize'; * @param policy Fallback policy used for display normalization when no original policy is found. * @param rateLookupPolicy Policy checked for the selected rate before searching other policies. Defaults to policy. */ -function useDisplayTransaction(transaction: OnyxEntry, isPolicyExpenseChat: boolean, policy?: OnyxEntry, rateLookupPolicy: OnyxEntry = policy) { +function useDisplayTransaction( + transaction: OnyxEntry, + isPolicyExpenseChat: boolean, + policy?: OnyxEntry, + rateLookupPolicy: OnyxEntry = policy, +): UseDisplayTransactionResult { const {translate} = useLocalize(); const {getCurrencySymbol} = useCurrencyListActions(); const customUnitRateID = isDistanceRequest(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; From 55aac36cc7e9593abe50755956851eaa7eb5ea67 Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 20 Oct 2026 18:37:02 +0200 Subject: [PATCH 23/27] Use a named function for restoreSavedReducedValues --- src/libs/TransactionUtils/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index a598d32ae426..3441c7be2561 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -294,8 +294,8 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ reducedDistance < fullDistance && Number.isFinite(storedReducedAmount) && !!parsedReducedMerchant; - const restoreSavedReducedValues = () => { - if (!canRestoreSavedReducedValues || !parsedReducedMerchant || typeof reducedDistance !== 'number') { + function restoreSavedReducedValues(): OnyxEntry { + if (!transaction || !unit || typeof fullDistance !== 'number' || !canRestoreSavedReducedValues || !parsedReducedMerchant || typeof reducedDistance !== 'number') { return transaction; } @@ -316,7 +316,7 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ merchant: `${fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} ${rateText}`, modifiedMerchant: undefined, }; - }; + } // When the rate cannot be resolved from policy data, recover from the available reduced snapshot // instead of leaving commuter values in a personal report. From 2612b5370d8a0efee7b1174633f3649f3e8f9275 Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 20 Oct 2026 18:54:15 +0200 Subject: [PATCH 24/27] Share distance merchant prefix formatting --- src/libs/TransactionUtils/index.ts | 6 +++--- src/libs/TransactionUtils/parseDistanceMerchant.ts | 7 ++++++- tests/unit/ParseDistanceMerchantTest.ts | 4 ++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 3441c7be2561..a0a802311b17 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -60,7 +60,7 @@ import getDistanceInMeters from './getDistanceInMeters'; import getSelectedRouteKey from './getSelectedRouteKey'; // eslint-disable-next-line import/no-cycle import {getClearedPendingFields, getDistanceMerchantForTransaction, getUpdatedTransaction} from './getUpdatedTransaction'; -import parseDistanceMerchant from './parseDistanceMerchant'; +import parseDistanceMerchant, {getDistanceMerchantPrefix} from './parseDistanceMerchant'; // eslint-disable-next-line import/no-cycle import { calculateTaxAmount, @@ -295,7 +295,7 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ Number.isFinite(storedReducedAmount) && !!parsedReducedMerchant; function restoreSavedReducedValues(): OnyxEntry { - if (!transaction || !unit || typeof fullDistance !== 'number' || !canRestoreSavedReducedValues || !parsedReducedMerchant || typeof reducedDistance !== 'number') { + if (!transaction || !unit || !storedUnit || typeof fullDistance !== 'number' || !canRestoreSavedReducedValues || !parsedReducedMerchant || typeof reducedDistance !== 'number') { return transaction; } @@ -313,7 +313,7 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ amount: (storedReducedAmount ?? 0) < 0 ? -fullAmount : fullAmount, convertedAmount: undefined, modifiedAmount: undefined, - merchant: `${fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${storedUnit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} ${rateText}`, + merchant: `${getDistanceMerchantPrefix(fullDistance, storedUnit)}${rateText}`, modifiedMerchant: undefined, }; } diff --git a/src/libs/TransactionUtils/parseDistanceMerchant.ts b/src/libs/TransactionUtils/parseDistanceMerchant.ts index e2d172c64801..1a1e807427c3 100644 --- a/src/libs/TransactionUtils/parseDistanceMerchant.ts +++ b/src/libs/TransactionUtils/parseDistanceMerchant.ts @@ -1,6 +1,10 @@ import CONST from '@src/CONST'; import type {Unit} from '@src/types/onyx/Policy'; +function getDistanceMerchantPrefix(distance: number, unit: Unit): string { + return `${distance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${unit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; +} + /** * Compatibility parser for historical distance expenses without a structured rate snapshot. * Distance, unit, and currency must come from the transaction, not from the label or live policy. @@ -24,7 +28,7 @@ function parseDistanceMerchant({ return; } - const prefix = `${distance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${unit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; + const prefix = getDistanceMerchantPrefix(distance, unit); if (!merchant?.startsWith(prefix)) { return; } @@ -48,4 +52,5 @@ function parseDistanceMerchant({ return {rateText, rate}; } +export {getDistanceMerchantPrefix}; export default parseDistanceMerchant; diff --git a/tests/unit/ParseDistanceMerchantTest.ts b/tests/unit/ParseDistanceMerchantTest.ts index d8a366dc20cf..85d5a1a140e9 100644 --- a/tests/unit/ParseDistanceMerchantTest.ts +++ b/tests/unit/ParseDistanceMerchantTest.ts @@ -1,7 +1,7 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {translate as translateWithLocale} from '@libs/Localize'; import {getDistanceMerchantForTransaction} from '@libs/TransactionUtils/getUpdatedTransaction'; -import parseDistanceMerchant from '@libs/TransactionUtils/parseDistanceMerchant'; +import parseDistanceMerchant, {getDistanceMerchantPrefix} from '@libs/TransactionUtils/parseDistanceMerchant'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -41,7 +41,7 @@ describe('parseDistanceMerchant', () => { // Then the exact rate survives the formatter/parser boundary, including fractional and large rates expect(parsed?.rate).toBeCloseTo(rate, 8); - expect(`${distance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${unit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} ${parsed?.rateText}`).toBe(merchant); + expect(`${getDistanceMerchantPrefix(distance, unit)}${parsed?.rateText}`).toBe(merchant); }); }); From 597b8d52f53bdd515c7f54dec26412414b8f5e8d Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 20 Oct 2026 20:55:50 +0200 Subject: [PATCH 25/27] Cover cross-policy rate lookup and Self DM route edits --- tests/unit/TransactionUtilsTest.ts | 30 ++++- .../unit/hooks/useDisplayTransaction.test.ts | 110 ++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/unit/hooks/useDisplayTransaction.test.ts diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 46799bf4e5a0..110c09825239 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1481,7 +1481,11 @@ describe('TransactionUtils', () => { } }); - it('preserves the saved commuter exclusion when recalculating edited waypoints', () => { + it.each([ + {context: 'with commuter exclusion enabled by default', shouldApplyCommuterExclusion: undefined, expectedAmount: 170, expectedDistance: 17}, + {context: 'in Self DM with commuter exclusion disabled', shouldApplyCommuterExclusion: false, expectedAmount: 200, expectedDistance: 20}, + ])('recalculates edited waypoints $context', ({shouldApplyCommuterExclusion, expectedAmount, expectedDistance}) => { + // Given a saved three-mile deduction and a policy that still enables commuter exclusion const fakePolicy: Policy = { ...createRandomPolicy(0), commuterExclusions: { @@ -1513,16 +1517,24 @@ describe('TransactionUtils', () => { }, }); + // When new waypoints and route geometry replace the original ten-mile trip with a twenty-mile trip const updatedTransaction = TransactionUtils.getUpdatedTransaction({ transaction, isFromExpenseReport: false, + shouldApplyCommuterExclusion, policy: fakePolicy, transactionChanges: { waypoints: {waypoint0: {address: 'New A'}, waypoint1: {address: 'New B'}}, routes: { route0: { distance: 32186.88, - geometry: {coordinates: [[0, 0]], type: 'LineString'}, + geometry: { + coordinates: [ + [0, 0], + [0.1, 0.1], + ], + type: 'LineString', + }, }, }, }, @@ -1531,8 +1543,18 @@ describe('TransactionUtils', () => { getCurrencySymbol: getCurrencySymbolLocal, }); - expect(updatedTransaction.comment?.customUnit).toMatchObject({quantity: 20, commuterExclusion: 3, reimbursableDistance: 17}); - expect(updatedTransaction.modifiedAmount).toBe(170); + // Then Self DM restores the entire new trip and clears deductions; the default behavior preserves the saved exclusion + expect(updatedTransaction.comment?.customUnit?.quantity).toBe(20); + expect(updatedTransaction.comment?.customUnit?.routeDistanceMeters).toBe(32186.88); + expect(updatedTransaction.modifiedAmount).toBe(expectedAmount); + expect(updatedTransaction.modifiedMerchant).toBe(`${expectedDistance.toFixed(2)} mi @ $0.10 / mi`); + if (shouldApplyCommuterExclusion === false) { + expect(updatedTransaction.comment?.customUnit?.commuterExclusion).toBeNull(); + expect(updatedTransaction.comment?.customUnit?.reimbursableDistance).toBeNull(); + expect(updatedTransaction.comment?.customUnit?.commuterExclusionMethod).toBeNull(); + } else { + expect(updatedTransaction.comment?.customUnit).toMatchObject({commuterExclusion: 3, reimbursableDistance: 17}); + } }); it('does not carry a home and office exclusion onto edited waypoints', () => { diff --git a/tests/unit/hooks/useDisplayTransaction.test.ts b/tests/unit/hooks/useDisplayTransaction.test.ts new file mode 100644 index 000000000000..4846cd39be0f --- /dev/null +++ b/tests/unit/hooks/useDisplayTransaction.test.ts @@ -0,0 +1,110 @@ +import {act, renderHook, waitFor} from '@testing-library/react-native'; + +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useDisplayTransaction from '@hooks/useDisplayTransaction'; +import useLocalize from '@hooks/useLocalize'; + +import {translate as translateWithLocale} from '@libs/Localize'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Transaction} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../../utils/collections/policies'; +import createMock from '../../utils/createMock'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +jest.mock('@hooks/useLocalize'); +jest.mock('@hooks/useCurrencyList'); + +describe('useDisplayTransaction', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + await IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(async () => { + await Onyx.clear(); + jest.mocked(useLocalize).mockReturnValue( + createMock({ + translate: (path, ...parameters) => translateWithLocale(CONST.LOCALES.EN, path, ...parameters), + }), + ); + jest.mocked(useCurrencyListActions).mockReturnValue( + createMock>({ + getCurrencySymbol: () => '$', + }), + ); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('uses the original policy when the supplied policy does not contain the distance rate', async () => { + // Given a reduced expense without modified snapshots, so restoration requires a policy rate + const suppliedPolicy: Policy = { + ...createRandomPolicy(1), + id: 'display-policy-without-rate', + customUnits: {}, + }; + const originalPolicy: Policy = { + ...createRandomPolicy(2), + id: 'display-original-policy', + customUnits: { + distance: { + customUnitID: 'distance', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: { + selectedRate: {customUnitRateID: 'selectedRate', rate: 76, currency: CONST.CURRENCY.USD, enabled: true}, + }, + }, + }, + }; + const transaction: Transaction = { + transactionID: 'display-distance-transaction', + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + amount: 415, + currency: CONST.CURRENCY.USD, + created: '2026-10-20', + merchant: '5.46 mi @ $0.76 / mi', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + customUnit: { + customUnitRateID: 'selectedRate', + quantity: 6.46, + reimbursableDistance: 5.46, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${suppliedPolicy.id}`, suppliedPolicy); + await waitForBatchedUpdates(); + const {result} = renderHook(() => useDisplayTransaction(transaction, false, suppliedPolicy)); + await waitForBatchedUpdates(); + expect(result.current.distanceOriginalPolicy).toBeUndefined(); + expect(result.current.displayTransaction?.amount).toBe(415); + + // When the actual cross-policy selector discovers the policy owning the selected rate + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${originalPolicy.id}`, originalPolicy); + }); + + // Then the hook uses that policy, rather than the rate-less fallback, to restore the full route + await waitFor(() => { + expect(result.current.distanceOriginalPolicy?.id).toBe(originalPolicy.id); + expect(result.current.displayTransaction?.amount).toBe(491); + expect(result.current.displayTransaction?.merchant).toBe('6.46 mi @ $0.76 / mi'); + }); + }); +}); From 7ec6bd97ffb1c90c9443ab9f9925703aa2c5133e Mon Sep 17 00:00:00 2001 From: Nabi Date: Thu, 22 Oct 2026 08:50:27 +0200 Subject: [PATCH 26/27] Preserve historical distance amounts after workspace rate changes --- src/libs/TransactionUtils/index.ts | 21 +++++++++++- tests/unit/TransactionUtilsTest.ts | 55 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 296f5c1a3d02..a1308e2403bd 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -211,7 +211,15 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ reducedDistance < fullDistance && (!transaction.modifiedCurrency || transaction.modifiedCurrency === transaction.currency) ) { - const fullRateText = parseDistanceMerchant({merchant: transaction.merchant, distance: fullDistance, unit: storedUnit})?.rateText; + const snapshotCurrency = getCurrency(transaction); + const parsedFullMerchant = parseDistanceMerchant({ + merchant: transaction.merchant, + distance: fullDistance, + unit: storedUnit, + currency: snapshotCurrency, + currencySymbol: getCurrencySymbol(snapshotCurrency), + }); + const fullRateText = parsedFullMerchant?.rateText; const reducedRateText = parseDistanceMerchant({merchant: transaction.modifiedMerchant, distance: reducedDistance, unit: storedUnit})?.rateText; if (fullRateText && fullRateText === reducedRateText && Number.isFinite(transaction.amount) && (modifiedAmount === undefined || Number.isFinite(modifiedAmount))) { const roundedFullDistance = Number(fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); @@ -226,6 +234,17 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const calculatedReducedAmount = Math.round(roundedReducedDistance * lowerRate); let isCalculatedAmount = modifiedAmount === undefined || (calculatedReducedAmount === Math.round(roundedReducedDistance * upperRate) && calculatedReducedAmount === Math.abs(modifiedAmount)); + if (!isCalculatedAmount && modifiedAmount !== undefined) { + const savedRate = parsedFullMerchant?.rate; + // Validate both snapshots with their historical rate before consulting a mutable policy rate. + if (savedRate !== undefined) { + const fullDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(roundedFullDistance, storedUnit); + const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(roundedReducedDistance, storedUnit); + isCalculatedAmount = + Math.abs(transaction.amount) === DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, storedUnit, savedRate) && + Math.abs(modifiedAmount) === DistanceRequestUtils.getDistanceRequestAmount(reducedDistanceInMeters, storedUnit, savedRate); + } + } if (!isCalculatedAmount && modifiedAmount !== undefined && typeof rate === 'number' && unit === storedUnit) { const resolvedCurrency = mileageRate?.currency ?? getCurrency(transaction); const savedCurrency = getCurrency(transaction); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 797eb884aa56..ffc213d99edc 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -2211,6 +2211,61 @@ describe('TransactionUtils', () => { expect(displayTransaction).toBe(transaction); }); + it.each([ + {fullDistance: 2.06, reducedDistance: 1.06, amount: 3, modifiedAmount: -2, liveRate: 2, expectedAmount: -3}, + {fullDistance: 56.64, reducedDistance: 55.64, amount: 85, modifiedAmount: -83, liveRate: 2, expectedAmount: -85}, + {fullDistance: 2.06, reducedDistance: 1.06, amount: 3, modifiedAmount: -2, liveRate: 100, expectedAmount: -3}, + {fullDistance: 2.06, reducedDistance: 1.06, amount: 3, modifiedAmount: -50, liveRate: 2, expectedAmount: -50}, + {fullDistance: 2.06, reducedDistance: 1.06, amount: -2, modifiedAmount: -2, liveRate: 2, expectedAmount: -2}, + ])( + 'validates paired snapshots at $fullDistance miles with live rate $liveRate and modified amount $modifiedAmount', + ({fullDistance, reducedDistance, amount, modifiedAmount, liveRate, expectedAmount}) => { + // Given matching historical rate labels, including manual-override and pre-sync reduced-only controls + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount, + modifiedAmount, + currency: CONST.CURRENCY.USD, + merchant: `${fullDistance.toFixed(2)} mi @ $0.015 / mi`, + modifiedMerchant: `${reducedDistance.toFixed(2)} mi @ $0.015 / mi`, + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: fullDistance, + reimbursableDistance: reducedDistance, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + const policy: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: {rate1: {customUnitRateID: 'rate1', currency: CONST.CURRENCY.USD, rate: liveRate}}, + }, + }, + }; + + // When Self DM normalizes the expense after the workspace rate changes + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then only verified full/reduced pairs restore the full amount; other saved amounts remain protected + expect(displayTransaction?.amount).toBe(expectedAmount); + expect(displayTransaction?.merchant).toBe(transaction.merchant); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + }, + ); + it('preserves the saved rate when the workspace rate changes', () => { // Given a personal distance expense saved at Af2 per mile before its workspace rate changed to Af3 const policyWithChangedRate: Policy = { From d1dc6de7761ec9b851668caa20a2ddad991fb6b1 Mon Sep 17 00:00:00 2001 From: Nabi Date: Fri, 23 Oct 2026 09:33:14 +0200 Subject: [PATCH 27/27] Fix commuter-excluded amounts when moving distance expenses back to reports --- src/libs/actions/Transaction.ts | 78 +++++++++++++++++++++ tests/actions/TransactionTest.ts | 115 ++++++++++++++++++++++++++++++- 2 files changed, 192 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 83098bd75204..69d1ad234925 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -56,6 +56,7 @@ import { shouldClearConvertedAmount, waypointHasValidAddress, } from '@libs/TransactionUtils'; +import parseDistanceMerchant from '@libs/TransactionUtils/parseDistanceMerchant'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import CONST from '@src/CONST'; @@ -1388,6 +1389,83 @@ function getChangeTransactionsReportOnyxData({ ...optimisticValue, }; } + } else if (transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID && !isFetchingWaypointsFromServer(transaction)) { + const customUnit = transaction.comment?.customUnit; + const currency = transaction.modifiedCurrency || transaction.currency; + const fullMerchant = transaction.modifiedMerchant ?? transaction.merchant; + const savedRate = parseDistanceMerchant({ + merchant: fullMerchant, + distance: customUnit?.quantity, + unit: customUnit?.distanceUnit, + currency, + currencySymbol: getCurrencySymbol(currency), + })?.rate; + const selectedRate = DistanceRequestUtils.getRateByCustomUnitRateID({customUnitRateID: currentRateID, policy}); + const storedAmount = Number(transaction.modifiedAmount ?? transaction.amount); + // Only restore a saved deduction for a verified full-route calculation. Do not reprice + // historical rates or replace amounts that differ from the selected rate's calculation. + if ( + customUnit?.distanceUnit && + typeof customUnit.quantity === 'number' && + (customUnit.commuterExclusion ?? 0) > 0 && + savedRate !== undefined && + selectedRate?.rate !== undefined && + Math.abs(savedRate - selectedRate.rate) < 1e-8 && + currency === selectedRate.currency && + customUnit.distanceUnit === selectedRate.unit && + Math.abs(storedAmount) === + DistanceRequestUtils.getDistanceRequestAmount( + DistanceRequestUtils.convertToDistanceInMeters(customUnit.quantity, customUnit.distanceUnit), + customUnit.distanceUnit, + selectedRate.rate, + ) + ) { + const exclusionData = DistanceRequestUtils.getTransactionCommuterExclusionData({ + transaction, + policy, + storedCustomUnit: customUnit, + // eslint-disable-next-line @typescript-eslint/no-deprecated + translate: translateLocal, + toLocaleDigit: (digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit), + getCurrencySymbol, + personalPolicyOutputCurrency, + }); + if (exclusionData && (exclusionData.customUnit.commuterExclusion ?? 0) > 0) { + const exclusionFields = { + commuterExclusion: exclusionData.customUnit.commuterExclusion, + reimbursableDistance: exclusionData.customUnit.reimbursableDistance, + commuterExclusionMethod: exclusionData.customUnit.commuterExclusionMethod, + }; + const optimisticValue = { + merchant: fullMerchant, + modifiedAmount: isExpenseReport(newReport) ? -exclusionData.modifiedAmount : exclusionData.modifiedAmount, + modifiedMerchant: exclusionData.modifiedMerchant, + comment: {customUnit: exclusionFields}, + }; + optimisticData.push({onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: optimisticValue}); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + merchant: transaction.merchant, + modifiedAmount: transaction.modifiedAmount ?? null, + modifiedMerchant: transaction.modifiedMerchant ?? null, + comment: { + customUnit: { + commuterExclusion: customUnit.commuterExclusion ?? null, + reimbursableDistance: customUnit.reimbursableDistance ?? null, + commuterExclusionMethod: customUnit.commuterExclusionMethod ?? null, + }, + }, + }, + }); + transactionForViolations = { + ...transaction, + ...optimisticValue, + comment: {...transaction.comment, customUnit: {...customUnit, ...exclusionFields}}, + }; + } + } } } diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts index eb4938eea084..3abf64bd6488 100644 --- a/tests/actions/TransactionTest.ts +++ b/tests/actions/TransactionTest.ts @@ -8,8 +8,9 @@ import {putOnHold} from '@libs/actions/IOU/Hold'; import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {createWorkspace, generatePolicyID, setWorkspaceApprovalMode} from '@libs/actions/Policy/Policy'; -import '@libs/actions/IOU/MoneyRequest'; import {createNewReport} from '@libs/actions/Report'; +import '@libs/actions/IOU/MoneyRequest'; +import * as API from '@libs/API'; import type * as PolicyUtils from '@libs/PolicyUtils'; import {getOriginalMessage, isDeletedAction, isMoneyRequestAction, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReportAction, getReportOrDraftReport} from '@libs/ReportUtils'; @@ -767,6 +768,118 @@ describe('actions/Transaction', () => { expect(updated?.modifiedMerchant).toContain('mi'); }); + it.each([ + {distance: 56.66, amount: -11332, savedRateText: '2.00', liveRate: 200, expectedAmount: -11132}, + {distance: 13.53, amount: -2706, savedRateText: '2.00', liveRate: 200, expectedAmount: -2506}, + {distance: 56.66, amount: -5000, savedRateText: '2.00', liveRate: 200, expectedAmount: -5000}, + {distance: 56.66, amount: -11332, savedRateText: '2.00', liveRate: 300, expectedAmount: -11332}, + {distance: 13.53, amount: -392, savedRateText: '0.29', liveRate: 29, expectedAmount: -363}, + {distance: 13.53, amount: -392, savedRateText: '0.29', liveRate: 30, expectedAmount: -392}, + {distance: 1.5, amount: -44, savedRateText: '0.29', liveRate: 29, expectedAmount: -15}, + {distance: 1.5, amount: -43, savedRateText: '0.29', liveRate: 29, expectedAmount: -43}, + ])('moves a tracked distance expense offline at $distance miles with amount $amount and live rate $liveRate', async ({distance, amount, savedRateText, liveRate, expectedAmount}) => { + // Given a Self DM rate edit with full-route values and retained commuter metadata + const policyID = 'commuter-move-policy'; + const mockPolicy: Policy = { + ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), + id: policyID, + outputCurrency: CONST.CURRENCY.USD, + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, fixedDistance: 1, fixedDistanceUnit: 'mi'}, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + enabled: true, + attributes: {unit: 'mi'}, + rates: {selected: {customUnitRateID: 'selected', rate: liveRate, currency: 'USD', enabled: true}}, + }, + }, + }; + const reducedDistance = Number((distance - 1).toFixed(2)); + const transaction: Transaction = { + transactionID: 'commuter-move-transaction', + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + amount, + modifiedAmount: amount, + currency: 'USD', + modifiedCurrency: 'USD', + merchant: `${distance.toFixed(2)} mi @ $1.00 / mi`, + modifiedMerchant: `${distance.toFixed(2)} mi @ $${savedRateText} / mi`, + created: '2026-10-23', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitRateID: 'selected', + quantity: distance, + distanceUnit: 'mi', + commuterExclusion: 1, + reimbursableDistance: reducedDistance, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }, + }, + }; + const report: Report = { + reportID: 'commuter-move-report', + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + currency: 'USD', + total: 0, + chatReportID: 'commuter-move-chat', + ownerAccountID: RORY_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + await Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: true}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, mockPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + await waitForBatchedUpdates(); + + // When moving to the workspace without a server response to reconcile its values + const writeSpy = jest.spyOn(API, 'write'); + changeTransactionsReport({ + isVendorMatchingBetaEnabled: false, + transactionIDs: [transaction.transactionID], + isASAPSubmitBetaEnabled: false, + accountID: RORY_ACCOUNT_ID, + email: RORY_EMAIL, + newReport: report, + policy: mockPolicy, + allTransactions: {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction}, + policyTagList: {}, + transactionViolations: {}, + reports: {[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report}, + isTrackIntentUser: false, + cardList: undefined, + }); + const failureData = writeSpy.mock.calls.at(-1)?.[2]?.failureData; + writeSpy.mockRestore(); + await waitForBatchedUpdates(); + + // Then a verified automatic amount receives the deduction, but overrides and historical rates are untouched + const updated = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`); + expect(updated?.modifiedAmount).toBe(expectedAmount); + expect(updated?.amount).toBe(amount); + expect(updated?.comment?.customUnit).toEqual(transaction.comment?.customUnit); + expect(updated?.modifiedMerchant).toBe(expectedAmount === amount ? transaction.modifiedMerchant : `${reducedDistance.toFixed(2)} mi @ $${savedRateText} / mi`); + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`); + expect(updatedReport?.total).toBe(expectedAmount); + + // When the request fails, its rollback restores the original Self DM fields and report total + expect(failureData).toBeDefined(); + await Onyx.update(failureData ?? []); + await waitForBatchedUpdates(); + const restored = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`); + expect(restored?.reportID).toBe(transaction.reportID); + expect(restored?.amount).toBe(transaction.amount); + expect(restored?.modifiedAmount).toBe(transaction.modifiedAmount); + expect(restored?.merchant).toBe(transaction.merchant); + expect(restored?.modifiedMerchant).toBe(transaction.modifiedMerchant); + expect(restored?.comment?.customUnit).toEqual(transaction.comment?.customUnit); + expect((await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`))?.total).toBe(0); + }); + it('does not save Search row display fields on the moved transaction', async () => { // Given an unreported expense selected from Search, which hands over the whole row item with its display-only fields const policyID = generatePolicyID();