From 7dbb42f0680229bf45912ff06192eac032c755f7 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 4 Jun 2026 01:16:45 +0530 Subject: [PATCH 01/12] feat: make distance request creation choose date-aware mileage rates Signed-off-by: krishna2323 --- src/hooks/useParticipantSubmission.ts | 17 ++- src/libs/DistanceRequestUtils.ts | 102 ++++++++++++++++-- src/libs/actions/IOU/MoneyRequest.ts | 9 +- ...andleMoneyRequestStepDistanceNavigation.ts | 3 + 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts index fe1dd0ec9fc6..daebae209140 100644 --- a/src/hooks/useParticipantSubmission.ts +++ b/src/hooks/useParticipantSubmission.ts @@ -185,6 +185,7 @@ function useParticipantSubmission({ policy: movingPolicy, isPolicyExpenseChat: false, lastSelectedDistanceRates: distanceRates, + expenseDate: transaction.created, }); setCustomUnitRateID(transaction.transactionID, rateID, transaction, movingPolicy); const shouldSetParticipantAutoAssignment = iouType === CONST.IOU.TYPE.CREATE; @@ -238,14 +239,24 @@ function useParticipantSubmission({ if (!isMovingTransactionFromTrackExpense || !isPolicyExpenseChat) { // If not moving the transaction from track expense, select the default rate automatically. // Otherwise, keep the original p2p rate and let the user manually change it to the one they want from the workspace. - const rateID = DistanceRequestUtils.getCustomUnitRateID({reportID: firstParticipantReportID, isPolicyExpenseChat, policy, lastSelectedDistanceRates: distanceRates}); - if (drafts.length > 0) { for (const transaction of drafts) { + const rateID = DistanceRequestUtils.getCustomUnitRateID({ + reportID: firstParticipantReportID, + isPolicyExpenseChat, + policy, + lastSelectedDistanceRates: distanceRates, + expenseDate: transaction.created, + }); setCustomUnitRateID(transaction.transactionID, rateID, transaction, policy); } } else { - // Fallback to using initialTransactionID directly + const rateID = DistanceRequestUtils.getCustomUnitRateID({ + reportID: firstParticipantReportID, + isPolicyExpenseChat, + policy, + lastSelectedDistanceRates: distanceRates, + }); setCustomUnitRateID(initialTransactionID, rateID, undefined, policy); } } diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 588a883ab71b..1553cde4dc89 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -20,6 +20,8 @@ type MileageRate = { name?: string; enabled?: boolean; index?: number; + startDate?: string; + endDate?: string; }; /** @private Only for getRate function */ @@ -63,6 +65,8 @@ function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates customUnitRateID: rate.customUnitRateID, enabled: rate.enabled, index: rate.index, + startDate: rate.startDate ?? undefined, + endDate: rate.endDate ?? undefined, }; } @@ -341,7 +345,77 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number { } /** - * Returns custom unit rate ID for the distance transaction + * Checks if a mileage rate is eligible for a given expense date. + * A rate is eligible if the date falls within its startDate/endDate bounds (inclusive). + * Missing bounds mean unbounded in that direction. + */ +function isRateEligibleForDate(rate: MileageRate, expenseDate: string): boolean { + if (rate.startDate && expenseDate < rate.startDate) { + return false; + } + if (rate.endDate && expenseDate > rate.endDate) { + return false; + } + return true; +} + +/** + * Returns whether a rate has both start and end date bounds. + */ +function isRateBounded(rate: MileageRate): boolean { + return !!rate.startDate && !!rate.endDate; +} + +/** + * Finds the best eligible rate for a given expense date from a set of mileage rates. + * Selection order per design doc: + * 1. Most specific date range (bounded beats unbounded) + * 2. Narrower date range for two bounded ranges + * 3. Latest start date + * 4. Lowest index (creation order) + */ +function getBestEligibleRate(mileageRates: Record, expenseDate: string): MileageRate | undefined { + const eligibleRates = Object.values(mileageRates).filter((rate) => rate.enabled && isRateEligibleForDate(rate, expenseDate)); + + if (eligibleRates.length === 0) { + return undefined; + } + + eligibleRates.sort((a, b) => { + const aBounded = isRateBounded(a); + const bBounded = isRateBounded(b); + if (aBounded !== bBounded) { + return aBounded ? -1 : 1; + } + + if (aBounded && bBounded && a.endDate && a.startDate && b.endDate && b.startDate) { + const aRange = new Date(a.endDate).getTime() - new Date(a.startDate).getTime(); + const bRange = new Date(b.endDate).getTime() - new Date(b.startDate).getTime(); + if (aRange !== bRange) { + return aRange - bRange; + } + } + + const aStart = a.startDate ?? ''; + const bStart = b.startDate ?? ''; + if (aStart !== bStart) { + return aStart < bStart ? 1 : -1; + } + + const aIndex = a.index ?? CONST.DEFAULT_NUMBER_ID; + const bIndex = b.index ?? CONST.DEFAULT_NUMBER_ID; + return aIndex - bIndex; + }); + + return eligibleRates.at(0); +} + +/** + * Returns custom unit rate ID for the distance transaction. + * When an expenseDate is provided, uses date-aware rate selection: + * 1. Last selected rate, if enabled and valid for the expense date + * 2. Best eligible rate for the expense date + * 3. Default rate fallback */ function getCustomUnitRateID({ reportID, @@ -349,12 +423,14 @@ function getCustomUnitRateID({ policy, isTrackDistanceExpense = false, lastSelectedDistanceRates, + expenseDate, }: { reportID: string | undefined; isPolicyExpenseChat: boolean; policy: OnyxEntry | undefined; lastSelectedDistanceRates?: OnyxEntry; isTrackDistanceExpense?: boolean; + expenseDate?: string; }): string { let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; @@ -366,19 +442,31 @@ function getCustomUnitRateID({ return customUnitRateID; } - // For TrackDistanceExpense we will return the default or last selected rate of the policyForMovingExpenses. if (isPolicyExpenseChat || isTrackDistanceExpense) { const distanceUnit = Object.values(policy.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy.id]; const lastSelectedDistanceRate = lastSelectedDistanceRateID ? distanceUnit?.rates[lastSelectedDistanceRateID] : undefined; - if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) { + + const mileageRates = getMileageRates(policy); + const lastSelectedMileageRate = lastSelectedDistanceRateID ? mileageRates[lastSelectedDistanceRateID] : undefined; + + if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID && (!expenseDate || !lastSelectedMileageRate || isRateEligibleForDate(lastSelectedMileageRate, expenseDate))) { customUnitRateID = lastSelectedDistanceRateID; + } else if (expenseDate) { + const bestRate = getBestEligibleRate(mileageRates, expenseDate); + if (bestRate?.customUnitRateID) { + customUnitRateID = bestRate.customUnitRateID; + } else { + const defaultMileageRate = getDefaultMileageRate(policy); + if (defaultMileageRate?.customUnitRateID) { + customUnitRateID = defaultMileageRate.customUnitRateID; + } + } } else { const defaultMileageRate = getDefaultMileageRate(policy); - if (!defaultMileageRate?.customUnitRateID) { - return customUnitRateID; + if (defaultMileageRate?.customUnitRateID) { + customUnitRateID = defaultMileageRate.customUnitRateID; } - customUnitRateID = defaultMileageRate.customUnitRateID; } } @@ -544,6 +632,8 @@ export default { isDistanceAmountWithinLimit, normalizeOdometerText, prepareTextForDisplay, + isRateEligibleForDate, + getBestEligibleRate, }; export type {MileageRate}; diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 2b0fa509bf0b..1e3769f14be9 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -307,7 +307,14 @@ function initMoneyRequest({ ) { if (!isFromGlobalCreate) { const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report) || isPolicyExpenseChatReportUtil(parentReport); - const customUnitRateID = DistanceRequestUtils.getCustomUnitRateID({reportID, isPolicyExpenseChat, isTrackDistanceExpense, policy, lastSelectedDistanceRates}); + const customUnitRateID = DistanceRequestUtils.getCustomUnitRateID({ + reportID, + isPolicyExpenseChat, + isTrackDistanceExpense, + policy, + lastSelectedDistanceRates, + expenseDate: created, + }); comment.customUnit = {customUnitRateID, name: CONST.CUSTOM_UNITS.NAME_DISTANCE}; } else if (hasOnlyPersonalPolicies) { comment.customUnit = {customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, name: CONST.CUSTOM_UNITS.NAME_DISTANCE}; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index 6db6227d5c6b..bef84381a3d7 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -281,6 +281,7 @@ function handleMoneyRequestStepDistanceNavigation({ isTrackDistanceExpense: true, policy: policyForMovingExpenses, isPolicyExpenseChat: false, + expenseDate: transaction?.created, }), attendees: transaction?.comment?.attendees, gpsCoordinates, @@ -352,6 +353,7 @@ function handleMoneyRequestStepDistanceNavigation({ isPolicyExpenseChat, policy, lastSelectedDistanceRates, + expenseDate: transaction?.created, }), splitShares: transaction?.splitShares, attendees: transaction?.comment?.attendees, @@ -418,6 +420,7 @@ function handleMoneyRequestStepDistanceNavigation({ policy: isSelfDMReport ? policyForMovingExpenses : defaultExpensePolicy, lastSelectedDistanceRates, isTrackDistanceExpense: isSelfDMReport, + expenseDate: transaction?.created, }); setTransactionReport(transactionID, {reportID: transactionReportID}, true); From 8c3ee069c25d96886bc40abcf091e13e3b4214a1 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 4 Jun 2026 01:38:55 +0530 Subject: [PATCH 02/12] recalculate distance rate when expense date changes during creation Signed-off-by: krishna2323 --- .../iou/request/step/IOURequestStepDate.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDate.tsx b/src/pages/iou/request/step/IOURequestStepDate.tsx index 8cb3b493dd83..95d1b9582164 100644 --- a/src/pages/iou/request/step/IOURequestStepDate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDate.tsx @@ -16,11 +16,13 @@ import usePolicy from '@hooks/usePolicy'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getFormattedCreated, hasReceipt} from '@libs/TransactionUtils'; -import {setMoneyRequestCreated} from '@userActions/IOU/MoneyRequest'; +import {isPolicyExpenseChat as isPolicyExpenseChatReportUtil} from '@libs/ReportUtils'; +import {getFormattedCreated, hasReceipt, isDistanceRequest} from '@libs/TransactionUtils'; +import {setCustomUnitRateID, setMoneyRequestCreated} from '@userActions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; import {updateMoneyRequestDate} from '@userActions/IOU/UpdateMoneyRequest'; import CONST from '@src/CONST'; @@ -63,6 +65,7 @@ function IOURequestStepDate({ const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const {isOffline} = useNetwork(); + const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); const isEditing = action === CONST.IOU.ACTION.EDIT; const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; const isSplitExpense = iouType === CONST.IOU.TYPE.SPLIT_EXPENSE; @@ -115,6 +118,18 @@ function IOURequestStepDate({ }); } else { setMoneyRequestCreated(transactionID, newCreated, isTransactionDraft, hasReceipt(transaction)); + + if (isDistanceRequest(transaction)) { + const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report); + const rateID = DistanceRequestUtils.getCustomUnitRateID({ + reportID, + isPolicyExpenseChat, + policy, + lastSelectedDistanceRates, + expenseDate: newCreated, + }); + setCustomUnitRateID(transactionID, rateID, transaction, policy); + } } navigateBack(); From 77c79be564e4b68aec71829469be382a1c4f8ff4 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 4 Jun 2026 19:10:17 +0530 Subject: [PATCH 03/12] accept null in MileageRate dates and restore fallback comment Signed-off-by: krishna2323 --- src/hooks/useParticipantSubmission.ts | 1 + src/libs/DistanceRequestUtils.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts index daebae209140..63b59d7e8759 100644 --- a/src/hooks/useParticipantSubmission.ts +++ b/src/hooks/useParticipantSubmission.ts @@ -251,6 +251,7 @@ function useParticipantSubmission({ setCustomUnitRateID(transaction.transactionID, rateID, transaction, policy); } } else { + // Fallback to using initialTransactionID directly const rateID = DistanceRequestUtils.getCustomUnitRateID({ reportID: firstParticipantReportID, isPolicyExpenseChat, diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 1553cde4dc89..f5f68890d58b 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -20,8 +20,8 @@ type MileageRate = { name?: string; enabled?: boolean; index?: number; - startDate?: string; - endDate?: string; + startDate?: string | null; + endDate?: string | null; }; /** @private Only for getRate function */ @@ -65,8 +65,8 @@ function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates customUnitRateID: rate.customUnitRateID, enabled: rate.enabled, index: rate.index, - startDate: rate.startDate ?? undefined, - endDate: rate.endDate ?? undefined, + startDate: rate.startDate, + endDate: rate.endDate, }; } From 9d119ce6171107f299c3dc11b7c7a7db9b2413ec Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 4 Jun 2026 19:22:34 +0530 Subject: [PATCH 04/12] restore comment. Signed-off-by: krishna2323 --- src/libs/DistanceRequestUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index f5f68890d58b..f53f79f02644 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -442,6 +442,7 @@ function getCustomUnitRateID({ return customUnitRateID; } + // For TrackDistanceExpense we will return the default or last selected rate of the policyForMovingExpenses. if (isPolicyExpenseChat || isTrackDistanceExpense) { const distanceUnit = Object.values(policy.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy.id]; From 97815c76af5641a41942c59306d35c3da09e41bb Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 4 Jun 2026 20:11:39 +0530 Subject: [PATCH 05/12] fix: treat omitted enabled as active in date-aware rate selection Signed-off-by: krishna2323 --- src/libs/DistanceRequestUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index f53f79f02644..4c7a6a8649a3 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -375,7 +375,7 @@ function isRateBounded(rate: MileageRate): boolean { * 4. Lowest index (creation order) */ function getBestEligibleRate(mileageRates: Record, expenseDate: string): MileageRate | undefined { - const eligibleRates = Object.values(mileageRates).filter((rate) => rate.enabled && isRateEligibleForDate(rate, expenseDate)); + const eligibleRates = Object.values(mileageRates).filter((rate) => rate.enabled !== false && isRateEligibleForDate(rate, expenseDate)); if (eligibleRates.length === 0) { return undefined; From 238829f8792591700e8d8148dfc4070c6e8f20e1 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 5 Jun 2026 18:27:23 +0530 Subject: [PATCH 06/12] defer getMileageRates call to only when expenseDate is provided Signed-off-by: krishna2323 --- src/libs/DistanceRequestUtils.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 4c7a6a8649a3..4881c0e26b79 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -448,12 +448,28 @@ function getCustomUnitRateID({ const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy.id]; const lastSelectedDistanceRate = lastSelectedDistanceRateID ? distanceUnit?.rates[lastSelectedDistanceRateID] : undefined; - const mileageRates = getMileageRates(policy); - const lastSelectedMileageRate = lastSelectedDistanceRateID ? mileageRates[lastSelectedDistanceRateID] : undefined; - - if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID && (!expenseDate || !lastSelectedMileageRate || isRateEligibleForDate(lastSelectedMileageRate, expenseDate))) { - customUnitRateID = lastSelectedDistanceRateID; + if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) { + if (expenseDate) { + const mileageRates = getMileageRates(policy); + const lastSelectedMileageRate = mileageRates[lastSelectedDistanceRateID]; + if (!lastSelectedMileageRate || isRateEligibleForDate(lastSelectedMileageRate, expenseDate)) { + customUnitRateID = lastSelectedDistanceRateID; + } else { + const bestRate = getBestEligibleRate(mileageRates, expenseDate); + if (bestRate?.customUnitRateID) { + customUnitRateID = bestRate.customUnitRateID; + } else { + const defaultMileageRate = getDefaultMileageRate(policy); + if (defaultMileageRate?.customUnitRateID) { + customUnitRateID = defaultMileageRate.customUnitRateID; + } + } + } + } else { + customUnitRateID = lastSelectedDistanceRateID; + } } else if (expenseDate) { + const mileageRates = getMileageRates(policy); const bestRate = getBestEligibleRate(mileageRates, expenseDate); if (bestRate?.customUnitRateID) { customUnitRateID = bestRate.customUnitRateID; From d0926185ab08bb76147a1fbb77c8d6b4fa2050e8 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 5 Jun 2026 18:54:34 +0530 Subject: [PATCH 07/12] Fix: use policyForMovingExpenses for track expense date-aware rate selection Signed-off-by: krishna2323 --- src/pages/iou/request/step/IOURequestStepDate.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDate.tsx b/src/pages/iou/request/step/IOURequestStepDate.tsx index 95d1b9582164..9fbc8c05206c 100644 --- a/src/pages/iou/request/step/IOURequestStepDate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDate.tsx @@ -13,6 +13,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -53,6 +54,9 @@ function IOURequestStepDate({ const styles = useThemeStyles(); const {translate} = useLocalize(); const policy = usePolicy(report?.policyID); + const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK; + const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); + const policyForTrackExpense = usePolicy(isTrackExpense ? policyForMovingExpensesID : undefined); const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactionID ? [transactionID] : []); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); @@ -121,14 +125,16 @@ function IOURequestStepDate({ if (isDistanceRequest(transaction)) { const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report); + const effectivePolicy = isTrackExpense ? policyForTrackExpense : policy; const rateID = DistanceRequestUtils.getCustomUnitRateID({ reportID, isPolicyExpenseChat, - policy, + policy: effectivePolicy, lastSelectedDistanceRates, + isTrackDistanceExpense: isTrackExpense, expenseDate: newCreated, }); - setCustomUnitRateID(transactionID, rateID, transaction, policy); + setCustomUnitRateID(transactionID, rateID, transaction, effectivePolicy); } } From 08c59f35bbf00c4d0cad7439bd5804a97bd038c6 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 5 Jun 2026 19:28:39 +0530 Subject: [PATCH 08/12] Add 3-tier boundedness to rate tie-breaking: fully bounded > partially bounded > unbounded Signed-off-by: krishna2323 --- src/libs/DistanceRequestUtils.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 4881c0e26b79..cc34091925b7 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -360,17 +360,23 @@ function isRateEligibleForDate(rate: MileageRate, expenseDate: string): boolean } /** - * Returns whether a rate has both start and end date bounds. + * Returns a boundedness score: 2 = fully bounded (both dates), 1 = partially bounded (one date), 0 = unbounded. */ -function isRateBounded(rate: MileageRate): boolean { - return !!rate.startDate && !!rate.endDate; +function getBoundednessScore(rate: MileageRate): number { + if (rate.startDate && rate.endDate) { + return 2; + } + if (rate.startDate || rate.endDate) { + return 1; + } + return 0; } /** * Finds the best eligible rate for a given expense date from a set of mileage rates. * Selection order per design doc: - * 1. Most specific date range (bounded beats unbounded) - * 2. Narrower date range for two bounded ranges + * 1. Most specific date range (fully bounded > partially bounded > unbounded) + * 2. Narrower date range for two fully bounded ranges * 3. Latest start date * 4. Lowest index (creation order) */ @@ -382,13 +388,13 @@ function getBestEligibleRate(mileageRates: Record, expenseD } eligibleRates.sort((a, b) => { - const aBounded = isRateBounded(a); - const bBounded = isRateBounded(b); - if (aBounded !== bBounded) { - return aBounded ? -1 : 1; + const aScore = getBoundednessScore(a); + const bScore = getBoundednessScore(b); + if (aScore !== bScore) { + return bScore - aScore; } - if (aBounded && bBounded && a.endDate && a.startDate && b.endDate && b.startDate) { + if (aScore === 2 && bScore === 2 && a.endDate && a.startDate && b.endDate && b.startDate) { const aRange = new Date(a.endDate).getTime() - new Date(a.startDate).getTime(); const bRange = new Date(b.endDate).getTime() - new Date(b.startDate).getTime(); if (aRange !== bRange) { From 3e3e9d42cab786fb8b01a016ccea3503c4520dae Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 5 Jun 2026 19:55:27 +0530 Subject: [PATCH 09/12] Skip rate recalculation for P2P distance requests on date change Signed-off-by: krishna2323 --- src/pages/iou/request/step/IOURequestStepDate.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDate.tsx b/src/pages/iou/request/step/IOURequestStepDate.tsx index 9fbc8c05206c..ff0606010102 100644 --- a/src/pages/iou/request/step/IOURequestStepDate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDate.tsx @@ -123,8 +123,8 @@ function IOURequestStepDate({ } else { setMoneyRequestCreated(transactionID, newCreated, isTransactionDraft, hasReceipt(transaction)); - if (isDistanceRequest(transaction)) { - const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report); + const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report); + if (isDistanceRequest(transaction) && (isPolicyExpenseChat || isTrackExpense)) { const effectivePolicy = isTrackExpense ? policyForTrackExpense : policy; const rateID = DistanceRequestUtils.getCustomUnitRateID({ reportID, From ea9ff943f8728f4031057f25c5349b64edd596b6 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 9 Jun 2026 20:26:24 +0530 Subject: [PATCH 10/12] Address review feedback on date-aware getCustomUnitRateID Signed-off-by: krishna2323 --- src/libs/DistanceRequestUtils.ts | 68 +++++----- tests/unit/DistanceRequestUtilsTest.ts | 174 +++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 35 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index cc34091925b7..a8b8d0cdd5ad 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -394,9 +394,9 @@ function getBestEligibleRate(mileageRates: Record, expenseD return bScore - aScore; } - if (aScore === 2 && bScore === 2 && a.endDate && a.startDate && b.endDate && b.startDate) { - const aRange = new Date(a.endDate).getTime() - new Date(a.startDate).getTime(); - const bRange = new Date(b.endDate).getTime() - new Date(b.startDate).getTime(); + if (aScore === 2 && bScore === 2) { + const aRange = new Date(a.endDate!).getTime() - new Date(a.startDate!).getTime(); + const bRange = new Date(b.endDate!).getTime() - new Date(b.startDate!).getTime(); if (aRange !== bRange) { return aRange - bRange; } @@ -416,6 +416,15 @@ function getBestEligibleRate(mileageRates: Record, expenseD return eligibleRates.at(0); } +function getBestEligibleRateOrPolicyDefault(mileageRates: Record, expenseDate: string, policy: OnyxEntry): MileageRate | undefined { + const bestRate = getBestEligibleRate(mileageRates, expenseDate); + if (bestRate) { + return bestRate; + } + + return getDefaultMileageRate(policy); +} + /** * Returns custom unit rate ID for the distance transaction. * When an expenseDate is provided, uses date-aware rate selection: @@ -454,42 +463,31 @@ function getCustomUnitRateID({ const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy.id]; const lastSelectedDistanceRate = lastSelectedDistanceRateID ? distanceUnit?.rates[lastSelectedDistanceRateID] : undefined; - if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) { - if (expenseDate) { - const mileageRates = getMileageRates(policy); - const lastSelectedMileageRate = mileageRates[lastSelectedDistanceRateID]; - if (!lastSelectedMileageRate || isRateEligibleForDate(lastSelectedMileageRate, expenseDate)) { - customUnitRateID = lastSelectedDistanceRateID; - } else { - const bestRate = getBestEligibleRate(mileageRates, expenseDate); - if (bestRate?.customUnitRateID) { - customUnitRateID = bestRate.customUnitRateID; - } else { - const defaultMileageRate = getDefaultMileageRate(policy); - if (defaultMileageRate?.customUnitRateID) { - customUnitRateID = defaultMileageRate.customUnitRateID; - } - } - } - } else { - customUnitRateID = lastSelectedDistanceRateID; - } - } else if (expenseDate) { - const mileageRates = getMileageRates(policy); - const bestRate = getBestEligibleRate(mileageRates, expenseDate); - if (bestRate?.customUnitRateID) { - customUnitRateID = bestRate.customUnitRateID; - } else { - const defaultMileageRate = getDefaultMileageRate(policy); - if (defaultMileageRate?.customUnitRateID) { - customUnitRateID = defaultMileageRate.customUnitRateID; - } + if (!expenseDate) { + if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) { + return lastSelectedDistanceRateID; } - } else { + const defaultMileageRate = getDefaultMileageRate(policy); if (defaultMileageRate?.customUnitRateID) { - customUnitRateID = defaultMileageRate.customUnitRateID; + return defaultMileageRate.customUnitRateID; } + + return customUnitRateID; + } + + const mileageRates = getMileageRates(policy); + if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) { + const lastSelectedMileageRate = mileageRates[lastSelectedDistanceRateID]; + // If the last selected rate is missing from mileageRates we cannot verify date eligibility, so keep using the last selected ID. + if (!lastSelectedMileageRate || isRateEligibleForDate(lastSelectedMileageRate, expenseDate)) { + return lastSelectedDistanceRateID; + } + } + + const bestRate = getBestEligibleRateOrPolicyDefault(mileageRates, expenseDate, policy); + if (bestRate?.customUnitRateID) { + return bestRate.customUnitRateID; } } diff --git a/tests/unit/DistanceRequestUtilsTest.ts b/tests/unit/DistanceRequestUtilsTest.ts index 6a637d0649fa..623fceeb5c6d 100644 --- a/tests/unit/DistanceRequestUtilsTest.ts +++ b/tests/unit/DistanceRequestUtilsTest.ts @@ -89,6 +89,63 @@ const FAKE_POLICY: Policy = { }, }; +const DATE_BOUND_POLICY: Policy = { + ...FAKE_POLICY, + customUnits: { + C9031B6F4725D: { + ...FAKE_POLICY.customUnits!.C9031B6F4725D, + rates: { + DEFAULT_RATE_ID: { + attributes: {}, + currency: 'USD', + customUnitRateID: 'DEFAULT_RATE_ID', + enabled: true, + name: 'Default Rate', + rate: 67, + subRates: [], + index: 0, + }, + RATE_2025_ID: { + attributes: {}, + currency: 'USD', + customUnitRateID: 'RATE_2025_ID', + enabled: true, + name: '2025 Rate', + rate: 70, + subRates: [], + index: 1, + startDate: '2025-01-01', + endDate: '2025-12-31', + }, + RATE_2026_ID: { + attributes: {}, + currency: 'USD', + customUnitRateID: 'RATE_2026_ID', + enabled: true, + name: '2026 Rate', + rate: 75, + subRates: [], + index: 2, + startDate: '2026-01-01', + endDate: '2026-12-31', + }, + RATE_2026_H1_ID: { + attributes: {}, + currency: 'USD', + customUnitRateID: 'RATE_2026_H1_ID', + enabled: true, + name: '2026 H1 Rate', + rate: 80, + subRates: [], + index: 3, + startDate: '2026-01-01', + endDate: '2026-06-30', + }, + }, + }, + }, +}; + describe('DistanceRequestUtils', () => { describe('getDistanceRequestAmount', () => { test.each([ @@ -186,6 +243,123 @@ describe('DistanceRequestUtils', () => { expect(result).toBe('222AAF6B93BCB'); }); + + it('returns last selected rate when no expense date is provided', () => { + const result = DistanceRequestUtils.getCustomUnitRateID({ + reportID: '1234', + isPolicyExpenseChat: true, + policy: DATE_BOUND_POLICY, + lastSelectedDistanceRates: {[DATE_BOUND_POLICY.id]: 'RATE_2025_ID'}, + }); + + expect(result).toBe('RATE_2025_ID'); + }); + + it('returns last selected rate when it is eligible for the expense date', () => { + const result = DistanceRequestUtils.getCustomUnitRateID({ + reportID: '1234', + isPolicyExpenseChat: true, + policy: DATE_BOUND_POLICY, + lastSelectedDistanceRates: {[DATE_BOUND_POLICY.id]: 'RATE_2026_ID'}, + expenseDate: '2026-03-15', + }); + + expect(result).toBe('RATE_2026_ID'); + }); + + it('returns the best eligible rate when the last selected rate is not eligible for the expense date', () => { + const result = DistanceRequestUtils.getCustomUnitRateID({ + reportID: '1234', + isPolicyExpenseChat: true, + policy: DATE_BOUND_POLICY, + lastSelectedDistanceRates: {[DATE_BOUND_POLICY.id]: 'RATE_2025_ID'}, + expenseDate: '2026-03-15', + }); + + expect(result).toBe('RATE_2026_H1_ID'); + }); + + it('returns the best eligible rate when no last selected rate is provided', () => { + const result = DistanceRequestUtils.getCustomUnitRateID({ + reportID: '1234', + isPolicyExpenseChat: true, + policy: DATE_BOUND_POLICY, + lastSelectedDistanceRates: undefined, + expenseDate: '2025-06-01', + }); + + expect(result).toBe('RATE_2025_ID'); + }); + + it('returns the unbounded default rate when no date-bound rates are eligible for the expense date', () => { + const result = DistanceRequestUtils.getCustomUnitRateID({ + reportID: '1234', + isPolicyExpenseChat: true, + policy: DATE_BOUND_POLICY, + lastSelectedDistanceRates: undefined, + expenseDate: '2024-06-01', + }); + + expect(result).toBe('DEFAULT_RATE_ID'); + }); + + it('returns the policy default rate when no expense date is provided and no last selected rate exists', () => { + const result = DistanceRequestUtils.getCustomUnitRateID({ + reportID: '1234', + isPolicyExpenseChat: true, + policy: DATE_BOUND_POLICY, + lastSelectedDistanceRates: undefined, + }); + + expect(result).toBe('DEFAULT_RATE_ID'); + }); + + it('returns the policy default rate as a fallback when no rates are eligible for the expense date', () => { + const boundedOnlyPolicy: Policy = { + ...DATE_BOUND_POLICY, + customUnits: { + C9031B6F4725D: { + ...DATE_BOUND_POLICY.customUnits!.C9031B6F4725D, + rates: { + DEFAULT_RATE_ID: { + attributes: {}, + currency: 'USD', + customUnitRateID: 'DEFAULT_RATE_ID', + enabled: true, + name: 'Default Rate', + rate: 67, + subRates: [], + index: 0, + startDate: '2025-01-01', + endDate: '2025-12-31', + }, + RATE_2026_ID: { + attributes: {}, + currency: 'USD', + customUnitRateID: 'RATE_2026_ID', + enabled: true, + name: '2026 Rate', + rate: 75, + subRates: [], + index: 1, + startDate: '2026-01-01', + endDate: '2026-12-31', + }, + }, + }, + }, + }; + + const result = DistanceRequestUtils.getCustomUnitRateID({ + reportID: '1234', + isPolicyExpenseChat: true, + policy: boundedOnlyPolicy, + lastSelectedDistanceRates: undefined, + expenseDate: '2024-06-01', + }); + + expect(result).toBe('DEFAULT_RATE_ID'); + }); }); describe('getDistanceForDisplay', () => { From 898079cc53bfa9a73cf7977a6ec27ac9420619ed Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 9 Jun 2026 20:32:19 +0530 Subject: [PATCH 11/12] update comment. Signed-off-by: krishna2323 --- src/libs/DistanceRequestUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index a8b8d0cdd5ad..f35ceaa210c4 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -479,7 +479,7 @@ function getCustomUnitRateID({ const mileageRates = getMileageRates(policy); if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) { const lastSelectedMileageRate = mileageRates[lastSelectedDistanceRateID]; - // If the last selected rate is missing from mileageRates we cannot verify date eligibility, so keep using the last selected ID. + // mileageRates may be empty when the distance unit has no attributes. Guard against undefined before calling isRateEligibleForDate, and preserve the user's last selected ID when rate metadata is unavailable. if (!lastSelectedMileageRate || isRateEligibleForDate(lastSelectedMileageRate, expenseDate)) { return lastSelectedDistanceRateID; } From 4caf62f0605b028a05894c244ecccbdb6d102d9c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 9 Jun 2026 21:27:55 +0530 Subject: [PATCH 12/12] Fix ESLint errors in DistanceRequestUtils and tests Signed-off-by: krishna2323 --- src/libs/DistanceRequestUtils.ts | 16 ++++++++++++---- tests/unit/DistanceRequestUtilsTest.ts | 23 +++++++++++++---------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index f35ceaa210c4..b08017bf6e62 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -372,6 +372,14 @@ function getBoundednessScore(rate: MileageRate): number { return 0; } +function getFullyBoundedDateRangeMs(rate: MileageRate): number | undefined { + if (!rate.startDate || !rate.endDate) { + return undefined; + } + + return new Date(rate.endDate).getTime() - new Date(rate.startDate).getTime(); +} + /** * Finds the best eligible rate for a given expense date from a set of mileage rates. * Selection order per design doc: @@ -395,9 +403,9 @@ function getBestEligibleRate(mileageRates: Record, expenseD } if (aScore === 2 && bScore === 2) { - const aRange = new Date(a.endDate!).getTime() - new Date(a.startDate!).getTime(); - const bRange = new Date(b.endDate!).getTime() - new Date(b.startDate!).getTime(); - if (aRange !== bRange) { + const aRange = getFullyBoundedDateRangeMs(a); + const bRange = getFullyBoundedDateRangeMs(b); + if (aRange !== undefined && bRange !== undefined && aRange !== bRange) { return aRange - bRange; } } @@ -447,7 +455,7 @@ function getCustomUnitRateID({ isTrackDistanceExpense?: boolean; expenseDate?: string; }): string { - let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; + const customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; if (!reportID) { return customUnitRateID; diff --git a/tests/unit/DistanceRequestUtilsTest.ts b/tests/unit/DistanceRequestUtilsTest.ts index 623fceeb5c6d..94c66a128906 100644 --- a/tests/unit/DistanceRequestUtilsTest.ts +++ b/tests/unit/DistanceRequestUtilsTest.ts @@ -11,6 +11,16 @@ const totalDistance = 1000; const taxClaimablePercentage = 0.5; const distanceUnit: Unit = CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; const customUnitRateIDWithOutTaxClaimablePercentage = 'EB515052039A4'; +const distanceCustomUnitBase = { + attributes: { + taxEnabled: true, + unit: distanceUnit, + }, + customUnitID: 'C9031B6F4725D', + defaultCategory: '', + enabled: true, + name: 'Distance', +}; const FAKE_POLICY: Policy = { id: 'CEEEDB0EC660F71A', name: 'Test', @@ -21,14 +31,7 @@ const FAKE_POLICY: Policy = { isPolicyExpenseChatEnabled: true, customUnits: { C9031B6F4725D: { - attributes: { - taxEnabled: true, - unit: distanceUnit, - }, - customUnitID: 'C9031B6F4725D', - defaultCategory: '', - enabled: true, - name: 'Distance', + ...distanceCustomUnitBase, rates: { // eslint-disable-next-line @typescript-eslint/naming-convention '222AAF6B93BCB': { @@ -93,7 +96,7 @@ const DATE_BOUND_POLICY: Policy = { ...FAKE_POLICY, customUnits: { C9031B6F4725D: { - ...FAKE_POLICY.customUnits!.C9031B6F4725D, + ...distanceCustomUnitBase, rates: { DEFAULT_RATE_ID: { attributes: {}, @@ -319,7 +322,7 @@ describe('DistanceRequestUtils', () => { ...DATE_BOUND_POLICY, customUnits: { C9031B6F4725D: { - ...DATE_BOUND_POLICY.customUnits!.C9031B6F4725D, + ...distanceCustomUnitBase, rates: { DEFAULT_RATE_ID: { attributes: {},