From c897376e33ff1756ae022ef1924f0d7bb75e4d50 Mon Sep 17 00:00:00 2001 From: "Jayesh Mangwani (via MelvinBot)" Date: Mon, 14 Sep 2026 06:04:04 +0000 Subject: [PATCH 1/4] Fix: keep odometer quantity unconverted when the rate unit changes Co-authored-by: Jayesh Mangwani --- src/libs/actions/IOU/MoneyRequest.ts | 6 +- tests/actions/IOU/MoneyRequestSettersTest.ts | 75 ++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index f101d8943236..0a9a5d903df0 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -775,7 +775,11 @@ function setCustomUnitRateID( const transactionQuantity = transaction.comment?.customUnit?.quantity; const shouldUpdateDistanceUnit = !!transactionDistanceUnit && !!distanceRate?.unit; - const shouldUpdateQuantity = transactionQuantity !== null && transactionQuantity !== undefined; + + // For odometer expenses the quantity is the raw difference between the end and start readings, not a measured + // distance, so re-scaling it to the new rate's unit would make it disagree with the readings the user entered. + // `setMoneyRequestDistanceRate` skips the same conversion for odometer expenses. + const shouldUpdateQuantity = transactionQuantity !== null && transactionQuantity !== undefined && !isOdometerDistanceRequestTransactionUtils(transaction); if (shouldUpdateDistanceUnit) { newDistanceUnit = distanceRate.unit; diff --git a/tests/actions/IOU/MoneyRequestSettersTest.ts b/tests/actions/IOU/MoneyRequestSettersTest.ts index 4ccf51d37653..1010d7cdf85d 100644 --- a/tests/actions/IOU/MoneyRequestSettersTest.ts +++ b/tests/actions/IOU/MoneyRequestSettersTest.ts @@ -1,6 +1,7 @@ import { initMoneyRequest, resetDraftTransactionsCustomUnit, + setCustomUnitRateID, setMoneyRequestAmount, setMoneyRequestBillable, setMoneyRequestCategory, @@ -335,6 +336,80 @@ describe('actions/IOU', () => { }); }); + describe('setCustomUnitRateID', () => { + const CUSTOM_UNIT_RATE_ID = 'customUnitRateID123'; + + function createPolicyWithKilometreRate(): Policy { + const policy = createRandomPolicy(1); + policy.customUnits = { + distanceUnitID: { + customUnitID: 'distanceUnitID', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + }, + rates: { + [CUSTOM_UNIT_RATE_ID]: { + customUnitRateID: CUSTOM_UNIT_RATE_ID, + name: 'Kilometre rate', + rate: 70, + currency: CONST.CURRENCY.USD, + enabled: true, + }, + }, + }, + }; + return policy; + } + + function createDistanceDraft(iouRequestType?: Transaction['iouRequestType']): Transaction { + return { + transactionID: 'testTransaction123', + amount: 1000, + currency: CONST.CURRENCY.USD, + ...(iouRequestType && {iouRequestType}), + comment: { + comment: 'Test transaction', + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 10, + }, + }, + created: DateUtils.getDBTime(), + merchant: 'Test Merchant', + reportID: 'testReport123', + }; + } + + it('converts distance quantity into the new rate unit', async () => { + const testTransaction = createDistanceDraft(); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${testTransaction.transactionID}`, testTransaction); + + setCustomUnitRateID(testTransaction.transactionID, CUSTOM_UNIT_RATE_ID, testTransaction, createPolicyWithKilometreRate(), false, CONST.CURRENCY.USD); + await waitForBatchedUpdates(); + + const transactionDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${testTransaction.transactionID}`); + expect(transactionDraft?.comment?.customUnit?.customUnitRateID).toBe(CUSTOM_UNIT_RATE_ID); + expect(transactionDraft?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + // 10 miles to kilometers = 10 / 0.000621371 * 0.001 = 16.093444978925636 + expect(transactionDraft?.comment?.customUnit?.quantity).toBe(16.093444978925636); + }); + + it('does not convert distance quantity for an odometer request', async () => { + const testTransaction = createDistanceDraft(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${testTransaction.transactionID}`, testTransaction); + + setCustomUnitRateID(testTransaction.transactionID, CUSTOM_UNIT_RATE_ID, testTransaction, createPolicyWithKilometreRate(), false, CONST.CURRENCY.USD); + await waitForBatchedUpdates(); + + const transactionDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${testTransaction.transactionID}`); + expect(transactionDraft?.comment?.customUnit?.customUnitRateID).toBe(CUSTOM_UNIT_RATE_ID); + expect(transactionDraft?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + // The quantity is the difference between the odometer readings, so it must stay untouched + expect(transactionDraft?.comment?.customUnit?.quantity).toBe(10); + }); + }); + describe('setMoneyRequestCategory', () => { it('should set the associated tax for the category based on the tax expense rules', async () => { // Given a policy with tax expense rules associated with category From 80df45d37d6370c10c7b76d4ab431de03728228d Mon Sep 17 00:00:00 2001 From: "Jayesh Mangwani (via MelvinBot)" Date: Tue, 20 Oct 2026 12:57:45 +0000 Subject: [PATCH 2/4] Show the converted quantity as the odometer Total distance Revert the odometer exemption in setCustomUnitRateID so the quantity is converted again when the workspace distance unit changes, and make the odometer page's Total distance read that converted quantity instead of end - start. The readings themselves stay as entered so they match the odometer photos. Co-authored-by: Jayesh Mangwani --- src/libs/OdometerUtils.ts | 26 ++++++++++++++++- src/libs/actions/IOU/MoneyRequest.ts | 6 +--- .../step/IOURequestStepDistanceOdometer.tsx | 9 +++--- tests/actions/IOU/MoneyRequestSettersTest.ts | 9 ++++-- tests/unit/OdometerUtils.test.ts | 28 ++++++++++++++++++- 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/src/libs/OdometerUtils.ts b/src/libs/OdometerUtils.ts index 2751f821dc18..f138c927546e 100644 --- a/src/libs/OdometerUtils.ts +++ b/src/libs/OdometerUtils.ts @@ -1,8 +1,10 @@ import CONST from '@src/CONST'; +import type {Comment} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import {getMimeTypeFromUri} from './fileDownload/FileUtils'; import getPlatform from './getPlatform'; +import {roundToTwoDecimalPlaces} from './NumberUtils'; function getOdometerImageUri(image: FileObject | string | null | undefined): string { return typeof image === 'string' ? image : (image?.uri ?? ''); @@ -127,6 +129,28 @@ function shouldInitializeOdometerFromTransaction(state: OdometerResyncState, isE ); } -export {getOdometerImageUri, getOdometerImageName, getOdometerImageType, getOdometerImageIdentity, isExternalOdometerResync, shouldInitializeOdometerFromTransaction}; +/** + * The total distance for the given odometer readings, in the transaction's current distance unit. + * + * Readings stay exactly as entered so they match the odometer photos, but the quantity is converted when the expense moves to + * a workspace with a different distance unit. So when the readings match the saved ones, the saved quantity is returned as is, + * and when they differ, the difference is scaled by the same factor the saved quantity was converted by. + */ +function getOdometerTotalDistance(start: number, end: number, comment: Comment | undefined): number { + const distance = end - start; + const savedStart = comment?.odometerStart; + const savedEnd = comment?.odometerEnd; + const savedQuantity = comment?.customUnit?.quantity; + + if (savedStart === undefined || savedEnd === undefined || savedQuantity === undefined || savedQuantity === null || savedEnd <= savedStart) { + return roundToTwoDecimalPlaces(distance); + } + if (start === savedStart && end === savedEnd) { + return savedQuantity; + } + return roundToTwoDecimalPlaces(distance * (savedQuantity / (savedEnd - savedStart))); +} + +export {getOdometerImageUri, getOdometerImageName, getOdometerImageType, getOdometerImageIdentity, getOdometerTotalDistance, isExternalOdometerResync, shouldInitializeOdometerFromTransaction}; export type {OdometerResyncState}; export default revokeOdometerImageUri; diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 0a9a5d903df0..f101d8943236 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -775,11 +775,7 @@ function setCustomUnitRateID( const transactionQuantity = transaction.comment?.customUnit?.quantity; const shouldUpdateDistanceUnit = !!transactionDistanceUnit && !!distanceRate?.unit; - - // For odometer expenses the quantity is the raw difference between the end and start readings, not a measured - // distance, so re-scaling it to the new rate's unit would make it disagree with the readings the user entered. - // `setMoneyRequestDistanceRate` skips the same conversion for odometer expenses. - const shouldUpdateQuantity = transactionQuantity !== null && transactionQuantity !== undefined && !isOdometerDistanceRequestTransactionUtils(transaction); + const shouldUpdateQuantity = transactionQuantity !== null && transactionQuantity !== undefined; if (shouldUpdateDistanceUnit) { newDistanceUnit = distanceRate.unit; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 67c6640da308..57ba07b9146b 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -45,7 +45,7 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {getOdometerImageIdentity} from '@libs/OdometerUtils'; +import {getOdometerImageIdentity, getOdometerTotalDistance} from '@libs/OdometerUtils'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; @@ -275,7 +275,7 @@ function IOURequestStepDistanceOdometer({ if (Number.isNaN(start) || Number.isNaN(end) || !startReading || !endReading) { return null; } - const distance = end - start; + const distance = getOdometerTotalDistance(start, end, currentTransaction?.comment); // Show 0 if distance is negative return distance <= 0 ? 0 : distance; })(); @@ -388,8 +388,7 @@ function IOURequestStepDistanceOdometer({ const navigateToNextPage = () => { const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); - const distance = end - start; - const calculatedDistance = roundToTwoDecimalPlaces(distance); + const calculatedDistance = getOdometerTotalDistance(start, end, currentTransaction?.comment); if (!isEditing) { setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); @@ -529,7 +528,7 @@ function IOURequestStepDistanceOdometer({ } // Validation: Check that distance * rate doesn't exceed the backend's safe amount limit - if (!DistanceRequestUtils.isDistanceAmountWithinLimit(distance, rate)) { + if (!DistanceRequestUtils.isDistanceAmountWithinLimit(getOdometerTotalDistance(start, end, currentTransaction?.comment), rate)) { setFormError(translate('iou.error.distanceAmountTooLargeReduceDistance')); return; } diff --git a/tests/actions/IOU/MoneyRequestSettersTest.ts b/tests/actions/IOU/MoneyRequestSettersTest.ts index 1010d7cdf85d..25323f8f3965 100644 --- a/tests/actions/IOU/MoneyRequestSettersTest.ts +++ b/tests/actions/IOU/MoneyRequestSettersTest.ts @@ -395,8 +395,9 @@ describe('actions/IOU', () => { expect(transactionDraft?.comment?.customUnit?.quantity).toBe(16.093444978925636); }); - it('does not convert distance quantity for an odometer request', async () => { + it('converts distance quantity for an odometer request but keeps the readings as entered', async () => { const testTransaction = createDistanceDraft(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER); + testTransaction.comment = {...testTransaction.comment, odometerStart: 1000, odometerEnd: 1010}; await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${testTransaction.transactionID}`, testTransaction); setCustomUnitRateID(testTransaction.transactionID, CUSTOM_UNIT_RATE_ID, testTransaction, createPolicyWithKilometreRate(), false, CONST.CURRENCY.USD); @@ -405,8 +406,10 @@ describe('actions/IOU', () => { const transactionDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${testTransaction.transactionID}`); expect(transactionDraft?.comment?.customUnit?.customUnitRateID).toBe(CUSTOM_UNIT_RATE_ID); expect(transactionDraft?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); - // The quantity is the difference between the odometer readings, so it must stay untouched - expect(transactionDraft?.comment?.customUnit?.quantity).toBe(10); + expect(transactionDraft?.comment?.customUnit?.quantity).toBe(16.093444978925636); + // The readings must still match the odometer photos + expect(transactionDraft?.comment?.odometerStart).toBe(1000); + expect(transactionDraft?.comment?.odometerEnd).toBe(1010); }); }); diff --git a/tests/unit/OdometerUtils.test.ts b/tests/unit/OdometerUtils.test.ts index 2801f9cd8503..1364818488c1 100644 --- a/tests/unit/OdometerUtils.test.ts +++ b/tests/unit/OdometerUtils.test.ts @@ -1,5 +1,5 @@ import type {OdometerResyncState} from '@libs/OdometerUtils'; -import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@libs/OdometerUtils'; +import {getOdometerTotalDistance, isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@libs/OdometerUtils'; // A steady state: initialized, transaction and local readings match, nothing being typed. const STEADY_STATE: OdometerResyncState = { @@ -83,3 +83,29 @@ describe('shouldInitializeOdometerFromTransaction', () => { expect(shouldInitializeOdometerFromTransaction(buildState({hasTransactionData: false, hasLocalState: false}), false)).toBe(false); }); }); + +describe('getOdometerTotalDistance', () => { + // Readings of 1000 -> 1100 entered on a km workspace, then moved to a mi workspace, which converts the quantity + const CONVERTED_COMMENT = {odometerStart: 1000, odometerEnd: 1100, customUnit: {quantity: 62.1371}}; + + it('returns the difference between the readings when nothing is saved yet', () => { + expect(getOdometerTotalDistance(1000, 1100.5, undefined)).toBe(100.5); + }); + + it('returns the difference between the readings when the saved quantity was not converted', () => { + expect(getOdometerTotalDistance(1000, 1100, {odometerStart: 1000, odometerEnd: 1100, customUnit: {quantity: 100}})).toBe(100); + }); + + it('returns the converted quantity when the readings match the saved ones', () => { + expect(getOdometerTotalDistance(1000, 1100, CONVERTED_COMMENT)).toBe(62.1371); + }); + + it('scales changed readings by the same conversion factor', () => { + expect(getOdometerTotalDistance(1000, 1200, CONVERTED_COMMENT)).toBe(124.27); + }); + + it('returns the difference between the readings when the saved readings are not usable', () => { + expect(getOdometerTotalDistance(1000, 1100, {odometerStart: 1000, odometerEnd: 1000, customUnit: {quantity: 50}})).toBe(100); + expect(getOdometerTotalDistance(1000, 1100, {odometerStart: 1000, odometerEnd: 1100, customUnit: {quantity: null}})).toBe(100); + }); +}); From 14d1e78ea9349b3c7fbe13874152aeeadcbcf70f Mon Sep 17 00:00:00 2001 From: "Jayesh Mangwani (via MelvinBot)" Date: Tue, 20 Oct 2026 13:04:49 +0000 Subject: [PATCH 3/4] Fix: Oxfmt formatting of OdometerUtils exports Co-authored-by: Jayesh Mangwani --- src/libs/OdometerUtils.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/libs/OdometerUtils.ts b/src/libs/OdometerUtils.ts index f138c927546e..5fc65e65b14e 100644 --- a/src/libs/OdometerUtils.ts +++ b/src/libs/OdometerUtils.ts @@ -151,6 +151,14 @@ function getOdometerTotalDistance(start: number, end: number, comment: Comment | return roundToTwoDecimalPlaces(distance * (savedQuantity / (savedEnd - savedStart))); } -export {getOdometerImageUri, getOdometerImageName, getOdometerImageType, getOdometerImageIdentity, getOdometerTotalDistance, isExternalOdometerResync, shouldInitializeOdometerFromTransaction}; +export { + getOdometerImageUri, + getOdometerImageName, + getOdometerImageType, + getOdometerImageIdentity, + getOdometerTotalDistance, + isExternalOdometerResync, + shouldInitializeOdometerFromTransaction, +}; export type {OdometerResyncState}; export default revokeOdometerImageUri; From c87d724922f165d3475c763d86106e1c47bf75bc Mon Sep 17 00:00:00 2001 From: "Jayesh Mangwani (via MelvinBot)" Date: Wed, 21 Oct 2026 18:21:50 +0000 Subject: [PATCH 4/4] Send odometerUnit with CreateDistanceRequest, TrackExpense and UpdateMoneyRequestDistance Store the unit the odometer readings were entered in on the transaction comment, so the backend can convert end - start into the rate's unit when they differ. Co-authored-by: Jayesh Mangwani --- .../parameters/CreateDistanceRequestParams.ts | 4 ++ src/libs/API/parameters/TrackExpenseParams.ts | 4 ++ .../parameters/UpdateMoneyRequestParams.ts | 4 ++ src/libs/DebugUtils.ts | 2 + .../buildOptimisticTransaction.ts | 6 ++ src/libs/actions/IOU/Duplicate.ts | 1 + src/libs/actions/IOU/MoneyRequestBuilder.ts | 4 ++ src/libs/actions/IOU/Split.ts | 6 ++ src/libs/actions/IOU/TrackExpense.ts | 8 ++- src/libs/actions/IOU/UpdateMoneyRequest.ts | 6 ++ .../types/TrackExpenseTransactionParams.ts | 2 + src/libs/actions/OdometerTransactionUtils.ts | 8 ++- .../step/IOURequestStepDistanceOdometer.tsx | 4 +- .../submission/useDistanceSubmission.ts | 1 + .../submission/useTrackExpenseSubmission.ts | 1 + src/types/onyx/Transaction.ts | 3 + tests/actions/IOUTest/SplitTest.ts | 27 ++++++++ tests/actions/IOUTest/TrackExpenseTest.ts | 36 ++++++++++ .../actions/IOUTest/UpdateMoneyRequestTest.ts | 69 +++++++++++++++++++ tests/actions/OdometerTransactionUtilsTest.ts | 29 ++++++++ 20 files changed, 222 insertions(+), 3 deletions(-) diff --git a/src/libs/API/parameters/CreateDistanceRequestParams.ts b/src/libs/API/parameters/CreateDistanceRequestParams.ts index 534f998a1c78..2864ed413529 100644 --- a/src/libs/API/parameters/CreateDistanceRequestParams.ts +++ b/src/libs/API/parameters/CreateDistanceRequestParams.ts @@ -1,3 +1,4 @@ +import type {Unit} from '@src/types/onyx/Policy'; import type {Receipt} from '@src/types/onyx/Transaction'; type CreateDistanceRequestParams = { @@ -32,6 +33,9 @@ type CreateDistanceRequestParams = { modifiedDistance?: number; odometerStart?: number; odometerEnd?: number; + + /** Distance unit the odometer readings were entered in, so the backend can convert them when the rate uses a different unit */ + odometerUnit?: Unit; gpsCoordinates?: string; distanceRequestType?: string; customUnitPolicyID?: string; diff --git a/src/libs/API/parameters/TrackExpenseParams.ts b/src/libs/API/parameters/TrackExpenseParams.ts index 242d1c71009f..0e06bf44e0f2 100644 --- a/src/libs/API/parameters/TrackExpenseParams.ts +++ b/src/libs/API/parameters/TrackExpenseParams.ts @@ -1,5 +1,6 @@ import type CONST from '@src/CONST'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {Unit} from '@src/types/onyx/Policy'; import type {Receipt} from '@src/types/onyx/Transaction'; import type {ValueOf} from 'type-fest'; @@ -47,6 +48,9 @@ type TrackExpenseParams = { isDistance?: boolean; odometerStart?: number; odometerEnd?: number; + + /** Distance unit the odometer readings were entered in, so the backend can convert them when the rate uses a different unit */ + odometerUnit?: Unit; }; export default TrackExpenseParams; diff --git a/src/libs/API/parameters/UpdateMoneyRequestParams.ts b/src/libs/API/parameters/UpdateMoneyRequestParams.ts index 4549f7d46715..521f9b6d6d12 100644 --- a/src/libs/API/parameters/UpdateMoneyRequestParams.ts +++ b/src/libs/API/parameters/UpdateMoneyRequestParams.ts @@ -1,5 +1,7 @@ import type {TransactionDetails} from '@libs/ReportUtils'; +import type {Unit} from '@src/types/onyx/Policy'; + type UpdateMoneyRequestParams = Partial & { reportID?: string; transactionID?: string; @@ -9,6 +11,8 @@ type UpdateMoneyRequestParams = Partial & { updates?: string; /** Distance in meters of the map route the user picked, sent when the selected alternate route changes */ selectedRouteDistance?: number; + /** Distance unit the odometer readings were entered in, so the backend can convert them when the rate uses a different unit */ + odometerUnit?: Unit; }; export default UpdateMoneyRequestParams; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 7766e9c90254..59ca78e37da2 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1154,6 +1154,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) rateAutoUpdated: CONST.RED_BRICK_ROAD_PENDING_ACTION, odometerStart: CONST.RED_BRICK_ROAD_PENDING_ACTION, odometerEnd: CONST.RED_BRICK_ROAD_PENDING_ACTION, + odometerUnit: CONST.RED_BRICK_ROAD_PENDING_ACTION, odometerStartImage: CONST.RED_BRICK_ROAD_PENDING_ACTION, odometerEndImage: CONST.RED_BRICK_ROAD_PENDING_ACTION, tripID: CONST.RED_BRICK_ROAD_PENDING_ACTION, @@ -1291,6 +1292,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) splitsEndDate: 'string', odometerStart: 'number', odometerEnd: 'number', + odometerUnit: 'string', odometerStartImage: 'object', odometerEndImage: 'object', tripID: 'string', diff --git a/src/libs/TransactionUtils/buildOptimisticTransaction.ts b/src/libs/TransactionUtils/buildOptimisticTransaction.ts index be5b0f4ac70c..a95c452529ae 100644 --- a/src/libs/TransactionUtils/buildOptimisticTransaction.ts +++ b/src/libs/TransactionUtils/buildOptimisticTransaction.ts @@ -10,6 +10,7 @@ import CONST from '@src/CONST'; import type {Policy, Transaction} from '@src/types/onyx'; import type {Attendee, Participant, SplitExpense} from '@src/types/onyx/IOU'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {Unit} from '@src/types/onyx/Policy'; import type {Comment, Receipt, Routes, TransactionCustomUnit, TransactionPendingFieldsKey, WaypointCollection} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -61,6 +62,7 @@ type TransactionParams = { waypoints?: WaypointCollection; odometerStart?: number; odometerEnd?: number; + odometerUnit?: Unit; routes?: Routes; gpsCoordinates?: string; type?: ValueOf; @@ -123,6 +125,7 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T waypoints, odometerStart, odometerEnd, + odometerUnit, routes, type, count, @@ -141,6 +144,9 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T if (odometerEnd !== undefined) { commentJSON.odometerEnd = odometerEnd; } + if (odometerUnit !== undefined) { + commentJSON.odometerUnit = odometerUnit; + } if (isDemoTransactionParam) { commentJSON.isDemoTransaction = true; } diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index d5c0c488eebe..cf0b05d88586 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -653,6 +653,7 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra originalTransactionID: undefined, odometerStart: transaction.comment?.odometerStart ?? undefined, odometerEnd: transaction.comment?.odometerEnd ?? undefined, + odometerUnit: transaction.comment?.odometerUnit ?? undefined, receipt: undefined, source: undefined, waypoints, diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 99ce2bd94319..4e0d9bc764b2 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -68,6 +68,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {ErrorFields, Errors, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; +import type {Unit} from '@src/types/onyx/Policy'; import type {ReportNextStep} from '@src/types/onyx/Report'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -151,6 +152,7 @@ type RequestMoneyTransactionParams = Omit & { customUnit?: TransactionCustomUnit; odometerStart?: number; odometerEnd?: number; + odometerUnit?: Unit; /** Transaction type (e.g., 'time' for time tracking expenses) */ type?: ValueOf; @@ -1371,6 +1373,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma waypoints, odometerStart, odometerEnd, + odometerUnit, isSelfDMSplit, selfDMReportID, shouldSkipReportHighlightRail, @@ -1581,6 +1584,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma waypoints, odometerStart, odometerEnd, + odometerUnit, }, isDemoTransactionParam: transactionParams.receipt?.isTestDriveReceipt, }); diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index f377d1510851..5a5bdc464828 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -64,6 +64,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant, Split} from '@src/types/onyx/IOU'; +import type {Unit} from '@src/types/onyx/Policy'; import type RecentlyUsedTags from '@src/types/onyx/RecentlyUsedTags'; import type {OnyxData} from '@src/types/onyx/Request'; import type {Receipt, ReceiptSource, SplitShares, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; @@ -119,6 +120,7 @@ type DistanceRequestTransactionParams = BaseTransactionParams & { receipt?: Receipt; odometerStart?: number; odometerEnd?: number; + odometerUnit?: Unit; gpsCoordinates?: string; distanceRequestType?: string; @@ -2110,6 +2112,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest receipt, odometerStart, odometerEnd, + odometerUnit, isFromGlobalCreate, gpsCoordinates, distanceRequestType, @@ -2207,6 +2210,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest attendees: attendees ? JSON.stringify(attendees) : undefined, odometerStart, odometerEnd, + odometerUnit, gpsCoordinates, distanceRequestType, selectedRouteDistance, @@ -2269,6 +2273,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest waypoints: validWaypoints, odometerStart, odometerEnd, + odometerUnit, customUnitRateID, }, shouldGenerateTransactionThreadReport: false, @@ -2325,6 +2330,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest receipt, odometerStart, odometerEnd, + odometerUnit, created, category, tag, diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 00d0293d6006..d689f822dafa 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -99,7 +99,7 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; -import type {CreatableWorkspaceType} from '@src/types/onyx/Policy'; +import type {CreatableWorkspaceType, Unit} from '@src/types/onyx/Policy'; import type {QuickActionName} from '@src/types/onyx/QuickAction'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -178,6 +178,7 @@ type GetTrackExpenseInformationTransactionParams = { distance?: number; odometerStart?: number; odometerEnd?: number; + odometerUnit?: Unit; gpsCoordinates?: string; distanceRequestType?: string; }; @@ -939,6 +940,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T attendees, odometerStart, odometerEnd, + odometerUnit, gpsCoordinates, } = transactionParams; @@ -1161,6 +1163,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T attendees, odometerStart: isOdometerDistanceRequest ? odometerStart : undefined, odometerEnd: isOdometerDistanceRequest ? odometerEnd : undefined, + odometerUnit: isOdometerDistanceRequest ? odometerUnit : undefined, gpsCoordinates: isGPSDistanceRequest ? gpsCoordinates : undefined, }, }); @@ -2567,6 +2570,7 @@ function trackExpense(params: CreateTrackExpenseParams) { attendees, odometerStart, odometerEnd, + odometerUnit, isFromGlobalCreate = false, gpsCoordinates, distanceRequestType, @@ -2687,6 +2691,7 @@ function trackExpense(params: CreateTrackExpenseParams) { attendees, odometerStart, odometerEnd, + odometerUnit, gpsCoordinates, }, policyParams: { @@ -2979,6 +2984,7 @@ function trackExpense(params: CreateTrackExpenseParams) { isOdometerDistanceRequestTransactionUtils(transaction), odometerStart, odometerEnd, + odometerUnit, }; if (actionableWhisperReportActionIDParam) { parameters.actionableWhisperReportActionID = actionableWhisperReportActionIDParam; diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index b04c06264e41..d87d4fb1f486 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -52,6 +52,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; +import type {Unit} from '@src/types/onyx/Policy'; import type RecentlyUsedTags from '@src/types/onyx/RecentlyUsedTags'; import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; @@ -1181,6 +1182,7 @@ type UpdateMoneyRequestDistanceParams = { isASAPSubmitBetaEnabled: boolean; odometerStart?: number; odometerEnd?: number; + odometerUnit?: Unit; distanceOriginalPolicy?: OnyxEntry; delegateAccountID: number | undefined; isTrackIntentUser: boolean | undefined; @@ -1213,6 +1215,7 @@ function updateMoneyRequestDistance({ isASAPSubmitBetaEnabled, odometerStart, odometerEnd, + odometerUnit, distanceOriginalPolicy, delegateAccountID, isTrackIntentUser, @@ -1282,6 +1285,9 @@ function updateMoneyRequestDistance({ if (odometerEnd !== undefined) { params.odometerEnd = odometerEnd; } + if (odometerUnit !== undefined) { + params.odometerUnit = odometerUnit; + } const selectedRouteDistance = selectedRouteKey ? transaction?.routes?.[selectedRouteKey]?.distance : undefined; if (selectedRouteDistance) { params.selectedRouteDistance = selectedRouteDistance; diff --git a/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts b/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts index 51b9791d298c..7114ca7f17ba 100644 --- a/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts +++ b/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts @@ -1,6 +1,7 @@ import type CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {Unit} from '@src/types/onyx/Policy'; import type {Receipt, WaypointCollection} from '@src/types/onyx/Transaction'; import type {ValueOf} from 'type-fest'; @@ -41,6 +42,7 @@ type TrackExpenseTransactionParams = { isLinkedTrackedExpenseReportArchived?: boolean; odometerStart?: number; odometerEnd?: number; + odometerUnit?: Unit; isFromGlobalCreate?: boolean; gpsCoordinates?: string; distanceRequestType?: string; diff --git a/src/libs/actions/OdometerTransactionUtils.ts b/src/libs/actions/OdometerTransactionUtils.ts index d57faf1c7846..8db434f4d8e8 100644 --- a/src/libs/actions/OdometerTransactionUtils.ts +++ b/src/libs/actions/OdometerTransactionUtils.ts @@ -7,6 +7,7 @@ import CONST from '@src/CONST'; import type {OdometerImageType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OdometerDraft, Transaction} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; import type {Comment} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -24,11 +25,16 @@ type SaveOdometerDraftParams = { endImage?: FileObject | string | null; }; -function setMoneyRequestOdometerReading(transactionID: string, startReading: number | null, endReading: number | null, isDraft: boolean) { +/** + * Set the odometer readings for a transaction + * @param odometerUnit - The distance unit the readings were entered in. Omit it when clearing the readings. + */ +function setMoneyRequestOdometerReading(transactionID: string, startReading: number | null, endReading: number | null, isDraft: boolean, odometerUnit: Unit | null = null) { Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { odometerStart: startReading, odometerEnd: endReading, + odometerUnit, }, }); } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 4f1bd7de197c..5db1901b6423 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -404,7 +404,8 @@ function IOURequestStepDistanceOdometer({ const calculatedDistance = getOdometerTotalDistance(start, end, currentTransaction?.comment); if (!isEditing) { - setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); + // Keep the unit the readings were first entered in, since they aren't converted when the rate's unit changes + setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft, currentTransaction?.comment?.odometerUnit ?? unit); setMoneyRequestDistance(transactionID, calculatedDistance, isTransactionDraft, unit); } // Local state has just been persisted to the transaction thus the resync guard can be lowered @@ -447,6 +448,7 @@ function IOURequestStepDistanceOdometer({ distance: calculatedDistance, odometerStart: start, odometerEnd: end, + odometerUnit: transaction?.comment?.odometerUnit ?? undefined, // Not required for odometer distance request transactionBackup: undefined, policy, diff --git a/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts b/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts index 9bb700fe0e2e..3001a68fb03d 100644 --- a/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts +++ b/src/pages/iou/request/step/confirmation/submission/useDistanceSubmission.ts @@ -184,6 +184,7 @@ function useDistanceSubmission({ receipt: isManualDistanceRequest || isOdometerDistanceRequest ? receiptFiles[transaction.transactionID] : undefined, odometerStart: isOdometerDistanceRequest ? transaction.comment?.odometerStart : undefined, odometerEnd: isOdometerDistanceRequest ? transaction.comment?.odometerEnd : undefined, + odometerUnit: isOdometerDistanceRequest ? (transaction.comment?.odometerUnit ?? undefined) : undefined, isFromGlobalCreate: getIsFromGlobalCreate(transaction), gpsCoordinates: isGPSDistanceRequest ? getStringifiedGPSCoordinates(gpsDraftDetails) : undefined, distanceRequestType: getDistanceRequestType(transaction), diff --git a/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts index 3f7272862eb5..aebf1dffdd9a 100644 --- a/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/submission/useTrackExpenseSubmission.ts @@ -242,6 +242,7 @@ function useTrackExpenseSubmission({ isLinkedTrackedExpenseReportArchived, odometerStart: isOdometerDistanceRequest ? item.comment?.odometerStart : undefined, odometerEnd: isOdometerDistanceRequest ? item.comment?.odometerEnd : undefined, + odometerUnit: isOdometerDistanceRequest ? (item.comment?.odometerUnit ?? undefined) : undefined, isFromGlobalCreate: getIsFromGlobalCreate(item), gpsCoordinates: isGPSDistanceRequest ? getStringifiedGPSCoordinates(gpsDraftDetails) : undefined, distanceRequestType: getDistanceRequestType(transaction), diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 54923d5f3601..1c534ffeed89 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -135,6 +135,9 @@ type Comment = { /** Odometer end reading for distance expenses */ odometerEnd?: number; + /** Distance unit the odometer readings were entered in, which can differ from customUnit.distanceUnit after the rate changes */ + odometerUnit?: Unit | null; + /** Both image fields are needed only locally because server receives only one merged image as receipt */ /** Odometer start image (File object with uri on web, URI string on native) */ odometerStartImage?: FileObject | string; diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 8205336e5039..874f38020c41 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -78,6 +78,7 @@ import { formatPhoneNumber, getCurrencyDecimalsLocal, getCurrencySymbolLocal, + getFetchMockCalls, getOnyxData, getRequiredOnyxUpdates, getRequiredWriteCall, @@ -11571,6 +11572,32 @@ describe('createDistanceRequest', () => { expect(createdTransaction?.comment?.odometerEnd).toBe(10050); }); + it('sends the unit the odometer readings were entered in with the distance request', async () => { + // Given readings entered in km on an expense whose distance was converted to miles + const testReport = createRandomReport(1, undefined); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${testReport.reportID}`, testReport); + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + + // When the distance request is created + const {transactionID} = createDistanceRequest( + getDefaultDistanceRequestParams( + testReport, + {amount: 500, distance: 2.49, odometerStart: 2, odometerEnd: 6, odometerUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, + recentWaypoints, + ), + ); + await waitForBatchedUpdates(); + + // Then the backend gets the readings' unit so it can convert end - start into the rate's unit + const requestBody = getFetchMockCalls(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST).at(0)?.[1]?.body; + const requestParams = requestBody instanceof FormData ? Object.fromEntries(requestBody) : {}; + expect(requestParams).toEqual(expect.objectContaining({odometerStart: '2', odometerEnd: '6', odometerUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS})); + + // And the optimistic transaction keeps the unit, so a later edit can send it too + const createdTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(createdTransaction?.comment?.odometerUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + }); + it('creates distance request with category in policy expense chat', async () => { const policyID = 'testPolicy123'; const testCategory = 'Travel'; diff --git a/tests/actions/IOUTest/TrackExpenseTest.ts b/tests/actions/IOUTest/TrackExpenseTest.ts index 8117690d18a6..4e56048b836b 100644 --- a/tests/actions/IOUTest/TrackExpenseTest.ts +++ b/tests/actions/IOUTest/TrackExpenseTest.ts @@ -1337,6 +1337,42 @@ describe('actions/IOU/TrackExpense', () => { expect(fallbackReportActions).toBeUndefined(); }); + it('sends the unit the odometer readings were entered in, so the backend can convert them to the rate unit', async () => { + // Given a selfDM report and an odometer expense whose readings were entered in km + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-odometer-unit', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When the expense is tracked + trackExpense({ + ...getDefaultTrackExpenseParams(selfDMReport, { + amount: 1500, + distance: 2.49, + odometerStart: 2, + odometerEnd: 6, + odometerUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + }), + existingTransaction: { + transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + reportID: selfDMReport.reportID, + amount: 1500, + currency: 'USD', + created: '', + merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: {type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE}}, + }, + }); + await waitForBatchedUpdates(); + + // Then the TrackExpense request carries the readings' unit alongside the readings + const requestBody = TestHelper.getFetchMockCalls(WRITE_COMMANDS.TRACK_EXPENSE).at(0)?.[1]?.body; + const requestParams = requestBody instanceof FormData ? Object.fromEntries(requestBody) : {}; + expect(requestParams).toEqual(expect.objectContaining({odometerStart: '2', odometerEnd: '6', odometerUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS})); + }); + it('should create optimistic transaction with correct amount and currency', async () => { // Given a selfDM report and transaction data const selfDMReport: Report = { diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index 4dfc4ba45877..61ec32ea0204 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -1893,6 +1893,75 @@ describe('actions/IOU/UpdateMoneyRequest', () => { expect(requestParams?.get('transactionID')).toBe(transactionID); expect(requestParams?.get('selectedRouteDistance')).toBe('1500'); }); + + it('sends the unit the odometer readings were entered in, so the backend can convert them to the rate unit', async () => { + // Given an odometer expense with readings entered in km, now on a rate that uses miles + const transactionID = 'odometer_unit_transaction'; + const policyID = 'odometer_unit_policy'; + const parentReportID = 'odometer_unit_parent'; + const transactionThreadReportID = 'odometer_unit_thread'; + const fakeTransaction: Transaction = { + transactionID, + amount: 1500, + currency: CONST.CURRENCY.USD, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'Odometer', + reportID: parentReportID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, quantity: 2.49}, + odometerStart: 2, + odometerEnd: 6, + odometerUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + }, + }; + const fakePolicy = createRandomPolicy(1); + const transactionThreadReport = createMock({reportID: transactionThreadReportID, type: CONST.REPORT.TYPE.EXPENSE}); + const parentReport = createMock({reportID: parentReportID, type: CONST.REPORT.TYPE.IOU}); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + + // When the end reading is edited + updateMoneyRequestDistance({ + isVendorMatchingBetaEnabled: false, + rules: undefined, + personalPolicyOutputCurrency: undefined, + reportPolicyTags: undefined, + transaction: fakeTransaction, + transactionThreadReport, + parentReport, + iouReportOwnerLogin: undefined, + recentWaypoints: [], + distance: 3.73, + policy: fakePolicy, + policyTagList: undefined, + policyCategories: undefined, + transactionBackup: undefined, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'test@example.com', + isASAPSubmitBetaEnabled: false, + odometerStart: 2, + odometerEnd: 8, + odometerUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + delegateAccountID: undefined, + isTrackIntentUser: false, + violations: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + }); + await waitForBatchedUpdates(); + + // Then the UpdateMoneyRequestDistance request carries the readings' unit alongside the readings + const distanceRequest = mockFetch.mock.calls.find(([url]) => typeof url === 'string' && url.endsWith(`/${WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE}?`)); + const requestBody = distanceRequest?.[1]?.body; + const requestParams = requestBody instanceof FormData ? requestBody : undefined; + expect(requestParams?.get('odometerEnd')).toBe('8'); + expect(requestParams?.get('odometerUnit')).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + }); }); describe('updateMoneyRequestReimbursable', () => { diff --git a/tests/actions/OdometerTransactionUtilsTest.ts b/tests/actions/OdometerTransactionUtilsTest.ts index 7b750692a759..cc742bae7bb5 100644 --- a/tests/actions/OdometerTransactionUtilsTest.ts +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -4,6 +4,7 @@ import { removeMoneyRequestOdometerImage, saveOdometerDraft, setMoneyRequestOdometerImage, + setMoneyRequestOdometerReading, } from '@libs/actions/OdometerTransactionUtils'; import type {OdometerUnsavedChangesState} from '@libs/actions/OdometerTransactionUtils'; import '@libs/actions/IOU/MoneyRequest'; @@ -123,6 +124,34 @@ describe('actions/OdometerTransactionUtils', () => { jest.clearAllMocks(); }); + describe('setMoneyRequestOdometerReading', () => { + it('stores the unit the readings were entered in, and clears it with the readings', async () => { + // Given a draft transaction + const transaction = createRandomTransaction(1); + const transactionID = transaction.transactionID; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction); + + // When readings are entered in km + setMoneyRequestOdometerReading(transactionID, 2, 6, true, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + await waitForBatchedUpdates(); + + // Then the unit is saved next to the readings, so it can be sent to the backend later + let draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(draftTransaction?.comment?.odometerStart).toBe(2); + expect(draftTransaction?.comment?.odometerEnd).toBe(6); + expect(draftTransaction?.comment?.odometerUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + + // When the readings are cleared + setMoneyRequestOdometerReading(transactionID, null, null, true); + await waitForBatchedUpdates(); + + // Then the unit is cleared too, so the next readings take the current rate's unit + draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(draftTransaction?.comment?.odometerStart).toBeUndefined(); + expect(draftTransaction?.comment?.odometerUnit).toBeUndefined(); + }); + }); + describe('setMoneyRequestOdometerImage and removeMoneyRequestOdometerImage', () => { beforeEach(() => { jest.mock('@libs/OdometerUtils', () => ({