Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions src/libs/actions/IOU/TrackExpense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<OnyxTypes.Transaction>): 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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -1507,7 +1538,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac
moneyRequestPreviewReportActionID: iouParams.reportActionID,
modifiedExpenseReportActionID: convertTrackedExpenseInformation.modifiedExpenseReportActionID,
reportPreviewReportActionID: chatParams.reportPreviewReportActionID,
...workspaceParams,
...workspaceParamsForAPI,
};

addTrackedExpenseToPolicy(params, {optimisticData, successData, failureData});
Expand Down Expand Up @@ -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});
}
Expand Down Expand Up @@ -2775,6 +2806,7 @@ export {
getDeleteTrackExpenseInformation,
getNavigationUrlAfterTrackExpenseDelete,
getTrackExpenseInformation,
hasManualDistanceOverride,
trackExpense,
requestMoney,
};
Expand Down
5 changes: 5 additions & 0 deletions src/libs/actions/IOU/UpdateMoneyRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1571,6 +1571,11 @@ function getUpdateTrackExpenseParams(

const dataToIncludeInParams: Partial<TransactionDetails> = 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,
Expand Down
2 changes: 2 additions & 0 deletions src/pages/iou/request/step/IOURequestStepDistance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -611,6 +612,7 @@ function IOURequestStepDistance({
duplicateWaypointsError,
atLeastTwoDifferentWaypointsError,
hasRouteError,
distanceOriginalPolicy,
]);

const renderItem = useCallback(
Expand Down
104 changes: 103 additions & 1 deletion tests/actions/IOUTest/TrackExpenseTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2431,4 +2438,99 @@ describe('actions/IOU/TrackExpense', () => {
}).not.toThrow();
});
});

describe('hasManualDistanceOverride', () => {
const KM_5_IN_METERS = 5000;

function buildDistanceTransaction(overrides: Partial<Transaction> = {}): 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<string, unknown>): Partial<Transaction> {
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<Transaction>;
}

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);
});
});
});
41 changes: 41 additions & 0 deletions tests/actions/IOUTest/UpdateMoneyRequestTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
});
Loading