Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 73 additions & 27 deletions src/libs/DistanceRequestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {getDistanceUnitLabel, getFormattedDistanceInUnits} from './DistanceDispl
import getStoredDefaultP2PMileageRate from './getStoredDefaultP2PMileageRate';
import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, getUnitRateValue} from './PolicyUtils';
import replaceAllDigits from './replaceAllDigits';
import {getCurrency, getRateID, isCustomUnitRateIDForP2P, isExpenseUnreported} from './TransactionUtils';
import {getCurrency, getFormattedCreated, getRateID, isCustomUnitRateIDForP2P, isExpenseUnreported} from './TransactionUtils';

type MileageRate = {
customUnitRateID?: string;
Expand Down Expand Up @@ -569,45 +569,51 @@ function getFullyBoundedDateRangeMs(rate: MileageRate): number | undefined {
}

/**
* Finds the best eligible rate for a given expense date from a set of mileage rates.
* Ranks two rates that are both eligible for the same expense date.
* 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 compareRatesByDateSpecificity(a: MileageRate, b: MileageRate): number {
const aScore = getBoundednessScore(a);
const bScore = getBoundednessScore(b);
if (aScore !== bScore) {
return bScore - aScore;
}

// Both scores are equal here, so aScore === 2 means both rates are fully bounded.
if (aScore === 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;
}

/**
* Finds the best eligible rate for a given expense date from a set of mileage rates.
*/
function getBestEligibleRate(mileageRates: Record<string, MileageRate>, 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;
});
eligibleRates.sort(compareRatesByDateSpecificity);

return eligibleRates.at(0);
}
Expand All @@ -621,6 +627,45 @@ function getBestEligibleRateOrPolicyDefault(mileageRates: Record<string, Mileage
return getDefaultMileageRate(policy);
}

/**
* Finds the rate that is equivalent to the expense's current rate: same value, same currency and same distance unit, and valid for the expense date.
* The unit is taken from the policy's distance custom unit, so a unit mismatch disqualifies every rate on that policy.
*/
function getRateMatchingCurrentRate(mileageRates: Record<string, MileageRate>, currentRate: MileageRate | undefined, expenseDate: string): MileageRate | undefined {
if (currentRate?.rate === undefined || !currentRate.currency) {
return undefined;
}

const equivalentRates = Object.fromEntries(
Object.entries(mileageRates).filter(([, rate]) => rate.rate === currentRate.rate && rate.currency === currentRate.currency && rate.unit === currentRate.unit),
);

return getBestEligibleRate(equivalentRates, expenseDate);
}

/**
* Selects the distance rate for an expense moving to a different workspace: an equivalent rate on the destination policy, else its
* best rate for the expense date, else its default rate, else nothing so the caller keeps the `customUnitOutOfPolicy` violation.
*
* `currentRate` resolves against the source policy, which this module cannot look up, so callers pass it in. It is optional because
* a P2P expense carries its rate on the transaction.
*
* Let's ensure this logic is consistent with the logic in the backend (Auth), which is authoritative here.
*/
Comment on lines +646 to +654

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MelvinBot Shorten this comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shortened in 40500a2 — 14 lines down to 9. Collapsed the numbered selection order into one sentence and dropped the "only to build optimistic data" clause, which the getRate cross-reference already implies.

/**
 * Selects the distance rate for an expense moving to a different workspace: an equivalent rate on the destination policy, else its
 * best rate for the expense date, else its default rate, else nothing so the caller keeps the `customUnitOutOfPolicy` violation.
 *
 * `currentRate` resolves against the source policy, which this module cannot look up, so callers pass it in. It is optional because
 * a P2P expense carries its rate on the transaction.
 *
 * Let's ensure this logic is consistent with the logic in the backend (Auth), which is authoritative here.
 */

Comment-only change. typecheck, lint, oxfmt, spell-changed, and DistanceRequestUtilsTest (63 passed) all pass.

function getRateForPolicyChange({transaction, policy, currentRate}: {transaction: OnyxEntry<Transaction>; policy: OnyxEntry<Policy>; currentRate?: MileageRate}): MileageRate | undefined {
Comment thread
neil-marcellini marked this conversation as resolved.
const expenseDate = getFormattedCreated(transaction);
const mileageRates = getMileageRates(policy);
const p2pRate = isCustomUnitRateIDForP2P(transaction) ? getRateForP2P(getCurrency(transaction), transaction) : undefined;
// getRateForP2P reports the loaded global default's unit, not the expense's, so read the unit off the transaction the way getRate does.
// Otherwise an expense saved in kilometers is matched as miles and the fallback rate reprices it.
const rateToMatch = currentRate ?? (p2pRate ? {...p2pRate, unit: getDistanceUnit(transaction, p2pRate)} : undefined);

const selectedRate = getRateMatchingCurrentRate(mileageRates, rateToMatch, expenseDate) ?? getBestEligibleRateOrPolicyDefault(mileageRates, expenseDate, policy);

// getDefaultMileageRate returns a fully shaped rate with an undefined customUnitRateID when the policy has no enabled rates, so normalize that case to undefined.
return selectedRate?.customUnitRateID ? selectedRate : undefined;
}

/**
* Returns custom unit rate ID for the distance transaction.
* When an expenseDate is provided, uses date-aware rate selection:
Expand Down Expand Up @@ -933,6 +978,7 @@ export default {
isRateEligibleForDate,
isUnsetDistanceCustomUnitRateID,
getBestEligibleRate,
getRateForPolicyChange,
getRateDateLabel,
};

Expand Down
224 changes: 223 additions & 1 deletion tests/unit/DistanceRequestUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import type {LocaleContextProps} from '@components/LocaleContextProvider';

import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import getStoredDefaultP2PMileageRate from '@libs/getStoredDefaultP2PMileageRate';

import CONST from '@src/CONST';
import en from '@src/languages/en';
import type {Unit} from '@src/types/onyx/Policy';
import type {Rate, Unit} from '@src/types/onyx/Policy';
import type Policy from '@src/types/onyx/Policy';
import type Transaction from '@src/types/onyx/Transaction';

import createRandomTransaction from '../utils/collections/transaction';
import {translateLocal} from '../utils/TestHelper';

// Auto-mocked so it returns undefined by default, which is the "default P2P rate not loaded yet" state the
// getRateForP2P tests below rely on. Individual tests override it when they need a loaded default.
jest.mock('@libs/getStoredDefaultP2PMileageRate');

const customUnitRateIDWithTaxClaimablePercentage = 'FG515011039A4';
const rateWithTaxClaimablePercentage = 100;
const totalDistance = 1000;
Expand Down Expand Up @@ -793,4 +798,221 @@ describe('DistanceRequestUtils', () => {
expect(DistanceRequestUtils.isRateEligibleForDate(boundedRate, '2026-01-01 00:00:00')).toBe(false);
});
});

describe('getRateForPolicyChange', () => {
const expenseDate = '2026-03-15';
const currentRate = {customUnitRateID: 'SOURCE_RATE_ID', rate: 70, currency: 'USD', unit: distanceUnit};

const buildRate = (customUnitRateID: string, rate: number, overrides: Partial<Rate> = {}): Rate => ({
attributes: {},
currency: 'USD',
customUnitRateID,
enabled: true,
name: customUnitRateID,
rate,
subRates: [],
...overrides,
});

const buildDestinationPolicy = (rates: Record<string, Rate>, unit: Unit = distanceUnit): Policy => ({
...FAKE_POLICY,
id: 'DESTINATION_POLICY_ID',
customUnits: {
C9031B6F4725D: {
...distanceCustomUnitBase,
attributes: {taxEnabled: true, unit},
rates,
},
},
});

const buildTransaction = (overrides: Partial<Transaction> = {}): Transaction =>
({
...createRandomTransaction(1),
created: expenseDate,
modifiedCreated: '',
currency: 'USD',
modifiedCurrency: '',
comment: {customUnit: {customUnitRateID: 'SOURCE_RATE_ID', distanceUnit}},
...overrides,
}) as Transaction;

const defaultRate = buildRate('DEFAULT_RATE_ID', 67, {index: 0});

afterEach(() => {
jest.mocked(getStoredDefaultP2PMileageRate).mockReset();
});

it('prefers a rate matching the current value, currency and unit over the default rate', () => {
// Given a destination policy that has a rate equivalent to the expense's current rate
const policy = buildDestinationPolicy({
DEFAULT_RATE_ID: defaultRate,
MATCHING_RATE_ID: buildRate('MATCHING_RATE_ID', 70, {index: 1}),
});

// When selecting the rate for the policy change
const result = DistanceRequestUtils.getRateForPolicyChange({transaction: buildTransaction(), policy, currentRate});

// Then the equivalent rate is chosen so the expense is not repriced
expect(result?.customUnitRateID).toBe('MATCHING_RATE_ID');
});

it('does not match a disabled rate and falls back to the default rate', () => {
// Given the only value-matching rate on the destination policy is disabled
const policy = buildDestinationPolicy({
DEFAULT_RATE_ID: defaultRate,
MATCHING_RATE_ID: buildRate('MATCHING_RATE_ID', 70, {index: 1, enabled: false}),
});

// When selecting the rate for the policy change
const result = DistanceRequestUtils.getRateForPolicyChange({transaction: buildTransaction(), policy, currentRate});

// Then the default rate is used instead
expect(result?.customUnitRateID).toBe('DEFAULT_RATE_ID');
});

it('does not match a rate with the same value in a different currency', () => {
// Given a destination rate with the same value but a different currency
const policy = buildDestinationPolicy({
DEFAULT_RATE_ID: defaultRate,
MATCHING_RATE_ID: buildRate('MATCHING_RATE_ID', 70, {index: 1, currency: 'GBP'}),
});

// When selecting the rate for the policy change
const result = DistanceRequestUtils.getRateForPolicyChange({transaction: buildTransaction(), policy, currentRate});

// Then it is not treated as a match
expect(result?.customUnitRateID).toBe('DEFAULT_RATE_ID');
});

it('does not match any rate when the destination policy uses a different distance unit', () => {
// Given a destination policy measured in kilometers while the expense is in miles
const policy = buildDestinationPolicy(
{
DEFAULT_RATE_ID: defaultRate,
MATCHING_RATE_ID: buildRate('MATCHING_RATE_ID', 70, {index: 1}),
},
CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS,
);

// When selecting the rate for the policy change
const result = DistanceRequestUtils.getRateForPolicyChange({transaction: buildTransaction(), policy, currentRate});

// Then the unit mismatch disqualifies the whole policy from matching and the default rate is used
expect(result?.customUnitRateID).toBe('DEFAULT_RATE_ID');
});

it('does not match a value-matching rate whose date range excludes the expense date', () => {
// Given a value-matching rate that expired before the expense date
const policy = buildDestinationPolicy({
DEFAULT_RATE_ID: defaultRate,
MATCHING_RATE_ID: buildRate('MATCHING_RATE_ID', 70, {index: 1, startDate: '2025-01-01', endDate: '2025-12-31'}),
});

// When selecting the rate for the policy change
const result = DistanceRequestUtils.getRateForPolicyChange({transaction: buildTransaction(), policy, currentRate});

// Then the default rate is used
expect(result?.customUnitRateID).toBe('DEFAULT_RATE_ID');
});

it('picks the narrowest date range when several rates match the current value', () => {
// Given two value-matching rates covering the expense date, one with a narrower range
const policy = buildDestinationPolicy({
DEFAULT_RATE_ID: defaultRate,
MATCHING_YEAR_RATE_ID: buildRate('MATCHING_YEAR_RATE_ID', 70, {index: 1, startDate: '2026-01-01', endDate: '2026-12-31'}),
MATCHING_H1_RATE_ID: buildRate('MATCHING_H1_RATE_ID', 70, {index: 2, startDate: '2026-01-01', endDate: '2026-06-30'}),
});

// When selecting the rate for the policy change
const result = DistanceRequestUtils.getRateForPolicyChange({transaction: buildTransaction(), policy, currentRate});

// Then the narrower range wins, matching the date ranking used everywhere else
expect(result?.customUnitRateID).toBe('MATCHING_H1_RATE_ID');
});

it('returns undefined when the destination policy has no enabled rate', () => {
// Given a destination policy whose only rate is disabled
const policy = buildDestinationPolicy({
DEFAULT_RATE_ID: buildRate('DEFAULT_RATE_ID', 67, {index: 0, enabled: false}),
});

// When selecting the rate for the policy change
const result = DistanceRequestUtils.getRateForPolicyChange({transaction: buildTransaction(), policy, currentRate});

// Then nothing is selected so the caller keeps the out of policy violation
expect(result).toBeUndefined();
});

it('derives the current rate from a P2P expense when no current rate is passed', () => {
// Given a P2P expense that stores its own rate value on the transaction
const transaction = buildTransaction({
comment: {customUnit: {customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, defaultP2PRate: 70, distanceUnit}},
});
const policy = buildDestinationPolicy({
DEFAULT_RATE_ID: defaultRate,
MATCHING_RATE_ID: buildRate('MATCHING_RATE_ID', 70, {index: 1}),
});

// When selecting the rate for the policy change without an explicit current rate
const result = DistanceRequestUtils.getRateForPolicyChange({transaction, policy});

// Then the P2P rate value is matched against the destination policy
expect(result?.customUnitRateID).toBe('MATCHING_RATE_ID');
});

it('matches a P2P expense against the unit saved on the transaction, not the loaded global default unit', () => {
// Given the global default P2P rate is loaded in miles while the expense itself was saved in kilometers
jest.mocked(getStoredDefaultP2PMileageRate).mockReturnValue({rate: 67, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES});
const transaction = buildTransaction({
comment: {customUnit: {customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, defaultP2PRate: 70, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}},
});
const policy = buildDestinationPolicy(
{
DEFAULT_RATE_ID: defaultRate,
MATCHING_RATE_ID: buildRate('MATCHING_RATE_ID', 70, {index: 1}),
},
CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS,
);

// When selecting the rate for the policy change
const result = DistanceRequestUtils.getRateForPolicyChange({transaction, policy});

// Then the kilometer rate still matches, so the expense is not repriced by the fallback rate
expect(result?.customUnitRateID).toBe('MATCHING_RATE_ID');
});

it('does not match a P2P expense against a destination policy whose unit differs from the transaction unit', () => {
// Given the global default P2P rate is loaded in miles and the destination policy is in miles, but the expense was saved in kilometers
jest.mocked(getStoredDefaultP2PMileageRate).mockReturnValue({rate: 67, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES});
const transaction = buildTransaction({
comment: {customUnit: {customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, defaultP2PRate: 70, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}},
});
const policy = buildDestinationPolicy({
DEFAULT_RATE_ID: defaultRate,
MATCHING_RATE_ID: buildRate('MATCHING_RATE_ID', 70, {index: 1}),
});

// When selecting the rate for the policy change
const result = DistanceRequestUtils.getRateForPolicyChange({transaction, policy});

// Then the unit mismatch disqualifies the match and the destination's default rate is used
expect(result?.customUnitRateID).toBe('DEFAULT_RATE_ID');
});

it('uses the modified date over the created date when checking rate eligibility', () => {
// Given an expense created in 2026 but backdated to 2025
const transaction = buildTransaction({modifiedCreated: '2025-06-15'});
const policy = buildDestinationPolicy({
DEFAULT_RATE_ID: defaultRate,
MATCHING_2025_RATE_ID: buildRate('MATCHING_2025_RATE_ID', 70, {index: 1, startDate: '2025-01-01', endDate: '2025-12-31'}),
});

// When selecting the rate for the policy change
const result = DistanceRequestUtils.getRateForPolicyChange({transaction, policy, currentRate});

// Then the rate valid for the modified date is matched
expect(result?.customUnitRateID).toBe('MATCHING_2025_RATE_ID');
});
});
});
Loading