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/OdometerUtils.ts b/src/libs/OdometerUtils.ts index 2751f821dc18..5fc65e65b14e 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,36 @@ 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/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 121d2995f934..be558b2ec721 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 e2a27d7bccc9..5db1901b6423 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -48,7 +48,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'; @@ -288,7 +288,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; })(); @@ -401,11 +401,11 @@ 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); + // 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 @@ -448,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, @@ -543,7 +544,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/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/IOU/MoneyRequestSettersTest.ts b/tests/actions/IOU/MoneyRequestSettersTest.ts index 69f1eec529d2..c932d929ba9b 100644 --- a/tests/actions/IOU/MoneyRequestSettersTest.ts +++ b/tests/actions/IOU/MoneyRequestSettersTest.ts @@ -1,6 +1,7 @@ import { initMoneyRequest, resetDraftTransactionsCustomUnit, + setCustomUnitRateID, setMoneyRequestAmount, setMoneyRequestBillable, setMoneyRequestCategory, @@ -318,6 +319,83 @@ 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('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); + 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); + 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); + }); + }); + 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 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 ef04c2a94672..caf078547320 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -1898,6 +1898,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', () => ({ 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); + }); +});