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
16 changes: 14 additions & 2 deletions src/hooks/useParticipantSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ function useParticipantSubmission({
policy: movingPolicy,
isPolicyExpenseChat: false,
lastSelectedDistanceRates: distanceRates,
expenseDate: transaction.created,
});
setCustomUnitRateID(transaction.transactionID, rateID, transaction, movingPolicy);
const shouldSetParticipantAutoAssignment = iouType === CONST.IOU.TYPE.CREATE;
Expand Down Expand Up @@ -238,14 +239,25 @@ function useParticipantSubmission({
if (!isMovingTransactionFromTrackExpense || !isPolicyExpenseChat) {
// If not moving the transaction from track expense, select the default rate automatically.
// Otherwise, keep the original p2p rate and let the user manually change it to the one they want from the workspace.
const rateID = DistanceRequestUtils.getCustomUnitRateID({reportID: firstParticipantReportID, isPolicyExpenseChat, policy, lastSelectedDistanceRates: distanceRates});

if (drafts.length > 0) {
for (const transaction of drafts) {
const rateID = DistanceRequestUtils.getCustomUnitRateID({
reportID: firstParticipantReportID,
isPolicyExpenseChat,
policy,
lastSelectedDistanceRates: distanceRates,
expenseDate: transaction.created,
});
setCustomUnitRateID(transaction.transactionID, rateID, transaction, policy);
}
} else {
// Fallback to using initialTransactionID directly
const rateID = DistanceRequestUtils.getCustomUnitRateID({
reportID: firstParticipantReportID,
isPolicyExpenseChat,
policy,
lastSelectedDistanceRates: distanceRates,
});
setCustomUnitRateID(initialTransactionID, rateID, undefined, policy);
}
}
Expand Down
135 changes: 127 additions & 8 deletions src/libs/DistanceRequestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type MileageRate = {
name?: string;
enabled?: boolean;
index?: number;
startDate?: string | null;
endDate?: string | null;
};

/** @private Only for getRate function */
Expand Down Expand Up @@ -63,6 +65,8 @@ function getMileageRates(policy: OnyxInputOrEntry<Policy>, includeDisabledRates
customUnitRateID: rate.customUnitRateID,
enabled: rate.enabled,
index: rate.index,
startDate: rate.startDate,
endDate: rate.endDate,
};
}

Expand Down Expand Up @@ -341,22 +345,117 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number {
}

/**
* Returns custom unit rate ID for the distance transaction
* Checks if a mileage rate is eligible for a given expense date.
* A rate is eligible if the date falls within its startDate/endDate bounds (inclusive).
* Missing bounds mean unbounded in that direction.
*/
function isRateEligibleForDate(rate: MileageRate, expenseDate: string): boolean {
if (rate.startDate && expenseDate < rate.startDate) {
return false;
}
if (rate.endDate && expenseDate > rate.endDate) {
return false;
}
return true;
}

/**
* Returns a boundedness score: 2 = fully bounded (both dates), 1 = partially bounded (one date), 0 = unbounded.
*/
function getBoundednessScore(rate: MileageRate): number {
if (rate.startDate && rate.endDate) {
return 2;
}
if (rate.startDate || rate.endDate) {
return 1;
}
return 0;
}

function getFullyBoundedDateRangeMs(rate: MileageRate): number | undefined {
if (!rate.startDate || !rate.endDate) {
return undefined;
}

return new Date(rate.endDate).getTime() - new Date(rate.startDate).getTime();
}

/**
* Finds the best eligible rate for a given expense date from a set of mileage rates.
* 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 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;
});

return eligibleRates.at(0);
}

function getBestEligibleRateOrPolicyDefault(mileageRates: Record<string, MileageRate>, expenseDate: string, policy: OnyxEntry<Policy>): MileageRate | undefined {
const bestRate = getBestEligibleRate(mileageRates, expenseDate);
if (bestRate) {
return bestRate;
}

return getDefaultMileageRate(policy);
}

/**
* Returns custom unit rate ID for the distance transaction.
* When an expenseDate is provided, uses date-aware rate selection:
* 1. Last selected rate, if enabled and valid for the expense date
* 2. Best eligible rate for the expense date
* 3. Default rate fallback
*/
function getCustomUnitRateID({
reportID,
isPolicyExpenseChat,
policy,
isTrackDistanceExpense = false,
lastSelectedDistanceRates,
expenseDate,
}: {
reportID: string | undefined;
isPolicyExpenseChat: boolean;
policy: OnyxEntry<Policy> | undefined;
lastSelectedDistanceRates?: OnyxEntry<LastSelectedDistanceRates>;
isTrackDistanceExpense?: boolean;
expenseDate?: string;
}): string {
let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID;
const customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID;

if (!reportID) {
return customUnitRateID;
Expand All @@ -371,14 +470,32 @@ function getCustomUnitRateID({
const distanceUnit = Object.values(policy.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy.id];
const lastSelectedDistanceRate = lastSelectedDistanceRateID ? distanceUnit?.rates[lastSelectedDistanceRateID] : undefined;
if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) {
customUnitRateID = lastSelectedDistanceRateID;
} else {

if (!expenseDate) {
if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) {
return lastSelectedDistanceRateID;
}

const defaultMileageRate = getDefaultMileageRate(policy);
if (!defaultMileageRate?.customUnitRateID) {
return customUnitRateID;
if (defaultMileageRate?.customUnitRateID) {
return defaultMileageRate.customUnitRateID;
}
customUnitRateID = defaultMileageRate.customUnitRateID;

return customUnitRateID;
}

const mileageRates = getMileageRates(policy);
if (lastSelectedDistanceRate?.enabled && lastSelectedDistanceRateID) {
const lastSelectedMileageRate = mileageRates[lastSelectedDistanceRateID];
// 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.
if (!lastSelectedMileageRate || isRateEligibleForDate(lastSelectedMileageRate, expenseDate)) {
return lastSelectedDistanceRateID;
}
}

const bestRate = getBestEligibleRateOrPolicyDefault(mileageRates, expenseDate, policy);
if (bestRate?.customUnitRateID) {
return bestRate.customUnitRateID;
}
}

Expand Down Expand Up @@ -544,6 +661,8 @@ export default {
isDistanceAmountWithinLimit,
normalizeOdometerText,
prepareTextForDisplay,
isRateEligibleForDate,
getBestEligibleRate,
};

export type {MileageRate};
9 changes: 8 additions & 1 deletion src/libs/actions/IOU/MoneyRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,14 @@ function initMoneyRequest({
) {
if (!isFromGlobalCreate) {
const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report) || isPolicyExpenseChatReportUtil(parentReport);
const customUnitRateID = DistanceRequestUtils.getCustomUnitRateID({reportID, isPolicyExpenseChat, isTrackDistanceExpense, policy, lastSelectedDistanceRates});
const customUnitRateID = DistanceRequestUtils.getCustomUnitRateID({
reportID,
isPolicyExpenseChat,
isTrackDistanceExpense,
policy,
lastSelectedDistanceRates,
expenseDate: created,
Comment thread
Krishna2323 marked this conversation as resolved.
});
comment.customUnit = {customUnitRateID, name: CONST.CUSTOM_UNITS.NAME_DISTANCE};
} else if (hasOnlyPersonalPolicies) {
comment.customUnit = {customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, name: CONST.CUSTOM_UNITS.NAME_DISTANCE};
Expand Down
25 changes: 23 additions & 2 deletions src/pages/iou/request/step/IOURequestStepDate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@ import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePermissions from '@hooks/usePermissions';
import usePolicy from '@hooks/usePolicy';
import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses';
import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure';
import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep';
import useThemeStyles from '@hooks/useThemeStyles';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {shouldUseTransactionDraft} from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
import {getFormattedCreated, hasReceipt} from '@libs/TransactionUtils';
import {setMoneyRequestCreated} from '@userActions/IOU/MoneyRequest';
import {isPolicyExpenseChat as isPolicyExpenseChatReportUtil} from '@libs/ReportUtils';
import {getFormattedCreated, hasReceipt, isDistanceRequest} from '@libs/TransactionUtils';
import {setCustomUnitRateID, setMoneyRequestCreated} from '@userActions/IOU/MoneyRequest';
import {setDraftSplitTransaction} from '@userActions/IOU/Split';
import {updateMoneyRequestDate} from '@userActions/IOU/UpdateMoneyRequest';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -51,6 +54,9 @@ function IOURequestStepDate({
const styles = useThemeStyles();
const {translate} = useLocalize();
const policy = usePolicy(report?.policyID);
const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK;
const {policyForMovingExpensesID} = usePolicyForMovingExpenses();
const policyForTrackExpense = usePolicy(isTrackExpense ? policyForMovingExpensesID : undefined);
const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactionID ? [transactionID] : []);
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`);
const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`);
Expand All @@ -63,6 +69,7 @@ function IOURequestStepDate({
const {isBetaEnabled} = usePermissions();
const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT);
const {isOffline} = useNetwork();
const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES);
const isEditing = action === CONST.IOU.ACTION.EDIT;
const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT;
const isSplitExpense = iouType === CONST.IOU.TYPE.SPLIT_EXPENSE;
Expand Down Expand Up @@ -115,6 +122,20 @@ function IOURequestStepDate({
});
} else {
setMoneyRequestCreated(transactionID, newCreated, isTransactionDraft, hasReceipt(transaction));

const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(report);
if (isDistanceRequest(transaction) && (isPolicyExpenseChat || isTrackExpense)) {
const effectivePolicy = isTrackExpense ? policyForTrackExpense : policy;
const rateID = DistanceRequestUtils.getCustomUnitRateID({
reportID,
isPolicyExpenseChat,
policy: effectivePolicy,
lastSelectedDistanceRates,
isTrackDistanceExpense: isTrackExpense,
expenseDate: newCreated,
});
setCustomUnitRateID(transactionID, rateID, transaction, effectivePolicy);
Comment thread
Krishna2323 marked this conversation as resolved.
Comment thread
Krishna2323 marked this conversation as resolved.
}
}

navigateBack();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ function handleMoneyRequestStepDistanceNavigation({
isTrackDistanceExpense: true,
policy: policyForMovingExpenses,
isPolicyExpenseChat: false,
expenseDate: transaction?.created,
}),
attendees: transaction?.comment?.attendees,
gpsCoordinates,
Expand Down Expand Up @@ -366,6 +367,7 @@ function handleMoneyRequestStepDistanceNavigation({
isPolicyExpenseChat,
policy,
lastSelectedDistanceRates,
expenseDate: transaction?.created,
}),
splitShares: transaction?.splitShares,
attendees: transaction?.comment?.attendees,
Expand Down Expand Up @@ -435,6 +437,7 @@ function handleMoneyRequestStepDistanceNavigation({
policy: isSelfDMReport ? policyForMovingExpenses : defaultExpensePolicy,
lastSelectedDistanceRates,
isTrackDistanceExpense: isSelfDMReport,
expenseDate: transaction?.created,
});

setTransactionReport(transactionID, {reportID: transactionReportID}, true);
Expand Down
Loading
Loading