diff --git a/src/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts index fe1dd0ec9fc6..63b59d7e8759 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,25 @@ 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..b08017bf6e62 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 | null; + endDate?: string | null; }; /** @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, + endDate: rate.endDate, }; } @@ -341,7 +345,100 @@ 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 a boundedness score: 2 = fully bounded (both dates), 1 = partially bounded (one date), 0 = unbounded. + */ +function getBoundednessScore(rate: MileageRate): number { + if (rate.startDate && rate.endDate) { + return 2; + } + if (rate.startDate || rate.endDate) { + return 1; + } + 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: + * 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) + */ +function getBestEligibleRate(mileageRates: Record, expenseDate: string): MileageRate | undefined { + const eligibleRates = Object.values(mileageRates).filter((rate) => rate.enabled !== false && isRateEligibleForDate(rate, expenseDate)); + + if (eligibleRates.length === 0) { + return undefined; + } + + eligibleRates.sort((a, b) => { + const aScore = getBoundednessScore(a); + const bScore = getBoundednessScore(b); + if (aScore !== bScore) { + return bScore - aScore; + } + + if (aScore === 2 && bScore === 2) { + const aRange = getFullyBoundedDateRangeMs(a); + const bRange = getFullyBoundedDateRangeMs(b); + if (aRange !== undefined && bRange !== undefined && 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); +} + +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: + * 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,14 +446,16 @@ 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; + const customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; if (!reportID) { return customUnitRateID; @@ -371,14 +470,32 @@ function getCustomUnitRateID({ 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) { - customUnitRateID = lastSelectedDistanceRateID; - } else { + + if (!expenseDate) { + if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) { + return lastSelectedDistanceRateID; + } + const defaultMileageRate = getDefaultMileageRate(policy); - if (!defaultMileageRate?.customUnitRateID) { - return customUnitRateID; + if (defaultMileageRate?.customUnitRateID) { + return defaultMileageRate.customUnitRateID; } - customUnitRateID = defaultMileageRate.customUnitRateID; + + return customUnitRateID; + } + + const mileageRates = getMileageRates(policy); + if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) { + const lastSelectedMileageRate = mileageRates[lastSelectedDistanceRateID]; + // 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; + } + } + + const bestRate = getBestEligibleRateOrPolicyDefault(mileageRates, expenseDate, policy); + if (bestRate?.customUnitRateID) { + return bestRate.customUnitRateID; } } @@ -544,6 +661,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 7c61bb9dd4fe..27a1fcebba6a 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -310,7 +310,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/IOURequestStepDate.tsx b/src/pages/iou/request/step/IOURequestStepDate.tsx index 8cb3b493dd83..ff0606010102 100644 --- a/src/pages/iou/request/step/IOURequestStepDate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDate.tsx @@ -13,14 +13,17 @@ 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'; +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'; @@ -51,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}`); @@ -63,6 +69,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 +122,20 @@ function IOURequestStepDate({ }); } else { setMoneyRequestCreated(transactionID, newCreated, isTransactionDraft, hasReceipt(transaction)); + + const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report); + if (isDistanceRequest(transaction) && (isPolicyExpenseChat || isTrackExpense)) { + const effectivePolicy = isTrackExpense ? policyForTrackExpense : policy; + const rateID = DistanceRequestUtils.getCustomUnitRateID({ + reportID, + isPolicyExpenseChat, + policy: effectivePolicy, + lastSelectedDistanceRates, + isTrackDistanceExpense: isTrackExpense, + expenseDate: newCreated, + }); + setCustomUnitRateID(transactionID, rateID, transaction, effectivePolicy); + } } navigateBack(); diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index e1705ee3aa57..c7b8c9920e12 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -294,6 +294,7 @@ function handleMoneyRequestStepDistanceNavigation({ isTrackDistanceExpense: true, policy: policyForMovingExpenses, isPolicyExpenseChat: false, + expenseDate: transaction?.created, }), attendees: transaction?.comment?.attendees, gpsCoordinates, @@ -366,6 +367,7 @@ function handleMoneyRequestStepDistanceNavigation({ isPolicyExpenseChat, policy, lastSelectedDistanceRates, + expenseDate: transaction?.created, }), splitShares: transaction?.splitShares, attendees: transaction?.comment?.attendees, @@ -435,6 +437,7 @@ function handleMoneyRequestStepDistanceNavigation({ policy: isSelfDMReport ? policyForMovingExpenses : defaultExpensePolicy, lastSelectedDistanceRates, isTrackDistanceExpense: isSelfDMReport, + expenseDate: transaction?.created, }); setTransactionReport(transactionID, {reportID: transactionReportID}, true); diff --git a/tests/unit/DistanceRequestUtilsTest.ts b/tests/unit/DistanceRequestUtilsTest.ts index 6a637d0649fa..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': { @@ -89,6 +92,63 @@ const FAKE_POLICY: Policy = { }, }; +const DATE_BOUND_POLICY: Policy = { + ...FAKE_POLICY, + customUnits: { + C9031B6F4725D: { + ...distanceCustomUnitBase, + 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 +246,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: { + ...distanceCustomUnitBase, + 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', () => {