Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0d71025
fix: use exact route distance on rate change
fedirjh Aug 11, 2026
b65e71d
test: cover exact commuter unit conversion
fedirjh Aug 11, 2026
89de934
fix: preserve manual distance on rate change
fedirjh Aug 11, 2026
b1fc312
test: cover manual distance rate change
fedirjh Aug 11, 2026
22418b0
test: cover cross-policy rate conversion
fedirjh Aug 11, 2026
7634441
Merge branch 'Expensify:main' into fix/98231-commuter-rate-flicker
fedirjh Aug 11, 2026
7923c5a
chore: Apply suggestions from code review
fedirjh Aug 11, 2026
fcc513f
fix: reuse exact route distance guard
fedirjh Aug 11, 2026
3123fba
test: cover legacy distance conversion
fedirjh Aug 11, 2026
f9fcbc0
Merge branch 'Expensify:main' into fix/98231-commuter-rate-flicker
fedirjh Aug 21, 2026
aa815ea
Rename manual distance edit variable
fedirjh Aug 21, 2026
eb53a58
Merge remote-tracking branch 'upstream' into fix/98231-commuter-rate-…
fedirjh Aug 26, 2026
b0ea726
Merge remote-tracking branch 'upstream' into fix/98231-commuter-rate-…
fedirjh Aug 26, 2026
581dc34
test: store the commuter exclusion the fixture describes
fedirjh Aug 26, 2026
5f1a488
Merge branch 'Expensify:main' into fix/98231-commuter-rate-flicker
fedirjh Sep 2, 2026
68e16f7
Merge remote-tracking branch 'upstream' into fix/98231-commuter-rate-…
fedirjh Sep 2, 2026
87ead16
chore: fix lint
fedirjh Sep 2, 2026
93706b5
Fix commuter exclusion test currency symbols
fedirjh Sep 2, 2026
cf7d4f1
Limit distance tolerance to commuter exclusions
fedirjh Sep 2, 2026
d451dc0
Merge branch 'Expensify:main' into fix/98231-commuter-rate-flicker
fedirjh Sep 7, 2026
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
23 changes: 21 additions & 2 deletions src/libs/TransactionUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,20 @@ function getUpdatedTransaction({
shouldStopSmartscan = true;

const existingDistanceUnit = transaction?.comment?.customUnit?.distanceUnit;
const routeDistanceMeters = transaction?.comment?.customUnit?.routeDistanceMeters;
const quantity = transaction?.comment?.customUnit?.quantity;
const hasCommuterExclusion = hasAppliedCommuterExclusion(transaction);
// For transactions with an applied commuter exclusion, `quantity` is the route distance rounded
// to 2dp, so it differs from the exact conversion by at most 0.005. A gap larger than this rounding
// tolerance means the user manually edited the distance, so we must convert their quantity instead.
const ROUNDING_TOLERANCE = 0.01;
const isDistanceManuallyEdited =
hasCommuterExclusion &&
typeof routeDistanceMeters === 'number' &&
typeof quantity === 'number' &&
!!existingDistanceUnit &&
Math.abs(quantity - DistanceRequestUtils.convertDistanceUnit(routeDistanceMeters, existingDistanceUnit)) > ROUNDING_TOLERANCE;
const shouldUseExactRouteDistance = hasCommuterExclusion && typeof routeDistanceMeters === 'number' && !isDistanceManuallyEdited;

// Get the new distance unit from the rate's unit
const newDistanceUnit = DistanceRequestUtils.getUpdatedDistanceUnit({transaction: updatedTransaction, policy});
Expand All @@ -929,7 +943,9 @@ function getUpdatedTransaction({
// Skip conversion for odometer transactions — odometer readings are physical car readings and should be retained as-is.
if (existingDistanceUnit && newDistanceUnit !== existingDistanceUnit && !isOdometerDistanceRequest(transaction)) {
const conversionFactor = existingDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS : CONST.CUSTOM_UNITS.KILOMETERS_TO_MILES;
const distance = roundToTwoDecimalPlaces((transaction?.comment?.customUnit?.quantity ?? 0) * conversionFactor);
const distance = roundToTwoDecimalPlaces(
shouldUseExactRouteDistance ? DistanceRequestUtils.convertDistanceUnit(routeDistanceMeters, newDistanceUnit) : (quantity ?? 0) * conversionFactor,
);
lodashSet(updatedTransaction, 'comment.customUnit.quantity', distance);
}

Expand All @@ -952,7 +968,10 @@ function getUpdatedTransaction({
const fallbackConversionFactor =
newDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS : CONST.CUSTOM_UNITS.KILOMETERS_TO_MILES;
const currentQuantity = updatedTransaction?.comment?.customUnit?.quantity ?? 0;
lodashSet(updatedTransaction, 'comment.customUnit.quantity', roundToTwoDecimalPlaces(currentQuantity * fallbackConversionFactor));
const distance = shouldUseExactRouteDistance
? DistanceRequestUtils.convertDistanceUnit(routeDistanceMeters, rateFromAnyPolicy.unit)
: currentQuantity * fallbackConversionFactor;
lodashSet(updatedTransaction, 'comment.customUnit.quantity', roundToTwoDecimalPlaces(distance));
}
}
}
Expand Down
110 changes: 110 additions & 0 deletions tests/unit/TransactionUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,116 @@ describe('TransactionUtils', () => {
expect(updatedTransaction.modifiedMerchant).not.toContain('20');
});

it('converts commuter exclusion data when the distance rate unit is changed', () => {
// Given a policy with a 3 mile fixed distance commuter exclusion and a kilometer rate
const fakePolicy: Policy = {
...createRandomPolicy(0),
commuterExclusions: {
method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE,
fixedDistance: 3,
fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
},
customUnits: {
distance: {
name: CONST.CUSTOM_UNITS.NAME_DISTANCE,
customUnitID: 'distance',
rates: {
// getMileageRates keys its result by the rates map key, so it must match customUnitRateID
ID1: {
customUnitRateID: '1',
currency: CONST.CURRENCY.EUR,
rate: 10,
},
ID2: {
customUnitRateID: '2',
currency: CONST.CURRENCY.EUR,
rate: 30,
},
},
attributes: {
unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS,
},
},
},
};

// And a 10 km route stored as a rounded mile quantity
const transaction = generateTransaction({
iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP,
comment: {
customUnit: {
customUnitRateID: 'ID1',
distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
quantity: 6.21,
routeDistanceMeters: 10000,
commuterExclusion: 3,
reimbursableDistance: 3.21,
commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE,
},
},
currency: CONST.CURRENCY.USD,
});

const updateRate = (transactionToUpdate: Transaction, policy: Policy | undefined, policies?: OnyxCollection<Policy>) =>
TransactionUtils.getUpdatedTransaction({
transaction: transactionToUpdate,
isFromExpenseReport: false,
policy,
policies,
transactionChanges: {customUnitRateID: 'ID2'},
personalPolicyOutputCurrency: undefined,
getCurrencyDecimals: getCurrencyDecimalsLocal,
getCurrencySymbol: getCurrencySymbolLocal,
});

// When the rate is changed
const updatedTransaction = updateRate(transaction, fakePolicy);

// Then the original distance and commuter exclusion are converted to kilometers
expect(updatedTransaction.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS);
expect(updatedTransaction.comment?.customUnit?.quantity).toBe(10);
expect(updatedTransaction.comment?.customUnit?.commuterExclusion).toBeCloseTo(4.83);
expect(updatedTransaction.comment?.customUnit?.reimbursableDistance).toBeCloseTo(5.17);

// And the amount and merchant use the converted reimbursable distance at the kilometer rate
expect(updatedTransaction.modifiedAmount).toBe(155);
expect(updatedTransaction.modifiedMerchant).toBe('5.17 km @ €0.30 / km');

const manuallyOverriddenTransaction = {
...transaction,
comment: {customUnit: {...transaction.comment?.customUnit, quantity: 8}},
};
const updatedManuallyOverriddenTransaction = updateRate(manuallyOverriddenTransaction, fakePolicy);

expect(updatedManuallyOverriddenTransaction.comment?.customUnit?.quantity).toBe(12.87);
expect(updatedManuallyOverriddenTransaction.comment?.customUnit?.reimbursableDistance).toBeCloseTo(8.04);
expect(updatedManuallyOverriddenTransaction.modifiedAmount).toBe(241);
expect(updatedManuallyOverriddenTransaction.modifiedMerchant).toBe('8.04 km @ €0.30 / km');

const transactionWithoutAppliedCommuterExclusion = {
...transaction,
comment: {
customUnit: {
...transaction.comment?.customUnit,
commuterExclusion: undefined,
reimbursableDistance: undefined,
commuterExclusionMethod: undefined,
},
},
};
expect(updateRate(transactionWithoutAppliedCommuterExclusion, fakePolicy).comment?.customUnit?.quantity).toBe(9.99);

const policies = {[`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`]: fakePolicy};
expect(updateRate(transaction, undefined, policies).comment?.customUnit?.quantity).toBe(10);
expect(updateRate(manuallyOverriddenTransaction, undefined, policies).comment?.customUnit?.quantity).toBe(12.87);
Comment thread
fedirjh marked this conversation as resolved.

const legacyTransaction = {
...transaction,
comment: {customUnit: {...transaction.comment?.customUnit, routeDistanceMeters: undefined, quantity: 10}},
};
expect(updateRate(legacyTransaction, fakePolicy).comment?.customUnit?.quantity).toBe(16.09);
});

it('threads personalPolicyOutputCurrency into the recalculated rate for a P2P distance expense with no policy', async () => {
// A P2P distance expense (FAKE_P2P_ID) has no policy rate, so the mileage currency comes from the
// resolved personal-policy currency that getUpdatedTransaction forwards to getRate. getRateForP2P only
Expand Down
Loading