@@ -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 */
346443function 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
549668export type { MileageRate } ;
0 commit comments