From 135caf9117a77b9839c7eafc73cce26e20f566fb Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Wed, 17 Jun 2026 14:07:30 +0000 Subject: [PATCH 01/64] Add commuter exclusion types, constants, and strings Foundation for the commuter exclusion feature: - TransactionCustomUnit gains commuterExclusion, reimbursableDistance, commuterExclusionType, and commuterExclusionMethod so the App can carry the PHP-computed exclusion metadata through Onyx. - Policy gains the commuterExclusions config object (method, fixedDistance, fixedDistanceUnit) so feature gating and the optimistic client-side compute can read it. - CONST.COMMUTER_EXCLUSIONS namespaces the method/type enums; a new COMMUTER_EXCLUSION report action type and a POLICYCHANGELOG_UPDATE_COMMUTER_EXCLUSIONS policy-change-log type are registered for the system message and workspace audit log. - en.ts adds the three-line breakdown labels, the manual/odometer blocked error copy, and the system-message fallback. --- src/CONST/index.ts | 13 +++++++++++++ src/languages/en.ts | 7 +++++++ src/types/onyx/Policy.ts | 16 ++++++++++++++++ src/types/onyx/Transaction.ts | 12 ++++++++++++ 4 files changed, 48 insertions(+) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b8d8414b8180..fda110bbbe1b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1640,6 +1640,7 @@ const CONST = { HOLD_COMMENT: 'HOLDCOMMENT', INTEGRATION_SYNC_FAILED: 'INTEGRATIONSYNCFAILED', COMPANY_CARD_CONNECTION_BROKEN: 'COMPANYCARDCONNECTIONBROKEN', + COMMUTER_EXCLUSION: 'COMMUTEREXCLUSION', PLAID_BALANCE_FAILURE: 'PLAIDBALANCEFAILURE', IOU: 'IOU', INTEGRATIONS_MESSAGE: 'INTEGRATIONSMESSAGE', // OldDot Action @@ -1750,6 +1751,7 @@ const CONST = { UPDATE_CATEGORY_TAX_RATE: 'POLICYCHANGELOG_UPDATE_CATEGORY_TAX_RATE', UPDATE_CATEGORIES: 'POLICYCHANGELOG_UPDATE_CATEGORIES', UPDATE_CURRENCY: 'POLICYCHANGELOG_UPDATE_CURRENCY', + UPDATE_COMMUTER_EXCLUSIONS: 'POLICYCHANGELOG_UPDATE_COMMUTER_EXCLUSIONS', UPDATE_CUSTOM_UNIT: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT', UPDATE_CUSTOM_UNIT_RATE: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT_RATE', UPDATE_CUSTOM_UNIT_SUB_RATE: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT_SUB_RATE', @@ -4286,6 +4288,17 @@ const CONST = { MERGE_EXPENSES: 'https://help.expensify.com/articles/new-expensify/reports-and-expenses/Merging-expenses', }, + COMMUTER_EXCLUSIONS: { + METHOD: { + FIXED_DISTANCE: 'fixedDistance', + HOME_AND_OFFICE: 'homeAndOffice', + }, + TYPE: { + DIRECT_COMMUTE: 'directCommute', + USUAL_COMMUTE: 'usualCommute', + }, + }, + CUSTOM_UNITS: { NAME_DISTANCE: 'Distance', NAME_PER_DIEM_INTERNATIONAL: 'Per Diem International', diff --git a/src/languages/en.ts b/src/languages/en.ts index 67528ab5a2fd..19e9f19d4587 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8817,6 +8817,13 @@ const translations = { }, error: { selectSuggestedAddress: 'Please select a suggested address or use current location', + manualOdometerNotAllowedWithExclusion: 'This workspace excludes part of your distance from reimbursement, so manual and odometer entry are disabled. Use the map to enter your route.', + }, + commuterExclusion: { + originalDistance: 'Original distance', + commutingDistance: 'Removed commute distance', + reimbursableDistance: 'Reimbursable distance', + systemMessage: ({distance, unit}: {distance: string; unit: string}) => `${distance} ${unit} was removed for the daily commute.`, }, odometer: { startReading: 'Start reading', diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 50add4836f66..6f8929d53ba2 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -2420,6 +2420,22 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the policy requires purchases to be on a company card */ requireCompanyCardsEnabled?: boolean; + + /** + * Workspace-level commuter exclusion config. + * When present and the method is supported, distance expenses on this policy + * deduct the configured exclusion from each expense's quantity. + */ + commuterExclusions?: { + /** R1 supports "fixedDistance"; R2 will add "homeAndOffice" */ + method: ValueOf; + + /** Distance to deduct from each expense (only set when method === "fixedDistance") */ + fixedDistance?: number; + + /** Unit the fixedDistance is expressed in ("mi" | "km") */ + fixedDistanceUnit?: Unit; + }; } & Partial, 'addWorkspaceRoom' | keyof ACHAccount | keyof Attributes | 'isHREnabled' | 'isTimeTrackingEnabled' | 'timeTrackingDefaultRate' >; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index e5f483bde0d1..8563c07cdc33 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -209,6 +209,18 @@ type TransactionCustomUnit = { /** Custom unit rate */ rate: number; }>; + + /** Distance deducted from quantity by the workspace commuter exclusion, in the same unit as quantity */ + commuterExclusion?: number; + + /** Reimbursable distance after commuter exclusion: max(0, quantity - commuterExclusion) */ + reimbursableDistance?: number; + + /** The kind of commute the exclusion represents (R3 — currently unused) */ + commuterExclusionType?: ValueOf; + + /** How the exclusion was configured on the policy (R1: fixedDistance; R2: homeAndOffice) */ + commuterExclusionMethod?: ValueOf; }; /** Types of geometry */ From 8324df271dcce73eb024b34a3ed19ea9bd79fb10 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Wed, 17 Jun 2026 14:25:34 +0000 Subject: [PATCH 02/64] Render commuter exclusion breakdown on confirmation, view, and edit Add a DistanceWithCommuterExclusion component that reads comment.customUnit.{quantity,commuterExclusion,reimbursableDistance, distanceUnit} and renders a three-line breakdown (original distance, removed commute distance, reimbursable distance). The component renders nothing when commuterExclusion is not set or non-positive. Mount it next to the existing DistanceField in TransactionDetailsFields (the confirmation / IOURequestStepConfirmation surface) and after the rate row in MoneyRequestView (the expense detail / history surface), so both creation and post-creation views show the same breakdown. --- .../DistanceWithCommuterExclusion.tsx | 62 +++++++++++++++++++ .../fieldGroups/TransactionDetailsFields.tsx | 39 +++++++----- .../ReportActionItem/MoneyRequestView.tsx | 2 + 3 files changed, 87 insertions(+), 16 deletions(-) create mode 100644 src/components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion.tsx diff --git a/src/components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion.tsx b/src/components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion.tsx new file mode 100644 index 000000000000..0d5a33ec05fa --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import {View} from 'react-native'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {Unit} from '@src/types/onyx/Policy'; +import type {Transaction} from '@src/types/onyx'; + +type DistanceWithCommuterExclusionProps = { + transaction: Transaction | undefined; +}; + +function formatDistance(distance: number | undefined | null, unit: Unit | undefined): string { + if (distance === undefined || distance === null || !unit) { + return ''; + } + return `${distance.toFixed(2)} ${unit}`; +} + +/** + * Three-line distance breakdown shown on the confirmation, edit, and history surfaces + * whenever a workspace commuter exclusion has been applied to the transaction. + * + * Reads `comment.customUnit.{quantity, commuterExclusion, reimbursableDistance, distanceUnit}` + * and renders nothing when `commuterExclusion` is not positive. + */ +function DistanceWithCommuterExclusion({transaction}: DistanceWithCommuterExclusionProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const customUnit = transaction?.comment?.customUnit; + const commuterExclusion = customUnit?.commuterExclusion; + + if (!customUnit || !commuterExclusion || commuterExclusion <= 0) { + return null; + } + + const unit = customUnit.distanceUnit; + const original = customUnit.quantity ?? 0; + const reimbursable = customUnit.reimbursableDistance ?? Math.max(0, original - commuterExclusion); + + return ( + + + {translate('distance.commuterExclusion.originalDistance')} + {formatDistance(original, unit)} + + + {translate('distance.commuterExclusion.commutingDistance')} + {`- ${formatDistance(commuterExclusion, unit)}`} + + + {translate('distance.commuterExclusion.reimbursableDistance')} + {formatDistance(reimbursable, unit)} + + + ); +} + +DistanceWithCommuterExclusion.displayName = 'DistanceWithCommuterExclusion'; + +export default DistanceWithCommuterExclusion; diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx index 2d407b19b366..9d16b0ff9cd8 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context'; +import DistanceWithCommuterExclusion from '@components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion'; import AmountField from '@components/MoneyRequestConfirmationList/sections/AmountField'; import DescriptionField from '@components/MoneyRequestConfirmationList/sections/DescriptionField'; import DistanceField from '@components/MoneyRequestConfirmationList/sections/DistanceField'; @@ -8,7 +9,9 @@ import MerchantField from '@components/MoneyRequestConfirmationList/sections/Mer import RateField from '@components/MoneyRequestConfirmationList/sections/RateField'; import TimeFields from '@components/MoneyRequestConfirmationList/sections/TimeFields'; import type {AmountDisplay, DistanceData, DistanceFlags, ErrorState, RequiredFlags} from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; +import useOnyx from '@hooks/useOnyx'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {FieldVisibility} from './fieldVisibility'; @@ -69,6 +72,7 @@ function TransactionDetailsFields({ isParticipantPickerVisible, }: TransactionDetailsFieldsProps) { const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isNewManualExpenseFlowEnabled, isPolicyExpenseChat} = useConfirmationFields(); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const shouldAutoFocusAmountField = !canUseTouchScreen(); return ( @@ -130,22 +134,25 @@ function TransactionDetailsFields({ /> {fieldVisibility.distance && ( - + <> + + + )} {!isCompactMode && fieldVisibility.rate && ( diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 2777962ff331..4cb810609451 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -7,6 +7,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; // eslint-disable-next-line no-restricted-imports import {useOnyx as originalUseOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import DistanceWithCommuterExclusion from '@components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; @@ -862,6 +863,7 @@ function MoneyRequestView({ copyable={!!distanceRateCopyValue} /> + ); From 4456e9a47e77687eac133a10c80e5f60934f5680 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Wed, 17 Jun 2026 14:32:41 +0000 Subject: [PATCH 03/64] Gate manual/odometer entry on commuter exclusion policies - DistanceRequestStartPage hides the Manual and Odometer tabs when every workspace the user belongs to has commuterExclusions configured. If at least one workspace lacks exclusions, all four tabs stay visible (the user can still pick that workspace). - useConfirmationValidation: allow zero-amount submission when the transaction carries a positive commuterExclusion, so a fully excluded distance expense (reimbursable = 0) can still be submitted in the split-bill-edit branch. The existing distance-request guard already handles the create branch. --- .../hooks/useConfirmationValidation.ts | 5 +- .../iou/request/DistanceRequestStartPage.tsx | 55 ++++++++++++------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 0162e8f6fbd1..09b8aaec9190 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -286,7 +286,10 @@ function useConfirmationValidation({ return {errorKey: 'iou.error.genericSmartscanFailureMessage', shouldSetDidConfirmSplit: true}; } - if (isEditingSplitBill && iouAmount === 0) { + // Allow zero-amount submission when a workspace commuter exclusion has fully consumed the distance + // (commuterExclusion >= quantity), since the user can still legitimately submit such an expense. + const hasCommuterExclusion = (transaction?.comment?.customUnit?.commuterExclusion ?? 0) > 0; + if (isEditingSplitBill && iouAmount === 0 && !hasCommuterExclusion) { return {errorKey: 'iou.error.invalidAmount'}; } diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index f8641c64524a..378562f55aec 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -50,6 +50,17 @@ function DistanceRequestStartPage({ const isLoadingSelectedTab = isLoadingOnyxValue(selectedTabResult); const isTrackDistanceExpense = iouType === CONST.IOU.TYPE.TRACK; + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + // When every workspace the user belongs to has commuter exclusions configured, manual and odometer + // entry are disabled — the exclusion is computed off the mapped route, so the user must use the map tab. + const shouldHideManualAndOdometerTabs = useMemo(() => { + const policies = Object.values(allPolicies ?? {}).filter((p): p is NonNullable => !!p && !!p.id); + if (policies.length === 0) { + return false; + } + return policies.every((p) => !!p.commuterExclusions); + }, [allPolicies]); + const tabTitles = { [CONST.IOU.TYPE.REQUEST]: translate('iou.trackDistance'), [CONST.IOU.TYPE.SUBMIT]: translate('iou.trackDistance'), @@ -139,16 +150,18 @@ function DistanceRequestStartPage({ )} - - {() => ( - - - - )} - + {!shouldHideManualAndOdometerTabs && ( + + {() => ( + + + + )} + + )} {() => ( @@ -159,16 +172,18 @@ function DistanceRequestStartPage({ )} - - {() => ( - - - - )} - + {!shouldHideManualAndOdometerTabs && ( + + {() => ( + + + + )} + + )} From cdafc1b9d501c50aebb175732388d0e7c6c97a90 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Wed, 17 Jun 2026 14:55:24 +0000 Subject: [PATCH 04/64] Route COMMUTER_EXCLUSION system action through ActionContentRouter - OriginalMessage gains OriginalMessageCommuterExclusion ({distance, unit}) keyed under CONST.REPORT.ACTIONS.TYPE.COMMUTER_EXCLUSION so callers reading the original message are properly typed. - ReportActionsUtils.getCommuterExclusionMessage formats the system message via the existing distance.commuterExclusion.systemMessage i18n key. - ActionContentRouter renders the COMMUTER_EXCLUSION action as a ReportActionItemBasicMessage, matching the existing pattern used for plaid/company-card system messages. The COMMUTER_EXCLUSION action type is already in CONST.REPORT.ACTIONS.TYPE and therefore in the supportedActionTypes set built dynamically in ReportActionsUtils, so it is treated as a visible chat action without further changes. --- src/libs/ReportActionsUtils.ts | 9 +++++++++ .../report/actionContents/ActionContentRouter.tsx | 4 ++++ src/types/onyx/OriginalMessage.ts | 14 ++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index f5a1e1ff5a2e..5d682423aba2 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4615,6 +4615,14 @@ function getPlaidBalanceFailureMessage(translate: LocalizedTranslate, action: On }); } +function getCommuterExclusionMessage(translate: LocalizedTranslate, action: OnyxEntry): string { + const original = getOriginalMessage(action as ReportAction) ?? {distance: '0', unit: ''}; + return translate('distance.commuterExclusion.systemMessage', { + distance: String(original.distance ?? '0'), + unit: String(original.unit ?? ''), + }); +} + function getManagerOnVacation(action: OnyxEntry): string | undefined { if (!isApprovedAction(action)) { return; @@ -4874,6 +4882,7 @@ export { isReopenedAction, isRetractedAction, getIntegrationSyncFailedMessage, + getCommuterExclusionMessage, getCompanyCardConnectionBrokenMessage, getPlaidBalanceFailureMessage, getPolicyChangeLogDefaultReimbursableMessage, diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index fd4845368f0e..0195d02d454a 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -16,6 +16,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import { getChangedApproverActionMessage, + getCommuterExclusionMessage, getCompanyCardConnectionBrokenMessage, getForwardedReportActionMessage, getIOUReportIDFromReportActionPreview, @@ -460,6 +461,9 @@ function ActionContentRouter({ ); } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.COMMUTER_EXCLUSION)) { + return ; + } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.CREATED) && isHarvestCreatedExpenseReport) { return ; } diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index bc7ed2640881..faf24478dc43 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1454,6 +1454,19 @@ type OriginalMessagePlaidBalanceFailure = { reasoning?: string; }; +/** + * Original message for a COMMUTER_EXCLUSION system action, + * posted to the workspace chat when a distance expense has the workspace's + * commuter exclusion applied to it. + */ +type OriginalMessageCommuterExclusion = { + /** The distance that was deducted, formatted to 2 decimals (e.g. "1.00") */ + distance: string; + + /** Display unit ("mi" or "km") */ + unit: string; +}; + /** * Original message for DEW_SUBMIT_FAILED and DEW_APPROVE_FAILED actions */ @@ -1702,6 +1715,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_DESCRIPTION_OPTIONS]: OriginalMessageConciergeDescriptionOptions; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_MAP_MCC_GROUPS]: OriginalMessageConciergeAutoMapMccGroups; [CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN]: OriginalMessageCompanyCardConnectionBroken; + [CONST.REPORT.ACTIONS.TYPE.COMMUTER_EXCLUSION]: OriginalMessageCommuterExclusion; [CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE]: OriginalMessagePlaidBalanceFailure; [CONST.REPORT.ACTIONS.TYPE.RETRACTED]: never; [CONST.REPORT.ACTIONS.TYPE.REOPENED]: never; From 44f5bd1e5a6c0f4159a021480c1a7cb8b1d065aa Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Wed, 17 Jun 2026 15:03:53 +0000 Subject: [PATCH 05/64] Optimistic commuter exclusion on createDistanceRequest When the destination policy has a fixedDistance commuter exclusion configured (method === "fixedDistance" with a positive fixedDistance), createDistanceRequest now writes: - A merge onto the optimistic transaction that fills the four new comment.customUnit fields (commuterExclusion, reimbursableDistance, commuterExclusionMethod) and sets modifiedAmount / modifiedMerchant from the reimbursable distance, so the three-line breakdown and the reduced amount render instantly without waiting on the server. - An optimistic COMMUTER_EXCLUSION report action posted to reportActions_${chatReportID} (pendingAction: ADD), so the system message renders instantly too. ActionContentRouter picks this up and renders the localized breakdown. Failure data reverts both side effects (transaction fields cleared, optimistic action removed). For commuterExclusions undefined and for the not-yet-supported homeAndOffice method, behavior is unchanged. Adds buildOptimisticCommuterExclusionReportAction in ReportUtils following the existing buildOptimisticCancelPaymentReportAction pattern. --- src/libs/ReportUtils.ts | 35 +++++++++++++++++++ src/libs/actions/IOU/Split.ts | 66 +++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e3cb7715d2d9..f949c93b5c62 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4051,6 +4051,40 @@ function buildOptimisticChangeFieldAction(reportField: PolicyReportField, previo }; } +/** + * Builds an optimistic COMMUTER_EXCLUSION report action posted to the workspace chat + * when a distance expense has the workspace's commuter exclusion applied. + * + * `distance` is the deducted distance formatted to two decimals; `unit` is "mi" or "km". + */ +function buildOptimisticCommuterExclusionReportAction(distance: string, unit: string, currentUserAccountID: number): ReportAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.COMMUTER_EXCLUSION, + actorAccountID: currentUserAccountID, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + text: `${distance} ${unit} was removed for the daily commute.`, + }, + ], + originalMessage: { + distance, + unit, + }, + person: [ + { + style: 'strong', + text: getCurrentUserDisplayNameOrEmail(), + type: 'TEXT', + }, + ], + reportActionID: rand64(), + shouldShow: true, + created: DateUtils.getDBTime(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + /** * Builds an optimistic REIMBURSEMENT_DEQUEUED report action with a randomly generated reportActionID. * @@ -13014,6 +13048,7 @@ export { buildOptimisticCreatedReportForUnapprovedAction, buildOptimisticUnapprovedReportAction, buildOptimisticCancelPaymentReportAction, + buildOptimisticCommuterExclusionReportAction, buildOptimisticChangedTaskAssigneeReportAction, buildOptimisticChatReport, buildOptimisticClosedReportAction, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 8ef2ef968820..fb1491bda10b 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -22,6 +22,7 @@ import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {getReportActionHtml, getReportActionText} from '@libs/ReportActionsUtils'; import { buildOptimisticChatReport, + buildOptimisticCommuterExclusionReportAction, buildOptimisticCreatedReportAction, buildOptimisticExpenseReport, buildOptimisticIOUReport, @@ -2094,6 +2095,71 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest onyxData = moneyRequestOnyxData; distanceIouReport = iouReport; + // Compute the commuter exclusion client-side for `fixedDistance` so the breakdown and + // system message render instantly. The server's createDistanceRequest response will + // confirm or correct these values via the success-data replay. For any other method + // (R2 homeAndOffice, future types) we leave optimistic behavior unchanged. + const exclusionConfig = policy?.commuterExclusions; + const fixedExclusionDistance = exclusionConfig?.method === CONST.COMMUTER_EXCLUSIONS.METHOD.FIXED_DISTANCE ? Math.max(0, exclusionConfig.fixedDistance ?? 0) : 0; + const quantity = transaction.comment?.customUnit?.quantity ?? 0; + const distanceUnit = transaction.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const distanceRate = transaction.comment?.customUnit?.defaultP2PRate ?? 0; + if (fixedExclusionDistance > 0 && quantity > 0 && chatReport?.reportID && currentUserAccountID) { + const commuterExclusion = Math.min(fixedExclusionDistance, quantity); + const reimbursableDistance = Math.max(0, quantity - commuterExclusion); + const modifiedRequestAmount = Math.round(reimbursableDistance * distanceRate); + const modifiedRequestMerchant = `${reimbursableDistance.toFixed(2)} ${distanceUnit} @ ${distanceRate} / ${distanceUnit}`; + + onyxData?.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + modifiedAmount: modifiedRequestAmount, + modifiedMerchant: modifiedRequestMerchant, + comment: { + customUnit: { + commuterExclusion, + reimbursableDistance, + commuterExclusionMethod: CONST.COMMUTER_EXCLUSIONS.METHOD.FIXED_DISTANCE, + }, + }, + }, + }); + + const optimisticCommuterExclusionAction = buildOptimisticCommuterExclusionReportAction(commuterExclusion.toFixed(2), distanceUnit, currentUserAccountID); + onyxData?.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [optimisticCommuterExclusionAction.reportActionID]: optimisticCommuterExclusionAction, + }, + }); + + // Failure: revert the optimistic transaction merge and remove the optimistic system action. + onyxData?.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + modifiedAmount: null, + modifiedMerchant: null, + comment: { + customUnit: { + commuterExclusion: null, + reimbursableDistance: null, + commuterExclusionMethod: null, + }, + }, + }, + }); + onyxData?.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [optimisticCommuterExclusionAction.reportActionID]: null, + }, + }); + } + const isGPSDistanceRequest = transaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_GPS; if (isDistanceExpenseType(transaction.iouRequestType)) { From 932c326786505f7410104b9565e41b0f90bb6445 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Wed, 17 Jun 2026 15:07:07 +0000 Subject: [PATCH 06/64] Block manual and odometer entry on commuter-exclusion workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the destination policy has commuterExclusions configured, the user is no longer allowed to submit a manual or odometer distance expense — the exclusion is computed off the mapped route, so the mapped flow is the only correct entry. Show a localized form error explaining why, instead of silently accepting an entry that the backend wouldn't apply exclusions to. This complements the tab-hiding in DistanceRequestStartPage (which only hides the tabs when *every* workspace the user belongs to has exclusions); when the user has any non-exclusion workspace, the tabs stay visible but selecting an exclusion workspace on submission surfaces this error. --- .../iou/request/step/IOURequestStepDistanceManual.tsx | 7 +++++++ .../iou/request/step/IOURequestStepDistanceOdometer.tsx | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 0ca84fa421eb..6cab5d95c29b 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -270,6 +270,13 @@ function IOURequestStepDistanceManual({ }; const submitAndNavigateToNextPage = () => { + // Workspaces with commuter exclusions configured require map-based distance, since the + // exclusion is computed off the mapped route. Block manual entry for these workspaces. + if (policy?.commuterExclusions) { + setFormError(translate('distance.error.manualOdometerNotAllowedWithExclusion')); + return; + } + const value = numberFormRef.current?.getNumber() ?? ''; if (!value.length || parseFloat(value) <= 0) { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 0fb1ab5ba4c3..5792d5a3214f 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -448,6 +448,13 @@ function IOURequestStepDistanceOdometer({ // Handle form submission with validation const handleNext = () => { + // Workspaces with commuter exclusions configured require map-based distance entry, since the + // exclusion is computed off the mapped route. Block odometer entry for these workspaces. + if (policy?.commuterExclusions) { + setFormError(translate('distance.error.manualOdometerNotAllowedWithExclusion')); + return; + } + // Validation: Start and end readings must not be empty if (!startReading || !endReading) { setFormError(translate('iou.error.invalidReadings')); From 290daeefc80735063a3ce483f8e7d5430fcbdd28 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 18 Jun 2026 15:56:07 +0100 Subject: [PATCH 07/64] Consolidate commuter exclusion constants under CONST.POLICY Remove the top-level COMMUTER_EXCLUSIONS object and use the existing COMMUTER_EXCLUSION_METHOD and COMMUTER_EXCLUSION_TYPE constants within CONST.POLICY instead. --- src/CONST/index.ts | 12 ------------ src/libs/actions/IOU/Split.ts | 4 ++-- src/types/onyx/Policy.ts | 16 ---------------- src/types/onyx/Transaction.ts | 4 ++-- 4 files changed, 4 insertions(+), 32 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7cce459752ab..ac9ca947bd80 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1758,7 +1758,6 @@ const CONST = { UPDATE_CATEGORY_TAX_RATE: 'POLICYCHANGELOG_UPDATE_CATEGORY_TAX_RATE', UPDATE_CATEGORIES: 'POLICYCHANGELOG_UPDATE_CATEGORIES', UPDATE_CURRENCY: 'POLICYCHANGELOG_UPDATE_CURRENCY', - UPDATE_COMMUTER_EXCLUSIONS: 'POLICYCHANGELOG_UPDATE_COMMUTER_EXCLUSIONS', UPDATE_CUSTOM_UNIT: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT', UPDATE_CUSTOM_UNIT_RATE: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT_RATE', UPDATE_CUSTOM_UNIT_SUB_RATE: 'POLICYCHANGELOG_UPDATE_CUSTOM_UNIT_SUB_RATE', @@ -4314,17 +4313,6 @@ const CONST = { MERGE_EXPENSES: 'https://help.expensify.com/articles/new-expensify/reports-and-expenses/Merging-expenses', }, - COMMUTER_EXCLUSIONS: { - METHOD: { - FIXED_DISTANCE: 'fixedDistance', - HOME_AND_OFFICE: 'homeAndOffice', - }, - TYPE: { - DIRECT_COMMUTE: 'directCommute', - USUAL_COMMUTE: 'usualCommute', - }, - }, - CUSTOM_UNITS: { NAME_DISTANCE: 'Distance', NAME_PER_DIEM_INTERNATIONAL: 'Per Diem International', diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 20a6e3e3b023..d0ed78f5a31b 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -2104,7 +2104,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest // confirm or correct these values via the success-data replay. For any other method // (R2 homeAndOffice, future types) we leave optimistic behavior unchanged. const exclusionConfig = policy?.commuterExclusions; - const fixedExclusionDistance = exclusionConfig?.method === CONST.COMMUTER_EXCLUSIONS.METHOD.FIXED_DISTANCE ? Math.max(0, exclusionConfig.fixedDistance ?? 0) : 0; + const fixedExclusionDistance = exclusionConfig?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE ? Math.max(0, exclusionConfig.fixedDistance ?? 0) : 0; const quantity = transaction.comment?.customUnit?.quantity ?? 0; const distanceUnit = transaction.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; const distanceRate = transaction.comment?.customUnit?.defaultP2PRate ?? 0; @@ -2124,7 +2124,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest customUnit: { commuterExclusion, reimbursableDistance, - commuterExclusionMethod: CONST.COMMUTER_EXCLUSIONS.METHOD.FIXED_DISTANCE, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, }, }, }, diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 86f91c52bd77..7473b11e5d4c 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -2438,22 +2438,6 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the policy requires purchases to be on a company card */ requireCompanyCardsEnabled?: boolean; - - /** - * Workspace-level commuter exclusion config. - * When present and the method is supported, distance expenses on this policy - * deduct the configured exclusion from each expense's quantity. - */ - commuterExclusions?: { - /** R1 supports "fixedDistance"; R2 will add "homeAndOffice" */ - method: ValueOf; - - /** Distance to deduct from each expense (only set when method === "fixedDistance") */ - fixedDistance?: number; - - /** Unit the fixedDistance is expressed in ("mi" | "km") */ - fixedDistanceUnit?: Unit; - }; } & Partial, 'addWorkspaceRoom' | keyof ACHAccount | keyof Attributes | 'isHREnabled' | 'isTimeTrackingEnabled' | 'timeTrackingDefaultRate' >; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 8563c07cdc33..a39fe40cdbc8 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -217,10 +217,10 @@ type TransactionCustomUnit = { reimbursableDistance?: number; /** The kind of commute the exclusion represents (R3 — currently unused) */ - commuterExclusionType?: ValueOf; + commuterExclusionType?: ValueOf; /** How the exclusion was configured on the policy (R1: fixedDistance; R2: homeAndOffice) */ - commuterExclusionMethod?: ValueOf; + commuterExclusionMethod?: ValueOf; }; /** Types of geometry */ From 2f9b65aabfeb35fdfaf0c6a754a2850fc1475c75 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 18 Jun 2026 18:30:03 +0100 Subject: [PATCH 08/64] Add commuter exclusion breakdown and unit label utilities Add getCommuterExclusionBreakdown to compute the excluded commuter distance and reimbursable distance (from the transaction or previewed from the policy), and getDistanceUnitLabel to return the translated, pluralized unit label. --- src/libs/DistanceRequestUtils.ts | 54 ++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index a0288d2847c6..8615ecbfd526 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -327,6 +327,47 @@ function getDistanceRequestAmount(distance: number, unit: Unit, rate: number): n return roundDistanceAmount(convertDistanceUnit(distance, unit), rate); } +/** + * Computes the commuter exclusion breakdown (in display units) for a distance request. + * + * Prefers values already stored on the transaction (set optimistically or by the backend), + * otherwise previews the exclusion from the policy's commuter exclusion configuration. + * + * @param transaction - The distance transaction being confirmed/edited + * @param policy - The policy the expense belongs to + * @param quantityInUnit - The full route distance expressed in display units (mi/km) + * @returns The commuter exclusion and reimbursable distance, or null when no exclusion applies + */ +function getCommuterExclusionBreakdown( + transaction: OnyxEntry, + policy: OnyxEntry, + quantityInUnit: number, +): {commuterExclusion: number; reimbursableDistance: number} | null { + const customUnit = transaction?.comment?.customUnit; + let commuterExclusion = customUnit?.commuterExclusion; + let reimbursableDistance = customUnit?.reimbursableDistance; + + if ((commuterExclusion === undefined || commuterExclusion === null) && policy?.commuterExclusions) { + const exclusionConfig = policy.commuterExclusions; + if (exclusionConfig.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE && quantityInUnit > 0) { + const fixedDistance = exclusionConfig.fixedDistance ?? 0; + if (fixedDistance > 0) { + commuterExclusion = Math.min(fixedDistance, quantityInUnit); + reimbursableDistance = Math.max(0, quantityInUnit - commuterExclusion); + } + } + } + + if (!commuterExclusion || commuterExclusion <= 0) { + return null; + } + + return { + commuterExclusion, + reimbursableDistance: reimbursableDistance ?? Math.max(0, quantityInUnit - commuterExclusion), + }; +} + /** * Converts the distance from kilometers or miles to meters. * @@ -519,6 +560,17 @@ function getDistanceUnit(transaction: OnyxEntry, mileageRate: OnyxE return transaction?.comment?.customUnit?.distanceUnit ?? mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; } +/** + * Returns the translated distance unit label based on the distance value (for singular/plural). + */ +function getDistanceUnitLabel(distance: number, unit: string, translate: LocaleContextProps['translate']): string { + const isSingular = distance === 1; + if (unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES) { + return translate(isSingular ? 'common.mile' : 'common.miles'); + } + return translate(isSingular ? 'common.kilometer' : 'common.kilometers'); +} + /** @private This is only for internal use for getRate function */ function getPersonalPolicy() { return Object.values(allPolicies ?? {}).find((policy) => policy?.type === CONST.POLICY.TYPE.PERSONAL); @@ -663,6 +715,7 @@ export default { getDefaultMileageRate, getDistanceMerchant, getDistanceRequestAmount, + getCommuterExclusionBreakdown, getFormattedRateValue, getMileageRates, getDistanceForDisplay, @@ -672,6 +725,7 @@ export default { convertToDistanceInMeters, getTaxableAmount, getDistanceUnit, + getDistanceUnitLabel, getUpdatedDistanceUnit, getRate, getRateByCustomUnitRateID, From bbe4aa3cf9e9616ed0c9349fd4e1914df6a86687 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 18 Jun 2026 18:31:21 +0100 Subject: [PATCH 09/64] Display commuter exclusion breakdown in the distance field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the distance field as "Distance · Original: X" with the reimbursable distance as the main value and a "Removed X commuter" line via the shared DistanceWithCommuterExclusion component, on both the confirmation page and the expense view. Add the supporting translation strings. --- .../DistanceWithCommuterExclusion.tsx | 30 +++++++++ .../MoneyRequestConfirmationList.tsx | 28 ++++++++- .../DistanceWithCommuterExclusion.tsx | 62 ------------------- .../sections/DistanceField.tsx | 45 +++++++++++++- .../fieldGroupTypes.ts | 23 ++++++- .../fieldGroups/TransactionDetailsFields.tsx | 40 +++++------- .../ReportActionItem/MoneyRequestView.tsx | 43 +++++++++++-- src/languages/en.ts | 23 +++---- 8 files changed, 184 insertions(+), 110 deletions(-) create mode 100644 src/components/DistanceWithCommuterExclusion.tsx delete mode 100644 src/components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion.tsx diff --git a/src/components/DistanceWithCommuterExclusion.tsx b/src/components/DistanceWithCommuterExclusion.tsx new file mode 100644 index 000000000000..a726cc610d89 --- /dev/null +++ b/src/components/DistanceWithCommuterExclusion.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import CONST from '@src/CONST'; +import type {Unit} from '@src/types/onyx/Policy'; +import Text from './Text'; + +type DistanceWithCommuterExclusionProps = { + /** The commuter exclusion distance that was removed */ + commuterExclusion: number; + + /** The unit of distance (mi/km) */ + distanceUnit: Unit; +}; + +function DistanceWithCommuterExclusion({commuterExclusion, distanceUnit}: DistanceWithCommuterExclusionProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const unitLabel = DistanceRequestUtils.getDistanceUnitLabel(commuterExclusion, distanceUnit, translate); + const commuterLabel = translate('common.commuter'); + const formattedDistance = `${commuterExclusion.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${commuterLabel} ${unitLabel}`; + + return {translate('distance.commuterExclusion.removedCommuterDistance', {formattedDistance})}; +} + +DistanceWithCommuterExclusion.displayName = 'DistanceWithCommuterExclusion'; + +export default DistanceWithCommuterExclusion; diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index f2bfe1000b3c..6829698dae02 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -273,6 +273,32 @@ function MoneyRequestConfirmationList({ const shouldShowRateAutoUpdatedTooltip = isDistanceRequest && !!transaction?.comment?.customUnit?.rateAutoUpdated && !!transaction.created && DistanceRequestUtils.isRateEligibleForDate(mileageRate, transaction.created); + // Compute commuter exclusion data for the distance breakdown + const commuterExclusionData = useMemo(() => { + const customUnit = transaction?.comment?.customUnit; + const distanceUnitValue = customUnit?.distanceUnit ?? unit; + if (!distanceUnitValue) { + return null; + } + + // Use quantity from customUnit if available; otherwise convert route distance from meters to display unit + let quantity = customUnit?.quantity ?? 0; + if (quantity <= 0 && distance > 0) { + quantity = DistanceRequestUtils.convertDistanceUnit(distance, distanceUnitValue); + } + + const breakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, quantity); + if (!breakdown) { + return null; + } + + return { + commuterExclusion: breakdown.commuterExclusion, + reimbursableDistance: breakdown.reimbursableDistance, + distanceUnit: distanceUnitValue, + }; + }, [transaction, policy, distance, unit]); + const shouldShowCategories = isTrackExpense ? !policy || shouldSelectPolicy || !!iouCategory || hasEnabledOptions(Object.values(policyCategories ?? {})) : (isPolicyExpenseChat || isTypeInvoice) && (!!iouCategory || hasEnabledOptions(Object.values(policyCategories ?? {}))); @@ -531,7 +557,7 @@ function MoneyRequestConfirmationList({ isPolicyExpenseChat={isPolicyExpenseChat} expenseMode={{isDistance: isDistanceRequest, isTime: isTimeRequest, isInvoice: isTypeInvoice, isPerDiem: isPerDiemRequest}} distanceFlags={{isManualDistanceRequest, isOdometerDistanceRequest, isGPSDistanceRequest}} - distanceData={{distance, hasRoute, unit, rate, distanceRateName: mileageRate.name, distanceRateCurrency: currency, shouldShowRateAutoUpdatedTooltip}} + distanceData={{distance, hasRoute, unit, rate, distanceRateName: mileageRate.name, distanceRateCurrency: currency, shouldShowRateAutoUpdatedTooltip, commuterExclusionData}} amountDisplay={{amount: amountToBeUsed, formattedAmount, formattedAmountPerAttendee}} requiredFlags={{isCategoryRequired, isMerchantRequired, isDescriptionRequired}} visibilityFlags={{ diff --git a/src/components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion.tsx b/src/components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion.tsx deleted file mode 100644 index 0d5a33ec05fa..000000000000 --- a/src/components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import type {Unit} from '@src/types/onyx/Policy'; -import type {Transaction} from '@src/types/onyx'; - -type DistanceWithCommuterExclusionProps = { - transaction: Transaction | undefined; -}; - -function formatDistance(distance: number | undefined | null, unit: Unit | undefined): string { - if (distance === undefined || distance === null || !unit) { - return ''; - } - return `${distance.toFixed(2)} ${unit}`; -} - -/** - * Three-line distance breakdown shown on the confirmation, edit, and history surfaces - * whenever a workspace commuter exclusion has been applied to the transaction. - * - * Reads `comment.customUnit.{quantity, commuterExclusion, reimbursableDistance, distanceUnit}` - * and renders nothing when `commuterExclusion` is not positive. - */ -function DistanceWithCommuterExclusion({transaction}: DistanceWithCommuterExclusionProps) { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - - const customUnit = transaction?.comment?.customUnit; - const commuterExclusion = customUnit?.commuterExclusion; - - if (!customUnit || !commuterExclusion || commuterExclusion <= 0) { - return null; - } - - const unit = customUnit.distanceUnit; - const original = customUnit.quantity ?? 0; - const reimbursable = customUnit.reimbursableDistance ?? Math.max(0, original - commuterExclusion); - - return ( - - - {translate('distance.commuterExclusion.originalDistance')} - {formatDistance(original, unit)} - - - {translate('distance.commuterExclusion.commutingDistance')} - {`- ${formatDistance(commuterExclusion, unit)}`} - - - {translate('distance.commuterExclusion.reimbursableDistance')} - {formatDistance(reimbursable, unit)} - - - ); -} - -DistanceWithCommuterExclusion.displayName = 'DistanceWithCommuterExclusion'; - -export default DistanceWithCommuterExclusion; diff --git a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx index b7bc582b66c6..003cd9e211c4 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx @@ -1,5 +1,7 @@ -import React from 'react'; +import React, {useMemo} from 'react'; +import DistanceWithCommuterExclusion from '@components/DistanceWithCommuterExclusion'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import type {CommuterExclusionData} from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -24,8 +26,13 @@ type DistanceFieldProps = { iouType: Exclude; reportID: string; reportActionID: string | undefined; + commuterExclusionData?: CommuterExclusionData; }; +function formatDistance(distanceValue: number, distanceUnit: Unit): string { + return `${distanceValue.toFixed(2)} ${distanceUnit}`; +} + function DistanceField({ hasRoute, distance, @@ -41,15 +48,47 @@ function DistanceField({ iouType, reportID, reportActionID, + commuterExclusionData, }: DistanceFieldProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + // When commuter exclusion applies, compute the original distance and format the description/title + const {descriptionLabel, displayTitle, furtherDetailsComponent} = useMemo(() => { + const baseDescription = translate('common.distance'); + const baseTitle = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, undefined, isManualDistanceRequest); + + if (!commuterExclusionData || !unit) { + return { + descriptionLabel: baseDescription, + displayTitle: baseTitle, + furtherDetailsComponent: undefined, + }; + } + + // Calculate original distance (reimbursable + commuter exclusion) + const originalDistance = commuterExclusionData.reimbursableDistance + commuterExclusionData.commuterExclusion; + const originalDistanceFormatted = formatDistance(originalDistance, commuterExclusionData.distanceUnit); + const reimbursableFormatted = formatDistance(commuterExclusionData.reimbursableDistance, commuterExclusionData.distanceUnit); + + return { + descriptionLabel: `${baseDescription} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`, + displayTitle: reimbursableFormatted, + furtherDetailsComponent: ( + + ), + }; + }, [translate, hasRoute, distance, unit, rate, isManualDistanceRequest, commuterExclusionData]); + return ( { diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts b/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts index 462298a94da6..c82ad1c7765a 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts @@ -8,6 +8,13 @@ type AmountDisplay = { formattedAmountPerAttendee: string; }; +/** Commuter exclusion breakdown data */ +type CommuterExclusionData = { + commuterExclusion: number; + reimbursableDistance: number; + distanceUnit: Unit; +} | null; + /** Distance-rate metadata threaded into Distance/Rate fields */ type DistanceData = { distance: number; @@ -17,6 +24,7 @@ type DistanceData = { distanceRateName: string | undefined; distanceRateCurrency: string; shouldShowRateAutoUpdatedTooltip?: boolean; + commuterExclusionData?: CommuterExclusionData; }; /** Distance-mode discriminators (manual / odometer / GPS) */ @@ -90,4 +98,17 @@ type ReceiptOptions = { onPDFPassword?: () => void; }; -export type {AmountDisplay, CompactControls, CompactState, DistanceData, DistanceFlags, ErrorState, ExpenseMode, ReceiptOptions, RequiredFlags, ToggleHandlers, VisibilityFlags}; +export type { + AmountDisplay, + CompactControls, + CompactState, + CommuterExclusionData, + DistanceData, + DistanceFlags, + ErrorState, + ExpenseMode, + ReceiptOptions, + RequiredFlags, + ToggleHandlers, + VisibilityFlags, +}; diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx index ccb23e226594..5535e5508015 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context'; -import DistanceWithCommuterExclusion from '@components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion'; import AmountField from '@components/MoneyRequestConfirmationList/sections/AmountField'; import DescriptionField from '@components/MoneyRequestConfirmationList/sections/DescriptionField'; import DistanceField from '@components/MoneyRequestConfirmationList/sections/DistanceField'; @@ -9,9 +8,7 @@ import MerchantField from '@components/MoneyRequestConfirmationList/sections/Mer import RateField from '@components/MoneyRequestConfirmationList/sections/RateField'; import TimeFields from '@components/MoneyRequestConfirmationList/sections/TimeFields'; import type {AmountDisplay, DistanceData, ErrorState, RequiredFlags} from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; -import useOnyx from '@hooks/useOnyx'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {FieldVisibility} from './fieldVisibility'; @@ -81,7 +78,6 @@ function TransactionDetailsFields({ isOdometerDistanceRequest, isGPSDistanceRequest, } = useConfirmationFields(); - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const shouldAutoFocusAmountField = !canUseTouchScreen(); return ( @@ -143,25 +139,23 @@ function TransactionDetailsFields({ /> {fieldVisibility.distance && ( - <> - - - + )} {!isCompactMode && fieldVisibility.rate && ( diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 7c6e0da02fc8..8aa170058de9 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1,13 +1,13 @@ import {policyTypeSelector} from '@selectors/Policy'; import {Str} from 'expensify-common'; -import React, {useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; // Use the original useOnyx hook to get the real-time data from Onyx and not from the snapshot // eslint-disable-next-line no-restricted-imports import {useOnyx as originalUseOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import DistanceWithCommuterExclusion from '@components/MoneyRequestConfirmationList/DistanceWithCommuterExclusion'; +import DistanceWithCommuterExclusion from '@components/DistanceWithCommuterExclusion'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; @@ -545,6 +545,39 @@ function MoneyRequestView({ isOffline, ); const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, undefined, isManualDistanceRequest); + + // Extract primitive values from customUnit to avoid React Compiler mutable reference issues + const customUnitCommuterExclusion = transaction?.comment?.customUnit?.commuterExclusion; + const customUnitReimbursableDistance = transaction?.comment?.customUnit?.reimbursableDistance; + const customUnitDistanceUnit = transaction?.comment?.customUnit?.distanceUnit; + + // Compute commuter exclusion display data for the distance field + const commuterExclusionDisplayData = useMemo(() => { + const commuterExclusion = customUnitCommuterExclusion; + const reimbursableDistance = customUnitReimbursableDistance; + const distanceUnitValue = customUnitDistanceUnit ?? unit; + + if (!commuterExclusion || commuterExclusion <= 0 || !reimbursableDistance || !distanceUnitValue) { + return null; + } + + const formatDistanceValue = (value: number, unitValue: string) => `${value.toFixed(2)} ${unitValue}`; + const originalDistance = reimbursableDistance + commuterExclusion; + const originalDistanceFormatted = formatDistanceValue(originalDistance, distanceUnitValue); + const reimbursableFormatted = formatDistanceValue(reimbursableDistance, distanceUnitValue); + + return { + description: `${translate('common.distance')} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`, + title: reimbursableFormatted, + furtherDetailsComponent: ( + + ), + }; + }, [customUnitCommuterExclusion, customUnitReimbursableDistance, customUnitDistanceUnit, unit, translate]); + let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; let amountTitle = formattedTransactionAmount?.toString() || ''; if (isTransactionScanning) { @@ -839,8 +872,9 @@ function MoneyRequestView({ <> - ); diff --git a/src/languages/en.ts b/src/languages/en.ts index f95e85126628..4d65b4201b2c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -346,6 +346,7 @@ const translations = { miles: 'miles', kilometer: 'kilometer', kilometers: 'kilometers', + commuter: 'commuter', recent: 'Recent', all: 'All', am: 'AM', @@ -8019,16 +8020,7 @@ const translations = { removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `removed "${prohibitedExpense}" from prohibited expenses`, commuterExclusions: { changedToFixedDistance: 'changed exclude commutes to a fixed distance per claim', - setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => { - const isSingular = distance === 1; - let unitLabel: string; - if (unit === 'mi') { - unitLabel = isSingular ? 'mile' : 'miles'; - } else { - unitLabel = isSingular ? 'kilometer' : 'kilometers'; - } - return `set fixed distance exclusion to ${distance} ${unitLabel} per claim`; - }, + setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `set fixed distance exclusion to ${formattedDistance} per claim`, changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => `changed fixed distance exclusion to ${newDistance} ${unit} per claim (previously ${oldDistance} ${unit})`, disabled: 'disabled exclude commutes for distance rates', @@ -8883,13 +8875,14 @@ const translations = { }, error: { selectSuggestedAddress: 'Please select a suggested address or use current location', - manualOdometerNotAllowedWithExclusion: 'This workspace excludes part of your distance from reimbursement, so manual and odometer entry are disabled. Use the map to enter your route.', + manualOdometerNotAllowedWithExclusion: + 'This workspace excludes part of your distance from reimbursement, so manual and odometer entry are disabled. Use the map to enter your route.', }, commuterExclusion: { - originalDistance: 'Original distance', - commutingDistance: 'Removed commute distance', - reimbursableDistance: 'Reimbursable distance', - systemMessage: ({distance, unit}: {distance: string; unit: string}) => `${distance} ${unit} was removed for the daily commute.`, + original: 'Original', + removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Removed ${formattedDistance}`, + systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) => + `Removed ${formattedDistance} based on workspace distance settings.`, }, odometer: { startReading: 'Start reading', From c41d0749add378803ec1db71488f460e276b4091 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 18 Jun 2026 18:31:38 +0100 Subject: [PATCH 10/64] Apply reimbursable amount and seed optimistic commuter exclusion data Base the distance request amount on the reimbursable distance after the commuter exclusion, and build the optimistic transaction fields and system action on creation. Derive the quantity from the route distance when it isn't populated yet so the exclusion is reflected immediately for route-based requests. --- .../DistanceRequestController.tsx | 6 +++--- .../hooks/useDistanceRequestState.ts | 8 +++++++- src/libs/actions/IOU/Split.ts | 17 +++++++++++------ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx index a093b2a7c694..a1cf8c2e8e2b 100644 --- a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx +++ b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx @@ -138,10 +138,10 @@ function DistanceRequestController({ if (isReadOnly) { return; } - const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0); - setMoneyRequestAmount(transactionID, amount, currency ?? ''); + // Use the (commuter-exclusion-aware) reimbursable amount so the seeded amount matches what the backend will calculate. + setMoneyRequestAmount(transactionID, distanceRequestAmount, currency ?? ''); isFirstUpdatedDistanceAmount.current = true; - }, [distance, rate, isReadOnly, unit, transactionID, currency, isDistanceRequest]); + }, [distanceRequestAmount, isReadOnly, transactionID, currency, isDistanceRequest]); useEffect(() => { if (!shouldCalculateDistanceAmount || !transactionID || isReadOnly) { diff --git a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts index 1c5717976390..081720a631c8 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts @@ -96,7 +96,13 @@ function useDistanceRequestState({ const hasRoute = hasRouteUtil(transaction, isDistanceRequest); const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense; - const distanceRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0); + // When a commuter exclusion applies, the reimbursable amount is based on the distance remaining after the exclusion. + const amountUnit = unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const quantityInUnit = DistanceRequestUtils.convertDistanceUnit(distance, amountUnit); + const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, quantityInUnit); + const reimbursableDistanceInMeters = commuterExclusionBreakdown ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionBreakdown.reimbursableDistance, amountUnit) : distance; + + const distanceRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(reimbursableDistanceInMeters, amountUnit, rate ?? 0); return { policyDraft, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index d0ed78f5a31b..3aff07746a3c 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -8,6 +8,7 @@ import type {CompleteSplitBillParams, CreateDistanceRequestParams, SplitBillPara import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {deferOrExecuteWrite} from '@libs/deferredLayoutWrite'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {calculateAmount as calculateIOUAmount, updateIOUOwnerAndTotal} from '@libs/IOUUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; @@ -2103,14 +2104,18 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest // system message render instantly. The server's createDistanceRequest response will // confirm or correct these values via the success-data replay. For any other method // (R2 homeAndOffice, future types) we leave optimistic behavior unchanged. - const exclusionConfig = policy?.commuterExclusions; - const fixedExclusionDistance = exclusionConfig?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE ? Math.max(0, exclusionConfig.fixedDistance ?? 0) : 0; - const quantity = transaction.comment?.customUnit?.quantity ?? 0; const distanceUnit = transaction.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; const distanceRate = transaction.comment?.customUnit?.defaultP2PRate ?? 0; - if (fixedExclusionDistance > 0 && quantity > 0 && chatReport?.reportID && currentUserAccountID) { - const commuterExclusion = Math.min(fixedExclusionDistance, quantity); - const reimbursableDistance = Math.max(0, quantity - commuterExclusion); + + // Route-based requests don't have a `quantity` until the route resolves, so fall back to converting the + // route distance (in meters) into display units to preview the exclusion immediately on creation. + let quantityInUnit = transaction.comment?.customUnit?.quantity ?? 0; + if (quantityInUnit <= 0 && distance && distance > 0) { + quantityInUnit = DistanceRequestUtils.convertDistanceUnit(distance, distanceUnit); + } + const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, quantityInUnit); + if (commuterExclusionBreakdown && chatReport?.reportID && currentUserAccountID) { + const {commuterExclusion, reimbursableDistance} = commuterExclusionBreakdown; const modifiedRequestAmount = Math.round(reimbursableDistance * distanceRate); const modifiedRequestMerchant = `${reimbursableDistance.toFixed(2)} ${distanceUnit} @ ${distanceRate} / ${distanceUnit}`; From 103dcd5b5c1cbafa7b5eaf9250a14d74e3c64f1c Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 18 Jun 2026 18:31:51 +0100 Subject: [PATCH 11/64] Render commuter exclusion system message as HTML with settings link Build the commuter exclusion and fixed-distance report action messages from a preformatted distance, and render the commuter exclusion message as HTML linking to the workspace distance settings. --- src/libs/ReportActionsUtils.ts | 19 +++++++++++++++---- .../actionContents/ActionContentRouter.tsx | 6 +++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 86f521e9a801..77ce8cb8f610 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -48,6 +48,7 @@ import {getBankName, isCardPendingActivate} from './CardUtils'; import {getDecodedCategoryName} from './CategoryUtils'; import {convertAmountToDisplayString, convertToBackendAmount, convertToDisplayString, convertToDisplayStringWithExplicitCurrency, convertToShortDisplayString} from './CurrencyUtils'; import DateUtils from './DateUtils'; +import DistanceRequestUtils from './DistanceRequestUtils'; import {getEnvironmentURL, getOldDotEnvironmentURL} from './Environment/Environment'; import getBase62ReportID from './getBase62ReportID'; import {isReportMessageAttachment} from './isReportMessageAttachment'; @@ -4204,7 +4205,9 @@ function getUpdatedCommuterExclusionsMessage(translate: LocalizedTranslate, repo const unit = originalMessage?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; if (originalMessage?.oldValue == null) { - return translate('workspaceActions.commuterExclusions.setFixedDistance', {distance: newValue, unit}); + const unitLabel = DistanceRequestUtils.getDistanceUnitLabel(newValue, unit, translate); + const formattedDistance = `${newValue} ${unitLabel}`; + return translate('workspaceActions.commuterExclusions.setFixedDistance', {formattedDistance}); } const oldValue = typeof originalMessage.oldValue === 'number' ? originalMessage.oldValue : Number(originalMessage.oldValue); @@ -4659,11 +4662,19 @@ function getPlaidBalanceFailureMessage(translate: LocalizedTranslate, action: On }); } -function getCommuterExclusionMessage(translate: LocalizedTranslate, action: OnyxEntry): string { +function getCommuterExclusionMessage(translate: LocalizedTranslate, action: OnyxEntry, policyID?: string): string { const original = getOriginalMessage(action as ReportAction) ?? {distance: '0', unit: ''}; + const distanceValue = Number(original.distance ?? 0); + const unit = String(original.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + const unitLabel = DistanceRequestUtils.getDistanceUnitLabel(distanceValue, unit, translate); + const commuterLabel = translate('common.commuter'); + const formattedDistance = `${distanceValue.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${commuterLabel} ${unitLabel}`; + + const workspaceDistanceSettingsLink = policyID ? `${environmentURL}/${ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID)}` : ''; + return translate('distance.commuterExclusion.systemMessage', { - distance: String(original.distance ?? '0'), - unit: String(original.unit ?? ''), + formattedDistance, + workspaceDistanceSettingsLink, }); } diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index 9608e96281c5..634f22d6f527 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -447,7 +447,11 @@ function ActionContentRouter({ ); } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.COMMUTER_EXCLUSION)) { - return ; + return ( + + ${getCommuterExclusionMessage(translate, action, report?.policyID)}`} /> + + ); } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.CREATED) && isHarvestCreatedExpenseReport) { return ; From a3660477a3aa65fd7e80dfd546e39405873ed2ce Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 18 Jun 2026 19:19:50 +0000 Subject: [PATCH 12/64] Remove buildOptimisticCommuterExclusionReportAction function and its references. Update distance request handling to calculate reimbursable distance using server-side logic instead of optimistic actions. --- src/libs/ReportUtils.ts | 35 ----------------------------------- src/libs/actions/IOU/Split.ts | 26 ++++++-------------------- 2 files changed, 6 insertions(+), 55 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 88e984d2b589..5f8fe25a7f9d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4209,40 +4209,6 @@ function buildOptimisticChangeFieldAction(reportField: PolicyReportField, previo }; } -/** - * Builds an optimistic COMMUTER_EXCLUSION report action posted to the workspace chat - * when a distance expense has the workspace's commuter exclusion applied. - * - * `distance` is the deducted distance formatted to two decimals; `unit` is "mi" or "km". - */ -function buildOptimisticCommuterExclusionReportAction(distance: string, unit: string, currentUserAccountID: number): ReportAction { - return { - actionName: CONST.REPORT.ACTIONS.TYPE.COMMUTER_EXCLUSION, - actorAccountID: currentUserAccountID, - message: [ - { - type: CONST.REPORT.MESSAGE.TYPE.COMMENT, - text: `${distance} ${unit} was removed for the daily commute.`, - }, - ], - originalMessage: { - distance, - unit, - }, - person: [ - { - style: 'strong', - text: getCurrentUserDisplayNameOrEmail(), - type: 'TEXT', - }, - ], - reportActionID: rand64(), - shouldShow: true, - created: DateUtils.getDBTime(), - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }; -} - /** * Builds an optimistic REIMBURSEMENT_DEQUEUED report action with a randomly generated reportActionID. * @@ -13222,7 +13188,6 @@ export { buildOptimisticCreatedReportForUnapprovedAction, buildOptimisticUnapprovedReportAction, buildOptimisticCancelPaymentReportAction, - buildOptimisticCommuterExclusionReportAction, buildOptimisticChangedTaskAssigneeReportAction, buildOptimisticChatReport, buildOptimisticClosedReportAction, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 3aff07746a3c..f9c1d752bf2f 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -23,7 +23,6 @@ import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {getReportActionHtml, getReportActionText} from '@libs/ReportActionsUtils'; import { buildOptimisticChatReport, - buildOptimisticCommuterExclusionReportAction, buildOptimisticCreatedReportAction, buildOptimisticExpenseReport, buildOptimisticIOUReport, @@ -2114,9 +2113,11 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest quantityInUnit = DistanceRequestUtils.convertDistanceUnit(distance, distanceUnit); } const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, quantityInUnit); - if (commuterExclusionBreakdown && chatReport?.reportID && currentUserAccountID) { + if (commuterExclusionBreakdown) { const {commuterExclusion, reimbursableDistance} = commuterExclusionBreakdown; - const modifiedRequestAmount = Math.round(reimbursableDistance * distanceRate); + // Use the canonical distance-amount calc so the optimistic amount rounds the same way the server does. + const reimbursableDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(reimbursableDistance, distanceUnit); + const modifiedRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(reimbursableDistanceInMeters, distanceUnit, distanceRate); const modifiedRequestMerchant = `${reimbursableDistance.toFixed(2)} ${distanceUnit} @ ${distanceRate} / ${distanceUnit}`; onyxData?.optimisticData?.push({ @@ -2135,16 +2136,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest }, }); - const optimisticCommuterExclusionAction = buildOptimisticCommuterExclusionReportAction(commuterExclusion.toFixed(2), distanceUnit, currentUserAccountID); - onyxData?.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, - value: { - [optimisticCommuterExclusionAction.reportActionID]: optimisticCommuterExclusionAction, - }, - }); - - // Failure: revert the optimistic transaction merge and remove the optimistic system action. + // Failure: revert the optimistic transaction merge. The commuter-exclusion system message is + // created and reconciled by the server (Auth's CreateDistanceRequest), so it isn't seeded here. onyxData?.failureData?.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, @@ -2160,13 +2153,6 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest }, }, }); - onyxData?.failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, - value: { - [optimisticCommuterExclusionAction.reportActionID]: null, - }, - }); } const isGPSDistanceRequest = transaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_GPS; From 283327809009290ba446203362428766ff4582ce Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 18 Jun 2026 19:54:39 +0000 Subject: [PATCH 13/64] fix: always use computed distanceRequestAmount for distance requests For distance requests, always use the computed distanceRequestAmount since it accounts for commuter exclusions and rate changes. This avoids stale iouAmount values that don't reflect the current commuter exclusion. --- .../hooks/useConfirmationAmount.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationAmount.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationAmount.ts index b126eda740f3..eae90e1a684a 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationAmount.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationAmount.ts @@ -79,10 +79,10 @@ function useConfirmationAmount({ const subRates = transaction?.comment?.customUnit?.subRates ?? []; const shouldCalculatePerDiemAmount = isPerDiemRequest && (iouAmount === 0 || JSON.stringify(prevSubRates) !== JSON.stringify(subRates) || prevCurrency !== currency); - let amountToBeUsed = iouAmount; - if (shouldCalculateDistanceAmount) { - amountToBeUsed = distanceRequestAmount; - } else if (shouldCalculatePerDiemAmount) { + // For distance requests, always use the computed distanceRequestAmount since it accounts for + // commuter exclusions and rate changes. This avoids stale iouAmount values. + let amountToBeUsed = isDistanceRequest ? distanceRequestAmount : iouAmount; + if (shouldCalculatePerDiemAmount) { amountToBeUsed = computePerDiemExpenseAmount({subRates}); } From 7bc17b88f94ccdafb02c720a9d2f98c26c135103 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 18 Jun 2026 19:54:50 +0000 Subject: [PATCH 14/64] fix: pass commuterExclusionBreakdown from hook to confirmation UI - Return commuterExclusionBreakdown from useDistanceRequestState - Create commuterExclusionData in MoneyRequestConfirmationList with proper unit fallbacks to handle undefined cases - Update DistanceField to display commuter exclusion UI using the computed breakdown, with fallback for unit prop --- .../MoneyRequestConfirmationList.tsx | 27 +++++++------------ .../hooks/useDistanceRequestState.ts | 1 + .../sections/DistanceField.tsx | 12 ++++++--- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 6829698dae02..85e7fd76c0da 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -258,7 +258,7 @@ function MoneyRequestConfirmationList({ const subRates = transaction?.comment?.customUnit?.subRates ?? []; const prevSubRates = usePrevious(subRates); - const {defaultRate, mileageRate, unit, rate, currency, prevCurrency, distance, shouldCalculateDistanceAmount, hasRoute, isDistanceRequestWithPendingRoute, distanceRequestAmount} = + const {defaultRate, mileageRate, unit, rate, currency, prevCurrency, distance, shouldCalculateDistanceAmount, hasRoute, isDistanceRequestWithPendingRoute, distanceRequestAmount, commuterExclusionBreakdown} = useDistanceRequestState({ transaction, policy, @@ -273,31 +273,21 @@ function MoneyRequestConfirmationList({ const shouldShowRateAutoUpdatedTooltip = isDistanceRequest && !!transaction?.comment?.customUnit?.rateAutoUpdated && !!transaction.created && DistanceRequestUtils.isRateEligibleForDate(mileageRate, transaction.created); - // Compute commuter exclusion data for the distance breakdown + // Build commuter exclusion data for the distance breakdown display from the hook's computed breakdown const commuterExclusionData = useMemo(() => { - const customUnit = transaction?.comment?.customUnit; - const distanceUnitValue = customUnit?.distanceUnit ?? unit; - if (!distanceUnitValue) { + if (!commuterExclusionBreakdown) { return null; } - // Use quantity from customUnit if available; otherwise convert route distance from meters to display unit - let quantity = customUnit?.quantity ?? 0; - if (quantity <= 0 && distance > 0) { - quantity = DistanceRequestUtils.convertDistanceUnit(distance, distanceUnitValue); - } - - const breakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, quantity); - if (!breakdown) { - return null; - } + // Use the same fallback as useDistanceRequestState for consistent unit handling + const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; return { - commuterExclusion: breakdown.commuterExclusion, - reimbursableDistance: breakdown.reimbursableDistance, + commuterExclusion: commuterExclusionBreakdown.commuterExclusion, + reimbursableDistance: commuterExclusionBreakdown.reimbursableDistance, distanceUnit: distanceUnitValue, }; - }, [transaction, policy, distance, unit]); + }, [commuterExclusionBreakdown, transaction?.comment?.customUnit?.distanceUnit, unit]); const shouldShowCategories = isTrackExpense ? !policy || shouldSelectPolicy || !!iouCategory || hasEnabledOptions(Object.values(policyCategories ?? {})) @@ -626,6 +616,7 @@ function MoneyRequestConfirmationList({ currentUserAccountID={currentUserPersonalDetails.accountID} setFormError={setFormError} clearFormErrors={clearFormErrors} + commuterExclusionBreakdown={commuterExclusionBreakdown} /> Date: Thu, 18 Jun 2026 19:54:59 +0000 Subject: [PATCH 15/64] fix: show reimbursable distance in merchant text with commuter exclusion When commuter exclusion applies, display the reimbursable distance (not the full distance) in the merchant text so it matches the calculated amount. --- .../DistanceRequestController.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx index a1cf8c2e8e2b..44c688326720 100644 --- a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx +++ b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx @@ -15,6 +15,11 @@ import type {Policy, Transaction} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; +type CommuterExclusionBreakdown = { + commuterExclusion: number; + reimbursableDistance: number; +} | null; + type DistanceRequestControllerProps = { transactionID: string | undefined; transaction: OnyxEntry; @@ -41,6 +46,7 @@ type DistanceRequestControllerProps = { selectedParticipantsProp: Participant[]; setFormError: (error: TranslationPaths | '') => void; clearFormErrors: (errors: string[]) => void; + commuterExclusionBreakdown: CommuterExclusionBreakdown; }; /** @@ -74,6 +80,7 @@ function DistanceRequestController({ selectedParticipantsProp, setFormError, clearFormErrors, + commuterExclusionBreakdown, }: DistanceRequestControllerProps) { const {translate, toLocaleDigit} = useLocalize(); const {getCurrencySymbol} = useCurrencyListActions(); @@ -218,9 +225,11 @@ function DistanceRequestController({ */ setMoneyRequestPendingFields(transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); + // When commuter exclusion applies, show the reimbursable distance in the merchant text + const displayDistance = commuterExclusionBreakdown ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionBreakdown.reimbursableDistance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES) : distance; const distanceMerchant = DistanceRequestUtils.getDistanceMerchant( hasRoute, - distance, + displayDistance, unit, rate ?? 0, currency ?? CONST.CURRENCY.USD, @@ -245,6 +254,7 @@ function DistanceRequestController({ isReadOnly, getCurrencySymbol, isManualDistanceRequest, + commuterExclusionBreakdown, ]); return null; From ad78962fa3fba380216229192f7e32a242ccb04c Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 18 Jun 2026 19:55:09 +0000 Subject: [PATCH 16/64] fix: compute commuter exclusion from policy in MoneyRequestView In offline mode, the transaction may not have commuterExclusion stored yet. Fall back to computing it from the policy's commuterExclusions config, enabling instant display of commuter exclusion breakdown before the server response. --- .../ReportActionItem/MoneyRequestView.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 8aa170058de9..4e12f842f065 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -550,21 +550,38 @@ function MoneyRequestView({ const customUnitCommuterExclusion = transaction?.comment?.customUnit?.commuterExclusion; const customUnitReimbursableDistance = transaction?.comment?.customUnit?.reimbursableDistance; const customUnitDistanceUnit = transaction?.comment?.customUnit?.distanceUnit; + const customUnitQuantity = transaction?.comment?.customUnit?.quantity; // Compute commuter exclusion display data for the distance field const commuterExclusionDisplayData = useMemo(() => { - const commuterExclusion = customUnitCommuterExclusion; - const reimbursableDistance = customUnitReimbursableDistance; - const distanceUnitValue = customUnitDistanceUnit ?? unit; + const distanceUnitValue = customUnitDistanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - if (!commuterExclusion || commuterExclusion <= 0 || !reimbursableDistance || !distanceUnitValue) { + let commuterExclusion = customUnitCommuterExclusion; + let reimbursableDistanceValue = customUnitReimbursableDistance; + + // If commuter exclusion isn't stored in transaction, compute it from policy + if ((commuterExclusion === undefined || commuterExclusion === null) && policy?.commuterExclusions) { + // Get quantity from transaction or compute from distance + let quantityInUnit = customUnitQuantity ?? 0; + if (quantityInUnit <= 0 && distance > 0) { + quantityInUnit = DistanceRequestUtils.convertDistanceUnit(distance, distanceUnitValue); + } + + const breakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, quantityInUnit); + if (breakdown) { + commuterExclusion = breakdown.commuterExclusion; + reimbursableDistanceValue = breakdown.reimbursableDistance; + } + } + + if (!commuterExclusion || commuterExclusion <= 0 || !reimbursableDistanceValue || !distanceUnitValue) { return null; } const formatDistanceValue = (value: number, unitValue: string) => `${value.toFixed(2)} ${unitValue}`; - const originalDistance = reimbursableDistance + commuterExclusion; + const originalDistance = reimbursableDistanceValue + commuterExclusion; const originalDistanceFormatted = formatDistanceValue(originalDistance, distanceUnitValue); - const reimbursableFormatted = formatDistanceValue(reimbursableDistance, distanceUnitValue); + const reimbursableFormatted = formatDistanceValue(reimbursableDistanceValue, distanceUnitValue); return { description: `${translate('common.distance')} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`, @@ -576,7 +593,7 @@ function MoneyRequestView({ /> ), }; - }, [customUnitCommuterExclusion, customUnitReimbursableDistance, customUnitDistanceUnit, unit, translate]); + }, [customUnitCommuterExclusion, customUnitReimbursableDistance, customUnitDistanceUnit, customUnitQuantity, unit, policy, transaction, distance, translate]); let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; let amountTitle = formattedTransactionAmount?.toString() || ''; From e45fffd4eb12cb00accb0ff05b83f26fd1e0316f Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 11:13:13 +0100 Subject: [PATCH 17/64] Use Onyx selector for commuter exclusion tab check, exclude personal policies --- .../iou/request/DistanceRequestStartPage.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 378562f55aec..3ec84553fab4 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -50,16 +50,12 @@ function DistanceRequestStartPage({ const isLoadingSelectedTab = isLoadingOnyxValue(selectedTabResult); const isTrackDistanceExpense = iouType === CONST.IOU.TYPE.TRACK; - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - // When every workspace the user belongs to has commuter exclusions configured, manual and odometer - // entry are disabled — the exclusion is computed off the mapped route, so the user must use the map tab. - const shouldHideManualAndOdometerTabs = useMemo(() => { - const policies = Object.values(allPolicies ?? {}).filter((p): p is NonNullable => !!p && !!p.id); - if (policies.length === 0) { - return false; - } - return policies.every((p) => !!p.commuterExclusions); - }, [allPolicies]); + const [shouldHideManualAndOdometerTabs] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: (allPolicies) => { + const policies = Object.values(allPolicies ?? {}).filter((p) => !!p?.id && p.type !== CONST.POLICY.TYPE.PERSONAL); + return policies.length > 0 && policies.every((p) => !!p?.commuterExclusions); + }, + }); const tabTitles = { [CONST.IOU.TYPE.REQUEST]: translate('iou.trackDistance'), From 9d802228c643a0cdaf3f472e592a9eb819208023 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 12:00:48 +0100 Subject: [PATCH 18/64] Centralize commuter exclusion distance display in getDistanceForDisplay Move quantity derivation into getCommuterExclusionBreakdown and let getDistanceForDisplay render the reimbursable distance via an optional commuterExclusionData param, removing duplicated formatting across MoneyRequestView and DistanceField. --- .../hooks/useDistanceRequestState.ts | 3 +- .../sections/DistanceField.tsx | 6 +- .../ReportActionItem/MoneyRequestView.tsx | 72 ++++++------------- src/libs/DistanceRequestUtils.ts | 36 +++++++--- src/libs/ReportActionsUtils.ts | 8 +-- src/libs/actions/IOU/Split.ts | 8 +-- 6 files changed, 53 insertions(+), 80 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts index 8a396f1d4a12..95dd81102e13 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts @@ -98,8 +98,7 @@ function useDistanceRequestState({ // When a commuter exclusion applies, the reimbursable amount is based on the distance remaining after the exclusion. const amountUnit = unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const quantityInUnit = DistanceRequestUtils.convertDistanceUnit(distance, amountUnit); - const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, quantityInUnit); + const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, distance, amountUnit); const reimbursableDistanceInMeters = commuterExclusionBreakdown ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionBreakdown.reimbursableDistance, amountUnit) : distance; const distanceRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(reimbursableDistanceInMeters, amountUnit, rate ?? 0); diff --git a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx index da872615f896..0dac179f4068 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx @@ -67,15 +67,11 @@ function DistanceField({ } // Use commuterExclusionData.distanceUnit as fallback when unit prop is undefined - const displayUnit = unit ?? commuterExclusionData.distanceUnit; - // Calculate original distance (reimbursable + commuter exclusion) const originalDistance = commuterExclusionData.reimbursableDistance + commuterExclusionData.commuterExclusion; const originalDistanceFormatted = formatDistance(originalDistance, commuterExclusionData.distanceUnit); - // Use reimbursable distance in meters for the display (includes rate formatting) - const reimbursableDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, commuterExclusionData.distanceUnit); - const reimbursableTitle = DistanceRequestUtils.getDistanceForDisplay(true, reimbursableDistanceInMeters, displayUnit, rate, translate, undefined, isManualDistanceRequest); + const reimbursableTitle = DistanceRequestUtils.getDistanceForDisplay(true, distance, unit, rate, translate, undefined, isManualDistanceRequest, commuterExclusionData); return { descriptionLabel: `${baseDescription} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`, diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3e3a0707e6d0..8832cf97ae98 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -544,56 +544,24 @@ function MoneyRequestView({ getCurrencySymbol, isOffline, ); - const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, undefined, isManualDistanceRequest); - - // Extract primitive values from customUnit to avoid React Compiler mutable reference issues - const customUnitCommuterExclusion = transaction?.comment?.customUnit?.commuterExclusion; - const customUnitReimbursableDistance = transaction?.comment?.customUnit?.reimbursableDistance; - const customUnitDistanceUnit = transaction?.comment?.customUnit?.distanceUnit; - const customUnitQuantity = transaction?.comment?.customUnit?.quantity; - - // Compute commuter exclusion display data for the distance field - const commuterExclusionDisplayData = useMemo(() => { - const distanceUnitValue = customUnitDistanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - - let commuterExclusion = customUnitCommuterExclusion; - let reimbursableDistanceValue = customUnitReimbursableDistance; - - // If commuter exclusion isn't stored in transaction, compute it from policy - if ((commuterExclusion === undefined || commuterExclusion === null) && policy?.commuterExclusions) { - // Get quantity from transaction or compute from distance - let quantityInUnit = customUnitQuantity ?? 0; - if (quantityInUnit <= 0 && distance > 0) { - quantityInUnit = DistanceRequestUtils.convertDistanceUnit(distance, distanceUnitValue); - } - - const breakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, quantityInUnit); - if (breakdown) { - commuterExclusion = breakdown.commuterExclusion; - reimbursableDistanceValue = breakdown.reimbursableDistance; - } - } - - if (!commuterExclusion || commuterExclusion <= 0 || !reimbursableDistanceValue || !distanceUnitValue) { - return null; - } - const formatDistanceValue = (value: number, unitValue: string) => `${value.toFixed(2)} ${unitValue}`; - const originalDistance = reimbursableDistanceValue + commuterExclusion; - const originalDistanceFormatted = formatDistanceValue(originalDistance, distanceUnitValue); - const reimbursableFormatted = formatDistanceValue(reimbursableDistanceValue, distanceUnitValue); - - return { - description: `${translate('common.distance')} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`, - title: reimbursableFormatted, - furtherDetailsComponent: ( - - ), - }; - }, [customUnitCommuterExclusion, customUnitReimbursableDistance, customUnitDistanceUnit, customUnitQuantity, unit, policy, transaction, distance, translate]); + const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, distance, distanceUnitValue); + const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, undefined, isManualDistanceRequest, commuterExclusionBreakdown); + let distanceToDisplayDescription = translate('common.distance'); + let distanceToDisplaySubtitle; + + if (commuterExclusionBreakdown) { + const {commuterExclusion, reimbursableDistance} = commuterExclusionBreakdown; + const originalDistanceFormatted = `${(reimbursableDistance + commuterExclusion).toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${distanceUnitValue}`; + distanceToDisplayDescription = `${translate('common.distance')} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`; + distanceToDisplaySubtitle = ( + + ); + } let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; let amountTitle = formattedTransactionAmount?.toString() || ''; @@ -889,9 +857,9 @@ function MoneyRequestView({ <> , policy: OnyxEntry, - quantityInUnit: number, -): {commuterExclusion: number; reimbursableDistance: number} | null { + distanceInMeters: number, + distanceUnit: Unit, +): {commuterExclusion: number; reimbursableDistance: number; distanceUnit: Unit} | null { const customUnit = transaction?.comment?.customUnit; + + // Prefer the stored quantity; fall back to converting the route distance for route-based + // requests whose quantity hasn't resolved yet. + let quantityInUnit = customUnit?.quantity ?? 0; + if (quantityInUnit <= 0 && distanceInMeters > 0) { + quantityInUnit = convertDistanceUnit(distanceInMeters, distanceUnit); + } + let commuterExclusion = customUnit?.commuterExclusion; let reimbursableDistance = customUnit?.reimbursableDistance; - if ((commuterExclusion === undefined || commuterExclusion === null) && policy?.commuterExclusions) { + if (!commuterExclusion && policy?.commuterExclusions) { const exclusionConfig = policy.commuterExclusions; if (exclusionConfig.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE && quantityInUnit > 0) { const fixedDistance = exclusionConfig.fixedDistance ?? 0; @@ -365,6 +380,7 @@ function getCommuterExclusionBreakdown( return { commuterExclusion, reimbursableDistance: reimbursableDistance ?? Math.max(0, quantityInUnit - commuterExclusion), + distanceUnit: distanceUnit, }; } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index ea97f88610df..0facbfc7efca 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4691,10 +4691,10 @@ function getPlaidBalanceFailureMessage(translate: LocalizedTranslate, action: On } function getCommuterExclusionMessage(translate: LocalizedTranslate, action: OnyxEntry, policyID?: string): string { - const original = getOriginalMessage(action as ReportAction) ?? {distance: '0', unit: ''}; - const distanceValue = Number(original.distance ?? 0); - const unit = String(original.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); - const unitLabel = DistanceRequestUtils.getDistanceUnitLabel(distanceValue, unit, translate); + const {distance, unit} = getOriginalMessage(action as ReportAction) ?? {distance: '0', unit: ''}; + const distanceValue = Number(distance ?? 0); + const unitValue = String(unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + const unitLabel = DistanceRequestUtils.getDistanceUnitLabel(distanceValue, unitValue, translate); const commuterLabel = translate('common.commuter'); const formattedDistance = `${distanceValue.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${commuterLabel} ${unitLabel}`; diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index f9c1d752bf2f..b72fe97b0506 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -2106,13 +2106,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest const distanceUnit = transaction.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; const distanceRate = transaction.comment?.customUnit?.defaultP2PRate ?? 0; - // Route-based requests don't have a `quantity` until the route resolves, so fall back to converting the - // route distance (in meters) into display units to preview the exclusion immediately on creation. - let quantityInUnit = transaction.comment?.customUnit?.quantity ?? 0; - if (quantityInUnit <= 0 && distance && distance > 0) { - quantityInUnit = DistanceRequestUtils.convertDistanceUnit(distance, distanceUnit); - } - const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, quantityInUnit); + const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, distance ?? 0, distanceUnit); if (commuterExclusionBreakdown) { const {commuterExclusion, reimbursableDistance} = commuterExclusionBreakdown; // Use the canonical distance-amount calc so the optimistic amount rounds the same way the server does. From 8b64f38b8267533f00a45647bc31302e75f66b6a Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 12:07:38 +0100 Subject: [PATCH 19/64] Align DistanceField commuter exclusion display with MoneyRequestView style --- .../sections/DistanceField.tsx | 43 ++++++------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx index 0dac179f4068..ac956e043a5d 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import DistanceWithCommuterExclusion from '@components/DistanceWithCommuterExclusion'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import type {CommuterExclusionData} from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; @@ -53,37 +53,22 @@ function DistanceField({ const styles = useThemeStyles(); const {translate} = useLocalize(); - // When commuter exclusion applies, compute the original distance and format the description/title - const {descriptionLabel, displayTitle, furtherDetailsComponent} = useMemo(() => { - const baseDescription = translate('common.distance'); - const baseTitle = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, undefined, isManualDistanceRequest); + // When commuter exclusion applies, the title shows the reimbursable distance and the description shows the original + const displayTitle = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, undefined, isManualDistanceRequest, commuterExclusionData); + let descriptionLabel = translate('common.distance'); + let furtherDetailsComponent; - if (!commuterExclusionData) { - return { - descriptionLabel: baseDescription, - displayTitle: baseTitle, - furtherDetailsComponent: undefined, - }; - } - - // Use commuterExclusionData.distanceUnit as fallback when unit prop is undefined - // Calculate original distance (reimbursable + commuter exclusion) + if (commuterExclusionData) { const originalDistance = commuterExclusionData.reimbursableDistance + commuterExclusionData.commuterExclusion; const originalDistanceFormatted = formatDistance(originalDistance, commuterExclusionData.distanceUnit); - - const reimbursableTitle = DistanceRequestUtils.getDistanceForDisplay(true, distance, unit, rate, translate, undefined, isManualDistanceRequest, commuterExclusionData); - - return { - descriptionLabel: `${baseDescription} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`, - displayTitle: reimbursableTitle, - furtherDetailsComponent: ( - - ), - }; - }, [translate, hasRoute, distance, unit, rate, isManualDistanceRequest, commuterExclusionData]); + descriptionLabel = `${translate('common.distance')} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`; + furtherDetailsComponent = ( + + ); + } return ( Date: Mon, 22 Jun 2026 12:09:48 +0100 Subject: [PATCH 20/64] Use shared CommuterExclusionData type in DistanceRequestController and getDistanceForDisplay --- .../DistanceRequestController.tsx | 14 +++++--------- src/libs/DistanceRequestUtils.ts | 3 ++- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx index 44c688326720..4679e6f7f2a7 100644 --- a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx +++ b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx @@ -14,11 +14,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Transaction} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; - -type CommuterExclusionBreakdown = { - commuterExclusion: number; - reimbursableDistance: number; -} | null; +import type { CommuterExclusionData } from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; type DistanceRequestControllerProps = { transactionID: string | undefined; @@ -46,7 +42,7 @@ type DistanceRequestControllerProps = { selectedParticipantsProp: Participant[]; setFormError: (error: TranslationPaths | '') => void; clearFormErrors: (errors: string[]) => void; - commuterExclusionBreakdown: CommuterExclusionBreakdown; + commuterExclusionData?: CommuterExclusionData; }; /** @@ -80,7 +76,7 @@ function DistanceRequestController({ selectedParticipantsProp, setFormError, clearFormErrors, - commuterExclusionBreakdown, + commuterExclusionData, }: DistanceRequestControllerProps) { const {translate, toLocaleDigit} = useLocalize(); const {getCurrencySymbol} = useCurrencyListActions(); @@ -226,7 +222,7 @@ function DistanceRequestController({ setMoneyRequestPendingFields(transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); // When commuter exclusion applies, show the reimbursable distance in the merchant text - const displayDistance = commuterExclusionBreakdown ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionBreakdown.reimbursableDistance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES) : distance; + const displayDistance = commuterExclusionData ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES) : distance; const distanceMerchant = DistanceRequestUtils.getDistanceMerchant( hasRoute, displayDistance, @@ -254,7 +250,7 @@ function DistanceRequestController({ isReadOnly, getCurrencySymbol, isManualDistanceRequest, - commuterExclusionBreakdown, + commuterExclusionData, ]); return null; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index c2ac6de48157..145fefd78ec4 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -14,6 +14,7 @@ import getStoredDefaultP2PMileageRate from './getStoredDefaultP2PMileageRate'; import {replaceAllDigits} from './MoneyRequestUtils'; import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, getUnitRateValue} from './PolicyUtils'; import {getCurrency, getRateID, isCustomUnitRateIDForP2P, isExpenseUnreported} from './TransactionUtils'; +import { CommuterExclusionData } from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; type MileageRate = { customUnitRateID?: string; @@ -225,7 +226,7 @@ function getDistanceForDisplay( translate: LocaleContextProps['translate'], useShortFormUnit?: boolean, isZeroDistanceAllowed?: boolean, - commuterExclusionData?: {reimbursableDistance: number; distanceUnit: Unit} | null, + commuterExclusionData?: CommuterExclusionData, ): string { const displayUnit = unit ?? commuterExclusionData?.distanceUnit; if (!hasRoute || !displayUnit) { From 1d2c6f6ee85e82022bd1ee40908b8900b7ddaf7b Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 12:24:45 +0100 Subject: [PATCH 21/64] Add getFormattedDistanceInUnits util for localized commuter exclusion original distance Share the display-unit formatting between getDistanceForDisplay, MoneyRequestView, and DistanceField instead of duplicating ad-hoc toFixed formatting. --- .../sections/DistanceField.tsx | 7 +--- .../ReportActionItem/MoneyRequestView.tsx | 4 +- src/libs/DistanceRequestUtils.ts | 37 +++++++++++++------ 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx index ac956e043a5d..f15a77580be3 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx @@ -29,10 +29,6 @@ type DistanceFieldProps = { commuterExclusionData?: CommuterExclusionData; }; -function formatDistance(distanceValue: number, distanceUnit: Unit): string { - return `${distanceValue.toFixed(2)} ${distanceUnit}`; -} - function DistanceField({ hasRoute, distance, @@ -59,8 +55,9 @@ function DistanceField({ let furtherDetailsComponent; if (commuterExclusionData) { + const displayUnit = unit ?? commuterExclusionData.distanceUnit; const originalDistance = commuterExclusionData.reimbursableDistance + commuterExclusionData.commuterExclusion; - const originalDistanceFormatted = formatDistance(originalDistance, commuterExclusionData.distanceUnit); + const originalDistanceFormatted = DistanceRequestUtils.getFormattedDistanceInUnits(originalDistance, displayUnit, translate); descriptionLabel = `${translate('common.distance')} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`; furtherDetailsComponent = ( Date: Mon, 22 Jun 2026 12:36:11 +0100 Subject: [PATCH 22/64] Reuse getFormattedDistanceInUnits for commuter distance labels Add isCommuterDistance flag and delegate the unit word to getDistanceUnitLabel, replacing duplicated commuter distance formatting in DistanceWithCommuterExclusion and ReportActionsUtils. --- src/components/DistanceWithCommuterExclusion.tsx | 5 +---- src/libs/DistanceRequestUtils.ts | 14 ++++++-------- src/libs/ReportActionsUtils.ts | 8 +++----- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/components/DistanceWithCommuterExclusion.tsx b/src/components/DistanceWithCommuterExclusion.tsx index a726cc610d89..2f35d92854e5 100644 --- a/src/components/DistanceWithCommuterExclusion.tsx +++ b/src/components/DistanceWithCommuterExclusion.tsx @@ -2,7 +2,6 @@ import React from 'react'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import CONST from '@src/CONST'; import type {Unit} from '@src/types/onyx/Policy'; import Text from './Text'; @@ -18,9 +17,7 @@ function DistanceWithCommuterExclusion({commuterExclusion, distanceUnit}: Distan const styles = useThemeStyles(); const {translate} = useLocalize(); - const unitLabel = DistanceRequestUtils.getDistanceUnitLabel(commuterExclusion, distanceUnit, translate); - const commuterLabel = translate('common.commuter'); - const formattedDistance = `${commuterExclusion.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${commuterLabel} ${unitLabel}`; + const formattedDistance = DistanceRequestUtils.getFormattedDistanceInUnits(commuterExclusion, distanceUnit, translate, false, true); return {translate('distance.commuterExclusion.removedCommuterDistance', {formattedDistance})}; } diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 3d38f105b36d..b183023b1e3e 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -153,18 +153,16 @@ function getRoundedDistanceInUnits(distanceInMeters: number, unit: Unit): string * @param unit Unit that should be used to display the distance * @param translate Translate function * @param useShortFormUnit If true, the unit will be returned in short form (e.g., "mi", "km"). + * @param isCommuterDistance If true, the commuter label is inserted before the unit (e.g. "12.34 commuter miles"). */ -function getFormattedDistanceInUnits(distanceInUnits: number, unit: Unit, translate: LocaleContextProps['translate'], useShortFormUnit?: boolean): string { +function getFormattedDistanceInUnits(distanceInUnits: number, unit: Unit, translate: LocaleContextProps['translate'], useShortFormUnit?: boolean, isCommuterDistance?: boolean): string { const roundedDistance = distanceInUnits.toFixed(CONST.DISTANCE_DECIMAL_PLACES); - if (useShortFormUnit) { - return `${roundedDistance} ${unit}`; + const unitLabel = useShortFormUnit ? unit : getDistanceUnitLabel(distanceInUnits, unit, translate); + if (isCommuterDistance) { + return `${roundedDistance} ${translate('common.commuter')} ${unitLabel}`; } - const distanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.miles') : translate('common.kilometers'); - const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer'); - const unitString = roundedDistance === '1' ? singularDistanceUnit : distanceUnit; - - return `${roundedDistance} ${unitString}`; + return `${roundedDistance} ${unitLabel}`; } /** diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 0facbfc7efca..2b12761a376b 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4691,12 +4691,10 @@ function getPlaidBalanceFailureMessage(translate: LocalizedTranslate, action: On } function getCommuterExclusionMessage(translate: LocalizedTranslate, action: OnyxEntry, policyID?: string): string { - const {distance, unit} = getOriginalMessage(action as ReportAction) ?? {distance: '0', unit: ''}; + const {distance, unit} = getOriginalMessage(action as ReportAction) ?? {distance: '0', unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}; const distanceValue = Number(distance ?? 0); - const unitValue = String(unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); - const unitLabel = DistanceRequestUtils.getDistanceUnitLabel(distanceValue, unitValue, translate); - const commuterLabel = translate('common.commuter'); - const formattedDistance = `${distanceValue.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${commuterLabel} ${unitLabel}`; + const unitValue = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const formattedDistance = DistanceRequestUtils.getFormattedDistanceInUnits(distanceValue, unitValue, translate, false, true); const workspaceDistanceSettingsLink = policyID ? `${environmentURL}/${ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID)}` : ''; From 61e604745e4a9b453130a83cc9bb37e132ad4a92 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 12:45:15 +0100 Subject: [PATCH 23/64] Refactor commuter exclusion handling in MoneyRequestConfirmationList Updated the useDistanceRequestState hook to utilize commuterExclusionData instead of commuterExclusionBreakdown for improved clarity and consistency. Adjusted related components to reflect this change, ensuring proper data flow and display for distance requests. --- .../MoneyRequestConfirmationList.tsx | 18 +----------------- .../hooks/useDistanceRequestState.ts | 6 +++--- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 85e7fd76c0da..08f48c81308c 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -258,7 +258,7 @@ function MoneyRequestConfirmationList({ const subRates = transaction?.comment?.customUnit?.subRates ?? []; const prevSubRates = usePrevious(subRates); - const {defaultRate, mileageRate, unit, rate, currency, prevCurrency, distance, shouldCalculateDistanceAmount, hasRoute, isDistanceRequestWithPendingRoute, distanceRequestAmount, commuterExclusionBreakdown} = + const {defaultRate, mileageRate, unit, rate, currency, prevCurrency, distance, shouldCalculateDistanceAmount, hasRoute, isDistanceRequestWithPendingRoute, distanceRequestAmount, commuterExclusionData} = useDistanceRequestState({ transaction, policy, @@ -273,22 +273,6 @@ function MoneyRequestConfirmationList({ const shouldShowRateAutoUpdatedTooltip = isDistanceRequest && !!transaction?.comment?.customUnit?.rateAutoUpdated && !!transaction.created && DistanceRequestUtils.isRateEligibleForDate(mileageRate, transaction.created); - // Build commuter exclusion data for the distance breakdown display from the hook's computed breakdown - const commuterExclusionData = useMemo(() => { - if (!commuterExclusionBreakdown) { - return null; - } - - // Use the same fallback as useDistanceRequestState for consistent unit handling - const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - - return { - commuterExclusion: commuterExclusionBreakdown.commuterExclusion, - reimbursableDistance: commuterExclusionBreakdown.reimbursableDistance, - distanceUnit: distanceUnitValue, - }; - }, [commuterExclusionBreakdown, transaction?.comment?.customUnit?.distanceUnit, unit]); - const shouldShowCategories = isTrackExpense ? !policy || shouldSelectPolicy || !!iouCategory || hasEnabledOptions(Object.values(policyCategories ?? {})) : (isPolicyExpenseChat || isTypeInvoice) && (!!iouCategory || hasEnabledOptions(Object.values(policyCategories ?? {}))); diff --git a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts index 95dd81102e13..73ef00639a0a 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts @@ -98,8 +98,8 @@ function useDistanceRequestState({ // When a commuter exclusion applies, the reimbursable amount is based on the distance remaining after the exclusion. const amountUnit = unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, distance, amountUnit); - const reimbursableDistanceInMeters = commuterExclusionBreakdown ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionBreakdown.reimbursableDistance, amountUnit) : distance; + const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, distance, amountUnit); + const reimbursableDistanceInMeters = commuterExclusionData ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, amountUnit) : distance; const distanceRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(reimbursableDistanceInMeters, amountUnit, rate ?? 0); @@ -123,7 +123,7 @@ function useDistanceRequestState({ hasRoute, isDistanceRequestWithPendingRoute, distanceRequestAmount, - commuterExclusionBreakdown, + commuterExclusionData, }; } From 057386ebe2ecb9dad3850ff05413ca3d6bffb34e Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 12:45:20 +0100 Subject: [PATCH 24/64] Rename getCommuterExclusionBreakdown to getCommuterExclusionData --- .../MoneyRequestConfirmationList.tsx | 36 ++++++++++++------- .../hooks/useDistanceRequestState.ts | 2 +- .../ReportActionItem/MoneyRequestView.tsx | 2 +- src/libs/DistanceRequestUtils.ts | 4 +-- src/libs/actions/IOU/Split.ts | 2 +- 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 08f48c81308c..16cdefc121b6 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -258,17 +258,29 @@ function MoneyRequestConfirmationList({ const subRates = transaction?.comment?.customUnit?.subRates ?? []; const prevSubRates = usePrevious(subRates); - const {defaultRate, mileageRate, unit, rate, currency, prevCurrency, distance, shouldCalculateDistanceAmount, hasRoute, isDistanceRequestWithPendingRoute, distanceRequestAmount, commuterExclusionData} = - useDistanceRequestState({ - transaction, - policy, - policyID, - policyForMovingExpenses, - isMovingTransactionFromTrackExpense, - isDistanceRequest, - iouAmount, - iouCurrencyCode, - }); + const { + defaultRate, + mileageRate, + unit, + rate, + currency, + prevCurrency, + distance, + shouldCalculateDistanceAmount, + hasRoute, + isDistanceRequestWithPendingRoute, + distanceRequestAmount, + commuterExclusionData, + } = useDistanceRequestState({ + transaction, + policy, + policyID, + policyForMovingExpenses, + isMovingTransactionFromTrackExpense, + isDistanceRequest, + iouAmount, + iouCurrencyCode, + }); const shouldShowRateAutoUpdatedTooltip = isDistanceRequest && !!transaction?.comment?.customUnit?.rateAutoUpdated && !!transaction.created && DistanceRequestUtils.isRateEligibleForDate(mileageRate, transaction.created); @@ -600,7 +612,7 @@ function MoneyRequestConfirmationList({ currentUserAccountID={currentUserPersonalDetails.accountID} setFormError={setFormError} clearFormErrors={clearFormErrors} - commuterExclusionBreakdown={commuterExclusionBreakdown} + commuterExclusionData={commuterExclusionData} /> , policy: OnyxEntry, distanceInMeters: number, @@ -742,7 +742,7 @@ export default { getDefaultMileageRate, getDistanceMerchant, getDistanceRequestAmount, - getCommuterExclusionBreakdown, + getCommuterExclusionData, getFormattedRateValue, getMileageRates, getDistanceForDisplay, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index b72fe97b0506..e2f1b898977c 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -2106,7 +2106,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest const distanceUnit = transaction.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; const distanceRate = transaction.comment?.customUnit?.defaultP2PRate ?? 0; - const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionBreakdown(transaction, policy, distance ?? 0, distanceUnit); + const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance ?? 0, distanceUnit); if (commuterExclusionBreakdown) { const {commuterExclusion, reimbursableDistance} = commuterExclusionBreakdown; // Use the canonical distance-amount calc so the optimistic amount rounds the same way the server does. From e2b751b1f01bb73625b4dc14b42e9e2f760da640 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 14:02:09 +0100 Subject: [PATCH 25/64] Pass commuterExclusionData to getDistanceMerchant Rename getCommuterExclusionBreakdown usages to getCommuterExclusionData and let getDistanceMerchant accept commuterExclusionData so the reimbursable distance is computed inside the util instead of at the call site. --- .../DistanceRequestController.tsx | 8 ++++---- src/libs/DistanceRequestUtils.ts | 4 +++- src/libs/actions/IOU/Split.ts | 6 +++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx index 4679e6f7f2a7..fd179fb2a67c 100644 --- a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx +++ b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx @@ -1,5 +1,6 @@ import {useEffect, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import type {CommuterExclusionData} from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -14,7 +15,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Transaction} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; -import type { CommuterExclusionData } from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; type DistanceRequestControllerProps = { transactionID: string | undefined; @@ -221,11 +221,10 @@ function DistanceRequestController({ */ setMoneyRequestPendingFields(transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); - // When commuter exclusion applies, show the reimbursable distance in the merchant text - const displayDistance = commuterExclusionData ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES) : distance; + // When commuter exclusion applies, getDistanceMerchant shows the reimbursable distance in the merchant text const distanceMerchant = DistanceRequestUtils.getDistanceMerchant( hasRoute, - displayDistance, + distance, unit, rate ?? 0, currency ?? CONST.CURRENCY.USD, @@ -233,6 +232,7 @@ function DistanceRequestController({ toLocaleDigit, getCurrencySymbol, isManualDistanceRequest, + commuterExclusionData, ); setMoneyRequestMerchant(transactionID, distanceMerchant, true); }, [ diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index a5727687aab9..ca20433938bf 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -273,6 +273,7 @@ function getDistanceForDisplayLabel(distanceInMeters: number, unit: Unit): strin * @param currency The currency associated with the rate * @param translate Translate function * @param toLocaleDigit Function to convert to localized digit + * @param commuterExclusionData When provided, the reimbursable distance (after the commuter exclusion) is shown instead of the full route distance. * @returns A string that describes the distance traveled and the rate used for expense calculation */ function getDistanceMerchant( @@ -285,6 +286,7 @@ function getDistanceMerchant( toLocaleDigit: LocaleContextProps['toLocaleDigit'], getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'], isZeroDistanceAllowed?: boolean, + commuterExclusionData?: CommuterExclusionData, ): string { if (!hasRoute || !rate) { return translate('iou.fieldPending'); @@ -294,7 +296,7 @@ function getDistanceMerchant( return ''; } - const distanceInUnits = getDistanceForDisplay(hasRoute, distanceInMeters, unit, rate, translate, true, isZeroDistanceAllowed); + const distanceInUnits = getDistanceForDisplay(hasRoute, distanceInMeters, unit, rate, translate, true, isZeroDistanceAllowed, commuterExclusionData); const ratePerUnit = getFormattedRateValue(unit, rate, currency, translate, toLocaleDigit, getCurrencySymbol, undefined, true); return `${distanceInUnits} ${CONST.DISTANCE_MERCHANT_SEPARATOR} ${ratePerUnit}`; diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index e2f1b898977c..79d5b763d1ea 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -2106,9 +2106,9 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest const distanceUnit = transaction.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; const distanceRate = transaction.comment?.customUnit?.defaultP2PRate ?? 0; - const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance ?? 0, distanceUnit); - if (commuterExclusionBreakdown) { - const {commuterExclusion, reimbursableDistance} = commuterExclusionBreakdown; + const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance ?? 0, distanceUnit); + if (commuterExclusionData) { + const {commuterExclusion, reimbursableDistance} = commuterExclusionData; // Use the canonical distance-amount calc so the optimistic amount rounds the same way the server does. const reimbursableDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(reimbursableDistance, distanceUnit); const modifiedRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(reimbursableDistanceInMeters, distanceUnit, distanceRate); From 1dabe25c52dd4a9bd115ca5b1623b39e514978d3 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 14:12:36 +0100 Subject: [PATCH 26/64] Break MoneyRequestUtils/DistanceRequestUtils dependency cycle Extract replaceAllDigits into its own module so DistanceRequestUtils no longer imports MoneyRequestUtils, breaking the MoneyRequestUtils -> ReportUtils -> ReportActionsUtils -> DistanceRequestUtils cycle. MoneyRequestUtils re-exports replaceAllDigits so other consumers are unaffected. --- src/libs/DistanceRequestUtils.ts | 2 +- src/libs/MoneyRequestUtils.ts | 18 +----------------- src/libs/replaceAllDigits.ts | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 18 deletions(-) create mode 100644 src/libs/replaceAllDigits.ts diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index ca20433938bf..b24966d638fb 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -12,8 +12,8 @@ import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import getStoredDefaultP2PMileageRate from './getStoredDefaultP2PMileageRate'; -import {replaceAllDigits} from './MoneyRequestUtils'; import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, getUnitRateValue} from './PolicyUtils'; +import replaceAllDigits from './replaceAllDigits'; import {getCurrency, getRateID, isCustomUnitRateIDForP2P, isExpenseUnreported} from './TransactionUtils'; type MileageRate = { diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 959a4e3ea86f..e649e455d042 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -3,6 +3,7 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import type {Report, Transaction} from '@src/types/onyx'; import {convertToFrontendAmountAsInteger} from './CurrencyUtils'; +import replaceAllDigits from './replaceAllDigits'; import {isInvoiceReport, isIOUReport} from './ReportUtils'; import StringUtils from './StringUtils'; import {isExpenseUnreported} from './TransactionUtils'; @@ -90,23 +91,6 @@ function validatePercentage(amount: string, allowExceedingHundred = false, allow return amount === '' || percentageRegex.test(amount); } -/** - * Replaces each character by calling `convertFn`. If `convertFn` throws an error, then - * the original character will be preserved. - */ -function replaceAllDigits(text: string, convertFn: (char: string) => string): string { - return text - .split('') - .map((char) => { - try { - return convertFn(char); - } catch { - return char; - } - }) - .join(''); -} - /** * Handles negative amount flipping by toggling the negative state and removing the '-' prefix * @param amount - The amount string to process diff --git a/src/libs/replaceAllDigits.ts b/src/libs/replaceAllDigits.ts new file mode 100644 index 000000000000..f6d0b5938003 --- /dev/null +++ b/src/libs/replaceAllDigits.ts @@ -0,0 +1,18 @@ +/** + * Replaces each character by calling `convertFn`. If `convertFn` throws an error, then + * the original character will be preserved. + */ +function replaceAllDigits(text: string, convertFn: (char: string) => string): string { + return text + .split('') + .map((char) => { + try { + return convertFn(char); + } catch { + return char; + } + }) + .join(''); +} + +export default replaceAllDigits; From d13d5c6844ce1dbc645acd030465bdbbf3a75230 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 15:00:11 +0100 Subject: [PATCH 27/64] Show map/GPS-required confirm modal for manual and odometer distance Replace the inline form error with a confirm modal (dismiss icon above a centered illustration, title, muted description, green "Got it" button) when manual or odometer entry is attempted on a commuter-exclusion workspace. Move the ConfirmContent dismiss icon above the image. --- src/components/ConfirmContent.tsx | 34 +++++++++---------- src/languages/en.ts | 6 ++-- .../step/IOURequestStepDistanceManual.tsx | 16 ++++++++- .../step/IOURequestStepDistanceOdometer.tsx | 17 ++++++++-- 4 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index 4acd2b09309a..e7bd5bc366d6 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -166,6 +166,23 @@ function ConfirmContent({ return ( <> + {shouldShowDismissIcon && ( + + + + + + + + )} {!!image && ( - {shouldShowDismissIcon && ( - - - - - - - - )} {!!iconSource && ( diff --git a/src/languages/en.ts b/src/languages/en.ts index 2f84223218cb..307907319669 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8911,8 +8911,10 @@ const translations = { }, error: { selectSuggestedAddress: 'Please select a suggested address or use current location', - manualOdometerNotAllowedWithExclusion: - 'This workspace excludes part of your distance from reimbursement, so manual and odometer entry are disabled. Use the map to enter your route.', + mapOrGpsDistanceRequired: { + title: 'Map or GPS distance required', + description: 'This workspace requires either map-based or GPS-tracked distance expenses.', + }, }, commuterExclusion: { original: 'Original', diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 6cab5d95c29b..c2fd97410f10 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -7,9 +7,11 @@ import type {NumberWithSymbolFormRef} from '@components/NumberWithSymbolForm'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useConfirmModal from '@hooks/useConfirmModal'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -61,6 +63,8 @@ function IOURequestStepDistanceManual({ currentUserPersonalDetails, }: IOURequestStepDistanceManualProps) { const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const illustrations = useMemoizedLazyIllustrations(['HouseMoney']); const styles = useThemeStyles(); const {isBetaEnabled} = usePermissions(); const {isExtraSmallScreenHeight} = useResponsiveLayout(); @@ -273,7 +277,17 @@ function IOURequestStepDistanceManual({ // Workspaces with commuter exclusions configured require map-based distance, since the // exclusion is computed off the mapped route. Block manual entry for these workspaces. if (policy?.commuterExclusions) { - setFormError(translate('distance.error.manualOdometerNotAllowedWithExclusion')); + showConfirmModal({ + title: translate('distance.error.mapOrGpsDistanceRequired.title'), + titleStyles: styles.textHeadline, + prompt: translate('distance.error.mapOrGpsDistanceRequired.description'), + promptStyles: styles.textSupporting, + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + shouldShowDismissIcon: true, + image: illustrations.HouseMoney, + shouldUseSuccessStyleForConfirm: true, + }); return; } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 5792d5a3214f..b66d9810257e 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -13,11 +13,12 @@ import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import useConfirmModal from '@hooks/useConfirmModal'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; @@ -79,6 +80,8 @@ function IOURequestStepDistanceOdometer({ currentUserPersonalDetails, }: IOURequestStepDistanceOdometerProps) { const {translate, fromLocaleDigit, numberFormat} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const illustrations = useMemoizedLazyIllustrations(['HouseMoney']); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -451,7 +454,17 @@ function IOURequestStepDistanceOdometer({ // Workspaces with commuter exclusions configured require map-based distance entry, since the // exclusion is computed off the mapped route. Block odometer entry for these workspaces. if (policy?.commuterExclusions) { - setFormError(translate('distance.error.manualOdometerNotAllowedWithExclusion')); + showConfirmModal({ + title: translate('distance.error.mapOrGpsDistanceRequired.title'), + titleStyles: styles.textHeadline, + prompt: translate('distance.error.mapOrGpsDistanceRequired.description'), + promptStyles: styles.textSupporting, + confirmText: translate('common.buttonConfirm'), + shouldUseSuccessStyleForConfirm: true, + shouldShowCancelButton: false, + shouldShowDismissIcon: true, + image: illustrations.HouseMoney, + }); return; } From b3c370c6ffca6f9a19f04d8d5acb608ddf77a14b Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 15:06:19 +0100 Subject: [PATCH 28/64] Refactor commuter exclusion distance formatting in multiple languages Updated the `setFixedDistance` function across various language files to use a formatted distance string instead of separate distance and unit parameters. This change simplifies the localization process and ensures consistency in how fixed distances are presented in user messages. --- src/languages/de.ts | 11 +---------- src/languages/es.ts | 11 +---------- src/languages/fr.ts | 11 +---------- src/languages/it.ts | 11 +---------- src/languages/ja.ts | 11 +---------- src/languages/nl.ts | 13 ++----------- src/languages/pl.ts | 15 +++------------ src/languages/zh-hans.ts | 11 +---------- 8 files changed, 11 insertions(+), 83 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 669976833fda..507d12db35ad 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8069,16 +8069,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `„${prohibitedExpense}“ aus verbotenen Ausgaben entfernt`, commuterExclusions: { changedToFixedDistance: 'hat das Ausschließen von Pendelstrecken in eine feste Entfernung pro Antrag geändert', - setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => { - const isSingular = distance === 1; - let unitLabel: string; - if (unit === 'mi') { - unitLabel = isSingular ? 'Meile' : 'Meilen'; - } else { - unitLabel = isSingular ? 'Kilometer' : 'Kilometer'; - } - return `feste Distanz von ${distance} ${unitLabel} pro Antrag ausschließen`; - }, + setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `feste Distanz von ${formattedDistance} pro Antrag ausschließen`, changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => `feste Entfernungsausschlussgrenze auf ${newDistance} ${unit} pro Antrag geändert (zuvor ${oldDistance} ${unit})`, disabled: 'Ausschluss von Pendelstrecken für Distanzsätze deaktiviert', diff --git a/src/languages/es.ts b/src/languages/es.ts index bd6ad6c23773..e3288269cfac 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7795,16 +7795,7 @@ ${amount} para ${merchant} - ${date}`, removedProhibitedExpense: ({prohibitedExpense}) => `eliminó "${prohibitedExpense}" de los gastos prohibidos`, commuterExclusions: { changedToFixedDistance: 'cambió la exclusión de trayectos al trabajo a una distancia fija por reclamación', - setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => { - const isSingular = distance === 1; - let unitLabel: string; - if (unit === 'mi') { - unitLabel = isSingular ? 'milla' : 'millas'; - } else { - unitLabel = isSingular ? 'kilómetro' : 'kilómetros'; - } - return `establecer exclusión de distancia fija en ${distance} ${unitLabel} por reclamación`; - }, + setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `establecer exclusión de distancia fija en ${formattedDistance} por reclamación`, changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => `cambió la exclusión de distancia fija a ${newDistance} ${unit} por reclamación (previamente ${oldDistance} ${unit})`, disabled: 'desactivó la opción de excluir desplazamientos para las tarifas por distancia', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 7d58a1e2c753..f90de477f9ac 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8102,16 +8102,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `a supprimé « ${prohibitedExpense} » des dépenses interdites`, commuterExclusions: { changedToFixedDistance: 'a modifié l’exclusion des trajets domicile-travail en une distance fixe par demande', - setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => { - const isSingular = distance === 1; - let unitLabel: string; - if (unit === 'mi') { - unitLabel = isSingular ? 'mile' : 'miles'; - } else { - unitLabel = isSingular ? 'kilomètre' : 'kilomètres'; - } - return `définir l’exclusion de distance fixe à ${distance} ${unitLabel} par demande`; - }, + setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `définir l’exclusion de distance fixe à ${formattedDistance} par demande`, changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => `a modifié l’exclusion de distance fixe à ${newDistance} ${unit} par demande (auparavant ${oldDistance} ${unit})`, disabled: 'désactivé exclure les trajets domicile-travail pour les taux de distance', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6b19e6adeb17..9e05df24066b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8058,16 +8058,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `ha rimosso "${prohibitedExpense}" dalle spese vietate`, commuterExclusions: { changedToFixedDistance: 'ha modificato l’esclusione dei tragitti casa-lavoro in una distanza fissa per rimborso', - setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => { - const isSingular = distance === 1; - let unitLabel: string; - if (unit === 'mi') { - unitLabel = isSingular ? 'miglio' : 'miglia'; - } else { - unitLabel = isSingular ? 'chilometro' : 'chilometri'; - } - return `imposta un'esclusione di distanza fissa di ${distance} ${unitLabel} per richiesta`; - }, + setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `imposta un'esclusione di distanza fissa di ${formattedDistance} per richiesta`, changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => `ha modificato l’esclusione a distanza fissa a ${newDistance} ${unit} per richiesta (in precedenza ${oldDistance} ${unit})`, disabled: 'esclusione delle tratte casa-lavoro per le tariffe a distanza disattivata', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index db646818cb52..213fbb69bede 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7961,16 +7961,7 @@ ${reportName}`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `禁止経費から「${prohibitedExpense}」を削除しました`, commuterExclusions: { changedToFixedDistance: '通勤分の除外方法を、申請ごとの固定距離に変更しました', - setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => { - const isSingular = distance === 1; - let unitLabel: string; - if (unit === 'mi') { - unitLabel = isSingular ? 'マイル' : 'マイル'; - } else { - unitLabel = isSingular ? 'キロメートル' : 'キロメートル'; - } - return `1件の経費申請につき固定除外距離を${distance} ${unitLabel}に設定します`; - }, + setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `1件の経費申請につき固定除外距離を${formattedDistance}に設定します`, changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => `1件あたりの固定距離除外を${oldDistance} ${unit}から${newDistance} ${unit}に変更しました`, disabled: '距離レートで通勤を除外する設定を無効にしました', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 22deed9f5319..dc8860c93a7e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8026,18 +8026,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `heeft „${prohibitedExpense}” verwijderd uit verboden uitgaven`, commuterExclusions: { changedToFixedDistance: 'heeft ‘woon-werkverkeer uitsluiten’ gewijzigd naar een vaste afstand per declaratie', - setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => { - const isSingular = distance === 1; - let unitLabel: string; - if (unit === 'mi') { - unitLabel = isSingular ? 'mijl' : 'mijlen'; - } else { - unitLabel = isSingular ? 'kilometer' : 'kilometers'; - } - return `stel vaste afstandsuitsluiting in op ${distance} ${unitLabel} per declaratie`; - }, + setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `stel vaste afstandsuitsluiting in op ${formattedDistance} per declaratie`; changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => - `heeft de vaste afstandsuitsluiting gewijzigd naar ${newDistance} ${unit} per declaratie (voorheen ${oldDistance} ${unit})`, + `heeft de vaste afstandsuitsluiting gewijzigd naar ${formattedDistance} per declaratie (voorheen ${oldDistance} ${unit})`, disabled: 'uitschakelen woon-werkverkeer uitsluiten voor afstandstarieven', }, updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 93104bfa07ce..00f265314334 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8018,18 +8018,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `usunięto „${prohibitedExpense}” z wydatków zabronionych`, commuterExclusions: { changedToFixedDistance: 'zmienił wykluczanie dojazdów na stały dystans na zgłoszenie', - setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => { - const isSingular = distance === 1; - let unitLabel: string; - if (unit === 'mi') { - unitLabel = isSingular ? 'mile' : 'mile'; - } else { - unitLabel = isSingular ? 'kilometr' : 'kilometry'; - } - return `ustaw stałe wykluczenie dystansu na ${distance} ${unitLabel} na zgłoszenie`; - }, - changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => - `zmienił stałe wykluczenie dystansu na ${newDistance} ${unit} na zgłoszenie (wcześniej ${oldDistance} ${unit})`, + setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `ustaw stałe wykluczenie dystansu na ${formattedDistance} na zgłoszenie`, + changedFixedDistance: ({formattedDistance, oldDistance}: {formattedDistance: string; oldDistance: string}) => + `zmienił stałe wykluczenie dystansu na ${formattedDistance} na zgłoszenie (wcześniej ${oldDistance})`, disabled: 'wyłączono wykluczanie dojazdów dla stawek za odległość', }, updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 212e5e7044dc..0c9981e87407 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7807,16 +7807,7 @@ ${reportName}`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `已从禁用报销类别中移除“${prohibitedExpense}”`, commuterExclusions: { changedToFixedDistance: '已将“排除通勤”更改为“按每次报销固定距离”', - setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => { - const isSingular = distance === 1; - let unitLabel: string; - if (unit === 'mi') { - unitLabel = isSingular ? '英里' : '英里'; - } else { - unitLabel = isSingular ? '公里' : '千米'; - } - return `将每次报销的固定排除距离设置为 ${distance} ${unitLabel}`; - }, + setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `将每次报销的固定排除距离设置为 ${formattedDistance}`, changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => `已将固定距离排除调整为每笔报销 ${newDistance} ${unit}(之前为 ${oldDistance} ${unit})`, disabled: '已停用“排除通勤距离费率”', From 27bc11b5ef144b2cbd517a1e8f0af91850331a06 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 15:22:24 +0100 Subject: [PATCH 29/64] Format commuter exclusion change-log messages via getFormattedDistanceInUnits Change changedFixedDistance copy to accept pre-formatted old/new distance strings (matching setFixedDistance) across all locales, and reuse getFormattedDistanceInUnits in getUpdatedCommuterExclusionsMessage. --- src/languages/de.ts | 4 ++-- src/languages/en.ts | 4 ++-- src/languages/es.ts | 4 ++-- src/languages/fr.ts | 4 ++-- src/languages/it.ts | 4 ++-- src/languages/ja.ts | 4 ++-- src/languages/nl.ts | 6 +++--- src/languages/pl.ts | 4 ++-- src/languages/pt-BR.ts | 15 +++------------ src/languages/zh-hans.ts | 4 ++-- src/libs/ReportActionsUtils.ts | 22 ++++++++-------------- 11 files changed, 30 insertions(+), 45 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 507d12db35ad..7dd9e82f6640 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8070,8 +8070,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc commuterExclusions: { changedToFixedDistance: 'hat das Ausschließen von Pendelstrecken in eine feste Entfernung pro Antrag geändert', setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `feste Distanz von ${formattedDistance} pro Antrag ausschließen`, - changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => - `feste Entfernungsausschlussgrenze auf ${newDistance} ${unit} pro Antrag geändert (zuvor ${oldDistance} ${unit})`, + changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) => + `feste Entfernungsausschlussgrenze auf ${formattedNewDistance} pro Antrag geändert (zuvor ${formattedOldDistance})`, disabled: 'Ausschluss von Pendelstrecken für Distanzsätze deaktiviert', }, updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => diff --git a/src/languages/en.ts b/src/languages/en.ts index 307907319669..e0ddd4ec0e69 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8057,8 +8057,8 @@ const translations = { commuterExclusions: { changedToFixedDistance: 'changed exclude commutes to a fixed distance per claim', setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `set fixed distance exclusion to ${formattedDistance} per claim`, - changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => - `changed fixed distance exclusion to ${newDistance} ${unit} per claim (previously ${oldDistance} ${unit})`, + changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) => + `changed fixed distance exclusion to ${formattedNewDistance} per claim (previously ${formattedOldDistance})`, disabled: 'disabled exclude commutes for distance rates', }, updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => diff --git a/src/languages/es.ts b/src/languages/es.ts index e3288269cfac..5691bd9b4bd0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7796,8 +7796,8 @@ ${amount} para ${merchant} - ${date}`, commuterExclusions: { changedToFixedDistance: 'cambió la exclusión de trayectos al trabajo a una distancia fija por reclamación', setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `establecer exclusión de distancia fija en ${formattedDistance} por reclamación`, - changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => - `cambió la exclusión de distancia fija a ${newDistance} ${unit} por reclamación (previamente ${oldDistance} ${unit})`, + changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) => + `cambió la exclusión de distancia fija a ${formattedNewDistance} por reclamación (previamente ${formattedOldDistance})`, disabled: 'desactivó la opción de excluir desplazamientos para las tarifas por distancia', }, updatedReimbursementChoice: (newReimbursementChoice, oldReimbursementChoice) => diff --git a/src/languages/fr.ts b/src/languages/fr.ts index f90de477f9ac..07661a2fa17c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8103,8 +8103,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e commuterExclusions: { changedToFixedDistance: 'a modifié l’exclusion des trajets domicile-travail en une distance fixe par demande', setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `définir l’exclusion de distance fixe à ${formattedDistance} par demande`, - changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => - `a modifié l’exclusion de distance fixe à ${newDistance} ${unit} par demande (auparavant ${oldDistance} ${unit})`, + changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) => + `a modifié l’exclusion de distance fixe à ${formattedNewDistance} par demande (auparavant ${formattedOldDistance})`, disabled: 'désactivé exclure les trajets domicile-travail pour les taux de distance', }, updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => diff --git a/src/languages/it.ts b/src/languages/it.ts index 9e05df24066b..d0b937cea3c0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8059,8 +8059,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, commuterExclusions: { changedToFixedDistance: 'ha modificato l’esclusione dei tragitti casa-lavoro in una distanza fissa per rimborso', setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `imposta un'esclusione di distanza fissa di ${formattedDistance} per richiesta`, - changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => - `ha modificato l’esclusione a distanza fissa a ${newDistance} ${unit} per richiesta (in precedenza ${oldDistance} ${unit})`, + changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) => + `ha modificato l’esclusione a distanza fissa a ${formattedNewDistance} per richiesta (in precedenza ${formattedOldDistance})`, disabled: 'esclusione delle tratte casa-lavoro per le tariffe a distanza disattivata', }, updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 213fbb69bede..0b3b11f31e67 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7962,8 +7962,8 @@ ${reportName}`, commuterExclusions: { changedToFixedDistance: '通勤分の除外方法を、申請ごとの固定距離に変更しました', setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `1件の経費申請につき固定除外距離を${formattedDistance}に設定します`, - changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => - `1件あたりの固定距離除外を${oldDistance} ${unit}から${newDistance} ${unit}に変更しました`, + changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) => + `1件あたりの固定距離除外を${formattedOldDistance}から${formattedNewDistance}に変更しました`, disabled: '距離レートで通勤を除外する設定を無効にしました', }, updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => diff --git a/src/languages/nl.ts b/src/languages/nl.ts index dc8860c93a7e..542b22fb4222 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8026,9 +8026,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `heeft „${prohibitedExpense}” verwijderd uit verboden uitgaven`, commuterExclusions: { changedToFixedDistance: 'heeft ‘woon-werkverkeer uitsluiten’ gewijzigd naar een vaste afstand per declaratie', - setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `stel vaste afstandsuitsluiting in op ${formattedDistance} per declaratie`; - changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => - `heeft de vaste afstandsuitsluiting gewijzigd naar ${formattedDistance} per declaratie (voorheen ${oldDistance} ${unit})`, + setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `stel vaste afstandsuitsluiting in op ${formattedDistance} per declaratie`, + changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) => + `heeft de vaste afstandsuitsluiting gewijzigd naar ${formattedNewDistance} per declaratie (voorheen ${formattedOldDistance})`, disabled: 'uitschakelen woon-werkverkeer uitsluiten voor afstandstarieven', }, updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 00f265314334..9eea1a5bbb28 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8019,8 +8019,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, commuterExclusions: { changedToFixedDistance: 'zmienił wykluczanie dojazdów na stały dystans na zgłoszenie', setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `ustaw stałe wykluczenie dystansu na ${formattedDistance} na zgłoszenie`, - changedFixedDistance: ({formattedDistance, oldDistance}: {formattedDistance: string; oldDistance: string}) => - `zmienił stałe wykluczenie dystansu na ${formattedDistance} na zgłoszenie (wcześniej ${oldDistance})`, + changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) => + `zmienił stałe wykluczenie dystansu na ${formattedNewDistance} na zgłoszenie (wcześniej ${formattedOldDistance})`, disabled: 'wyłączono wykluczanie dojazdów dla stawek za odległość', }, updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 9439f26cec1b..079a50a46e96 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8016,18 +8016,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `removeu "${prohibitedExpense}" das despesas proibidas`, commuterExclusions: { changedToFixedDistance: 'alterou "excluir trajetos casa-trabalho" para uma distância fixa por solicitação', - setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => { - const isSingular = distance === 1; - let unitLabel: string; - if (unit === 'mi') { - unitLabel = isSingular ? 'milha' : 'milhas'; - } else { - unitLabel = isSingular ? 'quilômetro' : 'quilômetros'; - } - return `definir exclusão de distância fixa de ${distance} ${unitLabel} por solicitação`; - }, - changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => - `alterou a exclusão de distância fixa para ${newDistance} ${unit} por solicitação (antes ${oldDistance} ${unit})`, + setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `definir exclusão de distância fixa de ${formattedDistance} por solicitação`, + changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) => + `alterou a exclusão de distância fixa para ${formattedNewDistance} por solicitação (antes ${formattedOldDistance})`, disabled: 'excluiu deslocamentos diários para tarifas por distância', }, updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 0c9981e87407..20aed1fdd31d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7808,8 +7808,8 @@ ${reportName}`, commuterExclusions: { changedToFixedDistance: '已将“排除通勤”更改为“按每次报销固定距离”', setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `将每次报销的固定排除距离设置为 ${formattedDistance}`, - changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) => - `已将固定距离排除调整为每笔报销 ${newDistance} ${unit}(之前为 ${oldDistance} ${unit})`, + changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) => + `已将固定距离排除调整为每笔报销 ${formattedNewDistance}(之前为 ${formattedOldDistance})`, disabled: '已停用“排除通勤距离费率”', }, updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => `将报销方式更改为“${newReimbursementChoice}”(原为“${oldReimbursementChoice}”)`, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 2b12761a376b..7c2f92b281f2 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4218,28 +4218,22 @@ function getUpdatedCommuterExclusionsMessage(translate: LocalizedTranslate, repo if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_COMMUTER_EXCLUSIONS)) { return getReportActionText(reportAction); } - const originalMessage = getOriginalMessage(reportAction); - const updatedField = originalMessage?.updatedField; + const {newValue, unit, oldValue, updatedField} = getOriginalMessage(reportAction as ReportAction) ?? {}; - if (updatedField === CONST.POLICY.COMMUTER_EXCLUSION_TYPE.METHOD) { - const newMethod = originalMessage?.newValue; - if (newMethod === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE) { - return translate('workspaceActions.commuterExclusions.changedToFixedDistance'); - } + if (updatedField === CONST.POLICY.COMMUTER_EXCLUSION_TYPE.METHOD && newValue === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE) { + return translate('workspaceActions.commuterExclusions.changedToFixedDistance'); } if (updatedField === CONST.POLICY.COMMUTER_EXCLUSION_TYPE.FIXED_DISTANCE) { - const newValue = typeof originalMessage?.newValue === 'number' ? originalMessage.newValue : Number(originalMessage?.newValue ?? 0); - const unit = originalMessage?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const distanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const formattedDistance = DistanceRequestUtils.getFormattedDistanceInUnits(Number(newValue ?? 0), distanceUnit, translate); - if (originalMessage?.oldValue == null) { - const unitLabel = DistanceRequestUtils.getDistanceUnitLabel(newValue, unit, translate); - const formattedDistance = `${newValue} ${unitLabel}`; + if (!oldValue) { return translate('workspaceActions.commuterExclusions.setFixedDistance', {formattedDistance}); } - const oldValue = typeof originalMessage.oldValue === 'number' ? originalMessage.oldValue : Number(originalMessage.oldValue); - return translate('workspaceActions.commuterExclusions.changedFixedDistance', {newDistance: newValue, oldDistance: oldValue, unit}); + const formattedOldDistance = DistanceRequestUtils.getFormattedDistanceInUnits(Number(oldValue ?? 0), distanceUnit, translate); + return translate('workspaceActions.commuterExclusions.changedFixedDistance', {formattedOldDistance, formattedNewDistance: formattedDistance}); } if (updatedField === CONST.POLICY.COMMUTER_EXCLUSION_TYPE.DISABLED) { From e6aa09d7aa94dbdaab1e63a5a5c12485fcde0249 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 15:24:21 +0100 Subject: [PATCH 30/64] Add mapOrGpsDistanceRequired translations for all locales --- src/languages/de.ts | 5 ++++- src/languages/es.ts | 4 ++++ src/languages/fr.ts | 5 ++++- src/languages/it.ts | 5 ++++- src/languages/ja.ts | 5 ++++- src/languages/nl.ts | 5 ++++- src/languages/pl.ts | 5 ++++- src/languages/pt-BR.ts | 5 ++++- src/languages/zh-hans.ts | 5 ++++- 9 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 7dd9e82f6640..f4b271234cf8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8856,6 +8856,10 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, error: { selectSuggestedAddress: 'Bitte wählen Sie eine vorgeschlagene Adresse aus oder verwenden Sie den aktuellen Standort', + mapOrGpsDistanceRequired: { + title: 'Karten- oder GPS-Entfernung erforderlich', + description: 'Dieser Workspace erfordert entweder kartenbasierte oder GPS-verfolgte Entfernungsspesen.', + }, }, odometer: { startReading: 'Mit dem Lesen beginnen', @@ -9716,7 +9720,6 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 5691bd9b4bd0..22abc711b912 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9097,6 +9097,10 @@ ${amount} para ${merchant} - ${date}`, }, error: { selectSuggestedAddress: 'Por favor, selecciona una dirección sugerida o usa la ubicación actual', + mapOrGpsDistanceRequired: { + title: 'Se requiere distancia por mapa o GPS', + description: 'Este espacio de trabajo requiere gastos de distancia basados en mapa o rastreados por GPS.', + }, }, odometer: { startReading: 'Lectura inicial', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 07661a2fa17c..bcffcaf26358 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8890,6 +8890,10 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, error: { selectSuggestedAddress: 'Veuillez sélectionner une adresse suggérée ou utiliser la position actuelle', + mapOrGpsDistanceRequired: { + title: 'Distance carte ou GPS requise', + description: 'Cet espace de travail requiert des dépenses de distance soit basées sur une carte, soit suivies par GPS.', + }, }, odometer: { startReading: 'Commencer la lecture', @@ -9748,7 +9752,6 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/it.ts b/src/languages/it.ts index d0b937cea3c0..547d022b1af0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8845,6 +8845,10 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, error: { selectSuggestedAddress: 'Seleziona un indirizzo suggerito o usa la posizione attuale', + mapOrGpsDistanceRequired: { + title: 'Distanza da mappa o GPS obbligatoria', + description: 'Questo spazio di lavoro richiede spese chilometriche basate su mappa o tracciate tramite GPS.', + }, }, odometer: { startReading: 'Inizia a leggere', @@ -9704,7 +9708,6 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0b3b11f31e67..94f63b94ac26 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8734,6 +8734,10 @@ ${reportName}`, }, error: { selectSuggestedAddress: '候補の住所を選択するか、現在地を使用してください', + mapOrGpsDistanceRequired: { + title: '地図または GPS の距離が必要です', + description: 'このワークスペースでは、地図ベースまたは GPS 追跡による距離経費のいずれかが必要です。', + }, }, odometer: { startReading: '読み始める', @@ -9581,7 +9585,6 @@ ${reportName}`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 542b22fb4222..0cfd1373e3c2 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8812,6 +8812,10 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, error: { selectSuggestedAddress: 'Selecteer een voorgesteld adres of gebruik de huidige locatie', + mapOrGpsDistanceRequired: { + title: 'Afstand op kaart of via GPS vereist', + description: 'Deze workspace vereist afstandskosten die zijn gebaseerd op een kaart of met GPS zijn gevolgd.', + }, }, odometer: { startReading: 'Begin met lezen', @@ -9670,7 +9674,6 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9eea1a5bbb28..e25e1e68a3be 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8804,6 +8804,10 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, error: { selectSuggestedAddress: 'Wybierz sugerowany adres lub użyj bieżącej lokalizacji', + mapOrGpsDistanceRequired: { + title: 'Wymagana odległość z mapy lub GPS', + description: 'Toje miejsce pracy wymaga wydatków za przejazdy obliczanych na podstawie mapy lub śledzonych za pomocą GPS.', + }, }, odometer: { startReading: 'Zacznij czytać', @@ -9655,7 +9659,6 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 079a50a46e96..9a59fd460f3e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8803,6 +8803,10 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, error: { selectSuggestedAddress: 'Selecione um endereço sugerido ou use a localização atual', + mapOrGpsDistanceRequired: { + title: 'Distância de mapa ou GPS obrigatória', + description: 'Este workspace exige despesas de distância baseadas em mapa ou rastreadas por GPS.', + }, }, odometer: { startReading: 'Começar a ler', @@ -9659,7 +9663,6 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 20aed1fdd31d..2253172bc87f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8559,6 +8559,10 @@ ${reportName}`, }, error: { selectSuggestedAddress: '请选择一个推荐地址或使用当前位置', + mapOrGpsDistanceRequired: { + title: '需要地图或 GPS 距离', + description: '此工作区要求使用基于地图或 GPS 轨迹的里程报销。', + }, }, odometer: { startReading: '开始阅读', @@ -9398,7 +9402,6 @@ ${reportName}`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { From c225c32351313a1958293d7fb92c94a89632d313 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 15:30:02 +0100 Subject: [PATCH 31/64] Add commuter exclusion translations for multiple languages This commit introduces the `commuterExclusion` section in various language files, providing localized messages for original distance, removed commuter distance, and system messages based on workspace distance settings. This enhances the user experience by ensuring consistent messaging across supported languages. --- src/languages/de.ts | 6 ++++++ src/languages/es.ts | 6 ++++++ src/languages/fr.ts | 6 ++++++ src/languages/it.ts | 6 ++++++ src/languages/ja.ts | 6 ++++++ src/languages/nl.ts | 6 ++++++ src/languages/pl.ts | 6 ++++++ src/languages/pt-BR.ts | 6 ++++++ src/languages/zh-hans.ts | 6 ++++++ 9 files changed, 54 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index f4b271234cf8..2357fde72b72 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8876,6 +8876,12 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc snapPhotoStart: 'Machen Sie zu Beginn Ihrer Fahrt ein Foto von Ihrem Kilometerzähler.', snapPhotoEnd: 'Machen Sie ein Foto von Ihrem Kilometerzähler am Ende Ihrer Fahrt.', }, + commuterExclusion: { + original: 'Original', + removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `${formattedDistance} entfernt`, + systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) => + `${formattedDistance} basierend auf den Entfernungseinstellungen des Arbeitsbereichs entfernt.`, + }, }, gps: { error: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 22abc711b912..502b4e73d08b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9117,6 +9117,12 @@ ${amount} para ${merchant} - ${date}`, snapPhotoStart: 'Haz una foto de tu odómetro al inicio de tu viaje.', snapPhotoEnd: 'Haz una foto de tu odómetro al final de tu viaje.', }, + commuterExclusion: { + original: 'Original', + removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Eliminó ${formattedDistance}`, + systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) => + `Se eliminaron ${formattedDistance} según la configuración de distancia del espacio de trabajo.`, + }, }, reportCardLostOrDamaged: { screenTitle: 'Notificar la pérdida o deterioro de la tarjeta', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index bcffcaf26358..5c463801e5f5 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8911,6 +8911,12 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e snapPhotoStart: 'Prenez une photo de votre compteur kilométrique au début de votre trajet.', snapPhotoEnd: 'Prenez une photo de votre compteur kilométrique à la fin de votre trajet.', }, + commuterExclusion: { + original: 'Original', + removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Supprimé ${formattedDistance}`, + systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) => + `${formattedDistance} supprimé en fonction des paramètres de distance de l'espace de travail.`, + }, }, gps: { error: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 547d022b1af0..6259fc0a2acc 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8865,6 +8865,12 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, snapPhotoStart: "Scatta una foto del contachilometri all'inizio del viaggio.", snapPhotoEnd: 'Scatta una foto del contachilometri alla fine del viaggio.', }, + commuterExclusion: { + original: 'Originale', + removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Rimossi ${formattedDistance}`, + systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) => + `Rimossi ${formattedDistance} in base alle impostazioni della distanza dello spazio di lavoro.`, + }, }, gps: { error: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 94f63b94ac26..8afb09b1c489 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8754,6 +8754,12 @@ ${reportName}`, snapPhotoStart: '移動を開始するときに、走行距離計の写真を撮影してください。', snapPhotoEnd: '走行の終了時に、オドメーターの写真を撮影してください。', }, + commuterExclusion: { + original: 'オリジナル', + removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `${formattedDistance} を削除しました`, + systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) => + `ワークスペースの距離設定に基づき、${formattedDistance} を削除しました。`, + }, }, gps: { error: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0cfd1373e3c2..e4755aa14a16 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8832,6 +8832,12 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, snapPhotoStart: 'Maak een foto van je kilometerteller aan het begin van je rit.', snapPhotoEnd: 'Maak een foto van je kilometerteller aan het einde van je rit.', }, + commuterExclusion: { + original: 'Origineel', + removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `${formattedDistance} verwijderd`, + systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) => + `${formattedDistance} verwijderd op basis van werkruimte-instellingen voor afstand.`, + }, }, gps: { error: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index e25e1e68a3be..56c3f904be7b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8824,6 +8824,12 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, snapPhotoStart: 'Zrób zdjęcie licznika kilometrów na początku podróży.', snapPhotoEnd: 'Zrób zdjęcie licznika kilometrów na koniec swojej podróży.', }, + commuterExclusion: { + original: 'Oryginał', + removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Usunięto ${formattedDistance}`, + systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) => + `Usunięto ${formattedDistance} na podstawie ustawień odległości w przestrzeni roboczej.`, + }, }, gps: { error: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 9a59fd460f3e..e13f95457b45 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8823,6 +8823,12 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, snapPhotoStart: 'Tire uma foto do odômetro no início da sua viagem.', snapPhotoEnd: 'Tire uma foto do odômetro no final da sua viagem.', }, + commuterExclusion: { + original: 'Original', + removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Removeu ${formattedDistance}`, + systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) => + `Removido ${formattedDistance} com base nas configurações de distância do workspace.`, + }, }, gps: { error: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2253172bc87f..b0dc3229591a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8579,6 +8579,12 @@ ${reportName}`, snapPhotoStart: '在行程开始时拍一张里程表照片。', snapPhotoEnd: '在行程结束时拍一张里程表的照片。', }, + commuterExclusion: { + original: '原始', + removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `已移除 ${formattedDistance}`, + systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) => + `已根据工作区距离设置移除 ${formattedDistance}。`, + }, }, gps: { error: { From caa7327fb5c0b604d68834049802974637a10dc9 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 15:35:19 +0100 Subject: [PATCH 32/64] Add mock implementations for distance request utilities in tests This commit adds mock functions for `getCommuterExclusionData` and `convertToDistanceInMeters` in the `useDistanceRequestState` test file. These additions enhance test coverage by simulating the behavior of distance request utilities, ensuring more robust unit tests. --- tests/unit/hooks/useDistanceRequestState.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/hooks/useDistanceRequestState.test.ts b/tests/unit/hooks/useDistanceRequestState.test.ts index c775ff1015d4..364c6e9f065d 100644 --- a/tests/unit/hooks/useDistanceRequestState.test.ts +++ b/tests/unit/hooks/useDistanceRequestState.test.ts @@ -8,6 +8,8 @@ jest.mock('@libs/DistanceRequestUtils', () => ({ getDefaultMileageRate: () => undefined, getRate: () => ({rate: 0.5, unit: 'mi', currency: 'USD'}), getDistanceRequestAmount: (distance: number, _unit: string, rate: number): number => Math.round(distance * rate * 100), + getCommuterExclusionData: () => null, + convertToDistanceInMeters: (distance: number): number => distance, }, })); From 814c91bdb056689ff0112a18e476983e23f53c43 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 22 Jun 2026 15:56:01 +0100 Subject: [PATCH 33/64] Scope distance amount recalculation to commuter exclusions via shouldCalculateDistanceAmount Fold the commuter-exclusion check into shouldCalculateDistanceAmount so only commuter requests force the reimbursable distanceRequestAmount, and revert useConfirmationAmount to its original iouAmount default for plain distance requests. --- .../hooks/useConfirmationAmount.ts | 8 ++++---- .../hooks/useDistanceRequestState.ts | 11 +++++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationAmount.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationAmount.ts index eae90e1a684a..b126eda740f3 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationAmount.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationAmount.ts @@ -79,10 +79,10 @@ function useConfirmationAmount({ const subRates = transaction?.comment?.customUnit?.subRates ?? []; const shouldCalculatePerDiemAmount = isPerDiemRequest && (iouAmount === 0 || JSON.stringify(prevSubRates) !== JSON.stringify(subRates) || prevCurrency !== currency); - // For distance requests, always use the computed distanceRequestAmount since it accounts for - // commuter exclusions and rate changes. This avoids stale iouAmount values. - let amountToBeUsed = isDistanceRequest ? distanceRequestAmount : iouAmount; - if (shouldCalculatePerDiemAmount) { + let amountToBeUsed = iouAmount; + if (shouldCalculateDistanceAmount) { + amountToBeUsed = distanceRequestAmount; + } else if (shouldCalculatePerDiemAmount) { amountToBeUsed = computePerDiemExpenseAmount({subRates}); } diff --git a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts index 632d55aa32d2..f0d7ccaf3db4 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts @@ -91,16 +91,19 @@ function useDistanceRequestState({ const distance = getDistanceInMeters(transaction, unit); const prevDistance = usePrevious(distance); - const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance || prevCurrency !== currency || prevUnit !== unit); - - const hasRoute = hasRouteUtil(transaction, isDistanceRequest); - const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense; // When a commuter exclusion applies, the reimbursable amount is based on the distance remaining after the exclusion. const amountUnit = unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance, amountUnit); const reimbursableDistanceInMeters = commuterExclusionData ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, amountUnit) : distance; + // Recalculate when the route/rate changes, or whenever a commuter exclusion applies so the reimbursable amount is used. + const shouldCalculateDistanceAmount = + isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance || prevCurrency !== currency || prevUnit !== unit || !!commuterExclusionData); + + const hasRoute = hasRouteUtil(transaction, isDistanceRequest); + const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense; + const distanceRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(reimbursableDistanceInMeters, amountUnit, rate ?? 0); return { From 1a4b275d9ea4a219b3a8fb3c6f1154b5ea00f70d Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 29 Jun 2026 12:27:56 +0100 Subject: [PATCH 34/64] Add 'commuter' translations for multiple languages --- src/languages/de.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + 9 files changed, 9 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 2677381272ea..ca5708183516 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -514,6 +514,7 @@ const translations: TranslationDeepObject = { editor: 'Editor', restrictions: 'Beschränkungen', off: 'Aus', + commuter: 'Pendler', }, socials: { podcast: 'Folgen Sie uns auf Podcast', diff --git a/src/languages/es.ts b/src/languages/es.ts index 42f740c3cb37..f457394d92f1 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -465,6 +465,7 @@ const translations: TranslationDeepObject = { editor: 'Editor', restrictions: 'Restricciones', off: 'Desactivado', + commuter: 'viajero diario', }, socials: { podcast: 'Síguenos en Podcast', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 45cbdf770f38..606950b002ce 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -514,6 +514,7 @@ const translations: TranslationDeepObject = { editor: 'Éditeur', restrictions: 'Restrictions', off: 'Désactivé', + commuter: 'navetteur', }, socials: { podcast: 'Suivez-nous sur Podcast', diff --git a/src/languages/it.ts b/src/languages/it.ts index ce91fd9d3d41..6a398c983dcb 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -514,6 +514,7 @@ const translations: TranslationDeepObject = { editor: 'Editor', restrictions: 'Restrizioni', off: 'Disattivato', + commuter: 'pendolare', }, socials: { podcast: 'Seguici su Podcast', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 96d097466432..18d4a67d77e9 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -513,6 +513,7 @@ const translations: TranslationDeepObject = { editor: '編集者', restrictions: '制限', off: 'オフ', + commuter: '通勤者', }, socials: { podcast: 'ポッドキャストでフォロー', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index acce44219a29..886c38767952 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -513,6 +513,7 @@ const translations: TranslationDeepObject = { editor: 'Editor', restrictions: 'Beperkingen', off: 'Uit', + commuter: 'forens', }, socials: { podcast: 'Volg ons op Podcast', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 044ab0b9434c..4683c55afe9d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -513,6 +513,7 @@ const translations: TranslationDeepObject = { editor: 'Edytor', restrictions: 'Ograniczenia', off: 'Wyłączone', + commuter: 'dojeżdżający', }, socials: { podcast: 'Śledź nas na Podcast', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 03108fcc54a3..fcddcadd8c89 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -512,6 +512,7 @@ const translations: TranslationDeepObject = { editor: 'Editor', restrictions: 'Restrições', off: 'Desligado', + commuter: 'usuário de transporte coletivo', }, socials: { podcast: 'Siga-nos no Podcast', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ec8501e7bb34..cb8a4025fbbb 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -509,6 +509,7 @@ const translations: TranslationDeepObject = { editor: '编辑', restrictions: '限制', off: '关', + commuter: '通勤者', }, socials: { podcast: '在播客上关注我们', From 0b8f004153e2ce9a0cecfe829d070b8d602323ee Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 29 Jun 2026 13:44:05 +0000 Subject: [PATCH 35/64] Fix commuter exclusion distance display --- src/libs/DistanceRequestUtils.ts | 10 ++- tests/unit/DistanceRequestUtilsTest.ts | 86 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index b24966d638fb..76c47a146670 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -253,7 +253,7 @@ function getDistanceForDisplay( } const distanceToDisplayInMeters = commuterExclusionData ? convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, displayUnit) : distanceInMeters; - if (!distanceToDisplayInMeters && !isZeroDistanceAllowed) { + if (!distanceToDisplayInMeters && !isZeroDistanceAllowed && !commuterExclusionData) { return ''; } @@ -292,7 +292,7 @@ function getDistanceMerchant( return translate('iou.fieldPending'); } - if (!distanceInMeters && !isZeroDistanceAllowed) { + if (!distanceInMeters && !isZeroDistanceAllowed && !commuterExclusionData) { return ''; } @@ -378,7 +378,11 @@ function getCommuterExclusionData( if (!commuterExclusion && policy?.commuterExclusions) { const exclusionConfig = policy.commuterExclusions; if (exclusionConfig.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE && quantityInUnit > 0) { - const fixedDistance = exclusionConfig.fixedDistance ?? 0; + const fixedDistanceUnit: Unit = + exclusionConfig.fixedDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS + ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS + : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const fixedDistance = convertDistanceUnit(convertToDistanceInMeters(exclusionConfig.fixedDistance ?? 0, fixedDistanceUnit), distanceUnit); if (fixedDistance > 0) { commuterExclusion = Math.min(fixedDistance, quantityInUnit); reimbursableDistance = Math.max(0, quantityInUnit - commuterExclusion); diff --git a/tests/unit/DistanceRequestUtilsTest.ts b/tests/unit/DistanceRequestUtilsTest.ts index b954d20ef22f..23dd4d689573 100644 --- a/tests/unit/DistanceRequestUtilsTest.ts +++ b/tests/unit/DistanceRequestUtilsTest.ts @@ -376,6 +376,55 @@ describe('DistanceRequestUtils', () => { const result = DistanceRequestUtils.getDistanceForDisplay(true, 0, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, 67, translateLocal, false, true); expect(result).toBe(`0.00 ${translateLocal('common.miles')}`); }); + + it('formats zero reimbursable commuter distance', () => { + const commuterExclusionData = { + commuterExclusion: 1, + reimbursableDistance: 0, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }; + + const result = DistanceRequestUtils.getDistanceForDisplay( + true, + DistanceRequestUtils.convertToDistanceInMeters(1, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES), + CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + 67, + translateLocal, + false, + false, + commuterExclusionData, + ); + + expect(result).toBe(`0.00 ${translateLocal('common.miles')}`); + }); + }); + + describe('getCommuterExclusionData', () => { + it('converts fixed distance exclusions into the request unit', () => { + const policy = { + ...FAKE_POLICY, + commuterExclusions: { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + fixedDistance: 1, + fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + }, + }; + const transaction = { + ...createRandomTransaction(1), + comment: {customUnit: {quantity: 1, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}}, + } as Transaction; + + const result = DistanceRequestUtils.getCommuterExclusionData( + transaction, + policy, + DistanceRequestUtils.convertToDistanceInMeters(1, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES), + CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + ); + + expect(result?.commuterExclusion).toBeCloseTo(0.62, 2); + expect(result?.reimbursableDistance).toBeCloseTo(0.38, 2); + expect(result?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + }); }); describe('getRate', () => { @@ -448,6 +497,43 @@ describe('DistanceRequestUtils', () => { ); expect(result).toBe('0.00 mi @ $0.67 / mi'); }); + + it('formats distance merchants with a currency amount', () => { + const result = DistanceRequestUtils.getDistanceMerchant( + true, + DistanceRequestUtils.convertToDistanceInMeters(3.49, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES), + CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + 67, + 'USD', + translateLocal, + toLocaleDigitMock, + getCurrencySymbolMock, + true, + ); + + expect(result).toBe('3.49 mi @ $0.67 / mi'); + }); + + it('formats zero reimbursable commuter distance', () => { + const result = DistanceRequestUtils.getDistanceMerchant( + true, + DistanceRequestUtils.convertToDistanceInMeters(1, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES), + CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + 67, + 'USD', + translateLocal, + toLocaleDigitMock, + getCurrencySymbolMock, + false, + { + commuterExclusion: 1, + reimbursableDistance: 0, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + ); + + expect(result).toBe('0.00 mi @ $0.67 / mi'); + }); }); describe('getTaxableAmount', () => { From 332ffb6ae99324d24ff4888f6edad9d5f3cd3a32 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 29 Jun 2026 13:45:27 +0000 Subject: [PATCH 36/64] Use shared distance merchant formatting --- src/libs/actions/IOU/Split.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 79d5b763d1ea..00b6d30f46a7 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -6,11 +6,13 @@ import ReceiptGeneric from '@assets/images/receipt-generic.png'; import * as API from '@libs/API'; import type {CompleteSplitBillParams, CreateDistanceRequestParams, SplitBillParams, StartSplitBillParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; +import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {deferOrExecuteWrite} from '@libs/deferredLayoutWrite'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {calculateAmount as calculateIOUAmount, updateIOUOwnerAndTotal} from '@libs/IOUUtils'; +import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import * as Localize from '@libs/Localize'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; @@ -60,6 +62,7 @@ import {notifyNewAction} from '@userActions/Report'; import {sanitizeWaypointsForAPI} from '@userActions/Transaction'; import {removeDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant, Split} from '@src/types/onyx/IOU'; @@ -2112,7 +2115,18 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest // Use the canonical distance-amount calc so the optimistic amount rounds the same way the server does. const reimbursableDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(reimbursableDistance, distanceUnit); const modifiedRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(reimbursableDistanceInMeters, distanceUnit, distanceRate); - const modifiedRequestMerchant = `${reimbursableDistance.toFixed(2)} ${distanceUnit} @ ${distanceRate} / ${distanceUnit}`; + const modifiedRequestMerchant = DistanceRequestUtils.getDistanceMerchant( + true, + reimbursableDistanceInMeters, + distanceUnit, + distanceRate, + currency, + // eslint-disable-next-line @typescript-eslint/no-deprecated + Localize.translateLocal, + (digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit), + getCurrencySymbol, + true, + ); onyxData?.optimisticData?.push({ onyxMethod: Onyx.METHOD.MERGE, From eddff4cda170cade70e0a36067afe841c5c3e0ed Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 29 Jun 2026 13:46:19 +0000 Subject: [PATCH 37/64] Add commuter fields to debug validation --- src/libs/DebugUtils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 901c774b39bc..ec8a585a10ab 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1069,6 +1069,10 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) name: CONST.RED_BRICK_ROAD_PENDING_ACTION, defaultP2PRate: CONST.RED_BRICK_ROAD_PENDING_ACTION, distanceUnit: CONST.RED_BRICK_ROAD_PENDING_ACTION, + commuterExclusion: CONST.RED_BRICK_ROAD_PENDING_ACTION, + commuterExclusionMethod: CONST.RED_BRICK_ROAD_PENDING_ACTION, + commuterExclusionType: CONST.RED_BRICK_ROAD_PENDING_ACTION, + reimbursableDistance: CONST.RED_BRICK_ROAD_PENDING_ACTION, rateAutoUpdated: CONST.RED_BRICK_ROAD_PENDING_ACTION, odometerStart: CONST.RED_BRICK_ROAD_PENDING_ACTION, odometerEnd: CONST.RED_BRICK_ROAD_PENDING_ACTION, From 67e2da5cccd3ba5fee5d84d50665ef64e97ca642 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 29 Jun 2026 15:13:10 +0100 Subject: [PATCH 38/64] Refactor fixed distance unit style --- src/libs/DistanceRequestUtils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 76c47a146670..ffa507e20247 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -379,9 +379,7 @@ function getCommuterExclusionData( const exclusionConfig = policy.commuterExclusions; if (exclusionConfig.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE && quantityInUnit > 0) { const fixedDistanceUnit: Unit = - exclusionConfig.fixedDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS - ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS - : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + exclusionConfig.fixedDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; const fixedDistance = convertDistanceUnit(convertToDistanceInMeters(exclusionConfig.fixedDistance ?? 0, fixedDistanceUnit), distanceUnit); if (fixedDistance > 0) { commuterExclusion = Math.min(fixedDistance, quantityInUnit); From 39a7adbb2c3b776bfeb0c9d5d075c4206c28931f Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 29 Jun 2026 14:33:44 +0000 Subject: [PATCH 39/64] Extract distance display formatting --- src/libs/DistanceDisplayUtils.ts | 29 +++++++++++++++++++++++++++++ src/libs/DistanceRequestUtils.ts | 31 +------------------------------ src/libs/ReportActionsUtils.ts | 8 ++++---- 3 files changed, 34 insertions(+), 34 deletions(-) create mode 100644 src/libs/DistanceDisplayUtils.ts diff --git a/src/libs/DistanceDisplayUtils.ts b/src/libs/DistanceDisplayUtils.ts new file mode 100644 index 000000000000..49d3a97cdba9 --- /dev/null +++ b/src/libs/DistanceDisplayUtils.ts @@ -0,0 +1,29 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import CONST from '@src/CONST'; +import type {Unit} from '@src/types/onyx/Policy'; + +/** + * Returns the translated distance unit label based on the distance value (for singular/plural). + */ +function getDistanceUnitLabel(distance: number, unit: Unit, translate: LocaleContextProps['translate']): string { + const isSingular = distance === 1; + if (unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES) { + return translate(isSingular ? 'common.mile' : 'common.miles'); + } + return translate(isSingular ? 'common.kilometer' : 'common.kilometers'); +} + +/** + * Formats a distance that is already expressed in display units (mi/km) into a localized string (e.g. "12.34 miles"). + */ +function getFormattedDistanceInUnits(distanceInUnits: number, unit: Unit, translate: LocaleContextProps['translate'], useShortFormUnit?: boolean, isCommuterDistance?: boolean): string { + const roundedDistance = distanceInUnits.toFixed(CONST.DISTANCE_DECIMAL_PLACES); + const unitLabel = useShortFormUnit ? unit : getDistanceUnitLabel(distanceInUnits, unit, translate); + if (isCommuterDistance) { + return `${roundedDistance} ${translate('common.commuter')} ${unitLabel}`; + } + + return `${roundedDistance} ${unitLabel}`; +} + +export {getDistanceUnitLabel, getFormattedDistanceInUnits}; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index ffa507e20247..f7b30ff67c7c 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -11,6 +11,7 @@ import type DefaultP2PMileageRate from '@src/types/onyx/DefaultP2PMileageRate'; import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {getDistanceUnitLabel, getFormattedDistanceInUnits} from './DistanceDisplayUtils'; import getStoredDefaultP2PMileageRate from './getStoredDefaultP2PMileageRate'; import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, getUnitRateValue} from './PolicyUtils'; import replaceAllDigits from './replaceAllDigits'; @@ -146,25 +147,6 @@ function getRoundedDistanceInUnits(distanceInMeters: number, unit: Unit): string return convertedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES); } -/** - * Formats a distance that is already expressed in display units (mi/km) into a localized string (e.g. "12.34 miles"). - * - * @param distanceInUnits Distance already converted to the display unit - * @param unit Unit that should be used to display the distance - * @param translate Translate function - * @param useShortFormUnit If true, the unit will be returned in short form (e.g., "mi", "km"). - * @param isCommuterDistance If true, the commuter label is inserted before the unit (e.g. "12.34 commuter miles"). - */ -function getFormattedDistanceInUnits(distanceInUnits: number, unit: Unit, translate: LocaleContextProps['translate'], useShortFormUnit?: boolean, isCommuterDistance?: boolean): string { - const roundedDistance = distanceInUnits.toFixed(CONST.DISTANCE_DECIMAL_PLACES); - const unitLabel = useShortFormUnit ? unit : getDistanceUnitLabel(distanceInUnits, unit, translate); - if (isCommuterDistance) { - return `${roundedDistance} ${translate('common.commuter')} ${unitLabel}`; - } - - return `${roundedDistance} ${unitLabel}`; -} - /** * @param unit Unit that should be used to display the distance * @param rate Expensable amount allowed per unit @@ -591,17 +573,6 @@ function getDistanceUnit(transaction: OnyxEntry, mileageRate: OnyxE return transaction?.comment?.customUnit?.distanceUnit ?? mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; } -/** - * Returns the translated distance unit label based on the distance value (for singular/plural). - */ -function getDistanceUnitLabel(distance: number, unit: string, translate: LocaleContextProps['translate']): string { - const isSingular = distance === 1; - if (unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES) { - return translate(isSingular ? 'common.mile' : 'common.miles'); - } - return translate(isSingular ? 'common.kilometer' : 'common.kilometers'); -} - /** @private This is only for internal use for getRate function */ function getPersonalPolicy() { return Object.values(allPolicies ?? {}).find((policy) => policy?.type === CONST.POLICY.TYPE.PERSONAL); diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 6e261bf30540..0f97adfb41d3 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -48,7 +48,7 @@ import {getBankName, isCardPendingActivate} from './CardUtils'; import {getDecodedCategoryName} from './CategoryUtils'; import {convertAmountToDisplayString, convertToBackendAmount, convertToDisplayString, convertToDisplayStringWithExplicitCurrency, convertToShortDisplayString} from './CurrencyUtils'; import DateUtils from './DateUtils'; -import DistanceRequestUtils from './DistanceRequestUtils'; +import {getFormattedDistanceInUnits} from './DistanceDisplayUtils'; import {getEnvironmentURL, getOldDotEnvironmentURL} from './Environment/Environment'; import getBase62ReportID from './getBase62ReportID'; import {isReportMessageAttachment} from './isReportMessageAttachment'; @@ -4229,13 +4229,13 @@ function getUpdatedCommuterExclusionsMessage(translate: LocalizedTranslate, repo if (updatedField === CONST.POLICY.COMMUTER_EXCLUSION_TYPE.FIXED_DISTANCE) { const distanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const formattedDistance = DistanceRequestUtils.getFormattedDistanceInUnits(Number(newValue ?? 0), distanceUnit, translate); + const formattedDistance = getFormattedDistanceInUnits(Number(newValue ?? 0), distanceUnit, translate); if (!oldValue) { return translate('workspaceActions.commuterExclusions.setFixedDistance', {formattedDistance}); } - const formattedOldDistance = DistanceRequestUtils.getFormattedDistanceInUnits(Number(oldValue ?? 0), distanceUnit, translate); + const formattedOldDistance = getFormattedDistanceInUnits(Number(oldValue ?? 0), distanceUnit, translate); return translate('workspaceActions.commuterExclusions.changedFixedDistance', {formattedOldDistance, formattedNewDistance: formattedDistance}); } @@ -4691,7 +4691,7 @@ function getCommuterExclusionMessage(translate: LocalizedTranslate, action: Onyx const {distance, unit} = getOriginalMessage(action as ReportAction) ?? {distance: '0', unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}; const distanceValue = Number(distance ?? 0); const unitValue = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const formattedDistance = DistanceRequestUtils.getFormattedDistanceInUnits(distanceValue, unitValue, translate, false, true); + const formattedDistance = getFormattedDistanceInUnits(distanceValue, unitValue, translate, false, true); const workspaceDistanceSettingsLink = policyID ? `${environmentURL}/${ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID)}` : ''; From 832893ec13e3b7c1e3ac233ca7ddf5410e18521f Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 29 Jun 2026 16:26:17 +0000 Subject: [PATCH 40/64] Cover commuter distance merchant updates --- .../DistanceRequestController.test.tsx | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx diff --git a/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx new file mode 100644 index 000000000000..1922db6d615d --- /dev/null +++ b/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx @@ -0,0 +1,104 @@ +import {render} from '@testing-library/react-native'; +import React from 'react'; +import DistanceRequestController from '@components/MoneyRequestConfirmationList/DistanceRequestController'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import CONST from '@src/CONST'; +import type {Transaction} from '@src/types/onyx'; +import createMock from '../../../utils/createMock'; + +const mockSetMoneyRequestAmount = jest.fn(); +const mockSetMoneyRequestMerchant = jest.fn(); +const mockSetMoneyRequestPendingFields = jest.fn(); + +jest.mock('@hooks/useCurrencyList', () => ({ + useCurrencyListActions: () => ({ + getCurrencySymbol: (currency: string) => (currency === 'USD' ? '$' : undefined), + }), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({ + translate: (key: string) => key, + toLocaleDigit: (digit: string) => digit, + }), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => [undefined], +})); + +jest.mock('@hooks/usePrevious', () => ({ + __esModule: true, + default: () => undefined, +})); + +jest.mock('@libs/actions/IOU/MoneyRequest', () => ({ + clearMoneyRequestRateAutoUpdated: jest.fn(), + setCustomUnitRateID: jest.fn(), + setMoneyRequestAmount: (...args: unknown[]) => { + mockSetMoneyRequestAmount(...args); + }, + setMoneyRequestMerchant: (...args: unknown[]) => { + mockSetMoneyRequestMerchant(...args); + }, + setMoneyRequestPendingFields: (...args: unknown[]) => { + mockSetMoneyRequestPendingFields(...args); + }, +})); + +jest.mock('@libs/actions/IOU/Split', () => ({ + setSplitShares: jest.fn(), +})); + +const transaction = createMock({ + transactionID: 'txn1', + currency: CONST.CURRENCY.USD, + comment: {customUnit: {}}, +}); + +describe('DistanceRequestController', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses commuter exclusion data when updating the distance merchant', () => { + render( + , + ); + + expect(mockSetMoneyRequestMerchant).toHaveBeenCalledWith('txn1', '3.00 mi @ $0.67 / mi', true); + }); +}); From 7468b01fc910d36f353b9e74859f4d9ed42058be Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 29 Jun 2026 17:38:41 +0100 Subject: [PATCH 41/64] Fix ESLint type-assertion errors in commuter exclusion messages Remove redundant type assertion narrowed by isActionOfType and replace an unsafe cast with an isActionOfType guard to stay within the seatbelt baseline. --- src/libs/ReportActionsUtils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 0f97adfb41d3..392e1d11b728 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4221,7 +4221,7 @@ function getUpdatedCommuterExclusionsMessage(translate: LocalizedTranslate, repo if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_COMMUTER_EXCLUSIONS)) { return getReportActionText(reportAction); } - const {newValue, unit, oldValue, updatedField} = getOriginalMessage(reportAction as ReportAction) ?? {}; + const {newValue, unit, oldValue, updatedField} = getOriginalMessage(reportAction) ?? {}; if (updatedField === CONST.POLICY.COMMUTER_EXCLUSION_TYPE.METHOD && newValue === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE) { return translate('workspaceActions.commuterExclusions.changedToFixedDistance'); @@ -4688,7 +4688,11 @@ function getPlaidBalanceFailureMessage(translate: LocalizedTranslate, action: On } function getCommuterExclusionMessage(translate: LocalizedTranslate, action: OnyxEntry, policyID?: string): string { - const {distance, unit} = getOriginalMessage(action as ReportAction) ?? {distance: '0', unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}; + if (!isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.COMMUTER_EXCLUSION)) { + return getReportActionText(action); + } + + const {distance, unit} = getOriginalMessage(action) ?? {distance: '0', unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}; const distanceValue = Number(distance ?? 0); const unitValue = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; const formattedDistance = getFormattedDistanceInUnits(distanceValue, unitValue, translate, false, true); From f678ffc02e9eba913834d3beec3c1992b012c168 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 29 Jun 2026 21:28:53 +0100 Subject: [PATCH 42/64] Add HouseWithMap illustration SVG and update imports --- .../simple-illustration__housewithmap.svg | 96 +++++++++++++++++++ .../Icon/chunks/illustrations.chunk.ts | 2 + 2 files changed, 98 insertions(+) create mode 100644 assets/images/simple-illustrations/simple-illustration__housewithmap.svg diff --git a/assets/images/simple-illustrations/simple-illustration__housewithmap.svg b/assets/images/simple-illustrations/simple-illustration__housewithmap.svg new file mode 100644 index 000000000000..a556da33cf23 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__housewithmap.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index ce1626f88da1..1710e9428710 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -153,6 +153,7 @@ import HeadSet from '@assets/images/simple-illustrations/simple-illustration__he import Hourglass from '@assets/images/simple-illustrations/simple-illustration__hourglass.svg'; import HouseMoney from '@assets/images/simple-illustrations/simple-illustration__house-money.svg'; import House from '@assets/images/simple-illustrations/simple-illustration__house.svg'; +import HouseWithMap from '@assets/images/simple-illustrations/simple-illustration__housewithmap.svg'; import InvoiceBlue from '@assets/images/simple-illustrations/simple-illustration__invoice.svg'; import LifeRing from '@assets/images/simple-illustrations/simple-illustration__lifering.svg'; import Lightbulb from '@assets/images/simple-illustrations/simple-illustration__lightbulb.svg'; @@ -400,6 +401,7 @@ const Illustrations = { HeadSet, Hourglass, House, + HouseWithMap, HouseMoney, LifeRing, Lightbulb, From 9d426378d2f03ed62d30ad9d0e2b861eaa2c7f27 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Tue, 30 Jun 2026 11:03:44 +0100 Subject: [PATCH 43/64] Update IOU request steps to use HouseWithMap illustration and add new image styles for better layout --- src/pages/iou/request/step/IOURequestStepDistanceManual.tsx | 6 ++++-- .../iou/request/step/IOURequestStepDistanceOdometer.tsx | 6 ++++-- src/styles/index.ts | 5 +++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index a71ffa783d05..b8dae1cae13d 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -65,7 +65,7 @@ function IOURequestStepDistanceManual({ }: IOURequestStepDistanceManualProps) { const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); - const illustrations = useMemoizedLazyIllustrations(['HouseMoney']); + const illustrations = useMemoizedLazyIllustrations(['HouseWithMap']); const styles = useThemeStyles(); const {isBetaEnabled} = usePermissions(); const {isExtraSmallScreenHeight} = useResponsiveLayout(); @@ -296,8 +296,10 @@ function IOURequestStepDistanceManual({ confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, shouldShowDismissIcon: true, - image: illustrations.HouseMoney, + image: illustrations.HouseWithMap, shouldUseSuccessStyleForConfirm: true, + shouldFitImageToContainer: true, + imageStyles: styles.commuterExclusionStaticIllustration, }); return; } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index b66d9810257e..60b86c544d03 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -81,7 +81,7 @@ function IOURequestStepDistanceOdometer({ }: IOURequestStepDistanceOdometerProps) { const {translate, fromLocaleDigit, numberFormat} = useLocalize(); const {showConfirmModal} = useConfirmModal(); - const illustrations = useMemoizedLazyIllustrations(['HouseMoney']); + const illustrations = useMemoizedLazyIllustrations(['HouseWithMap']); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -463,7 +463,9 @@ function IOURequestStepDistanceOdometer({ shouldUseSuccessStyleForConfirm: true, shouldShowCancelButton: false, shouldShowDismissIcon: true, - image: illustrations.HouseMoney, + image: illustrations.HouseWithMap, + shouldFitImageToContainer: true, + imageStyles: styles.commuterExclusionStaticIllustration, }); return; } diff --git a/src/styles/index.ts b/src/styles/index.ts index 374497651c32..cf8fd466fd11 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6162,6 +6162,11 @@ const staticStyles = (theme: ThemeColors) => width: 112, height: 160, }, + commuterExclusionStaticIllustration: { + width: 160, + height: 140, + alignSelf: 'center', + }, helpStaticIllustration: { width: 174, height: 156, From 1d7bc195182c93dd78a002ef712edac9f1d9e300 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Tue, 30 Jun 2026 11:05:52 +0100 Subject: [PATCH 44/64] Refactor HouseWithMap SVG illustration by removing unnecessary metadata and optimizing paths for improved performance and clarity. --- .../simple-illustration__housewithmap.svg | 97 +------------------ 1 file changed, 1 insertion(+), 96 deletions(-) diff --git a/assets/images/simple-illustrations/simple-illustration__housewithmap.svg b/assets/images/simple-illustrations/simple-illustration__housewithmap.svg index a556da33cf23..aff0b5ce0830 100644 --- a/assets/images/simple-illustrations/simple-illustration__housewithmap.svg +++ b/assets/images/simple-illustrations/simple-illustration__housewithmap.svg @@ -1,96 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file From 4a93ae81b20c7b3834ef396f1b8b2009ae9a112e Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 6 Jul 2026 12:19:01 +0100 Subject: [PATCH 45/64] refactor: reorganize imports across multiple files for consistency and clarity --- src/components/DistanceWithCommuterExclusion.tsx | 6 +++++- .../DistanceRequestController.tsx | 1 + src/libs/DistanceDisplayUtils.ts | 1 + src/libs/DistanceRequestUtils.ts | 2 +- .../iou/request/step/IOURequestStepDistanceManual.tsx | 2 +- .../iou/request/step/IOURequestStepDistanceOdometer.tsx | 2 +- .../DistanceRequestController.test.tsx | 7 ++++++- 7 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/components/DistanceWithCommuterExclusion.tsx b/src/components/DistanceWithCommuterExclusion.tsx index 2f35d92854e5..a3d39ba6e159 100644 --- a/src/components/DistanceWithCommuterExclusion.tsx +++ b/src/components/DistanceWithCommuterExclusion.tsx @@ -1,8 +1,12 @@ -import React from 'react'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; + import DistanceRequestUtils from '@libs/DistanceRequestUtils'; + import type {Unit} from '@src/types/onyx/Policy'; + +import React from 'react'; + import Text from './Text'; type DistanceWithCommuterExclusionProps = { diff --git a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx index cf5c7d274cc3..0fe72e1aec94 100644 --- a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx +++ b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx @@ -1,4 +1,5 @@ import type {CommuterExclusionData} from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; + import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; diff --git a/src/libs/DistanceDisplayUtils.ts b/src/libs/DistanceDisplayUtils.ts index 49d3a97cdba9..8faf706446c8 100644 --- a/src/libs/DistanceDisplayUtils.ts +++ b/src/libs/DistanceDisplayUtils.ts @@ -1,4 +1,5 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; + import CONST from '@src/CONST'; import type {Unit} from '@src/types/onyx/Policy'; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 02b7f66dc99f..d100f7ae0427 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -9,7 +9,6 @@ import type DefaultP2PMileageRate from '@src/types/onyx/DefaultP2PMileageRate'; import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {getDistanceUnitLabel, getFormattedDistanceInUnits} from './DistanceDisplayUtils'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -17,6 +16,7 @@ import {format, parseISO} from 'date-fns'; import Onyx from 'react-native-onyx'; import DateUtils from './DateUtils'; +import {getDistanceUnitLabel, getFormattedDistanceInUnits} from './DistanceDisplayUtils'; import getStoredDefaultP2PMileageRate from './getStoredDefaultP2PMileageRate'; import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, getUnitRateValue} from './PolicyUtils'; import replaceAllDigits from './replaceAllDigits'; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 52c789175854..90e7427f235d 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -4,8 +4,8 @@ import type {NumberWithSymbolFormRef} from '@components/NumberWithSymbolForm'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import useConfirmModal from '@hooks/useConfirmModal'; +import useConfirmModal from '@hooks/useConfirmModal'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 93cd7eb996fa..bedcc44aa32c 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -7,8 +7,8 @@ import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import useConfirmModal from '@hooks/useConfirmModal'; +import useConfirmModal from '@hooks/useConfirmModal'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; diff --git a/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx index 1922db6d615d..77434c6ee88d 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx +++ b/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx @@ -1,9 +1,14 @@ import {render} from '@testing-library/react-native'; -import React from 'react'; + import DistanceRequestController from '@components/MoneyRequestConfirmationList/DistanceRequestController'; + import DistanceRequestUtils from '@libs/DistanceRequestUtils'; + import CONST from '@src/CONST'; import type {Transaction} from '@src/types/onyx'; + +import React from 'react'; + import createMock from '../../../utils/createMock'; const mockSetMoneyRequestAmount = jest.fn(); From 1423373bba82a6c7f14b0dbdb13cc630e40360cf Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 6 Jul 2026 12:56:43 +0100 Subject: [PATCH 46/64] refactor: remove DistanceWithCommuterExclusion component and update references in DistanceField and MoneyRequestView --- .../DistanceWithCommuterExclusion.tsx | 31 ------------------- .../sections/DistanceField.tsx | 19 +++++------- .../ReportActionItem/MoneyRequestView.tsx | 27 +++++++--------- 3 files changed, 18 insertions(+), 59 deletions(-) delete mode 100644 src/components/DistanceWithCommuterExclusion.tsx diff --git a/src/components/DistanceWithCommuterExclusion.tsx b/src/components/DistanceWithCommuterExclusion.tsx deleted file mode 100644 index a3d39ba6e159..000000000000 --- a/src/components/DistanceWithCommuterExclusion.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import DistanceRequestUtils from '@libs/DistanceRequestUtils'; - -import type {Unit} from '@src/types/onyx/Policy'; - -import React from 'react'; - -import Text from './Text'; - -type DistanceWithCommuterExclusionProps = { - /** The commuter exclusion distance that was removed */ - commuterExclusion: number; - - /** The unit of distance (mi/km) */ - distanceUnit: Unit; -}; - -function DistanceWithCommuterExclusion({commuterExclusion, distanceUnit}: DistanceWithCommuterExclusionProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - const formattedDistance = DistanceRequestUtils.getFormattedDistanceInUnits(commuterExclusion, distanceUnit, translate, false, true); - - return {translate('distance.commuterExclusion.removedCommuterDistance', {formattedDistance})}; -} - -DistanceWithCommuterExclusion.displayName = 'DistanceWithCommuterExclusion'; - -export default DistanceWithCommuterExclusion; diff --git a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx index dc3f2e0e4820..649d94c8a24f 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx @@ -1,4 +1,3 @@ -import DistanceWithCommuterExclusion from '@components/DistanceWithCommuterExclusion'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import type {CommuterExclusionData} from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; @@ -54,21 +53,17 @@ function DistanceField({ const {translate} = useLocalize(); // When commuter exclusion applies, the title shows the reimbursable distance and the description shows the original - const displayTitle = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, undefined, isManualDistanceRequest, commuterExclusionData); + const displayTitle = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, true, isManualDistanceRequest, commuterExclusionData); let descriptionLabel = translate('common.distance'); - let furtherDetailsComponent; + let distanceToDisplayHintText; if (commuterExclusionData) { const displayUnit = unit ?? commuterExclusionData.distanceUnit; const originalDistance = commuterExclusionData.reimbursableDistance + commuterExclusionData.commuterExclusion; - const originalDistanceFormatted = DistanceRequestUtils.getFormattedDistanceInUnits(originalDistance, displayUnit, translate); - descriptionLabel = `${translate('common.distance')} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`; - furtherDetailsComponent = ( - - ); + const originalDistanceFormatted = DistanceRequestUtils.getFormattedDistanceInUnits(originalDistance, displayUnit, translate, true); + descriptionLabel += ` ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`; + const formattedDistance = DistanceRequestUtils.getFormattedDistanceInUnits(commuterExclusionData.commuterExclusion, commuterExclusionData.distanceUnit, translate, false, true); + distanceToDisplayHintText = translate('distance.commuterExclusion.removedCommuterDistance', {formattedDistance}); } return ( @@ -76,7 +71,7 @@ function DistanceField({ shouldShowRightIcon={!isReadOnly && !isGPSDistanceRequest} title={displayTitle} description={descriptionLabel} - furtherDetailsComponent={furtherDetailsComponent} + hintText={distanceToDisplayHintText} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 634d3685aa01..346211821408 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1,4 +1,3 @@ -import DistanceWithCommuterExclusion from '@components/DistanceWithCommuterExclusion'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; @@ -578,21 +577,17 @@ function MoneyRequestView({ ); const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const commuterExclusionBreakdown = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance, distanceUnitValue); - const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, undefined, isManualDistanceRequest, commuterExclusionBreakdown); + const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance, distanceUnitValue); + const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, true, isManualDistanceRequest, commuterExclusionBreakdown); let distanceToDisplayDescription = translate('common.distance'); - let distanceToDisplaySubtitle; - - if (commuterExclusionBreakdown) { - const {commuterExclusion, reimbursableDistance} = commuterExclusionBreakdown; - const originalDistanceFormatted = DistanceRequestUtils.getFormattedDistanceInUnits(reimbursableDistance + commuterExclusion, distanceUnitValue, translate); - distanceToDisplayDescription = `${translate('common.distance')} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`; - distanceToDisplaySubtitle = ( - - ); + let distanceToDisplayHintText; + + if (commuterExclusionData) { + const {commuterExclusion, reimbursableDistance} = commuterExclusionData; + const originalDistanceFormatted = DistanceRequestUtils.getFormattedDistanceInUnits(reimbursableDistance + commuterExclusion, distanceUnitValue, translate, false); + distanceToDisplayDescription += ` ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`; + const formattedDistance = DistanceRequestUtils.getFormattedDistanceInUnits(commuterExclusion, distanceUnitValue, translate, false, true); + distanceToDisplayHintText = translate('distance.commuterExclusion.removedCommuterDistance', {formattedDistance}); } let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; @@ -915,7 +910,7 @@ function MoneyRequestView({ Date: Mon, 6 Jul 2026 13:05:13 +0100 Subject: [PATCH 47/64] refactor: extract getDistanceDisplayDetailsWithCommuter to DRY commuter exclusion display logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getDistanceDisplayDetailsWithCommuter helper in DistanceRequestUtils - Update DistanceField and MoneyRequestView to use the shared helper - Fixes incorrect variable (commuterExclusionBreakdown → commuterExclusionData) in MoneyRequestView --- .../sections/DistanceField.tsx | 15 ++------ .../ReportActionItem/MoneyRequestView.tsx | 13 ++----- src/libs/DistanceRequestUtils.ts | 34 +++++++++++++++++++ 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx index 649d94c8a24f..790cfd3c6248 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx @@ -53,24 +53,15 @@ function DistanceField({ const {translate} = useLocalize(); // When commuter exclusion applies, the title shows the reimbursable distance and the description shows the original + const displayUnit = unit ?? commuterExclusionData?.distanceUnit; const displayTitle = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, true, isManualDistanceRequest, commuterExclusionData); - let descriptionLabel = translate('common.distance'); - let distanceToDisplayHintText; - - if (commuterExclusionData) { - const displayUnit = unit ?? commuterExclusionData.distanceUnit; - const originalDistance = commuterExclusionData.reimbursableDistance + commuterExclusionData.commuterExclusion; - const originalDistanceFormatted = DistanceRequestUtils.getFormattedDistanceInUnits(originalDistance, displayUnit, translate, true); - descriptionLabel += ` ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`; - const formattedDistance = DistanceRequestUtils.getFormattedDistanceInUnits(commuterExclusionData.commuterExclusion, commuterExclusionData.distanceUnit, translate, false, true); - distanceToDisplayHintText = translate('distance.commuterExclusion.removedCommuterDistance', {formattedDistance}); - } + const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, displayUnit, translate); return ( Date: Mon, 6 Jul 2026 13:31:15 +0100 Subject: [PATCH 48/64] refactor: extract useCommuterExclusionGuard hook to deduplicate commuter exclusion modal - New hook centralizes the policy?.commuterExclusions guard + showConfirmModal with HouseWithMap illustration. - Both IOURequestStepDistanceOdometer (handleNext) and IOURequestStepDistanceManual (submitAndNavigateToNextPage) now early-return via the guard. - Removes duplicated modal config and related imports from the two step files. --- src/hooks/useCommuterExclusionGuard.ts | 47 +++++++++++++++++++ .../step/IOURequestStepDistanceManual.tsx | 23 ++------- .../step/IOURequestStepDistanceOdometer.tsx | 24 ++-------- 3 files changed, 54 insertions(+), 40 deletions(-) create mode 100644 src/hooks/useCommuterExclusionGuard.ts diff --git a/src/hooks/useCommuterExclusionGuard.ts b/src/hooks/useCommuterExclusionGuard.ts new file mode 100644 index 000000000000..412311ff4e64 --- /dev/null +++ b/src/hooks/useCommuterExclusionGuard.ts @@ -0,0 +1,47 @@ +import useConfirmModal from '@hooks/useConfirmModal'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type Policy from '@src/types/onyx/Policy'; + +import type {OnyxEntry} from 'react-native-onyx'; + +/** + * Returns a guard function that blocks manual/odometer distance entry for policies + * that have commuter exclusions configured. + * + * When a block occurs, it surfaces a modal explaining that only map/GPS distance + * is supported (because exclusions are computed from the mapped route) and returns + * true so callers can early return. + */ +function useCommuterExclusionGuard(policy: OnyxEntry | undefined) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {showConfirmModal} = useConfirmModal(); + const illustrations = useMemoizedLazyIllustrations(['HouseWithMap']); + + return () => { + if (!policy?.commuterExclusions) { + return false; + } + + showConfirmModal({ + title: translate('distance.error.mapOrGpsDistanceRequired.title'), + titleStyles: styles.textHeadline, + prompt: translate('distance.error.mapOrGpsDistanceRequired.description'), + promptStyles: styles.textSupporting, + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + shouldShowDismissIcon: true, + image: illustrations.HouseWithMap, + shouldUseSuccessStyleForConfirm: true, + shouldFitImageToContainer: true, + imageStyles: styles.commuterExclusionStaticIllustration, + }); + + return true; + }; +} + +export default useCommuterExclusionGuard; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 90e7427f235d..349e69c8b27b 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -5,12 +5,11 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import useConfirmModal from '@hooks/useConfirmModal'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; -import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -72,8 +71,6 @@ function IOURequestStepDistanceManual({ currentUserPersonalDetails, }: IOURequestStepDistanceManualProps) { const {translate} = useLocalize(); - const {showConfirmModal} = useConfirmModal(); - const illustrations = useMemoizedLazyIllustrations(['HouseWithMap']); const styles = useThemeStyles(); const {isBetaEnabled} = usePermissions(); const {isExtraSmallScreenHeight} = useResponsiveLayout(); @@ -86,6 +83,7 @@ function IOURequestStepDistanceManual({ iouType, transaction, }); + const guardCommuterExclusion = useCommuterExclusionGuard(policy); const personalPolicy = usePersonalPolicy(); const defaultExpensePolicy = useDefaultExpensePolicy(); const {policyForMovingExpenses} = usePolicyForMovingExpenses(); @@ -293,22 +291,7 @@ function IOURequestStepDistanceManual({ }; const submitAndNavigateToNextPage = () => { - // Workspaces with commuter exclusions configured require map-based distance, since the - // exclusion is computed off the mapped route. Block manual entry for these workspaces. - if (policy?.commuterExclusions) { - showConfirmModal({ - title: translate('distance.error.mapOrGpsDistanceRequired.title'), - titleStyles: styles.textHeadline, - prompt: translate('distance.error.mapOrGpsDistanceRequired.description'), - promptStyles: styles.textSupporting, - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - shouldShowDismissIcon: true, - image: illustrations.HouseWithMap, - shouldUseSuccessStyleForConfirm: true, - shouldFitImageToContainer: true, - imageStyles: styles.commuterExclusionStaticIllustration, - }); + if (guardCommuterExclusion()) { return; } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index bedcc44aa32c..70553c844e66 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -8,12 +8,12 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import useConfirmModal from '@hooks/useConfirmModal'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; -import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; @@ -87,8 +87,6 @@ function IOURequestStepDistanceOdometer({ currentUserPersonalDetails, }: IOURequestStepDistanceOdometerProps) { const {translate, fromLocaleDigit, numberFormat} = useLocalize(); - const {showConfirmModal} = useConfirmModal(); - const illustrations = useMemoizedLazyIllustrations(['HouseWithMap']); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -118,6 +116,7 @@ function IOURequestStepDistanceOdometer({ const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [parentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const policy = usePolicy(report?.policyID); + const guardCommuterExclusion = useCommuterExclusionGuard(policy); const distanceOriginalPolicy = useDistanceRateOriginalPolicy(transaction?.comment?.customUnit?.customUnitRateID); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`); @@ -459,22 +458,7 @@ function IOURequestStepDistanceOdometer({ // Handle form submission with validation const handleNext = () => { - // Workspaces with commuter exclusions configured require map-based distance entry, since the - // exclusion is computed off the mapped route. Block odometer entry for these workspaces. - if (policy?.commuterExclusions) { - showConfirmModal({ - title: translate('distance.error.mapOrGpsDistanceRequired.title'), - titleStyles: styles.textHeadline, - prompt: translate('distance.error.mapOrGpsDistanceRequired.description'), - promptStyles: styles.textSupporting, - confirmText: translate('common.buttonConfirm'), - shouldUseSuccessStyleForConfirm: true, - shouldShowCancelButton: false, - shouldShowDismissIcon: true, - image: illustrations.HouseWithMap, - shouldFitImageToContainer: true, - imageStyles: styles.commuterExclusionStaticIllustration, - }); + if (guardCommuterExclusion()) { return; } From da3aa04cd9949bfe30f0d1e404a9a2693a00c38f Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 6 Jul 2026 13:31:48 +0100 Subject: [PATCH 49/64] chore: add justification for eslint-disable on deprecated Localize.translateLocal In Split.ts, translateLocal is intentionally used outside a React render (in the createDistanceRequest action creator when building optimistic data), so the useLocalize hook is unavailable. Added the required comment per CONSISTENCY-5. --- src/libs/actions/IOU/Split.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index de51c7dce729..e49061bbe9a7 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -2150,6 +2150,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest distanceRate, currency, // eslint-disable-next-line @typescript-eslint/no-deprecated + // translateLocal is used intentionally: this runs outside a React render (in an + // action creator building optimistic data), so the useLocalize hook is unavailable. Localize.translateLocal, (digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit), getCurrencySymbol, From 8e19689cecf108554d30db7d091bff9c6e7ecdc9 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 6 Jul 2026 13:58:57 +0100 Subject: [PATCH 50/64] refactor: move commuter exclusion stored check inside getCommuterExclusionData - Add shouldFallbackToStoredPolicyCommuter param (default false) so getCommuterExclusionData can decide whether to only use data stored on the transaction (for historical views) or fall back to the current policy's commuterExclusions config (for create/confirm flows). - Update MoneyRequestView to pass true for read-only historical display. - Use selected policy rate (getRate) instead of defaultP2PRate when computing optimistic commuter-exclusion amounts. - Fix eslint-disable placement for translateLocal in action creator and add justification comment. --- .../ReportActionItem/MoneyRequestView.tsx | 2 +- src/libs/DistanceRequestUtils.ts | 15 ++++++++++++++- src/libs/actions/IOU/Split.ts | 5 +++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 5a460b88a9ab..45b459ff288c 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -577,7 +577,7 @@ function MoneyRequestView({ ); const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance, distanceUnitValue); + const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance, distanceUnitValue, true); const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, true, isManualDistanceRequest, commuterExclusionData); const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, distanceUnitValue, translate); diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 893f2bc06cf8..75966bfbdf7b 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -336,12 +336,18 @@ function getDistanceRequestAmount(distance: number, unit: Unit, rate: number): n * Computes the commuter exclusion breakdown (in display units) for a distance request. * * Prefers values already stored on the transaction (set optimistically or by the backend), - * otherwise previews the exclusion from the policy's commuter exclusion configuration. + * otherwise previews the exclusion from the policy's commuter exclusion configuration + * (unless shouldFallbackToStoredPolicyCommuter is true). * * @param transaction - The distance transaction being confirmed/edited * @param policy - The policy the expense belongs to * @param distanceInMeters - The full route distance in meters * @param distanceUnit - The display unit (mi/km) the breakdown is expressed in + * @param shouldFallbackToStoredPolicyCommuter - When true, only use the commuter exclusion data + * already stored on the transaction (from the policy + * at creation time). For historical/readonly views. + * When false (default), fall back to the current + * policy's commuterExclusions to preview/compute. * @returns The commuter exclusion and reimbursable distance, or null when no exclusion applies */ function getCommuterExclusionData( @@ -349,9 +355,16 @@ function getCommuterExclusionData( policy: OnyxEntry, distanceInMeters: number, distanceUnit: Unit, + shouldFallbackToStoredPolicyCommuter = false, ): {commuterExclusion: number; reimbursableDistance: number; distanceUnit: Unit} | null { const customUnit = transaction?.comment?.customUnit; + const hasStoredExclusionData = typeof customUnit?.commuterExclusion === 'number' || typeof customUnit?.reimbursableDistance === 'number'; + + if (shouldFallbackToStoredPolicyCommuter && !hasStoredExclusionData) { + return null; + } + // Prefer the stored quantity; fall back to converting the route distance for route-based // requests whose quantity hasn't resolved yet. let quantityInUnit = customUnit?.quantity ?? 0; diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index e49061bbe9a7..4ff0a184372d 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -2135,7 +2135,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest // confirm or correct these values via the success-data replay. For any other method // (R2 homeAndOffice, future types) we leave optimistic behavior unchanged. const distanceUnit = transaction.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const distanceRate = transaction.comment?.customUnit?.defaultP2PRate ?? 0; + const selectedRate = DistanceRequestUtils.getRate({transaction, policy}); + const distanceRate = selectedRate?.rate ?? 0; const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance ?? 0, distanceUnit); if (commuterExclusionData) { @@ -2149,9 +2150,9 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest distanceUnit, distanceRate, currency, - // eslint-disable-next-line @typescript-eslint/no-deprecated // translateLocal is used intentionally: this runs outside a React render (in an // action creator building optimistic data), so the useLocalize hook is unavailable. + // eslint-disable-next-line @typescript-eslint/no-deprecated Localize.translateLocal, (digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit), getCurrencySymbol, From e671fd43e19d36d72258b9096e942f8b337e7bbb Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Mon, 6 Jul 2026 14:46:07 +0100 Subject: [PATCH 51/64] refactor: update import paths in useCommuterExclusionGuard hook --- src/hooks/useCommuterExclusionGuard.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hooks/useCommuterExclusionGuard.ts b/src/hooks/useCommuterExclusionGuard.ts index 412311ff4e64..daa8faf77afd 100644 --- a/src/hooks/useCommuterExclusionGuard.ts +++ b/src/hooks/useCommuterExclusionGuard.ts @@ -1,12 +1,12 @@ -import useConfirmModal from '@hooks/useConfirmModal'; -import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; - import type Policy from '@src/types/onyx/Policy'; import type {OnyxEntry} from 'react-native-onyx'; +import useConfirmModal from './useConfirmModal'; +import {useMemoizedLazyIllustrations} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useThemeStyles from './useThemeStyles'; + /** * Returns a guard function that blocks manual/odometer distance entry for policies * that have commuter exclusions configured. From 73ac23ff0a104e230662cec1e005072a72b0efb3 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 9 Jul 2026 11:09:34 +0100 Subject: [PATCH 52/64] Refactor ConfirmContent component to reposition dismiss icon rendering --- src/components/ConfirmContent.tsx | 34 +++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index bc98374f07e0..c658a997cf73 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -171,23 +171,6 @@ function ConfirmContent({ return ( <> - {shouldShowDismissIcon && ( - - - - - - - - )} {!!image && ( + {shouldShowDismissIcon && ( + + + + + + + + )} {!!iconSource && ( From 16a95aba29f30fa2d6641e2c2d7d2f62006cb4bc Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 9 Jul 2026 11:33:28 +0100 Subject: [PATCH 53/64] Guard commuter exclusions during workspace changes --- .../MoneyRequestConfirmationList.tsx | 13 ++++++- src/hooks/useCommuterExclusionGuard.ts | 38 +++++++++++++++---- .../DynamicReportChangeWorkspacePage.tsx | 11 ++++++ .../step/IOURequestStepConfirmation.tsx | 10 +++++ .../step/IOURequestStepDistanceManual.tsx | 7 +++- .../step/IOURequestStepDistanceOdometer.tsx | 7 +++- 6 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 04e334579393..7dfb921ca554 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1,4 +1,5 @@ import useAttendees from '@hooks/useAttendees'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useLocalize from '@hooks/useLocalize'; @@ -254,6 +255,11 @@ function MoneyRequestConfirmationList({ const isDistanceRequest = isDistanceRequestUtil(transaction); const isManualDistanceRequest = isManualDistanceRequestUtil(transaction); const isGPSDistanceRequest = isGPSDistanceRequestUtil(transaction); + const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ + policyID: policy?.id, + isManualDistanceRequest, + isOdometerDistanceRequest, + }); const iouAmount = hasValidModifiedAmount(transaction) ? Number(transaction?.modifiedAmount) : (transaction?.amount ?? 0); const iouCurrencyCode = getCurrency(transaction); @@ -512,7 +518,12 @@ function MoneyRequestConfirmationList({ setFormError, setDidConfirmSplit, showDelegateNoAccessModal, - onConfirm, + onConfirm: () => { + if (shouldBlockManualOrOdometerDistanceRequest()) { + return; + } + onConfirm?.(); + }, onSendMoney, }); diff --git a/src/hooks/useCommuterExclusionGuard.ts b/src/hooks/useCommuterExclusionGuard.ts index daa8faf77afd..85ea49f81a50 100644 --- a/src/hooks/useCommuterExclusionGuard.ts +++ b/src/hooks/useCommuterExclusionGuard.ts @@ -1,28 +1,53 @@ +import ONYXKEYS from '@src/ONYXKEYS'; import type Policy from '@src/types/onyx/Policy'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import useConfirmModal from './useConfirmModal'; import {useMemoizedLazyIllustrations} from './useLazyAsset'; import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; import useThemeStyles from './useThemeStyles'; +type UseCommuterExclusionGuardParams = { + /** Policy ID to check by default */ + policyID?: string; + + /** Whether the current flow is for a manual distance request */ + isManualDistanceRequest?: boolean; + + /** Whether the current flow is for an odometer distance request */ + isOdometerDistanceRequest?: boolean; +}; + +type PoliciesWithCommuterExclusions = Record; + +const policiesWithCommuterExclusionsSelector = (policies: OnyxCollection): PoliciesWithCommuterExclusions => + Object.values(policies ?? {}).reduce((acc, policy) => { + if (policy?.id && policy.commuterExclusions) { + acc[policy.id] = true; + } + return acc; + }, {}); + /** - * Returns a guard function that blocks manual/odometer distance entry for policies - * that have commuter exclusions configured. + * Returns a guard function that blocks manual/odometer distance flows for policies + * that have commuter exclusions configured. Callers can pass an override policy ID + * when checking a newly selected workspace before committing it. * * When a block occurs, it surfaces a modal explaining that only map/GPS distance * is supported (because exclusions are computed from the mapped route) and returns * true so callers can early return. */ -function useCommuterExclusionGuard(policy: OnyxEntry | undefined) { +function useCommuterExclusionGuard({policyID, isManualDistanceRequest = false, isOdometerDistanceRequest = false}: UseCommuterExclusionGuardParams) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {showConfirmModal} = useConfirmModal(); const illustrations = useMemoizedLazyIllustrations(['HouseWithMap']); + const [policiesWithCommuterExclusions] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policiesWithCommuterExclusionsSelector}); - return () => { - if (!policy?.commuterExclusions) { + return (policyIDToCheck = policyID) => { + if ((!isManualDistanceRequest && !isOdometerDistanceRequest) || !policyIDToCheck || !policiesWithCommuterExclusions?.[policyIDToCheck]) { return false; } @@ -33,7 +58,6 @@ function useCommuterExclusionGuard(policy: OnyxEntry | undefined) { promptStyles: styles.textSupporting, confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, - shouldShowDismissIcon: true, image: illustrations.HouseWithMap, shouldUseSuccessStyleForConfirm: true, shouldFitImageToContainer: true, diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx index b7fef1ffffab..0d78e641a669 100644 --- a/src/pages/DynamicReportChangeWorkspacePage.tsx +++ b/src/pages/DynamicReportChangeWorkspacePage.tsx @@ -6,6 +6,7 @@ import SelectionList from '@components/SelectionList'; import type {WorkspaceListItemType} from '@components/SelectionList/ListItem/types'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; @@ -35,6 +36,7 @@ import { isWorkspaceEligibleForReportChange, } from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import {isManualDistanceRequest, isOdometerDistanceRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -90,12 +92,21 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const navigateBackFromChangeWorkspacePath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_CHANGE_WORKSPACE.path); + const hasManualDistanceRequest = reportTransactions.some((transaction) => isManualDistanceRequest(transaction)); + const hasOdometerDistanceRequest = reportTransactions.some((transaction) => isOdometerDistanceRequest(transaction)); + const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ + isManualDistanceRequest: hasManualDistanceRequest, + isOdometerDistanceRequest: hasOdometerDistanceRequest, + }); const selectPolicy = (policyID?: string) => { const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; if (!policyID || !policy) { return; } + if (shouldBlockManualOrOdometerDistanceRequest(policyID)) { + return; + } if (shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriods, amountOwed, currentUserPersonalDetails.accountID)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index c0058e1491e7..f674a53a1822 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -10,6 +10,7 @@ import ParticipantPicker from '@components/ParticipantPicker'; import PrevNextButtons from '@components/PrevNextButtons'; import ScreenWrapper from '@components/ScreenWrapper'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultParticipants from '@hooks/useDefaultParticipants'; @@ -228,6 +229,11 @@ function IOURequestStepConfirmation({ const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); const isManualRequest = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); + const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ + policyID: policy?.id, + isManualDistanceRequest, + isOdometerDistanceRequest, + }); const isTimeRequest = requestType === CONST.IOU.REQUEST_TYPE.TIME; const [lastLocationPermissionPrompt] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT); const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); @@ -326,6 +332,9 @@ function IOURequestStepConfirmation({ return; } const selectedParticipant = participantsList.at(0); + if (shouldBlockManualOrOdometerDistanceRequest(selectedParticipant?.policyID)) { + return; + } // P2P chats don't support negative amounts. When a negative amount was entered before a participant // was selected (e.g. "Submit it to someone" from a self DM), assigning it to a P2P participant would // fail at submit, so keep the expense on the self DM (its default) instead of assigning the P2P @@ -393,6 +402,7 @@ function IOURequestStepConfirmation({ lastSelectedDistanceRates, transaction, personalPolicy?.outputCurrency, + shouldBlockManualOrOdometerDistanceRequest, ], ); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 95e5394513c3..63a9aacbbbea 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -83,7 +83,10 @@ function IOURequestStepDistanceManual({ iouType, transaction, }); - const guardCommuterExclusion = useCommuterExclusionGuard(policy); + const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ + policyID: policy?.id, + isManualDistanceRequest: true, + }); const personalPolicy = usePersonalPolicy(); const defaultExpensePolicy = useDefaultExpensePolicy(); const {policyForMovingExpenses} = usePolicyForMovingExpenses(); @@ -292,7 +295,7 @@ function IOURequestStepDistanceManual({ }; const submitAndNavigateToNextPage = () => { - if (guardCommuterExclusion()) { + if (shouldBlockManualOrOdometerDistanceRequest()) { return; } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 2632d276fa02..1bf7741701da 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -123,7 +123,10 @@ function IOURequestStepDistanceOdometer({ const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [parentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const policy = usePolicy(report?.policyID); - const guardCommuterExclusion = useCommuterExclusionGuard(policy); + const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ + policyID: policy?.id, + isOdometerDistanceRequest: true, + }); const distanceOriginalPolicy = useDistanceRateOriginalPolicy(transaction?.comment?.customUnit?.customUnitRateID); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`); @@ -467,7 +470,7 @@ function IOURequestStepDistanceOdometer({ // Handle form submission with validation const handleNext = () => { - if (guardCommuterExclusion()) { + if (shouldBlockManualOrOdometerDistanceRequest()) { return; } From 9f368a0b1e24a8da3fa38d584ee59b9cfb63efd6 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 9 Jul 2026 16:11:46 +0100 Subject: [PATCH 54/64] Block workspace changes for commuter exclusions --- .../ReportActionItem/MoneyRequestView.tsx | 2 +- src/hooks/useExpenseActions.ts | 4 ++- src/libs/DistanceRequestUtils.ts | 14 ++++----- src/libs/TransactionUtils/index.ts | 5 +++ src/libs/actions/IOU/Split.ts | 8 ++--- src/libs/actions/Report/index.ts | 31 +++++++++++++++++-- .../DynamicReportChangeWorkspacePage.tsx | 5 +-- 7 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 62377a34c400..4f79dbf15a29 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -596,7 +596,7 @@ function MoneyRequestView({ ); const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance, distanceUnitValue, true); + const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance, distanceUnitValue, {shouldUseStoredExclusionOnly: true}); const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, true, isManualDistanceRequest, commuterExclusionData); const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, distanceUnitValue, translate); diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 6cc47eb136f0..915b452d3d31 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -34,6 +34,7 @@ import { getDeleteExpenseTitle, getOriginalTransactionWithSplitInfo, hasCustomUnitOutOfPolicyViolation as hasCustomUnitOutOfPolicyViolationTransactionUtils, + hasAppliedCommuterExclusion, isDistanceRequest, isPerDiemRequest, isTransactionPendingDelete, @@ -124,6 +125,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic nonPendingDeleteTransactions.push(transaction); } } + const hasCommuterExclusionDistanceRequest = nonPendingDeleteTransactions.some(hasAppliedCommuterExclusion); const currentTransaction = transactions.at(0); const splitEffectivePolicy = useSplitEffectivePolicy(moneyRequestReport, undefined, currentTransaction); @@ -433,7 +435,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic icon: expensifyIcons.Buildings, value: CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.CHANGE_WORKSPACE, - shouldShow: transactions.length === 0 || nonPendingDeleteTransactions.length > 0, + shouldShow: (transactions.length === 0 || nonPendingDeleteTransactions.length > 0) && !hasCommuterExclusionDistanceRequest, onSelected: () => { if (!moneyRequestReport) { return; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index e8ff826ae524..53beedea54c6 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -337,17 +337,17 @@ function getDistanceRequestAmount(distance: number, unit: Unit, rate: number): n * * Prefers values already stored on the transaction (set optimistically or by the backend), * otherwise previews the exclusion from the policy's commuter exclusion configuration - * (unless shouldFallbackToStoredPolicyCommuter is true). + * (unless shouldUseStoredExclusionOnly is true). * * @param transaction - The distance transaction being confirmed/edited * @param policy - The policy the expense belongs to * @param distanceInMeters - The full route distance in meters * @param distanceUnit - The display unit (mi/km) the breakdown is expressed in - * @param shouldFallbackToStoredPolicyCommuter - When true, only use the commuter exclusion data + * @param options.shouldUseStoredExclusionOnly - When true, only use the commuter exclusion data * already stored on the transaction (from the policy * at creation time). For historical/readonly views. - * When false (default), fall back to the current - * policy's commuterExclusions to preview/compute. + * When false (default), use the current policy's + * commuterExclusions to preview/compute. * @returns The commuter exclusion and reimbursable distance, or null when no exclusion applies */ function getCommuterExclusionData( @@ -355,13 +355,13 @@ function getCommuterExclusionData( policy: OnyxEntry, distanceInMeters: number, distanceUnit: Unit, - shouldFallbackToStoredPolicyCommuter = false, + {shouldUseStoredExclusionOnly = false}: {shouldUseStoredExclusionOnly?: boolean} = {}, ): {commuterExclusion: number; reimbursableDistance: number; distanceUnit: Unit} | null { const customUnit = transaction?.comment?.customUnit; const hasStoredExclusionData = typeof customUnit?.commuterExclusion === 'number' || typeof customUnit?.reimbursableDistance === 'number'; - if (shouldFallbackToStoredPolicyCommuter && !hasStoredExclusionData) { + if (shouldUseStoredExclusionOnly && !hasStoredExclusionData) { return null; } @@ -375,7 +375,7 @@ function getCommuterExclusionData( let commuterExclusion = customUnit?.commuterExclusion; let reimbursableDistance = customUnit?.reimbursableDistance; - if (!commuterExclusion && policy?.commuterExclusions) { + if (!commuterExclusion && !shouldUseStoredExclusionOnly && policy?.commuterExclusions) { const exclusionConfig = policy.commuterExclusions; if (exclusionConfig.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE && quantityInUnit > 0) { const fixedDistanceUnit: Unit = diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 221843b45246..73b882e6b0c0 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -185,6 +185,10 @@ function isOdometerDistanceRequest(transaction: OnyxEntry): boolean return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER; } +function hasAppliedCommuterExclusion(transaction: OnyxEntry): boolean { + return isDistanceRequest(transaction) && (transaction?.comment?.customUnit?.commuterExclusion ?? 0) > 0; +} + /** * Whether a distance expense's receipt is a map/route receipt (as opposed to an odometer photo or a * pure manual entry that has no route). Used to decide whether the full distance e-receipt (map + @@ -3177,6 +3181,7 @@ export { isGPSDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, + hasAppliedCommuterExclusion, isDistanceExpenseType, isFetchingWaypointsFromServer, hasPendingDistanceReceiptRegeneration, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 723e8b045f5a..575d76629e4d 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -2126,10 +2126,10 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest onyxData = moneyRequestOnyxData; distanceIouReport = iouReport; - // Compute the commuter exclusion client-side for `fixedDistance` so the breakdown and - // system message render instantly. The server's createDistanceRequest response will - // confirm or correct these values via the success-data replay. For any other method - // (R2 homeAndOffice, future types) we leave optimistic behavior unchanged. + // Compute the commuter exclusion client-side for `fixedDistance` so the transaction + // breakdown renders instantly. The COMMUTER_EXCLUSION system message is created by the + // server from the authoritative distance calculation, so it is not seeded optimistically. + // For any other method (R2 homeAndOffice, future types) we leave optimistic behavior unchanged. const distanceUnit = transaction.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; const selectedRate = DistanceRequestUtils.getRate({transaction, policy}); const distanceRate = selectedRate?.rate ?? 0; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 223210742db8..bd1ade1cc89b 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -167,7 +167,17 @@ import { } from '@libs/ReportUtils'; import {buildOptimisticSnapshotData, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import playSound, {SOUNDS} from '@libs/Sound'; -import {getAmount, getCurrency, getNegatedAmountTransaction, isOnHold, recalculateUnreportedTransactionDetails, shouldClearConvertedAmount} from '@libs/TransactionUtils'; +import { + getAmount, + getCurrency, + getNegatedAmountTransaction, + hasAppliedCommuterExclusion, + isOnHold, + isManualDistanceRequest, + isOdometerDistanceRequest, + recalculateUnreportedTransactionDetails, + shouldClearConvertedAmount, +} from '@libs/TransactionUtils'; import {getSearchParamFromPath} from '@libs/Url'; import {buildSecureDownloadURL} from '@libs/UrlUtils'; import Visibility from '@libs/Visibility'; @@ -7672,6 +7682,13 @@ function buildOptimisticChangePolicyData({ return {optimisticData, successData, failureData, optimisticReportPreviewAction, optimisticMovedReportAction}; } +function shouldBlockChangeReportPolicyForCommuterExclusion(reportID: string | undefined, policy: Policy): boolean { + const hasTargetPolicyCommuterExclusions = !!policy.commuterExclusions; + return getReportTransactions(reportID).some( + (transaction) => hasAppliedCommuterExclusion(transaction) || (hasTargetPolicyCommuterExclusions && (isManualDistanceRequest(transaction) || isOdometerDistanceRequest(transaction))), + ); +} + /** * Changes the policy of a report and all its child reports, and moves the report to the new policy's expense chat. */ @@ -7704,7 +7721,7 @@ function changeReportPolicy({ isReportLastVisibleArchived?: boolean; reportPreviewAction: OnyxEntry; }) { - if (!report || !policy || report.policyID === policy.id || !isExpenseReport(report)) { + if (!report || !policy || report.policyID === policy.id || !isExpenseReport(report) || shouldBlockChangeReportPolicyForCommuterExclusion(report.reportID, policy)) { return; } @@ -7774,7 +7791,15 @@ function changeReportPolicyAndInviteSubmitter({ reportActionsList: OnyxCollection; reportPreviewAction: OnyxEntry; }) { - if (!report.reportID || !policy?.id || report.policyID === policy.id || !isExpenseReport(report) || !report.ownerAccountID || !submitterLogin) { + if ( + !report.reportID || + !policy?.id || + report.policyID === policy.id || + !isExpenseReport(report) || + !report.ownerAccountID || + !submitterLogin || + shouldBlockChangeReportPolicyForCommuterExclusion(report.reportID, policy) + ) { return; } diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx index 0d78e641a669..97127c233d78 100644 --- a/src/pages/DynamicReportChangeWorkspacePage.tsx +++ b/src/pages/DynamicReportChangeWorkspacePage.tsx @@ -36,7 +36,7 @@ import { isWorkspaceEligibleForReportChange, } from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {isManualDistanceRequest, isOdometerDistanceRequest} from '@libs/TransactionUtils'; +import {hasAppliedCommuterExclusion, isManualDistanceRequest, isOdometerDistanceRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -92,6 +92,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const navigateBackFromChangeWorkspacePath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_CHANGE_WORKSPACE.path); + const hasCommuterExclusionDistanceRequest = reportTransactions.some((transaction) => hasAppliedCommuterExclusion(transaction)); const hasManualDistanceRequest = reportTransactions.some((transaction) => isManualDistanceRequest(transaction)); const hasOdometerDistanceRequest = reportTransactions.some((transaction) => isOdometerDistanceRequest(transaction)); const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ @@ -201,7 +202,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace headerMessage: shouldShowNoResultsFoundMessage ? translate('common.noResultsFound') : '', }; - if (!isMoneyRequestReport(report) || isMoneyRequestReportPendingDeletion(report)) { + if (!isMoneyRequestReport(report) || isMoneyRequestReportPendingDeletion(report) || hasCommuterExclusionDistanceRequest) { return ; } From c67fb8b474f86d89b1d3656be4e3b4d3cca2df4c Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 9 Jul 2026 16:12:54 +0100 Subject: [PATCH 55/64] Store commuter exclusions on distance requests --- .../MoneyRequestConfirmationList.tsx | 37 ++--- .../DistanceRequestController.tsx | 32 +++- .../hooks/useDistanceRequestState.ts | 13 +- .../sections/DistanceField.tsx | 10 +- .../fieldGroupTypes.ts | 3 +- .../fieldGroups/TransactionDetailsFields.tsx | 2 +- .../ReportActionItem/MoneyRequestView.tsx | 2 +- src/libs/DistanceRequestUtils.ts | 156 ++++++++++++------ src/libs/TransactionUtils/index.ts | 23 ++- src/libs/actions/IOU/MoneyRequest.ts | 72 +++++++- src/libs/actions/IOU/MoneyRequestBuilder.ts | 29 ++-- src/libs/actions/IOU/Split.ts | 71 +------- .../IOU/types/BaseTransactionParams.ts | 1 + ...andleMoneyRequestStepDistanceNavigation.ts | 2 + .../step/confirmation/useExpenseSubmission.ts | 2 + 15 files changed, 269 insertions(+), 186 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 7dfb921ca554..0683fb86a041 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -280,29 +280,17 @@ function MoneyRequestConfirmationList({ const subRates = transaction?.comment?.customUnit?.subRates ?? []; const prevSubRates = usePrevious(subRates); - const { - defaultRate, - mileageRate, - unit, - rate, - currency, - prevCurrency, - distance, - shouldCalculateDistanceAmount, - hasRoute, - isDistanceRequestWithPendingRoute, - distanceRequestAmount, - commuterExclusionData, - } = useDistanceRequestState({ - transaction, - policy, - policyID, - policyForMovingExpenses, - isMovingTransactionFromTrackExpense, - isDistanceRequest, - iouAmount, - iouCurrencyCode, - }); + const {defaultRate, mileageRate, unit, rate, currency, prevCurrency, distance, shouldCalculateDistanceAmount, hasRoute, isDistanceRequestWithPendingRoute, distanceRequestAmount} = + useDistanceRequestState({ + transaction, + policy, + policyID, + policyForMovingExpenses, + isMovingTransactionFromTrackExpense, + isDistanceRequest, + iouAmount, + iouCurrencyCode, + }); const shouldShowRateAutoUpdatedTooltip = isDistanceRequest && !!transaction?.comment?.customUnit?.rateAutoUpdated && !!transaction.created && DistanceRequestUtils.isRateEligibleForDate(mileageRate, transaction.created); @@ -584,7 +572,7 @@ function MoneyRequestConfirmationList({ expenseDate: getCreated(transaction), customUnitRateID, shouldShowRateAutoUpdatedTooltip, - commuterExclusionData, + customUnit: transaction?.comment?.customUnit, }} amountDisplay={{amount: amountToBeUsed, formattedAmount, formattedAmountPerAttendee}} requiredFlags={{isCategoryRequired, isMerchantRequired, isDescriptionRequired}} @@ -655,7 +643,6 @@ function MoneyRequestConfirmationList({ currentUserAccountID={currentUserPersonalDetails.accountID} setFormError={setFormError} clearFormErrors={clearFormErrors} - commuterExclusionData={commuterExclusionData} /> void; clearFormErrors: (errors: string[]) => void; - commuterExclusionData?: CommuterExclusionData; }; /** @@ -83,7 +87,6 @@ function DistanceRequestController({ selectedParticipantsProp, setFormError, clearFormErrors, - commuterExclusionData, }: DistanceRequestControllerProps) { const {translate, toLocaleDigit} = useLocalize(); const {getCurrencySymbol} = useCurrencyListActions(); @@ -231,7 +234,6 @@ function DistanceRequestController({ */ setMoneyRequestPendingFields(transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); - // When commuter exclusion applies, getDistanceMerchant shows the reimbursable distance in the merchant text const distanceMerchant = DistanceRequestUtils.getDistanceMerchant( hasRoute, distance, @@ -242,9 +244,21 @@ function DistanceRequestController({ toLocaleDigit, getCurrencySymbol, isManualDistanceRequest, - commuterExclusionData, ); setMoneyRequestMerchant(transactionID, distanceMerchant, true); + + setMoneyRequestCommuterExclusionFields({ + transactionID, + transaction, + policy, + customUnitRateID, + routeDistanceMeters: distance, + distanceUnit: unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + translate, + toLocaleDigit, + getCurrencySymbol, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, + }); }, [ isDistanceRequestWithPendingRoute, hasRoute, @@ -260,7 +274,9 @@ function DistanceRequestController({ isReadOnly, getCurrencySymbol, isManualDistanceRequest, - commuterExclusionData, + policy, + customUnitRateID, + personalPolicy?.outputCurrency, ]); return null; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts index e9572696adfc..87fefbbb5f91 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts @@ -94,20 +94,12 @@ function useDistanceRequestState({ const distance = getDistanceInMeters(transaction, unit); const prevDistance = usePrevious(distance); - - // When a commuter exclusion applies, the reimbursable amount is based on the distance remaining after the exclusion. - const amountUnit = unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance, amountUnit); - const reimbursableDistanceInMeters = commuterExclusionData ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, amountUnit) : distance; - - // Recalculate when the route/rate changes, or whenever a commuter exclusion applies so the reimbursable amount is used. - const shouldCalculateDistanceAmount = - isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance || prevCurrency !== currency || prevUnit !== unit || !!commuterExclusionData); + const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance || prevCurrency !== currency || prevUnit !== unit); const hasRoute = hasRouteUtil(transaction, isDistanceRequest); const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense; - const distanceRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(reimbursableDistanceInMeters, amountUnit, rate ?? 0); + const distanceRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0); return { policyDraft, @@ -129,7 +121,6 @@ function useDistanceRequestState({ hasRoute, isDistanceRequestWithPendingRoute, distanceRequestAmount, - commuterExclusionData, }; } diff --git a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx index 790cfd3c6248..053aebf27664 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx @@ -1,5 +1,4 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import type {CommuterExclusionData} from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -11,6 +10,7 @@ import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Unit} from '@src/types/onyx/Policy'; +import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; import React from 'react'; @@ -29,7 +29,7 @@ type DistanceFieldProps = { iouType: Exclude; reportID: string; reportActionID: string | undefined; - commuterExclusionData?: CommuterExclusionData; + customUnit?: TransactionCustomUnit; }; function DistanceField({ @@ -47,13 +47,13 @@ function DistanceField({ iouType, reportID, reportActionID, - commuterExclusionData, + customUnit, }: DistanceFieldProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - // When commuter exclusion applies, the title shows the reimbursable distance and the description shows the original - const displayUnit = unit ?? commuterExclusionData?.distanceUnit; + const displayUnit = unit ?? customUnit?.distanceUnit; + const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionDisplayData(customUnit, displayUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); const displayTitle = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, true, isManualDistanceRequest, commuterExclusionData); const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, displayUnit, translate); diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts b/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts index bf0d5ea45916..f2662d82ca04 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts @@ -2,6 +2,7 @@ import type {MileageRate} from '@libs/DistanceRequestUtils'; import type {TranslationPaths} from '@src/languages/types'; import type {Unit} from '@src/types/onyx/Policy'; +import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; /** Pre-formatted amount values displayed in Amount/Attendee fields */ type AmountDisplay = { @@ -29,7 +30,7 @@ type DistanceData = { expenseDate: string | undefined; customUnitRateID: string | undefined; shouldShowRateAutoUpdatedTooltip?: boolean; - commuterExclusionData?: CommuterExclusionData; + customUnit?: TransactionCustomUnit; }; /** Distance-mode discriminators (manual / odometer / GPS) */ diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx index 22f6dcc0ff5e..0f7e3e733105 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx @@ -154,7 +154,7 @@ function TransactionDetailsFields({ iouType={iouType} reportID={reportID} reportActionID={reportActionID} - commuterExclusionData={distanceData.commuterExclusionData} + customUnit={distanceData.customUnit} /> )} diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 4f79dbf15a29..09d0555cb3eb 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -596,7 +596,7 @@ function MoneyRequestView({ ); const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance, distanceUnitValue, {shouldUseStoredExclusionOnly: true}); + const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionDisplayData(transaction?.comment?.customUnit, distanceUnitValue); const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, true, isManualDistanceRequest, commuterExclusionData); const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, distanceUnitValue, translate); diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 53beedea54c6..1c7a906b2e8a 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -8,6 +8,7 @@ import type {LastSelectedDistanceRates, OnyxInputOrEntry, Transaction} from '@sr import type DefaultP2PMileageRate from '@src/types/onyx/DefaultP2PMileageRate'; import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; +import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -34,6 +35,12 @@ type MileageRate = { endDate?: string | null; }; +type TransactionCommuterExclusionData = { + modifiedAmount: number; + modifiedMerchant?: string; + customUnit: TransactionCustomUnit; +}; + /** @private Only for getRate function */ let allPolicies: OnyxCollection; @@ -332,70 +339,111 @@ function getDistanceRequestAmount(distance: number, unit: Unit, rate: number): n return roundDistanceAmount(convertDistanceUnit(distance, unit), rate); } -/** - * Computes the commuter exclusion breakdown (in display units) for a distance request. - * - * Prefers values already stored on the transaction (set optimistically or by the backend), - * otherwise previews the exclusion from the policy's commuter exclusion configuration - * (unless shouldUseStoredExclusionOnly is true). - * - * @param transaction - The distance transaction being confirmed/edited - * @param policy - The policy the expense belongs to - * @param distanceInMeters - The full route distance in meters - * @param distanceUnit - The display unit (mi/km) the breakdown is expressed in - * @param options.shouldUseStoredExclusionOnly - When true, only use the commuter exclusion data - * already stored on the transaction (from the policy - * at creation time). For historical/readonly views. - * When false (default), use the current policy's - * commuterExclusions to preview/compute. - * @returns The commuter exclusion and reimbursable distance, or null when no exclusion applies - */ -function getCommuterExclusionData( - transaction: OnyxEntry, - policy: OnyxEntry, - distanceInMeters: number, - distanceUnit: Unit, - {shouldUseStoredExclusionOnly = false}: {shouldUseStoredExclusionOnly?: boolean} = {}, -): {commuterExclusion: number; reimbursableDistance: number; distanceUnit: Unit} | null { - const customUnit = transaction?.comment?.customUnit; +function getCommuterExclusionDisplayData(customUnit: TransactionCustomUnit | undefined, distanceUnit: Unit): CommuterExclusionData | null { + const commuterExclusion = customUnit?.commuterExclusion; + if (typeof commuterExclusion !== 'number' || commuterExclusion <= 0) { + return null; + } - const hasStoredExclusionData = typeof customUnit?.commuterExclusion === 'number' || typeof customUnit?.reimbursableDistance === 'number'; + return { + commuterExclusion, + reimbursableDistance: typeof customUnit?.reimbursableDistance === 'number' ? customUnit.reimbursableDistance : Math.max(0, (customUnit?.quantity ?? 0) - commuterExclusion), + distanceUnit: customUnit?.distanceUnit ?? distanceUnit, + }; +} - if (shouldUseStoredExclusionOnly && !hasStoredExclusionData) { - return null; +function getTransactionCommuterExclusionData({ + transaction, + policy, + customUnit, + translate, + toLocaleDigit, + getCurrencySymbol, + personalPolicyOutputCurrency, +}: { + transaction: OnyxEntry; + policy: OnyxEntry; + customUnit?: TransactionCustomUnit; + translate?: LocaleContextProps['translate']; + toLocaleDigit?: LocaleContextProps['toLocaleDigit']; + getCurrencySymbol?: CurrencyListActionsContextType['getCurrencySymbol']; + personalPolicyOutputCurrency?: string; +}): TransactionCommuterExclusionData | undefined { + const policyCommuterExclusions = policy?.commuterExclusions; + if ( + transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL || + transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER || + policyCommuterExclusions?.method !== CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE + ) { + return; } - // Prefer the stored quantity; fall back to converting the route distance for route-based - // requests whose quantity hasn't resolved yet. - let quantityInUnit = customUnit?.quantity ?? 0; - if (quantityInUnit <= 0 && distanceInMeters > 0) { - quantityInUnit = convertDistanceUnit(distanceInMeters, distanceUnit); + const existingCustomUnit = customUnit ?? transaction?.comment?.customUnit; + const selectedRate = existingCustomUnit?.customUnitRateID + ? (getRateByCustomUnitRateID({customUnitRateID: existingCustomUnit.customUnitRateID, policy}) ?? getRate({transaction, policy, personalPolicyOutputCurrency})) + : getRate({transaction, policy, personalPolicyOutputCurrency}); + const requestDistanceUnit = existingCustomUnit?.distanceUnit ?? selectedRate.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const routeDistanceInMeters = + typeof existingCustomUnit?.routeDistanceMeters === 'number' + ? existingCustomUnit.routeDistanceMeters + : typeof transaction?.routes?.route0?.distance === 'number' + ? transaction.routes.route0.distance + : undefined; + const routeDistance = + typeof existingCustomUnit?.quantity === 'number' + ? existingCustomUnit.quantity + : routeDistanceInMeters !== undefined + ? convertDistanceUnit(routeDistanceInMeters, requestDistanceUnit) + : undefined; + if (routeDistance === undefined) { + return; } - let commuterExclusion = customUnit?.commuterExclusion; - let reimbursableDistance = customUnit?.reimbursableDistance; + const fixedDistanceUnit: Unit = + policyCommuterExclusions.fixedDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const fixedDistanceInRequestUnit = convertDistanceUnit(convertToDistanceInMeters(policyCommuterExclusions.fixedDistance ?? 0, fixedDistanceUnit), requestDistanceUnit); - if (!commuterExclusion && !shouldUseStoredExclusionOnly && policy?.commuterExclusions) { - const exclusionConfig = policy.commuterExclusions; - if (exclusionConfig.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE && quantityInUnit > 0) { - const fixedDistanceUnit: Unit = - exclusionConfig.fixedDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const fixedDistance = convertDistanceUnit(convertToDistanceInMeters(exclusionConfig.fixedDistance ?? 0, fixedDistanceUnit), distanceUnit); - if (fixedDistance > 0) { - commuterExclusion = Math.min(fixedDistance, quantityInUnit); - reimbursableDistance = Math.max(0, quantityInUnit - commuterExclusion); - } - } + if (fixedDistanceInRequestUnit <= 0) { + return; } - if (!commuterExclusion || commuterExclusion <= 0) { - return null; + const commuterExclusion = Math.min(fixedDistanceInRequestUnit, routeDistance); + if (commuterExclusion <= 0) { + return; } + const reimbursableDistance = Math.max(0, routeDistance - commuterExclusion); + const reimbursableDistanceInMeters = convertToDistanceInMeters(reimbursableDistance, requestDistanceUnit); + const distanceRate = selectedRate.rate ?? 0; + const modifiedMerchant = + translate && toLocaleDigit && getCurrencySymbol + ? getDistanceMerchant( + true, + reimbursableDistanceInMeters, + requestDistanceUnit, + distanceRate, + selectedRate.currency ?? transaction?.currency ?? CONST.CURRENCY.USD, + translate, + toLocaleDigit, + getCurrencySymbol, + true, + ) + : undefined; + return { - commuterExclusion, - reimbursableDistance: reimbursableDistance ?? Math.max(0, quantityInUnit - commuterExclusion), - distanceUnit, + modifiedAmount: getDistanceRequestAmount(reimbursableDistanceInMeters, requestDistanceUnit, distanceRate), + modifiedMerchant, + customUnit: { + ...existingCustomUnit, + customUnitRateID: existingCustomUnit?.customUnitRateID ?? selectedRate.customUnitRateID, + distanceUnit: requestDistanceUnit, + quantity: routeDistance, + name: existingCustomUnit?.name ?? CONST.CUSTOM_UNITS.NAME_DISTANCE, + ...(routeDistanceInMeters !== undefined && {routeDistanceMeters: routeDistanceInMeters}), + commuterExclusion, + reimbursableDistance, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }, }; } @@ -800,7 +848,8 @@ export default { getDefaultMileageRate, getDistanceMerchant, getDistanceRequestAmount, - getCommuterExclusionData, + getCommuterExclusionDisplayData, + getTransactionCommuterExclusionData, getDistanceDisplayDetailsWithCommuter, getFormattedRateValue, getMileageRates, @@ -828,5 +877,6 @@ export default { getBestEligibleRate, getRateDateLabel, }; +export type {TransactionCommuterExclusionData}; export type {MileageRate}; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 73b882e6b0c0..c69e134d9214 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -98,6 +98,7 @@ import getDistanceInMeters from './getDistanceInMeters'; type TransactionParams = { amount: number; modifiedAmount?: number; + modifiedMerchant?: string; currency: string; reportID: string | undefined; comment?: string; @@ -335,6 +336,7 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T const { amount, modifiedAmount, + modifiedMerchant, currency, reportID, distance, @@ -421,11 +423,16 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T if (customUnit) { lodashSet(commentJSON, 'customUnit', customUnit); } else { + const routeDistanceMeters = routes?.route0?.distance ?? existingTransaction?.routes?.route0?.distance; + lodashSet(commentJSON, 'customUnit', existingTransaction?.comment?.customUnit ?? {}); // Set the distance unit, which comes from the policy distance unit or the P2P rate data lodashSet(commentJSON, 'customUnit.distanceUnit', DistanceRequestUtils.getUpdatedDistanceUnit({transaction: existingTransaction, policy})); lodashSet(commentJSON, 'customUnit.quantity', distance); lodashSet(commentJSON, 'customUnit.customUnitRateID', customUnitRateID); lodashSet(commentJSON, 'customUnit.name', existingTransaction?.comment?.customUnit?.name ?? CONST.CUSTOM_UNITS.NAME_DISTANCE); + if (typeof routeDistanceMeters === 'number') { + lodashSet(commentJSON, 'customUnit.routeDistanceMeters', routeDistanceMeters); + } } } @@ -465,6 +472,7 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T taxAmount, taxValue, modifiedAmount, + modifiedMerchant, billable, reimbursable, inserted: DateUtils.getDBTime(), @@ -842,7 +850,19 @@ function getUpdatedTransaction({ } const distanceInMeters = getDistanceInMeters(updatedTransaction, unit); - let amount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0); + const commuterExclusionTransactionData = hasAppliedCommuterExclusion(updatedTransaction) + ? DistanceRequestUtils.getTransactionCommuterExclusionData({ + transaction: updatedTransaction, + policy, + personalPolicyOutputCurrency, + }) + : undefined; + + if (commuterExclusionTransactionData) { + lodashSet(updatedTransaction, 'comment.customUnit', commuterExclusionTransactionData.customUnit); + } + + let amount = commuterExclusionTransactionData?.modifiedAmount ?? DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0); amount = isFromExpenseReport || isUnReportedExpense ? -amount : amount; const updatedCurrency = updatedMileageRate.currency ?? CONST.CURRENCY.USD; const updatedMerchant = DistanceRequestUtils.getDistanceMerchant( @@ -855,6 +875,7 @@ function getUpdatedTransaction({ (digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit), getCurrencySymbol, isManualDistanceRequest(transaction), + DistanceRequestUtils.getCommuterExclusionDisplayData(commuterExclusionTransactionData?.customUnit, unit), ); // No locally resolvable rate (e.g. track expense without policy loaded) → scale the previous diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 71e93f0f2ab5..f66641be0062 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -1,4 +1,5 @@ -import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {CurrencyListActionsContextType} from '@components/CurrencyListContextProvider'; +import type {LocalizedTranslate, LocaleContextProps} from '@components/LocaleContextProvider'; import DateUtils from '@libs/DateUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -97,6 +98,19 @@ type CreateTransactionParams = { currentUserLocalCurrency: string | undefined; }; +type SetMoneyRequestCommuterExclusionFieldsParams = { + transactionID: string; + transaction: OnyxEntry; + policy: OnyxEntry; + customUnitRateID: string; + routeDistanceMeters: number; + distanceUnit: Unit; + translate: LocaleContextProps['translate']; + toLocaleDigit: LocaleContextProps['toLocaleDigit']; + getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; + personalPolicyOutputCurrency?: string; +}; + function createTransaction({ transactions, iouType, @@ -759,6 +773,61 @@ function setMoneyRequestDistance(transactionID: string, distanceAsFloat: number, Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {comment: {customUnit: {quantity: distanceAsFloat, distanceUnit}}}); } +function setMoneyRequestCommuterExclusionFields({ + transactionID, + transaction, + policy, + customUnitRateID, + routeDistanceMeters, + distanceUnit, + translate, + toLocaleDigit, + getCurrencySymbol, + personalPolicyOutputCurrency, +}: SetMoneyRequestCommuterExclusionFieldsParams) { + const fields = DistanceRequestUtils.getTransactionCommuterExclusionData({ + transaction, + policy, + customUnit: { + ...transaction?.comment?.customUnit, + customUnitRateID, + routeDistanceMeters, + distanceUnit, + }, + translate, + toLocaleDigit, + getCurrencySymbol, + personalPolicyOutputCurrency, + }); + + if (fields) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + modifiedAmount: fields.modifiedAmount, + modifiedMerchant: fields.modifiedMerchant, + comment: {customUnit: fields.customUnit}, + }); + return; + } + + const customUnit = transaction?.comment?.customUnit; + if (!customUnit?.commuterExclusion && !customUnit?.reimbursableDistance && !customUnit?.commuterExclusionType && !customUnit?.commuterExclusionMethod) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + modifiedAmount: null, + modifiedMerchant: null, + comment: { + customUnit: { + commuterExclusion: null, + reimbursableDistance: null, + commuterExclusionType: null, + commuterExclusionMethod: null, + }, + }, + }); +} + /** * Remember the most recently selected distance rate for a policy so the rate picker * defaults to it on the next distance expense for that workspace. @@ -1004,6 +1073,7 @@ export { resetDraftTransactionsCustomUnit, setCustomUnitID, setMoneyRequestDistance, + setMoneyRequestCommuterExclusionFields, setMoneyRequestDistanceRate, setMoneyRequestAmount, clearMoneyRequestAmount, diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index ffe1aa61a683..eb233bff252a 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -41,6 +41,7 @@ import { buildOptimisticTransaction, getAmount, getCurrency, + hasAppliedCommuterExclusion, hasSubmissionBlockingViolationInReport, isDistanceRequest as isDistanceRequestTransactionUtils, isManualDistanceRequest as isManualDistanceRequestTransactionUtils, @@ -1250,6 +1251,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma amount, distance, modifiedAmount, + modifiedMerchant, comment = '', currency, source = '', @@ -1347,23 +1349,27 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma const optimisticTransactionID = existingTransactionID ?? providedOptimisticTransactionID ?? rand64(); const optimisticReportID = optimisticIOUReportID ?? generateReportID(); + const shouldUseCommuterModifiedValues = hasAppliedCommuterExclusion(existingTransaction); + const reportAmount = shouldUseCommuterModifiedValues && modifiedAmount !== undefined ? modifiedAmount : amount; + const reportMerchant = shouldUseCommuterModifiedValues ? (modifiedMerchant ?? merchant) : merchant; + if (!iouReport || shouldCreateNewMoneyRequestReport) { - const nonReimbursableTotal = reimbursable ? 0 : amount; - const reportTransactions = buildMinimalTransactionForFormula(optimisticTransactionID, optimisticReportID, created, amount, currency, merchant); + const nonReimbursableTotal = reimbursable ? 0 : reportAmount; + const reportTransactions = buildMinimalTransactionForFormula(optimisticTransactionID, optimisticReportID, created, reportAmount, currency, reportMerchant); iouReport = isPolicyExpenseChat ? buildOptimisticExpenseReport({ chatReportID: chatReport.reportID, policyID: chatReport.policyID, payeeAccountID, - total: amount, + total: reportAmount, currency, nonReimbursableTotal, optimisticIOUReportID: optimisticReportID, reportTransactions, betas, }) - : buildOptimisticIOUReport(payeeAccountID, payerAccountID, amount, chatReport.reportID, currency, undefined, undefined, optimisticReportID); + : buildOptimisticIOUReport(payeeAccountID, payerAccountID, reportAmount, chatReport.reportID, currency, undefined, undefined, optimisticReportID); } else if (isPolicyExpenseChat) { // Capture previous fresh reimbursable totals before mutating, so the diff applies whether or // not the iouReport already had reimbursableTotal/unheldReimbursableTotal populated locally. @@ -1377,18 +1383,18 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma if (newReportTotal) { iouReport.total = newReportTotal; } else { - iouReport.total -= amount; + iouReport.total -= reportAmount; } if (!reimbursable) { if (newNonReimbursableTotal !== undefined) { iouReport.nonReimbursableTotal = newNonReimbursableTotal; } else { - iouReport.nonReimbursableTotal = (iouReport.nonReimbursableTotal ?? 0) - amount; + iouReport.nonReimbursableTotal = (iouReport.nonReimbursableTotal ?? 0) - reportAmount; } } else { // Reimbursable transaction: reflect the change in the freshly tracked reimbursableTotal too. - iouReport.reimbursableTotal = previousReimbursableTotal - amount; + iouReport.reimbursableTotal = previousReimbursableTotal - reportAmount; } iouReport = maybeUpdateReportNameForFormulaTitle(iouReport, policy); @@ -1398,15 +1404,15 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma if (newReportTotal) { iouReport.unheldTotal = newReportTotal; } else { - iouReport.unheldTotal -= amount; + iouReport.unheldTotal -= reportAmount; } if (reimbursable) { - iouReport.unheldReimbursableTotal = previousUnheldReimbursableTotal - amount; + iouReport.unheldReimbursableTotal = previousUnheldReimbursableTotal - reportAmount; } } } } else { - iouReport = updateIOUOwnerAndTotal(iouReport, payeeAccountID, amount, currency); + iouReport = updateIOUOwnerAndTotal(iouReport, payeeAccountID, reportAmount, currency); } // For selfDM split, use UNREPORTED_REPORT_ID for the transaction @@ -1426,6 +1432,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma amount: shouldNegateAmount ? -amount : amount, distance, ...(modifiedAmount !== undefined && {modifiedAmount: shouldNegateAmount ? -modifiedAmount : modifiedAmount}), + ...(modifiedMerchant !== undefined && {modifiedMerchant}), currency, reportID: transactionReportID, comment, @@ -1509,7 +1516,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma // For selfDM split: use TRACK type, split amount, and isPersonalTrackingExpense to match BE response // (linkedTrackedExpenseReportAction is undefined for new splits - we search by new transaction ID which doesn't exist yet) const iouActionType = isSelfDMSplit ? CONST.IOU.REPORT_ACTION_TYPE.TRACK : CONST.IOU.REPORT_ACTION_TYPE.CREATE; - const iouActionAmount = isSplitExpense && modifiedAmount !== undefined ? Math.abs(modifiedAmount) : amount; + const iouActionAmount = Math.abs(reportAmount); const [optimisticCreatedActionForChat, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = buildOptimisticMoneyRequestEntities({ iouReport, diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 575d76629e4d..a0aabfc39a1d 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -3,13 +3,10 @@ import ReceiptGeneric from '@assets/images/receipt-generic.png'; import * as API from '@libs/API'; import type {CompleteSplitBillParams, CreateDistanceRequestParams, SplitBillParams, StartSplitBillParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; -import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {deferOrExecuteWrite} from '@libs/deferredLayoutWrite'; -import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {calculateAmount as calculateIOUAmount, updateIOUOwnerAndTotal} from '@libs/IOUUtils'; -import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import * as Localize from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; @@ -62,7 +59,6 @@ import {sanitizeWaypointsForAPI} from '@userActions/Transaction'; import {removeDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant, Split} from '@src/types/onyx/IOU'; @@ -1955,6 +1951,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest taxValue, merchant, modifiedAmount, + modifiedMerchant, billable, reimbursable, validWaypoints, @@ -2093,6 +2090,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest }, transactionParams: { amount, + modifiedAmount, + modifiedMerchant, distance, currency, comment, @@ -2126,70 +2125,6 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest onyxData = moneyRequestOnyxData; distanceIouReport = iouReport; - // Compute the commuter exclusion client-side for `fixedDistance` so the transaction - // breakdown renders instantly. The COMMUTER_EXCLUSION system message is created by the - // server from the authoritative distance calculation, so it is not seeded optimistically. - // For any other method (R2 homeAndOffice, future types) we leave optimistic behavior unchanged. - const distanceUnit = transaction.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const selectedRate = DistanceRequestUtils.getRate({transaction, policy}); - const distanceRate = selectedRate?.rate ?? 0; - - const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionData(transaction, policy, distance ?? 0, distanceUnit); - if (commuterExclusionData) { - const {commuterExclusion, reimbursableDistance} = commuterExclusionData; - // Use the canonical distance-amount calc so the optimistic amount rounds the same way the server does. - const reimbursableDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(reimbursableDistance, distanceUnit); - const modifiedRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(reimbursableDistanceInMeters, distanceUnit, distanceRate); - const modifiedRequestMerchant = DistanceRequestUtils.getDistanceMerchant( - true, - reimbursableDistanceInMeters, - distanceUnit, - distanceRate, - currency, - // translateLocal is used intentionally: this runs outside a React render (in an - // action creator building optimistic data), so the useLocalize hook is unavailable. - // eslint-disable-next-line @typescript-eslint/no-deprecated - Localize.translateLocal, - (digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit), - getCurrencySymbol, - true, - ); - - onyxData?.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, - value: { - modifiedAmount: modifiedRequestAmount, - modifiedMerchant: modifiedRequestMerchant, - comment: { - customUnit: { - commuterExclusion, - reimbursableDistance, - commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, - }, - }, - }, - }); - - // Failure: revert the optimistic transaction merge. The commuter-exclusion system message is - // created and reconciled by the server (Auth's CreateDistanceRequest), so it isn't seeded here. - onyxData?.failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, - value: { - modifiedAmount: null, - modifiedMerchant: null, - comment: { - customUnit: { - commuterExclusion: null, - reimbursableDistance: null, - commuterExclusionMethod: null, - }, - }, - }, - }); - } - const isGPSDistanceRequest = transaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_GPS; if (isDistanceExpenseType(transaction.iouRequestType)) { diff --git a/src/libs/actions/IOU/types/BaseTransactionParams.ts b/src/libs/actions/IOU/types/BaseTransactionParams.ts index 844517e689c5..1ec0feba7470 100644 --- a/src/libs/actions/IOU/types/BaseTransactionParams.ts +++ b/src/libs/actions/IOU/types/BaseTransactionParams.ts @@ -1,6 +1,7 @@ type BaseTransactionParams = { amount: number; modifiedAmount?: number; + modifiedMerchant?: string; currency: string; created: string; merchant: string; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index c5cdf255e5d2..b526a09ae8f4 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -369,6 +369,8 @@ function handleMoneyRequestStepDistanceNavigation({ existingTransaction: transaction, transactionParams: { amount, + ...(typeof transaction?.modifiedAmount === 'number' && {modifiedAmount: transaction.modifiedAmount}), + ...(transaction?.modifiedMerchant && {modifiedMerchant: transaction.modifiedMerchant}), distance, comment: '', created: transaction?.created ?? '', diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 495ef735e307..fb94cdd28aea 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -744,6 +744,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }, transactionParams: { amount: transaction.amount, + ...(typeof transaction.modifiedAmount === 'number' && {modifiedAmount: transaction.modifiedAmount}), + ...(transaction.modifiedMerchant && {modifiedMerchant: transaction.modifiedMerchant}), comment: trimmedComment, distance: transactionDistance, created: transaction.created, From c56d0d02d878fd8b9879c58deef861de5efbf37c Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 9 Jul 2026 16:13:44 +0100 Subject: [PATCH 56/64] Cover commuter exclusion distance updates --- tests/unit/DistanceRequestUtilsTest.ts | 60 ++++++++++++++----- tests/unit/TransactionUtilsTest.ts | 54 +++++++++++++++++ .../DistanceRequestController.test.tsx | 23 ++++--- 3 files changed, 116 insertions(+), 21 deletions(-) diff --git a/tests/unit/DistanceRequestUtilsTest.ts b/tests/unit/DistanceRequestUtilsTest.ts index f9dabcb0c530..cb91f779a866 100644 --- a/tests/unit/DistanceRequestUtilsTest.ts +++ b/tests/unit/DistanceRequestUtilsTest.ts @@ -401,31 +401,63 @@ describe('DistanceRequestUtils', () => { }); }); - describe('getCommuterExclusionData', () => { - it('converts fixed distance exclusions into the request unit', () => { + describe('getCommuterExclusionDisplayData', () => { + it('returns stored commuter display data from custom unit', () => { + const result = DistanceRequestUtils.getCommuterExclusionDisplayData( + { + quantity: 4, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + commuterExclusion: 1, + reimbursableDistance: 3, + }, + CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + ); + + expect(result).toEqual({ + commuterExclusion: 1, + reimbursableDistance: 3, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }); + }); + }); + + describe('getTransactionCommuterExclusionData', () => { + it('builds optimistic commuter fields from route distance in meters', () => { + const getCurrencySymbolMock = (currency: string): string | undefined => (currency === CONST.CURRENCY.USD ? '$' : undefined); + const toLocaleDigitMock = (digit: string) => digit; + const transaction = { + ...createRandomTransaction(1), + currency: CONST.CURRENCY.USD, + comment: { + customUnit: { + customUnitRateID: '222AAF6B93BCB', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + routeDistanceMeters: DistanceRequestUtils.convertToDistanceInMeters(4, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES), + }, + }, + } as Transaction; const policy = { ...FAKE_POLICY, commuterExclusions: { method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, fixedDistance: 1, - fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, }, }; - const transaction = { - ...createRandomTransaction(1), - comment: {customUnit: {quantity: 1, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}}, - } as Transaction; - const result = DistanceRequestUtils.getCommuterExclusionData( + const result = DistanceRequestUtils.getTransactionCommuterExclusionData({ transaction, policy, - DistanceRequestUtils.convertToDistanceInMeters(1, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES), - CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - ); + translate: translateLocal, + toLocaleDigit: toLocaleDigitMock, + getCurrencySymbol: getCurrencySymbolMock, + }); - expect(result?.commuterExclusion).toBeCloseTo(0.62, 2); - expect(result?.reimbursableDistance).toBeCloseTo(0.38, 2); - expect(result?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + expect(result?.modifiedAmount).toBe(201); + expect(result?.modifiedMerchant).toBe('3.00 mi @ $0.67 / mi'); + expect(result?.customUnit.quantity).toBe(4); + expect(result?.customUnit.commuterExclusion).toBe(1); + expect(result?.customUnit.reimbursableDistance).toBe(3); }); }); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 0b8e7161ee69..ee3bc2e899a3 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -487,6 +487,60 @@ describe('TransactionUtils', () => { }); }); + it('recalculates commuter exclusion data when distance is changed', () => { + 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: { + default: { + customUnitRateID: '1', + currency: CONST.CURRENCY.USD, + rate: 1, + }, + }, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }, + }; + const transaction = generateTransaction({ + comment: { + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 10, + commuterExclusion: 3, + reimbursableDistance: 7, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }, + }, + currency: CONST.CURRENCY.USD, + }); + + const updatedTransaction = TransactionUtils.getUpdatedTransaction({ + transaction, + isFromExpenseReport: false, + policy: fakePolicy, + transactionChanges: {distance: 20}, + personalPolicyOutputCurrency: undefined, + }); + + expect(updatedTransaction.comment?.customUnit?.quantity).toBe(20); + expect(updatedTransaction.comment?.customUnit?.commuterExclusion).toBe(3); + expect(updatedTransaction.comment?.customUnit?.reimbursableDistance).toBe(17); + expect(updatedTransaction.modifiedAmount).toBe(17); + expect(updatedTransaction.modifiedMerchant).toContain('17'); + expect(updatedTransaction.modifiedMerchant).not.toContain('20'); + }); + 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 diff --git a/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx index 77434c6ee88d..81640fb4a545 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx +++ b/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx @@ -12,6 +12,7 @@ import React from 'react'; import createMock from '../../../utils/createMock'; const mockSetMoneyRequestAmount = jest.fn(); +const mockSetMoneyRequestCommuterExclusionFields = jest.fn(); const mockSetMoneyRequestMerchant = jest.fn(); const mockSetMoneyRequestPendingFields = jest.fn(); @@ -45,6 +46,9 @@ jest.mock('@libs/actions/IOU/MoneyRequest', () => ({ setMoneyRequestAmount: (...args: unknown[]) => { mockSetMoneyRequestAmount(...args); }, + setMoneyRequestCommuterExclusionFields: (...args: unknown[]) => { + mockSetMoneyRequestCommuterExclusionFields(...args); + }, setMoneyRequestMerchant: (...args: unknown[]) => { mockSetMoneyRequestMerchant(...args); }, @@ -68,7 +72,7 @@ describe('DistanceRequestController', () => { jest.clearAllMocks(); }); - it('uses commuter exclusion data when updating the distance merchant', () => { + it('updates the base distance merchant and delegates commuter fields to the commuter action', () => { render( { selectedParticipantsProp={[]} setFormError={jest.fn()} clearFormErrors={jest.fn()} - commuterExclusionData={{ - commuterExclusion: 1, - reimbursableDistance: 3, - distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - }} />, ); - expect(mockSetMoneyRequestMerchant).toHaveBeenCalledWith('txn1', '3.00 mi @ $0.67 / mi', true); + expect(mockSetMoneyRequestMerchant).toHaveBeenCalledWith('txn1', '4.00 mi @ $0.67 / mi', true); + expect(mockSetMoneyRequestCommuterExclusionFields).toHaveBeenCalledWith( + expect.objectContaining({ + transactionID: 'txn1', + transaction, + policy: undefined, + customUnitRateID: '', + routeDistanceMeters: DistanceRequestUtils.convertToDistanceInMeters(4, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES), + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }), + ); }); }); From ea201934fed0483600a14425a8fb8b15efe06b1a Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 9 Jul 2026 16:32:52 +0100 Subject: [PATCH 57/64] Clean up commuter exclusion distance display --- .../sections/DistanceField.tsx | 4 +- .../fieldGroupTypes.ts | 22 +--------- .../fieldGroups/TransactionDetailsFields.tsx | 1 - .../ReportActionItem/MoneyRequestView.tsx | 2 +- src/libs/DistanceRequestUtils.ts | 41 ++++++++----------- src/pages/iou/SplitExpenseEditPage.tsx | 2 +- 6 files changed, 22 insertions(+), 50 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx index 053aebf27664..da252fdca4df 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx @@ -18,7 +18,6 @@ type DistanceFieldProps = { hasRoute: boolean; distance: number; unit: Unit | undefined; - rate: number | undefined; isManualDistanceRequest: boolean; isOdometerDistanceRequest: boolean; isGPSDistanceRequest: boolean; @@ -36,7 +35,6 @@ function DistanceField({ hasRoute, distance, unit, - rate, isManualDistanceRequest, isOdometerDistanceRequest, isGPSDistanceRequest, @@ -54,7 +52,7 @@ function DistanceField({ const displayUnit = unit ?? customUnit?.distanceUnit; const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionDisplayData(customUnit, displayUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); - const displayTitle = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, true, isManualDistanceRequest, commuterExclusionData); + const displayTitle = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, translate, true, isManualDistanceRequest, commuterExclusionData); const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, displayUnit, translate); return ( diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts b/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts index f2662d82ca04..c79910e444e5 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts @@ -11,13 +11,6 @@ type AmountDisplay = { formattedAmountPerAttendee: string; }; -/** Commuter exclusion breakdown data */ -type CommuterExclusionData = { - commuterExclusion: number; - reimbursableDistance: number; - distanceUnit: Unit; -} | null; - /** Distance-rate metadata threaded into Distance/Rate fields */ type DistanceData = { distance: number; @@ -104,17 +97,4 @@ type ReceiptOptions = { onPDFPassword?: () => void; }; -export type { - AmountDisplay, - CompactControls, - CompactState, - CommuterExclusionData, - DistanceData, - DistanceFlags, - ErrorState, - ExpenseMode, - ReceiptOptions, - RequiredFlags, - ToggleHandlers, - VisibilityFlags, -}; +export type {AmountDisplay, CompactControls, CompactState, DistanceData, DistanceFlags, ErrorState, ExpenseMode, ReceiptOptions, RequiredFlags, ToggleHandlers, VisibilityFlags}; diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx index 0f7e3e733105..e15895fb25ec 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx @@ -143,7 +143,6 @@ function TransactionDetailsFields({ hasRoute={distanceData.hasRoute} distance={distanceData.distance} unit={distanceData.unit} - rate={distanceData.rate} isManualDistanceRequest={isManualDistanceRequest} isOdometerDistanceRequest={isOdometerDistanceRequest} isGPSDistanceRequest={isGPSDistanceRequest} diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 09d0555cb3eb..fe5ce8872c22 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -597,7 +597,7 @@ function MoneyRequestView({ const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionDisplayData(transaction?.comment?.customUnit, distanceUnitValue); - const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, true, isManualDistanceRequest, commuterExclusionData); + const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, translate, true, isManualDistanceRequest, commuterExclusionData); const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, distanceUnitValue, translate); let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 1c7a906b2e8a..dbfc7ee99213 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -1,6 +1,5 @@ import type {CurrencyListActionsContextType} from '@components/CurrencyListContextProvider'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; -import type {CommuterExclusionData} from '@components/MoneyRequestConfirmationListFooter/fieldGroupTypes'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -35,11 +34,8 @@ type MileageRate = { endDate?: string | null; }; -type TransactionCommuterExclusionData = { - modifiedAmount: number; - modifiedMerchant?: string; - customUnit: TransactionCustomUnit; -}; +type CommuterExclusionData = Required>; +type TransactionCommuterExclusionData = Pick & {modifiedAmount: number; customUnit: TransactionCustomUnit}; /** @private Only for getRate function */ let allPolicies: OnyxCollection; @@ -235,11 +231,10 @@ function getDistanceForDisplay( hasRoute: boolean, distanceInMeters: number, unit: Unit | undefined, - rate: number | undefined, translate: LocaleContextProps['translate'], useShortFormUnit?: boolean, isZeroDistanceAllowed?: boolean, - commuterExclusionData?: CommuterExclusionData, + commuterExclusionData?: CommuterExclusionData | null, ): string { const displayUnit = unit ?? commuterExclusionData?.distanceUnit; if (!hasRoute || !displayUnit) { @@ -280,7 +275,7 @@ function getDistanceMerchant( toLocaleDigit: LocaleContextProps['toLocaleDigit'], getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'], isZeroDistanceAllowed?: boolean, - commuterExclusionData?: CommuterExclusionData, + commuterExclusionData?: CommuterExclusionData | null, ): string { if (!hasRoute || !rate) { return translate('iou.fieldPending'); @@ -290,7 +285,7 @@ function getDistanceMerchant( return ''; } - const distanceInUnits = getDistanceForDisplay(hasRoute, distanceInMeters, unit, rate, translate, true, isZeroDistanceAllowed, commuterExclusionData); + const distanceInUnits = getDistanceForDisplay(hasRoute, distanceInMeters, unit, translate, true, isZeroDistanceAllowed, commuterExclusionData); const ratePerUnit = getFormattedRateValue(unit, rate, currency, translate, toLocaleDigit, getCurrencySymbol, undefined, true); return `${distanceInUnits} ${CONST.DISTANCE_MERCHANT_SEPARATOR} ${ratePerUnit}`; @@ -383,18 +378,19 @@ function getTransactionCommuterExclusionData({ ? (getRateByCustomUnitRateID({customUnitRateID: existingCustomUnit.customUnitRateID, policy}) ?? getRate({transaction, policy, personalPolicyOutputCurrency})) : getRate({transaction, policy, personalPolicyOutputCurrency}); const requestDistanceUnit = existingCustomUnit?.distanceUnit ?? selectedRate.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const routeDistanceInMeters = - typeof existingCustomUnit?.routeDistanceMeters === 'number' - ? existingCustomUnit.routeDistanceMeters - : typeof transaction?.routes?.route0?.distance === 'number' - ? transaction.routes.route0.distance - : undefined; - const routeDistance = - typeof existingCustomUnit?.quantity === 'number' - ? existingCustomUnit.quantity - : routeDistanceInMeters !== undefined - ? convertDistanceUnit(routeDistanceInMeters, requestDistanceUnit) - : undefined; + let routeDistanceInMeters: number | undefined; + if (typeof existingCustomUnit?.routeDistanceMeters === 'number') { + routeDistanceInMeters = existingCustomUnit.routeDistanceMeters; + } else if (typeof transaction?.routes?.route0?.distance === 'number') { + routeDistanceInMeters = transaction.routes.route0.distance; + } + + let routeDistance: number | undefined; + if (typeof existingCustomUnit?.quantity === 'number') { + routeDistance = existingCustomUnit.quantity; + } else if (routeDistanceInMeters !== undefined) { + routeDistance = convertDistanceUnit(routeDistanceInMeters, requestDistanceUnit); + } if (routeDistance === undefined) { return; } @@ -877,6 +873,5 @@ export default { getBestEligibleRate, getRateDateLabel, }; -export type {TransactionCommuterExclusionData}; export type {MileageRate}; diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/SplitExpenseEditPage.tsx index 1a7bb504f36a..0692705726e3 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/SplitExpenseEditPage.tsx @@ -184,7 +184,7 @@ function SplitExpenseEditPage({route}: SplitExpensePageProps) { isDistance && distance && rate ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) * originalSign : Math.abs(Number(splitExpenseDraftTransaction?.amount)) * originalSign; - const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(true, distance, unit, rate, translate, false, isManualDistance); + const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(true, distance, unit, translate, false, isManualDistance); const currentRateID = getRateID(splitExpenseDraftTransaction); const rates = DistanceRequestUtils.getMileageRates(effectivePolicy, false, currentRateID); From 7a0eddc5e3d551386df1bdac7f106c1de216a9ec Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 9 Jul 2026 16:42:20 +0100 Subject: [PATCH 58/64] Tighten commuter exclusion validation --- .../hooks/useConfirmationValidation.ts | 7 +++---- src/libs/DistanceRequestUtils.ts | 3 +-- tests/unit/TransactionUtilsTest.ts | 2 ++ 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 363298253c02..80de21a33f08 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -300,10 +300,9 @@ function useConfirmationValidation({ return {errorKey: 'iou.error.genericSmartscanFailureMessage', shouldSetDidConfirmSplit: true}; } - // Allow zero-amount submission when a workspace commuter exclusion has fully consumed the distance - // (commuterExclusion >= quantity), since the user can still legitimately submit such an expense. - const hasCommuterExclusion = (transaction?.comment?.customUnit?.commuterExclusion ?? 0) > 0; - if (isEditingSplitBill && iouAmount === 0 && !hasCommuterExclusion) { + const isFullyCoveredByCommuterExclusion = + isDistanceRequest && (transaction?.comment?.customUnit?.commuterExclusion ?? 0) > 0 && transaction?.comment?.customUnit?.reimbursableDistance === 0; + if (isEditingSplitBill && iouAmount === 0 && !isFullyCoveredByCommuterExclusion) { return {errorKey: 'iou.error.invalidAmount'}; } diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index dbfc7ee99213..6062abdcc79f 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -35,7 +35,6 @@ type MileageRate = { }; type CommuterExclusionData = Required>; -type TransactionCommuterExclusionData = Pick & {modifiedAmount: number; customUnit: TransactionCustomUnit}; /** @private Only for getRate function */ let allPolicies: OnyxCollection; @@ -363,7 +362,7 @@ function getTransactionCommuterExclusionData({ toLocaleDigit?: LocaleContextProps['toLocaleDigit']; getCurrencySymbol?: CurrencyListActionsContextType['getCurrencySymbol']; personalPolicyOutputCurrency?: string; -}): TransactionCommuterExclusionData | undefined { +}): (Pick & {modifiedAmount: number; customUnit: TransactionCustomUnit}) | undefined { const policyCommuterExclusions = policy?.commuterExclusions; if ( transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL || diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index ee3bc2e899a3..6dc3f845f871 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -447,6 +447,7 @@ describe('TransactionUtils', () => { }, }; const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { customUnit: { distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, @@ -513,6 +514,7 @@ describe('TransactionUtils', () => { }, }; const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: { customUnit: { distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, From 1ea13ba7a322ceb7a629f6e549f3b2f8d6007af5 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 9 Jul 2026 16:52:56 +0100 Subject: [PATCH 59/64] Update distance display tests --- tests/unit/DistanceRequestUtilsTest.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit/DistanceRequestUtilsTest.ts b/tests/unit/DistanceRequestUtilsTest.ts index cb91f779a866..8855b998ece0 100644 --- a/tests/unit/DistanceRequestUtilsTest.ts +++ b/tests/unit/DistanceRequestUtilsTest.ts @@ -370,12 +370,12 @@ describe('DistanceRequestUtils', () => { describe('getDistanceForDisplay', () => { it('returns empty string when distance is 0 and isManualDistanceRequest is false', () => { - const result = DistanceRequestUtils.getDistanceForDisplay(true, 0, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, 67, translateLocal, false, false); + const result = DistanceRequestUtils.getDistanceForDisplay(true, 0, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, translateLocal, false, false); expect(result).toBe(''); }); it('formats zero distance when isManualDistanceRequest is true', () => { - const result = DistanceRequestUtils.getDistanceForDisplay(true, 0, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, 67, translateLocal, false, true); + const result = DistanceRequestUtils.getDistanceForDisplay(true, 0, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, translateLocal, false, true); expect(result).toBe(`0.00 ${translateLocal('common.miles')}`); }); @@ -390,7 +390,6 @@ describe('DistanceRequestUtils', () => { true, DistanceRequestUtils.convertToDistanceInMeters(1, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES), CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - 67, translateLocal, false, false, From 393ac2425311368f94f2fc09f2820fe7b02cb45b Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Tue, 14 Jul 2026 13:58:51 +0100 Subject: [PATCH 60/64] Exclude inactive workspaces from commuter exclusion checks --- src/pages/iou/request/DistanceRequestStartPage.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 1ed02f84765e..fc0fe88a416a 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -3,6 +3,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TabSelector from '@components/TabSelector/TabSelector'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; @@ -13,6 +14,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import {getActivePolicies, isGroupPolicy} from '@libs/PolicyUtils'; import {getPayeeName} from '@libs/ReportUtils'; import {endSpan} from '@libs/telemetry/activeSpans'; @@ -56,11 +58,12 @@ function DistanceRequestStartPage({ const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const isLoadingSelectedTab = isLoadingOnyxValue(selectedTabResult); const isTrackDistanceExpense = iouType === CONST.IOU.TYPE.TRACK; + const {login} = useCurrentUserPersonalDetails(); const [shouldHideManualAndOdometerTabs] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { selector: (allPolicies) => { - const policies = Object.values(allPolicies ?? {}).filter((p) => !!p?.id && p.type !== CONST.POLICY.TYPE.PERSONAL); - return policies.length > 0 && policies.every((p) => !!p?.commuterExclusions); + const activeGroupPolicies = getActivePolicies(allPolicies ?? null, login).filter(isGroupPolicy); + return activeGroupPolicies.length > 0 && activeGroupPolicies.every((activePolicy) => !!activePolicy.commuterExclusions); }, }); From b309577ca9626d48100402f7240e7640393bff75 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Wed, 15 Jul 2026 14:08:37 +0100 Subject: [PATCH 61/64] Check default workspace for commuter exclusions --- src/hooks/useCommuterExclusionGuard.ts | 6 +++++- .../iou/request/step/IOURequestStepDistanceManual.tsx | 9 +++++---- .../iou/request/step/IOURequestStepDistanceOdometer.tsx | 8 ++++---- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/hooks/useCommuterExclusionGuard.ts b/src/hooks/useCommuterExclusionGuard.ts index 85ea49f81a50..02899295b52a 100644 --- a/src/hooks/useCommuterExclusionGuard.ts +++ b/src/hooks/useCommuterExclusionGuard.ts @@ -47,7 +47,11 @@ function useCommuterExclusionGuard({policyID, isManualDistanceRequest = false, i const [policiesWithCommuterExclusions] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policiesWithCommuterExclusionsSelector}); return (policyIDToCheck = policyID) => { - if ((!isManualDistanceRequest && !isOdometerDistanceRequest) || !policyIDToCheck || !policiesWithCommuterExclusions?.[policyIDToCheck]) { + if (!isManualDistanceRequest && !isOdometerDistanceRequest) { + return false; + } + + if (!policyIDToCheck || !policiesWithCommuterExclusions?.[policyIDToCheck]) { return false; } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index db069d9780d4..7e58d52ade9d 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -86,15 +86,16 @@ function IOURequestStepDistanceManual({ iouType, transaction, }); - const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ - policyID: policy?.id, - isManualDistanceRequest: true, - }); const personalPolicy = usePersonalPolicy(); const defaultExpensePolicy = useDefaultExpensePolicy(); const {policyForMovingExpenses} = usePolicyForMovingExpenses(); const reportAttributesDerived = useReportAttributes(); + const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ + policyID: policy?.id ?? defaultExpensePolicy?.id, + isManualDistanceRequest: true, + }); + const [selectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.DISTANCE_REQUEST_TYPE}`); const distanceOriginalPolicy = useDistanceRateOriginalPolicy(transaction?.comment?.customUnit?.customUnitRateID); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index d1b24030cfed..37d973a42529 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -124,15 +124,15 @@ function IOURequestStepDistanceOdometer({ const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [parentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const policy = usePolicy(report?.policyID); - const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ - policyID: policy?.id, - isOdometerDistanceRequest: true, - }); const distanceOriginalPolicy = useDistanceRateOriginalPolicy(transaction?.comment?.customUnit?.customUnitRateID); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`); const personalPolicy = usePersonalPolicy(); const defaultExpensePolicy = useDefaultExpensePolicy(); + const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ + policyID: policy?.id ?? defaultExpensePolicy?.id, + isOdometerDistanceRequest: true, + }); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); From 9ba8011faab0e0ca37ce7dd192eb9e88d636eaaf Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Wed, 15 Jul 2026 16:33:20 +0100 Subject: [PATCH 62/64] Apply commuter exclusions to distance request amounts --- .../hooks/useDistanceRequestState.ts | 15 ++++-- .../hooks/useDistanceRequestState.test.ts | 50 +++++++++++++++++-- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts index 87fefbbb5f91..de20703f668a 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts @@ -85,8 +85,9 @@ function useDistanceRequestState({ const distanceRate = mileageRate.rate; const distanceUnit = mileageRate.unit; const calculateFromTransactionData = isMovingTransactionFromTrackExpense && !distanceRate; - const unit = calculateFromTransactionData ? transaction?.comment?.customUnit?.distanceUnit : distanceUnit; - const rate = calculateFromTransactionData ? Math.abs(iouAmount) / (transaction?.comment?.customUnit?.quantity ?? 1) : distanceRate; + const customUnit = transaction?.comment?.customUnit; + const unit = calculateFromTransactionData ? customUnit?.distanceUnit : distanceUnit; + const rate = calculateFromTransactionData ? Math.abs(iouAmount) / (customUnit?.quantity ?? 1) : distanceRate; const currency = calculateFromTransactionData ? iouCurrencyCode : (mileageRate.currency ?? CONST.CURRENCY.USD); const prevRate = usePrevious(rate); const prevUnit = usePrevious(unit); @@ -94,12 +95,18 @@ function useDistanceRequestState({ const distance = getDistanceInMeters(transaction, unit); const prevDistance = usePrevious(distance); - const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance || prevCurrency !== currency || prevUnit !== unit); + const amountUnit = unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionDisplayData(customUnit, amountUnit); + const reimbursableDistanceInMeters = commuterExclusionData + ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, commuterExclusionData.distanceUnit) + : distance; + const shouldCalculateDistanceAmount = + isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance || prevCurrency !== currency || prevUnit !== unit || !!commuterExclusionData); const hasRoute = hasRouteUtil(transaction, isDistanceRequest); const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense; - const distanceRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0); + const distanceRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(reimbursableDistanceInMeters, amountUnit, rate ?? 0); return { policyDraft, diff --git a/tests/unit/hooks/useDistanceRequestState.test.ts b/tests/unit/hooks/useDistanceRequestState.test.ts index ee9fb84be75b..1cf8645b842c 100644 --- a/tests/unit/hooks/useDistanceRequestState.test.ts +++ b/tests/unit/hooks/useDistanceRequestState.test.ts @@ -10,20 +10,29 @@ jest.mock('@libs/DistanceRequestUtils', () => ({ getDefaultMileageRate: () => undefined, getRate: () => ({rate: 0.5, unit: 'mi', currency: 'USD'}), getDistanceRequestAmount: (distance: number, _unit: string, rate: number): number => Math.round(distance * rate * 100), - getCommuterExclusionData: () => null, + getCommuterExclusionDisplayData: (customUnit: {commuterExclusion?: number; reimbursableDistance?: number; distanceUnit?: string} | undefined, distanceUnit: string) => { + if (!customUnit?.commuterExclusion) { + return null; + } + return { + commuterExclusion: customUnit.commuterExclusion, + reimbursableDistance: customUnit.reimbursableDistance ?? 0, + distanceUnit: customUnit.distanceUnit ?? distanceUnit, + }; + }, convertToDistanceInMeters: (distance: number): number => distance, }, })); jest.mock('@libs/TransactionUtils', () => ({ - getDistanceInMeters: (transaction: {comment?: {customUnit?: {distance?: number}}} | undefined): number => transaction?.comment?.customUnit?.distance ?? 0, - hasRoute: (transaction: {comment?: {customUnit?: {distance?: number}}} | undefined): boolean => !!transaction?.comment?.customUnit?.distance, + getDistanceInMeters: (transaction: {comment?: {customUnit?: {routeDistanceMeters?: number}}} | undefined): number => transaction?.comment?.customUnit?.routeDistanceMeters ?? 0, + hasRoute: (transaction: {comment?: {customUnit?: {routeDistanceMeters?: number}}} | undefined): boolean => !!transaction?.comment?.customUnit?.routeDistanceMeters, })); type Params = Parameters[0]; const baseParams: Params = { - transaction: {transactionID: 'txn1', comment: {customUnit: {distance: 10}}} as unknown as OnyxTypes.Transaction, + transaction: {transactionID: 'txn1', comment: {customUnit: {routeDistanceMeters: 10}}} as unknown as OnyxTypes.Transaction, policy: undefined, policyID: 'policy1', policyForMovingExpenses: undefined, @@ -41,6 +50,39 @@ describe('useDistanceRequestState', () => { expect(result.current.distanceRequestAmount).toBe(500); // 10 * 0.5 * 100 }); + it('calculates the amount from the reimbursable distance when the custom unit has a commuter exclusion', () => { + const transaction = baseParams.transaction; + if (!transaction) { + throw new Error('Expected a transaction'); + } + + const {result} = renderHook(() => + useDistanceRequestState({ + ...baseParams, + transaction: { + ...transaction, + transactionID: 'txn1', + comment: { + ...transaction.comment, + customUnit: { + ...transaction.comment?.customUnit, + routeDistanceMeters: 10, + quantity: 10, + distanceUnit: 'mi', + commuterExclusion: 2, + reimbursableDistance: 8, + }, + }, + }, + iouAmount: 500, + }), + ); + + expect(result.current.distance).toBe(10); + expect(result.current.distanceRequestAmount).toBe(400); + expect(result.current.shouldCalculateDistanceAmount).toBe(true); + }); + it('isDistanceRequestWithPendingRoute is true when transaction has no route', () => { const {result} = renderHook(() => useDistanceRequestState({ From 74cb1fc1f95c0ea7d0356c573d9511153c7ccc26 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Wed, 15 Jul 2026 20:56:07 +0100 Subject: [PATCH 63/64] Block forced-commuter workspace switches in money request confirmation selector Prevent committing a manual/odometer distance recipient when the selected policy has commuter exclusions, so the workspace never changes before the blocking modal is shown. --- src/components/ParticipantPicker.tsx | 8 ++++++++ src/hooks/useParticipantSubmission.ts | 15 ++++++++++++-- .../MoneyRequestParticipantsSelector.tsx | 8 ++++++++ .../iou/request/ParticipantSearchResults.tsx | 20 +++++++++++++++---- .../step/IOURequestStepConfirmation.tsx | 4 +++- .../step/IOURequestStepParticipants.tsx | 8 +++++++- 6 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/components/ParticipantPicker.tsx b/src/components/ParticipantPicker.tsx index 1efc0ace0b39..d6bcc3ed7f38 100644 --- a/src/components/ParticipantPicker.tsx +++ b/src/components/ParticipantPicker.tsx @@ -48,6 +48,12 @@ type ParticipantPickerProps = { /** Callback fired when the modal backdrop (the area outside the picker) is pressed. Falls back to onClose when omitted. */ onBackdropPress?: () => void; + + /** + * Called before committing a participant/workspace selection. + * Return true to block the selection (e.g. manual/odometer distance into a commuter-exclusion workspace). + */ + shouldBlockParticipantSelection?: (policyID?: string) => boolean; }; function ParticipantPicker({ @@ -63,6 +69,7 @@ function ParticipantPicker({ isVisible = true, onClose, onBackdropPress, + shouldBlockParticipantSelection, }: ParticipantPickerProps) { const {translate} = useLocalize(); const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT; @@ -84,6 +91,7 @@ function ParticipantPicker({ onCloseParticipantPicker={onClose} initiallySelectedReportID={selectedParticipant?.reportID} shouldMoveSelectedToTop + shouldBlockParticipantSelection={shouldBlockParticipantSelection} /> ); diff --git a/src/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts index c3824affb143..52ac039f6473 100644 --- a/src/hooks/useParticipantSubmission.ts +++ b/src/hooks/useParticipantSubmission.ts @@ -5,9 +5,9 @@ import HttpUtils from '@libs/HttpUtils'; import {isParticipantP2P} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isGroupPolicy} from '@libs/PolicyUtils'; -import {findSelfDMReportID, generateReportID, isInvoiceRoomWithID} from '@libs/ReportUtils'; +import {findSelfDMReportID, generateReportID, getReportOrDraftReport, isInvoiceRoomWithID} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {isDistanceRequest} from '@libs/TransactionUtils'; +import {isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest} from '@libs/TransactionUtils'; import { resetDraftTransactionsCustomUnit, @@ -33,6 +33,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useEffect, useRef} from 'react'; +import useCommuterExclusionGuard from './useCommuterExclusionGuard'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useLocalize from './useLocalize'; import useMappedPolicies from './useMappedPolicies'; @@ -101,6 +102,10 @@ function useParticipantSubmission({ // explicit useMemo is needed here. const transactionIDs = draftTransactions?.map((transaction) => transaction.transactionID); const [transactions] = useTransactionsByID(transactionIDs); + const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ + isManualDistanceRequest: isManualDistanceRequest(initialTransaction), + isOdometerDistanceRequest: isOdometerDistanceRequest(initialTransaction), + }); const isActivePolicyRequest = iouType === CONST.IOU.TYPE.CREATE && @@ -237,6 +242,12 @@ function useParticipantSubmission({ return; } + // Block selecting a workspace with commuter exclusions before participants/workspace are committed. + const selectedPolicyID = firstParticipant?.policyID ?? (firstParticipant?.reportID ? getReportOrDraftReport(firstParticipant.reportID)?.policyID : undefined); + if (shouldBlockManualOrOdometerDistanceRequest(selectedPolicyID)) { + return; + } + const {allPolicies: policies, lastSelectedDistanceRates: distanceRates, draftTransactions: drafts} = dataRef.current; const firstParticipantReportID = val.at(0)?.reportID; const isPolicyExpenseChat = !!firstParticipant?.isPolicyExpenseChat; diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 0bd12aa14548..ada15250b975 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -55,6 +55,12 @@ type MoneyRequestParticipantsSelectorProps = { /** Callback to dismiss the participant picker overlay before the referral banner navigates, so the referral RHP isn't covered */ onCloseParticipantPicker?: () => void; + /** + * Called before committing a participant/workspace selection. + * Return true to block the selection (e.g. manual/odometer distance into a commuter-exclusion workspace). + */ + shouldBlockParticipantSelection?: (policyID?: string) => boolean; + /** Reference to the outer element */ ref?: Ref; }; @@ -79,6 +85,7 @@ function MoneyRequestParticipantsSelector({ shouldMoveSelectedToTop = false, onRestrictedParticipantSelected, onCloseParticipantPicker, + shouldBlockParticipantSelection, ref, }: MoneyRequestParticipantsSelectorProps) { const platform = getPlatform(); @@ -115,6 +122,7 @@ function MoneyRequestParticipantsSelector({ shouldMoveSelectedToTop={shouldMoveSelectedToTop} onRestrictedParticipantSelected={onRestrictedParticipantSelected} onCloseParticipantPicker={onCloseParticipantPicker} + shouldBlockParticipantSelection={shouldBlockParticipantSelection} /> ); } diff --git a/src/pages/iou/request/ParticipantSearchResults.tsx b/src/pages/iou/request/ParticipantSearchResults.tsx index 6972cfe6ad1c..89b121657382 100644 --- a/src/pages/iou/request/ParticipantSearchResults.tsx +++ b/src/pages/iou/request/ParticipantSearchResults.tsx @@ -31,7 +31,7 @@ import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMa import type {OptionWithKey} from '@libs/OptionsListUtils/types'; import {getActiveAdminWorkspaces, isGroupPolicy as isGroupPolicyUtil} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {isInvoiceRoom} from '@libs/ReportUtils'; +import {getReportOrDraftReport, isInvoiceRoom} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {expensifyLoginsSelector} from '@libs/UserUtils'; @@ -115,6 +115,12 @@ type ParticipantSearchResultsProps = { /** Callback to dismiss the participant picker overlay before the referral banner navigates, so the referral RHP isn't covered */ onCloseParticipantPicker?: () => void; + + /** + * Called before committing a participant/workspace selection. + * Return true to block the selection (e.g. manual/odometer distance into a commuter-exclusion workspace). + */ + shouldBlockParticipantSelection?: (policyID?: string) => boolean; }; function ParticipantSearchResults({ @@ -136,6 +142,7 @@ function ParticipantSearchResults({ shouldMoveSelectedToTop = false, onRestrictedParticipantSelected, onCloseParticipantPicker, + shouldBlockParticipantSelection, }: ParticipantSearchResultsProps) { const getParticipantOptionKey = (option: Partial) => option.reportID ?? option.accountID?.toString() ?? option.login ?? option.phoneNumber ?? ''; const isIOUSplit = iouType === CONST.IOU.TYPE.SPLIT; @@ -456,15 +463,20 @@ function ParticipantSearchResults({ ); const onSelectRow = (option: Participant) => { - const optionPolicy = option.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${option.policyID}`] : undefined; + const optionPolicyID = option.policyID ?? (option.reportID ? getReportOrDraftReport(option.reportID)?.policyID : undefined); + if (shouldBlockParticipantSelection?.(optionPolicyID)) { + return; + } + + const optionPolicy = optionPolicyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${optionPolicyID}`] : undefined; if ( option.isPolicyExpenseChat && - option.policyID && + optionPolicyID && optionPolicy && shouldRestrictUserBillableActions(optionPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) ) { onRestrictedParticipantSelected?.(); - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(option.policyID)); + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(optionPolicyID)); return; } diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index b23727096426..07654e69627b 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -337,7 +337,8 @@ function IOURequestStepConfirmation({ return; } const selectedParticipant = participantsList.at(0); - if (shouldBlockManualOrOdometerDistanceRequest(selectedParticipant?.policyID)) { + const selectedPolicyID = selectedParticipant?.policyID ?? (selectedParticipant?.reportID ? getReportOrDraftReport(selectedParticipant.reportID)?.policyID : undefined); + if (shouldBlockManualOrOdometerDistanceRequest(selectedPolicyID)) { return; } // P2P chats don't support negative amounts. When a negative amount was entered before a participant @@ -1013,6 +1014,7 @@ function IOURequestStepConfirmation({ // Clicking the backdrop (outside the panel) should dismiss the whole expense creation RHP, // matching standard RHP behavior, not just close the stacked participant picker. onBackdropPress={() => Navigation.dismissModal()} + shouldBlockParticipantSelection={shouldBlockManualOrOdometerDistanceRequest} /> )} diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index e0945288fbfd..66ce8271c8c7 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -1,5 +1,6 @@ import FormHelpMessage from '@components/FormHelpMessage'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useParticipantSubmission from '@hooks/useParticipantSubmission'; @@ -9,7 +10,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getIsWorkspacesOnlyForTransaction, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {endSpan} from '@libs/telemetry/activeSpans'; -import {getRequestType, isFromCreditCardImport, isPerDiemRequest, isTimeRequest as isTimeRequestUtil} from '@libs/TransactionUtils'; +import {getRequestType, isFromCreditCardImport, isManualDistanceRequest, isOdometerDistanceRequest, isPerDiemRequest, isTimeRequest as isTimeRequestUtil} from '@libs/TransactionUtils'; import MoneyRequestParticipantsSelector from '@pages/iou/request/MoneyRequestParticipantsSelector'; @@ -80,6 +81,10 @@ function IOURequestStepParticipants({ isMovingTransactionFromTrackExpense, isFocused, }); + const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({ + isManualDistanceRequest: isManualDistanceRequest(initialTransaction), + isOdometerDistanceRequest: isOdometerDistanceRequest(initialTransaction), + }); const hasEndedSpan = useRef(false); useEffect(() => { @@ -163,6 +168,7 @@ function IOURequestStepParticipants({ shouldExcludeP2P={(initialTransaction?.amount ?? 0) < 0} initiallySelectedReportID={selectedParticipant?.reportID} shouldMoveSelectedToTop + shouldBlockParticipantSelection={shouldBlockManualOrOdometerDistanceRequest} /> ); From 55083b7a8276bdacdade06e3fd618c40f3da843c Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Wed, 15 Jul 2026 20:56:14 +0100 Subject: [PATCH 64/64] Add unit tests for useCommuterExclusionGuard Cover blocking forced-commuter workspaces for manual distance and the cases that should still allow selection. --- .../hooks/useCommuterExclusionGuard.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/unit/hooks/useCommuterExclusionGuard.test.ts diff --git a/tests/unit/hooks/useCommuterExclusionGuard.test.ts b/tests/unit/hooks/useCommuterExclusionGuard.test.ts new file mode 100644 index 000000000000..d30081549770 --- /dev/null +++ b/tests/unit/hooks/useCommuterExclusionGuard.test.ts @@ -0,0 +1,92 @@ +import {renderHook} from '@testing-library/react-native'; + +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const mockShowConfirmModal = jest.fn(); + +jest.mock('@hooks/useConfirmModal', () => () => ({ + showConfirmModal: mockShowConfirmModal, +})); + +jest.mock('@hooks/useLocalize', () => () => ({ + translate: (key: string) => key, +})); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyIllustrations: () => ({HouseWithMap: 'HouseWithMap'}), +})); + +describe('useCommuterExclusionGuard', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockShowConfirmModal.mockClear(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('blocks selecting a workspace with commuter exclusions for manual distance before the workspace changes', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy_forced`, { + id: 'policy_forced', + name: 'Forced workspace', + commuterExclusions: { + method: 'fixedDistance', + fixedDistance: 1, + fixedDistanceUnit: 'mi', + }, + }); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => + useCommuterExclusionGuard({ + isManualDistanceRequest: true, + }), + ); + + expect(result.current('policy_forced')).toBe(true); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + }); + + it('does not block selecting a workspace without commuter exclusions', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy_open`, { + id: 'policy_open', + name: 'Open workspace', + }); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => + useCommuterExclusionGuard({ + isManualDistanceRequest: true, + }), + ); + + expect(result.current('policy_open')).toBe(false); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + + it('does not block non-manual and non-odometer flows', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy_forced`, { + id: 'policy_forced', + name: 'Forced workspace', + commuterExclusions: { + method: 'fixedDistance', + fixedDistance: 1, + fixedDistanceUnit: 'mi', + }, + }); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useCommuterExclusionGuard({})); + + expect(result.current('policy_forced')).toBe(false); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); +});