Skip to content

Commit 29083e8

Browse files
Merge pull request #84791 from Expensify/claude-loadDefaultP2PRateOnDistanceExpense
[Payment due @daledah] Load single default P2P mileage rate
2 parents 45b6e25 + c59d552 commit 29083e8

10 files changed

Lines changed: 164 additions & 687 deletions

File tree

src/CONST/index.ts

Lines changed: 0 additions & 673 deletions
Large diffs are not rendered by default.

src/ONYXKEYS.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {OnboardingCompanySize} from './libs/actions/Welcome/OnboardingFlow'
66
import type Platform from './libs/getPlatform/types';
77
import type * as FormTypes from './types/form';
88
import type * as OnyxTypes from './types/onyx';
9+
import type DefaultP2PMileageRate from './types/onyx/DefaultP2PMileageRate';
910
import type {Attendee, DistanceExpenseType, Participant} from './types/onyx/IOU';
1011
import type Onboarding from './types/onyx/Onboarding';
1112
import type {AnyOnyxUpdate} from './types/onyx/Request';
@@ -27,6 +28,9 @@ const ONYXKEYS = {
2728
* which tab is the leader, and which ones are the followers */
2829
ACTIVE_CLIENTS: 'activeClients',
2930

31+
/** Contains the default rate and unit to use for P2P distance expenses, based on the user's personal policy outputCurrency (default / report currency). */
32+
DEFAULT_P2P_MILEAGE_RATE: 'defaultP2PMileageRate',
33+
3034
/** A unique ID for the device */
3135
DEVICE_ID: 'deviceID',
3236

@@ -1440,6 +1444,7 @@ type OnyxCollectionValuesMapping = {
14401444
type OnyxValuesMapping = {
14411445
[ONYXKEYS.ACCOUNT]: OnyxTypes.Account;
14421446
[ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string;
1447+
[ONYXKEYS.DEFAULT_P2P_MILEAGE_RATE]: DefaultP2PMileageRate;
14431448

14441449
[ONYXKEYS.NVP_ONBOARDING]: Onboarding;
14451450

src/libs/API/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,6 +1296,7 @@ type WriteCommandParameters = {
12961296
};
12971297

12981298
const READ_COMMANDS = {
1299+
GET_DEFAULT_P2P_MILEAGE_RATE: 'GetDefaultP2PMileageRate',
12991300
GET_CORPAY_BANK_ACCOUNT_FIELDS: 'GetCorpayBankAccountFields',
13001301
CONNECT_POLICY_TO_QUICKBOOKS_ONLINE: 'ConnectPolicyToQuickbooksOnline',
13011302
CONNECT_POLICY_TO_XERO: 'ConnectPolicyToXero',
@@ -1401,6 +1402,7 @@ const READ_COMMANDS = {
14011402
type ReadCommand = ValueOf<typeof READ_COMMANDS>;
14021403

14031404
type ReadCommandParameters = {
1405+
[READ_COMMANDS.GET_DEFAULT_P2P_MILEAGE_RATE]: null;
14041406
[READ_COMMANDS.CONNECT_POLICY_TO_QUICKBOOKS_ONLINE]: Parameters.ConnectPolicyToAccountingIntegrationParams;
14051407
[READ_COMMANDS.CONNECT_POLICY_TO_XERO]: Parameters.ConnectPolicyToAccountingIntegrationParams;
14061408
[READ_COMMANDS.CONNECT_POLICY_TO_GUSTO]: Parameters.ConnectPolicyToGustoParams;

src/libs/DistanceRequestUtils.ts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider';
55
import CONST from '@src/CONST';
66
import ONYXKEYS from '@src/ONYXKEYS';
77
import type {LastSelectedDistanceRates, OnyxInputOrEntry, Transaction} from '@src/types/onyx';
8+
import type DefaultP2PMileageRate from '@src/types/onyx/DefaultP2PMileageRate';
89
import type {Unit} from '@src/types/onyx/Policy';
910
import type Policy from '@src/types/onyx/Policy';
1011
import {isEmptyObject} from '@src/types/utils/EmptyObject';
12+
import getStoredDefaultP2PMileageRate from './getStoredDefaultP2PMileageRate';
1113
import {replaceAllDigits} from './MoneyRequestUtils';
1214
import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, getUnitRateValue} from './PolicyUtils';
1315
import {getCurrency, getRateID, isCustomUnitRateIDForP2P, isExpenseUnreported} from './TransactionUtils';
@@ -35,6 +37,7 @@ Onyx.connectWithoutView({
3537

3638
const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters
3739
const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter
40+
const DEFAULT_P2P_RATE_CENTS_PER_MILE = 67;
3841

3942
function getMileageRates(policy: OnyxInputOrEntry<Policy>, includeDisabledRates = false, selectedRateID?: string): Record<string, MileageRate> {
4043
const mileageRates: Record<string, MileageRate> = {};
@@ -280,13 +283,6 @@ function getDistanceMerchant(
280283
return `${distanceInUnits} ${CONST.DISTANCE_MERCHANT_SEPARATOR} ${ratePerUnit}`;
281284
}
282285

283-
function ensureRateDefined(rate: number | undefined): asserts rate is number {
284-
if (rate !== undefined) {
285-
return;
286-
}
287-
throw new Error('All default P2P rates should have a rate defined');
288-
}
289-
290286
/**
291287
* Retrieves the rate and unit for a P2P distance expense for a given currency.
292288
*
@@ -296,16 +292,16 @@ function ensureRateDefined(rate: number | undefined): asserts rate is number {
296292
* @returns The rate and unit in MileageRate object.
297293
*/
298294
function getRateForP2P(currency: string, transaction: OnyxEntry<Transaction>): MileageRate {
299-
const currencyWithExistingRate = CONST.CURRENCY_TO_DEFAULT_MILEAGE_RATE[currency] ? currency : CONST.CURRENCY.USD;
300-
const mileageRate = CONST.CURRENCY_TO_DEFAULT_MILEAGE_RATE[currencyWithExistingRate];
301-
ensureRateDefined(mileageRate.rate);
295+
const defaultRate = getStoredDefaultP2PMileageRate();
296+
const p2pRate: DefaultP2PMileageRate = defaultRate ?? {rate: DEFAULT_P2P_RATE_CENTS_PER_MILE, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES};
297+
const rate = transaction && getCurrency(transaction) === currency ? (transaction.comment?.customUnit?.defaultP2PRate ?? p2pRate.rate) : p2pRate.rate;
302298

303-
// Ensure the rate is updated when the currency changes, otherwise use the stored rate
304-
const rate = getCurrency(transaction) === currency ? (transaction?.comment?.customUnit?.defaultP2PRate ?? mileageRate.rate) : mileageRate.rate;
299+
// If a distance expense is being edited, the defaultP2PRate may not have been loaded yet, so use data from the existing transaction.
300+
const fallbackUnit = transaction?.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES;
305301
return {
306-
...mileageRate,
307-
currency: currencyWithExistingRate,
308302
rate,
303+
unit: defaultRate ? p2pRate.unit : fallbackUnit,
304+
currency: defaultRate ? currency : getCurrency(transaction),
309305
};
310306
}
311307

src/libs/actions/IOU/MoneyRequest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils,
2929
} from '@libs/TransactionUtils';
3030
import type {ReceiptFile} from '@pages/iou/request/step/IOURequestStepScan/types';
31+
import {getDefaultP2PMileageRate} from '@userActions/Transaction';
3132
import {getRemoveDraftTransactionsByIDsData, removeDraftTransactionsByIDs} from '@userActions/TransactionEdit';
3233
import type {IOURequestType} from '@src/CONST';
3334
import CONST from '@src/CONST';
@@ -447,6 +448,7 @@ function startDistanceRequest(
447448
backToReport?: string,
448449
isFromFloatingActionButton?: boolean,
449450
) {
451+
getDefaultP2PMileageRate();
450452
clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, draftTransactionIDs, skipConfirmation);
451453
if (isFromFloatingActionButton) {
452454
Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, {isFromFloatingActionButton});

src/libs/actions/Transaction.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,6 +1810,10 @@ function changeTransactionsReport({
18101810
});
18111811
}
18121812

1813+
function getDefaultP2PMileageRate() {
1814+
API.read(READ_COMMANDS.GET_DEFAULT_P2P_MILEAGE_RATE, null);
1815+
}
1816+
18131817
function mergeTransactionIdsHighlightOnSearchRoute(type: SearchDataTypes, data: Record<string, boolean> | null) {
18141818
return Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[type]: data});
18151819
}
@@ -1845,6 +1849,7 @@ export {
18451849
revert,
18461850
changeTransactionsReport,
18471851
setTransactionReport,
1852+
getDefaultP2PMileageRate,
18481853
mergeTransactionIdsHighlightOnSearchRoute,
18491854
getDuplicateTransactionDetails,
18501855
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// This module is used to load the default P2P mileage rate for a user based on their personal policy outputCurrency (default / reporting currency).
2+
// Whenever a user starts the "Track distance" flow the getDefaultP2PMileageRate action will fetch the rate and unit from the hard coded mapping stored in Auth
3+
// (CURRENCY_TO_DEFAULT_MILEAGE_RATE), via the API read command GetDefaultP2PMileageRate.
4+
// The rate will be stored in Onyx and loaded into a variable here via Onyx.connectWithoutView. Normally useOnyx should be used instead, but because
5+
// the default P2P mileage rate is used across many library functions an exception is allowed to prevent having to pass the value through many functions
6+
// across the codebase.
7+
// DO NOT use this pattern for other Onyx data unless you get authorization from the internal Expensify team in Slack.
8+
import Onyx from 'react-native-onyx';
9+
import ONYXKEYS from '@src/ONYXKEYS';
10+
import type DefaultP2PMileageRate from '@src/types/onyx/DefaultP2PMileageRate';
11+
12+
let defaultP2PMileageRate: DefaultP2PMileageRate | undefined;
13+
Onyx.connectWithoutView({
14+
key: ONYXKEYS.DEFAULT_P2P_MILEAGE_RATE,
15+
callback: (value) => {
16+
defaultP2PMileageRate = value;
17+
},
18+
});
19+
20+
function getStoredDefaultP2PMileageRate() {
21+
return defaultP2PMileageRate;
22+
}
23+
24+
export default getStoredDefaultP2PMileageRate;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type {Unit} from './Policy';
2+
3+
/** Default P2P mileage rate fetched from Auth for the user's personal policy outputCurrency (default / report currency) */
4+
type DefaultP2PMileageRate = {
5+
/** Rate in cents per unit (e.g. 67 = $0.67/mile) */
6+
rate: number;
7+
8+
/** Distance unit: "mi" or "km" */
9+
unit: Unit;
10+
};
11+
12+
export default DefaultP2PMileageRate;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import Onyx from 'react-native-onyx';
2+
import {getDefaultP2PMileageRate} from '@libs/actions/Transaction';
3+
import * as API from '@libs/API';
4+
import {READ_COMMANDS} from '@libs/API/types';
5+
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
6+
import getStoredDefaultP2PMileageRate from '@libs/getStoredDefaultP2PMileageRate';
7+
import CONST from '@src/CONST';
8+
import ONYXKEYS from '@src/ONYXKEYS';
9+
import createRandomTransaction from '../utils/collections/transaction';
10+
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
11+
12+
describe('Default P2P mileage rate', () => {
13+
beforeAll(() => {
14+
Onyx.init({
15+
keys: ONYXKEYS,
16+
});
17+
});
18+
19+
beforeEach(() => {
20+
return Onyx.clear().then(waitForBatchedUpdates);
21+
});
22+
23+
afterEach(() => {
24+
jest.restoreAllMocks();
25+
});
26+
27+
describe('getDefaultP2PMileageRate', () => {
28+
it('calls API.read with GetDefaultP2PMileageRate', () => {
29+
const readSpy = jest.spyOn(API, 'read').mockImplementation(() => {});
30+
31+
getDefaultP2PMileageRate();
32+
33+
expect(readSpy).toHaveBeenCalledWith(READ_COMMANDS.GET_DEFAULT_P2P_MILEAGE_RATE, null);
34+
});
35+
});
36+
37+
describe('getRateForP2P', () => {
38+
it('falls back to USD 67¢/mile when no rate has been stored in Onyx', () => {
39+
const result = DistanceRequestUtils.getRateForP2P('EUR', undefined);
40+
41+
expect(result).toEqual({rate: 67, unit: 'mi', currency: CONST.CURRENCY.USD});
42+
});
43+
44+
it('uses the stored Onyx rate with the caller currency once a rate is available', async () => {
45+
await Onyx.set(ONYXKEYS.DEFAULT_P2P_MILEAGE_RATE, {rate: 5500, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS});
46+
await waitForBatchedUpdates();
47+
48+
expect(getStoredDefaultP2PMileageRate()).toEqual({rate: 5500, unit: 'km'});
49+
50+
const result = DistanceRequestUtils.getRateForP2P('EUR', undefined);
51+
52+
expect(result).toEqual({rate: 5500, unit: 'km', currency: 'EUR'});
53+
});
54+
55+
it('uses the transaction defaultP2PRate when the transaction currency matches', async () => {
56+
await Onyx.set(ONYXKEYS.DEFAULT_P2P_MILEAGE_RATE, {rate: 5500, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES});
57+
await waitForBatchedUpdates();
58+
59+
const transaction = {
60+
...createRandomTransaction(1),
61+
currency: CONST.CURRENCY.USD,
62+
comment: {customUnit: {defaultP2PRate: 99}},
63+
};
64+
65+
const result = DistanceRequestUtils.getRateForP2P(CONST.CURRENCY.USD, transaction);
66+
67+
expect(result).toEqual({rate: 99, unit: 'mi', currency: CONST.CURRENCY.USD});
68+
});
69+
});
70+
});

tests/unit/DistanceRequestUtilsTest.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils';
22
import CONST from '@src/CONST';
33
import type {Unit} from '@src/types/onyx/Policy';
44
import type Policy from '@src/types/onyx/Policy';
5+
import type Transaction from '@src/types/onyx/Transaction';
56
import createRandomTransaction from '../utils/collections/transaction';
67
import {translateLocal} from '../utils/TestHelper';
78

@@ -391,6 +392,39 @@ describe('DistanceRequestUtils', () => {
391392
});
392393
});
393394

395+
describe('getRateForP2P', () => {
396+
// These tests run with the default P2P mileage rate unloaded (it's fetched asynchronously when a
397+
// distance request starts), which is the case for flows that don't start a new distance request,
398+
// such as editing an existing distance expense.
399+
it('falls back to the existing transaction currency and unit when the default P2P rate is not loaded', () => {
400+
// Given an existing P2P distance expense in GBP measured in kilometers, with its own saved rate
401+
const transaction = {
402+
...createRandomTransaction(1),
403+
currency: 'GBP',
404+
comment: {customUnit: {distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, defaultP2PRate: 45}},
405+
} as Transaction;
406+
407+
// When reading the P2P rate for that transaction's currency
408+
const result = DistanceRequestUtils.getRateForP2P('GBP', transaction);
409+
410+
// Then it preserves the transaction's currency, unit, and saved rate instead of flipping to USD/miles
411+
expect(result.currency).toBe('GBP');
412+
expect(result.unit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS);
413+
expect(result.rate).toBe(45);
414+
});
415+
416+
it('falls back to USD and miles for a brand-new request with no transaction', () => {
417+
// Given a brand-new distance request that has no transaction yet
418+
// When reading the P2P rate
419+
const result = DistanceRequestUtils.getRateForP2P('GBP', undefined);
420+
421+
// Then it falls back to the hardcoded USD/miles default
422+
expect(result.currency).toBe(CONST.CURRENCY.USD);
423+
expect(result.unit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES);
424+
expect(result.rate).toBe(67);
425+
});
426+
});
427+
394428
describe('getDistanceMerchant', () => {
395429
const toLocaleDigitMock = (dot: string): string => dot;
396430
const getCurrencySymbolMock = (currency: string): string | undefined => {

0 commit comments

Comments
 (0)