Skip to content

Commit ff3909e

Browse files
authored
Merge pull request #92578 from Krishna2323/krishna/89834-date-aware-mileage-rate-selection
feat: make distance request creation choose date-aware mileage rates
2 parents 0f5f69c + 4caf62f commit ff3909e

6 files changed

Lines changed: 360 additions & 21 deletions

File tree

src/hooks/useParticipantSubmission.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ function useParticipantSubmission({
185185
policy: movingPolicy,
186186
isPolicyExpenseChat: false,
187187
lastSelectedDistanceRates: distanceRates,
188+
expenseDate: transaction.created,
188189
});
189190
setCustomUnitRateID(transaction.transactionID, rateID, transaction, movingPolicy);
190191
const shouldSetParticipantAutoAssignment = iouType === CONST.IOU.TYPE.CREATE;
@@ -238,14 +239,25 @@ function useParticipantSubmission({
238239
if (!isMovingTransactionFromTrackExpense || !isPolicyExpenseChat) {
239240
// If not moving the transaction from track expense, select the default rate automatically.
240241
// Otherwise, keep the original p2p rate and let the user manually change it to the one they want from the workspace.
241-
const rateID = DistanceRequestUtils.getCustomUnitRateID({reportID: firstParticipantReportID, isPolicyExpenseChat, policy, lastSelectedDistanceRates: distanceRates});
242-
243242
if (drafts.length > 0) {
244243
for (const transaction of drafts) {
244+
const rateID = DistanceRequestUtils.getCustomUnitRateID({
245+
reportID: firstParticipantReportID,
246+
isPolicyExpenseChat,
247+
policy,
248+
lastSelectedDistanceRates: distanceRates,
249+
expenseDate: transaction.created,
250+
});
245251
setCustomUnitRateID(transaction.transactionID, rateID, transaction, policy);
246252
}
247253
} else {
248254
// Fallback to using initialTransactionID directly
255+
const rateID = DistanceRequestUtils.getCustomUnitRateID({
256+
reportID: firstParticipantReportID,
257+
isPolicyExpenseChat,
258+
policy,
259+
lastSelectedDistanceRates: distanceRates,
260+
});
249261
setCustomUnitRateID(initialTransactionID, rateID, undefined, policy);
250262
}
251263
}

src/libs/DistanceRequestUtils.ts

Lines changed: 127 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type MileageRate = {
2020
name?: string;
2121
enabled?: boolean;
2222
index?: number;
23+
startDate?: string | null;
24+
endDate?: string | null;
2325
};
2426

2527
/** @private Only for getRate function */
@@ -63,6 +65,8 @@ function getMileageRates(policy: OnyxInputOrEntry<Policy>, includeDisabledRates
6365
customUnitRateID: rate.customUnitRateID,
6466
enabled: rate.enabled,
6567
index: rate.index,
68+
startDate: rate.startDate,
69+
endDate: rate.endDate,
6670
};
6771
}
6872

@@ -341,22 +345,117 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number {
341345
}
342346

343347
/**
344-
* Returns custom unit rate ID for the distance transaction
348+
* Checks if a mileage rate is eligible for a given expense date.
349+
* A rate is eligible if the date falls within its startDate/endDate bounds (inclusive).
350+
* Missing bounds mean unbounded in that direction.
351+
*/
352+
function isRateEligibleForDate(rate: MileageRate, expenseDate: string): boolean {
353+
if (rate.startDate && expenseDate < rate.startDate) {
354+
return false;
355+
}
356+
if (rate.endDate && expenseDate > rate.endDate) {
357+
return false;
358+
}
359+
return true;
360+
}
361+
362+
/**
363+
* Returns a boundedness score: 2 = fully bounded (both dates), 1 = partially bounded (one date), 0 = unbounded.
364+
*/
365+
function getBoundednessScore(rate: MileageRate): number {
366+
if (rate.startDate && rate.endDate) {
367+
return 2;
368+
}
369+
if (rate.startDate || rate.endDate) {
370+
return 1;
371+
}
372+
return 0;
373+
}
374+
375+
function getFullyBoundedDateRangeMs(rate: MileageRate): number | undefined {
376+
if (!rate.startDate || !rate.endDate) {
377+
return undefined;
378+
}
379+
380+
return new Date(rate.endDate).getTime() - new Date(rate.startDate).getTime();
381+
}
382+
383+
/**
384+
* Finds the best eligible rate for a given expense date from a set of mileage rates.
385+
* Selection order per design doc:
386+
* 1. Most specific date range (fully bounded > partially bounded > unbounded)
387+
* 2. Narrower date range for two fully bounded ranges
388+
* 3. Latest start date
389+
* 4. Lowest index (creation order)
390+
*/
391+
function getBestEligibleRate(mileageRates: Record<string, MileageRate>, expenseDate: string): MileageRate | undefined {
392+
const eligibleRates = Object.values(mileageRates).filter((rate) => rate.enabled !== false && isRateEligibleForDate(rate, expenseDate));
393+
394+
if (eligibleRates.length === 0) {
395+
return undefined;
396+
}
397+
398+
eligibleRates.sort((a, b) => {
399+
const aScore = getBoundednessScore(a);
400+
const bScore = getBoundednessScore(b);
401+
if (aScore !== bScore) {
402+
return bScore - aScore;
403+
}
404+
405+
if (aScore === 2 && bScore === 2) {
406+
const aRange = getFullyBoundedDateRangeMs(a);
407+
const bRange = getFullyBoundedDateRangeMs(b);
408+
if (aRange !== undefined && bRange !== undefined && aRange !== bRange) {
409+
return aRange - bRange;
410+
}
411+
}
412+
413+
const aStart = a.startDate ?? '';
414+
const bStart = b.startDate ?? '';
415+
if (aStart !== bStart) {
416+
return aStart < bStart ? 1 : -1;
417+
}
418+
419+
const aIndex = a.index ?? CONST.DEFAULT_NUMBER_ID;
420+
const bIndex = b.index ?? CONST.DEFAULT_NUMBER_ID;
421+
return aIndex - bIndex;
422+
});
423+
424+
return eligibleRates.at(0);
425+
}
426+
427+
function getBestEligibleRateOrPolicyDefault(mileageRates: Record<string, MileageRate>, expenseDate: string, policy: OnyxEntry<Policy>): MileageRate | undefined {
428+
const bestRate = getBestEligibleRate(mileageRates, expenseDate);
429+
if (bestRate) {
430+
return bestRate;
431+
}
432+
433+
return getDefaultMileageRate(policy);
434+
}
435+
436+
/**
437+
* Returns custom unit rate ID for the distance transaction.
438+
* When an expenseDate is provided, uses date-aware rate selection:
439+
* 1. Last selected rate, if enabled and valid for the expense date
440+
* 2. Best eligible rate for the expense date
441+
* 3. Default rate fallback
345442
*/
346443
function getCustomUnitRateID({
347444
reportID,
348445
isPolicyExpenseChat,
349446
policy,
350447
isTrackDistanceExpense = false,
351448
lastSelectedDistanceRates,
449+
expenseDate,
352450
}: {
353451
reportID: string | undefined;
354452
isPolicyExpenseChat: boolean;
355453
policy: OnyxEntry<Policy> | undefined;
356454
lastSelectedDistanceRates?: OnyxEntry<LastSelectedDistanceRates>;
357455
isTrackDistanceExpense?: boolean;
456+
expenseDate?: string;
358457
}): string {
359-
let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID;
458+
const customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID;
360459

361460
if (!reportID) {
362461
return customUnitRateID;
@@ -371,14 +470,32 @@ function getCustomUnitRateID({
371470
const distanceUnit = Object.values(policy.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
372471
const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy.id];
373472
const lastSelectedDistanceRate = lastSelectedDistanceRateID ? distanceUnit?.rates[lastSelectedDistanceRateID] : undefined;
374-
if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) {
375-
customUnitRateID = lastSelectedDistanceRateID;
376-
} else {
473+
474+
if (!expenseDate) {
475+
if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) {
476+
return lastSelectedDistanceRateID;
477+
}
478+
377479
const defaultMileageRate = getDefaultMileageRate(policy);
378-
if (!defaultMileageRate?.customUnitRateID) {
379-
return customUnitRateID;
480+
if (defaultMileageRate?.customUnitRateID) {
481+
return defaultMileageRate.customUnitRateID;
380482
}
381-
customUnitRateID = defaultMileageRate.customUnitRateID;
483+
484+
return customUnitRateID;
485+
}
486+
487+
const mileageRates = getMileageRates(policy);
488+
if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) {
489+
const lastSelectedMileageRate = mileageRates[lastSelectedDistanceRateID];
490+
// 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.
491+
if (!lastSelectedMileageRate || isRateEligibleForDate(lastSelectedMileageRate, expenseDate)) {
492+
return lastSelectedDistanceRateID;
493+
}
494+
}
495+
496+
const bestRate = getBestEligibleRateOrPolicyDefault(mileageRates, expenseDate, policy);
497+
if (bestRate?.customUnitRateID) {
498+
return bestRate.customUnitRateID;
382499
}
383500
}
384501

@@ -544,6 +661,8 @@ export default {
544661
isDistanceAmountWithinLimit,
545662
normalizeOdometerText,
546663
prepareTextForDisplay,
664+
isRateEligibleForDate,
665+
getBestEligibleRate,
547666
};
548667

549668
export type {MileageRate};

src/libs/actions/IOU/MoneyRequest.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,14 @@ function initMoneyRequest({
310310
) {
311311
if (!isFromGlobalCreate) {
312312
const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report) || isPolicyExpenseChatReportUtil(parentReport);
313-
const customUnitRateID = DistanceRequestUtils.getCustomUnitRateID({reportID, isPolicyExpenseChat, isTrackDistanceExpense, policy, lastSelectedDistanceRates});
313+
const customUnitRateID = DistanceRequestUtils.getCustomUnitRateID({
314+
reportID,
315+
isPolicyExpenseChat,
316+
isTrackDistanceExpense,
317+
policy,
318+
lastSelectedDistanceRates,
319+
expenseDate: created,
320+
});
314321
comment.customUnit = {customUnitRateID, name: CONST.CUSTOM_UNITS.NAME_DISTANCE};
315322
} else if (hasOnlyPersonalPolicies) {
316323
comment.customUnit = {customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, name: CONST.CUSTOM_UNITS.NAME_DISTANCE};

src/pages/iou/request/step/IOURequestStepDate.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@ import useNetwork from '@hooks/useNetwork';
1313
import useOnyx from '@hooks/useOnyx';
1414
import usePermissions from '@hooks/usePermissions';
1515
import usePolicy from '@hooks/usePolicy';
16+
import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses';
1617
import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure';
1718
import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep';
1819
import useThemeStyles from '@hooks/useThemeStyles';
20+
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
1921
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
2022
import {shouldUseTransactionDraft} from '@libs/IOUUtils';
2123
import Navigation from '@libs/Navigation/Navigation';
22-
import {getFormattedCreated, hasReceipt} from '@libs/TransactionUtils';
23-
import {setMoneyRequestCreated} from '@userActions/IOU/MoneyRequest';
24+
import {isPolicyExpenseChat as isPolicyExpenseChatReportUtil} from '@libs/ReportUtils';
25+
import {getFormattedCreated, hasReceipt, isDistanceRequest} from '@libs/TransactionUtils';
26+
import {setCustomUnitRateID, setMoneyRequestCreated} from '@userActions/IOU/MoneyRequest';
2427
import {setDraftSplitTransaction} from '@userActions/IOU/Split';
2528
import {updateMoneyRequestDate} from '@userActions/IOU/UpdateMoneyRequest';
2629
import CONST from '@src/CONST';
@@ -51,6 +54,9 @@ function IOURequestStepDate({
5154
const styles = useThemeStyles();
5255
const {translate} = useLocalize();
5356
const policy = usePolicy(report?.policyID);
57+
const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK;
58+
const {policyForMovingExpensesID} = usePolicyForMovingExpenses();
59+
const policyForTrackExpense = usePolicy(isTrackExpense ? policyForMovingExpensesID : undefined);
5460
const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactionID ? [transactionID] : []);
5561
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`);
5662
const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`);
@@ -63,6 +69,7 @@ function IOURequestStepDate({
6369
const {isBetaEnabled} = usePermissions();
6470
const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT);
6571
const {isOffline} = useNetwork();
72+
const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES);
6673
const isEditing = action === CONST.IOU.ACTION.EDIT;
6774
const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT;
6875
const isSplitExpense = iouType === CONST.IOU.TYPE.SPLIT_EXPENSE;
@@ -115,6 +122,20 @@ function IOURequestStepDate({
115122
});
116123
} else {
117124
setMoneyRequestCreated(transactionID, newCreated, isTransactionDraft, hasReceipt(transaction));
125+
126+
const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report);
127+
if (isDistanceRequest(transaction) && (isPolicyExpenseChat || isTrackExpense)) {
128+
const effectivePolicy = isTrackExpense ? policyForTrackExpense : policy;
129+
const rateID = DistanceRequestUtils.getCustomUnitRateID({
130+
reportID,
131+
isPolicyExpenseChat,
132+
policy: effectivePolicy,
133+
lastSelectedDistanceRates,
134+
isTrackDistanceExpense: isTrackExpense,
135+
expenseDate: newCreated,
136+
});
137+
setCustomUnitRateID(transactionID, rateID, transaction, effectivePolicy);
138+
}
118139
}
119140

120141
navigateBack();

src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ function handleMoneyRequestStepDistanceNavigation({
294294
isTrackDistanceExpense: true,
295295
policy: policyForMovingExpenses,
296296
isPolicyExpenseChat: false,
297+
expenseDate: transaction?.created,
297298
}),
298299
attendees: transaction?.comment?.attendees,
299300
gpsCoordinates,
@@ -366,6 +367,7 @@ function handleMoneyRequestStepDistanceNavigation({
366367
isPolicyExpenseChat,
367368
policy,
368369
lastSelectedDistanceRates,
370+
expenseDate: transaction?.created,
369371
}),
370372
splitShares: transaction?.splitShares,
371373
attendees: transaction?.comment?.attendees,
@@ -435,6 +437,7 @@ function handleMoneyRequestStepDistanceNavigation({
435437
policy: isSelfDMReport ? policyForMovingExpenses : defaultExpensePolicy,
436438
lastSelectedDistanceRates,
437439
isTrackDistanceExpense: isSelfDMReport,
440+
expenseDate: transaction?.created,
438441
});
439442

440443
setTransactionReport(transactionID, {reportID: transactionReportID}, true);

0 commit comments

Comments
 (0)