diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 9616324a2893..6a7276591286 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -14,6 +14,7 @@ import type { import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {deferOrExecuteWrite} from '@libs/deferredLayoutWrite'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import GoogleTagManager from '@libs/GoogleTagManager'; import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils} from '@libs/IOUUtils'; @@ -1408,6 +1409,28 @@ function addTrackedExpenseToPolicy(parameters: AddTrackedExpenseToPolicyParam, o API.write(WRITE_COMMANDS.ADD_TRACKED_EXPENSE_TO_POLICY, parameters, onyxData); } +/** + * Returns `true` when `customUnit.quantity` (in `distanceUnit`) diverges from + * `customUnit.routeDistanceMeters` — i.e. the user typed a manual value on the "Manual" tab + * after a map route had already populated the distance. + * + * Returns `false` for map-routed / GPS-tracked expenses (quantity matches the route) and any + * case missing the data needed for a confident compare — those flows must keep `waypoints` so + * BE can preserve / regenerate the map receipt. + */ +function hasManualDistanceOverride(transaction: OnyxEntry): boolean { + const quantity = transaction?.comment?.customUnit?.quantity; + const distanceUnit = transaction?.comment?.customUnit?.distanceUnit; + const routeDistanceMeters = transaction?.comment?.customUnit?.routeDistanceMeters; + if (typeof quantity !== 'number' || !distanceUnit || typeof routeDistanceMeters !== 'number' || routeDistanceMeters <= 0) { + return false; + } + // Compare in display units — `quantity` is stored at 2dp, so a meters round-trip would + // exceed any sub-meter tolerance from rounding alone (~5m km / ~8m mi). + const routeQuantity = DistanceRequestUtils.convertDistanceUnit(routeDistanceMeters, distanceUnit); + return Math.abs(quantity - routeQuantity) > 0.01; +} + function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrackedExpenseToRequestParams) { const {payerParams, transactionParams, chatParams, iouParams, onyxData, workspaceParams, currentUserAccountID} = convertTrackedExpenseParams; const {accountID: payerAccountID, email: payerEmail} = payerParams; @@ -1474,6 +1497,13 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac }); } + // Drop `waypoints` only when the transaction's stored distance diverges from the route's + // computed distance, so BE doesn't recompute and overwrite the manual override. GPS-tracked + // / map-routed expenses match the route — they must keep waypoints so BE can regenerate the + // map receipt. + const sourceTransaction = getAllTransactions()?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const hasManualDistanceOverrideForRequest = hasManualDistanceOverride(sourceTransaction); + if (workspaceParams) { const additionalFailureData = getConvertTrackedExpenseWorkspaceFailureData({ iouReportID: iouParams.reportID, @@ -1491,6 +1521,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac // Removing the ghost IOU report on API failure which can cause unexpected errors. failureData?.push(...additionalFailureData); + const workspaceParamsForAPI = hasManualDistanceOverrideForRequest ? {...workspaceParams, waypoints: undefined} : workspaceParams; const params = { amount, distance, @@ -1507,7 +1538,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac moneyRequestPreviewReportActionID: iouParams.reportActionID, modifiedExpenseReportActionID: convertTrackedExpenseInformation.modifiedExpenseReportActionID, reportPreviewReportActionID: chatParams.reportPreviewReportActionID, - ...workspaceParams, + ...workspaceParamsForAPI, }; addTrackedExpenseToPolicy(params, {optimisticData, successData, failureData}); @@ -1536,7 +1567,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac reportPreviewReportActionID: chatParams.reportPreviewReportActionID, isDistance, customUnitRateID, - waypoints, + waypoints: hasManualDistanceOverrideForRequest ? undefined : waypoints, }; API.write(WRITE_COMMANDS.CONVERT_TRACKED_EXPENSE_TO_REQUEST, parameters, {optimisticData, successData, failureData}); } @@ -2775,6 +2806,7 @@ export { getDeleteTrackExpenseInformation, getNavigationUrlAfterTrackExpenseDelete, getTrackExpenseInformation, + hasManualDistanceOverride, trackExpense, requestMoney, }; diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index a61ba58ee7d2..684a9fbf22cd 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -1571,6 +1571,11 @@ function getUpdateTrackExpenseParams( const dataToIncludeInParams: Partial = Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => key in transactionChanges)); + // Preserve full-precision distance to avoid `increasedDistance` drift; mirrors `getUpdateMoneyRequestParams`. + if ('distance' in transactionChanges && typeof transactionChanges.distance === 'number') { + dataToIncludeInParams.distance = transactionChanges.distance; + } + const apiParams: UpdateMoneyRequestParams = { ...dataToIncludeInParams, reportID: chatReport?.reportID, diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 5d985830eea3..b172e7014560 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -577,6 +577,7 @@ function IOURequestStepDistance({ isASAPSubmitBetaEnabled, parentReportNextStep, recentWaypoints, + distanceOriginalPolicy, }); transactionWasSaved.current = true; // Remove the backup eagerly so the parent report view reads the optimistic transaction @@ -611,6 +612,7 @@ function IOURequestStepDistance({ duplicateWaypointsError, atLeastTwoDifferentWaypointsError, hasRouteError, + distanceOriginalPolicy, ]); const renderItem = useCallback( diff --git a/tests/actions/IOUTest/TrackExpenseTest.ts b/tests/actions/IOUTest/TrackExpenseTest.ts index 9bdd270f4224..467d9c299051 100644 --- a/tests/actions/IOUTest/TrackExpenseTest.ts +++ b/tests/actions/IOUTest/TrackExpenseTest.ts @@ -2,7 +2,14 @@ import {format} from 'date-fns'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import {convertBulkTrackedExpensesToIOU, deleteTrackExpense, getDeleteTrackExpenseInformation, getTrackExpenseInformation, trackExpense} from '@libs/actions/IOU/TrackExpense'; +import { + convertBulkTrackedExpensesToIOU, + deleteTrackExpense, + getDeleteTrackExpenseInformation, + getTrackExpenseInformation, + hasManualDistanceOverride, + trackExpense, +} from '@libs/actions/IOU/TrackExpense'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {addComment, openReport} from '@libs/actions/Report'; import {subscribeToUserEvents} from '@libs/actions/User'; @@ -2431,4 +2438,99 @@ describe('actions/IOU/TrackExpense', () => { }).not.toThrow(); }); }); + + describe('hasManualDistanceOverride', () => { + const KM_5_IN_METERS = 5000; + + function buildDistanceTransaction(overrides: Partial = {}): Transaction { + return { + transactionID: 'distance_txn', + amount: 1000, + currency: CONST.CURRENCY.USD, + merchant: 'Test', + created: DateUtils.getDBTime(), + reportID: 'r1', + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 5, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + routeDistanceMeters: KM_5_IN_METERS, + }, + }, + ...overrides, + } as Transaction; + } + + function withCustomUnit(overrides: Record): Partial { + return { + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 5, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + routeDistanceMeters: KM_5_IN_METERS, + ...overrides, + }, + }, + } as Partial; + } + + it('returns false when quantity matches the route distance (GPS-tracked expense)', () => { + // 5 km display vs 5000 m route → 0 diff in display units. + expect(hasManualDistanceOverride(buildDistanceTransaction())).toBe(false); + }); + + it('returns false when quantity is at the 2dp rounding boundary (unedited GPS expense)', () => { + // `customUnit.quantity` is stored at 2dp via `roundToTwoDecimalPlaces`. A 5005 m route + // is 5.005 km in display units, well within the 0.01 km tolerance. + const transaction = buildDistanceTransaction(withCustomUnit({routeDistanceMeters: 5005})); + expect(hasManualDistanceOverride(transaction)).toBe(false); + }); + + it('returns true when quantity diverges from the route distance (manual override on map)', () => { + // 5 km display vs 10000 m (10 km) route → diff of 5 km, well above tolerance. + const transaction = buildDistanceTransaction(withCustomUnit({routeDistanceMeters: 10000})); + expect(hasManualDistanceOverride(transaction)).toBe(true); + }); + + it('returns false when transaction has no route distance (pure manual expense)', () => { + const transaction = buildDistanceTransaction(withCustomUnit({routeDistanceMeters: 0})); + expect(hasManualDistanceOverride(transaction)).toBe(false); + }); + + it('returns false when transaction has no customUnit.quantity', () => { + const transaction = buildDistanceTransaction({ + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + routeDistanceMeters: KM_5_IN_METERS, + }, + }, + }); + expect(hasManualDistanceOverride(transaction)).toBe(false); + }); + + it('returns false when transaction has no distanceUnit', () => { + const transaction = buildDistanceTransaction({ + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 5, + routeDistanceMeters: KM_5_IN_METERS, + }, + }, + }); + expect(hasManualDistanceOverride(transaction)).toBe(false); + }); + + it('returns false when transaction is undefined', () => { + expect(hasManualDistanceOverride(undefined)).toBe(false); + }); + }); }); diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index 6c45f653b404..844d9d8ff979 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -3,6 +3,7 @@ import {format} from 'date-fns'; import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import { + getUpdateTrackExpenseParams, updateMoneyRequestAmountAndCurrency, updateMoneyRequestAttendees, updateMoneyRequestBillable, @@ -1299,4 +1300,44 @@ describe('actions/IOU/UpdateMoneyRequest', () => { expect(transaction2AfterUpdate?.transactionID).toBe(transactionID2); }); }); + + describe('getUpdateTrackExpenseParams', () => { + 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'; + const transactionThreadReportID = 'thread_precision'; + const policyID = 'policy_precision'; + + const fakeTransaction: Transaction = { + transactionID, + amount: 5000, + currency: CONST.CURRENCY.USD, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'Precision Test', + reportID: 'parent_precision', + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 5, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + waypoints: {}, + }, + }; + const fakeThreadReport = {reportID: transactionThreadReportID, type: CONST.REPORT.TYPE.CHAT} as Report; + const fakePolicy = createRandomPolicy(Number(1)); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, fakeThreadReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + await waitForBatchedUpdates(); + + // When the caller passes a higher-precision distance than what `customUnit.quantity` would round to + const {params} = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, {distance: 5.555}, fakePolicy); + + // Then the raw caller value flows into the API params instead of the rounded display value (5.56). + expect(params.distance).toBe(5.555); + }); + }); });