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..aff0b5ce0830
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__housewithmap.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 08eab278027a..6f36cbb24599 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -1629,6 +1629,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
diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts
index 6a31da27fc90..33fe063557aa 100644
--- a/src/components/Icon/chunks/illustrations.chunk.ts
+++ b/src/components/Icon/chunks/illustrations.chunk.ts
@@ -157,6 +157,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';
@@ -415,6 +416,7 @@ const Illustrations = {
HeadSet,
Hourglass,
House,
+ HouseWithMap,
HouseMoney,
LifeRing,
Lightbulb,
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index b6793e364a6a..7fb4633f23e2 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 blockManualOrOdometerDistanceRequestIfNeeded = useCommuterExclusionGuard({
+ policyID: isPolicyExpenseChat ? policy?.id : undefined,
+ isManualDistanceRequest,
+ isOdometerDistanceRequest,
+ });
const iouAmount = hasValidModifiedAmount(transaction) ? Number(transaction?.modifiedAmount) : (transaction?.amount ?? 0);
const iouCurrencyCode = getCurrency(transaction);
@@ -305,6 +311,7 @@ function MoneyRequestConfirmationList({
isMovingTransactionFromTrackExpense,
customUnitRateID,
distance,
+ distanceUnit: unit,
previousTransactionCurrency,
});
@@ -504,7 +511,12 @@ function MoneyRequestConfirmationList({
setFormError,
setDidConfirmSplit,
showDelegateNoAccessModal,
- onConfirm,
+ onConfirm: () => {
+ if (blockManualOrOdometerDistanceRequestIfNeeded()) {
+ return;
+ }
+ onConfirm?.();
+ },
onSendMoney,
});
@@ -558,13 +570,13 @@ function MoneyRequestConfirmationList({
distance,
hasRoute,
unit,
- rate,
distanceRateName: mileageRate.name,
distanceRateCurrency: currency,
mileageRate,
expenseDate: getCreated(transaction),
customUnitRateID,
shouldShowRateAutoUpdatedTooltip,
+ customUnit: transaction?.comment?.customUnit,
}}
amountDisplay={{amount: amountToBeUsed, formattedAmount, formattedAmountPerAttendee}}
requiredFlags={{isCategoryRequired, isMerchantRequired, isDescriptionRequired}}
diff --git a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx
index 5c7c7516ba39..f0dfac337eb2 100644
--- a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx
+++ b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx
@@ -4,7 +4,14 @@ import useOnyx from '@hooks/useOnyx';
import usePersonalPolicy from '@hooks/usePersonalPolicy';
import usePrevious from '@hooks/usePrevious';
-import {clearMoneyRequestRateAutoUpdated, setCustomUnitRateID, setMoneyRequestAmount, setMoneyRequestMerchant, setMoneyRequestPendingFields} from '@libs/actions/IOU/MoneyRequest';
+import {
+ clearMoneyRequestRateAutoUpdated,
+ setCustomUnitRateID,
+ setMoneyRequestAmount,
+ setMoneyRequestCommuterExclusionFields,
+ setMoneyRequestMerchant,
+ setMoneyRequestPendingFields,
+} from '@libs/actions/IOU/MoneyRequest';
import {setSplitShares} from '@libs/actions/IOU/Split';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import type {MileageRate} from '@libs/DistanceRequestUtils';
@@ -146,10 +153,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) {
@@ -239,6 +246,19 @@ function DistanceRequestController({
isManualDistanceRequest,
);
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,
@@ -254,6 +274,9 @@ function DistanceRequestController({
isReadOnly,
getCurrencySymbol,
isManualDistanceRequest,
+ policy,
+ customUnitRateID,
+ personalPolicy?.outputCurrency,
]);
return null;
diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
index b2f92e391cc5..b77e792e1446 100644
--- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
+++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
@@ -322,7 +322,9 @@ function useConfirmationValidation({
return {errorKey: 'iou.error.genericSmartscanFailureMessage', shouldSetDidConfirmSplit: true};
}
- if (isEditingSplitBill && iouAmount === 0) {
+ 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/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts
index 87fefbbb5f91..1c332e164b83 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,21 @@ 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 reimbursableDistance = commuterExclusionData?.reimbursableDistance;
+ const prevReimbursableDistance = usePrevious(reimbursableDistance);
+ const reimbursableDistanceInMeters = commuterExclusionData
+ ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, commuterExclusionData.distanceUnit)
+ : distance;
+ const shouldCalculateDistanceAmount =
+ isDistanceRequest &&
+ (iouAmount === 0 || prevRate !== rate || prevDistance !== distance || prevCurrency !== currency || prevUnit !== unit || prevReimbursableDistance !== reimbursableDistance);
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/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts
index 6c3b000fab8f..ad9b6e7fe257 100644
--- a/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts
+++ b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts
@@ -6,6 +6,7 @@ import {calculateTaxAmount, getDefaultTaxCode, getTaxValue, hasTaxRateWithMatchi
import CONST from '@src/CONST';
import type * as OnyxTypes from '@src/types/onyx';
+import type {Unit} from '@src/types/onyx/Policy';
import type {OnyxEntry} from 'react-native-onyx';
@@ -31,6 +32,9 @@ type UseTaxAmountParams = {
/** Distance value used to compute the taxable amount for distance requests */
distance: number;
+ /** Active distance unit for the selected mileage rate */
+ distanceUnit: Unit | undefined;
+
/** Currency the transaction had on the previous render, used to detect currency changes */
previousTransactionCurrency: string | undefined;
};
@@ -51,6 +55,7 @@ function useTaxAmount({
isMovingTransactionFromTrackExpense,
customUnitRateID,
distance,
+ distanceUnit,
previousTransactionCurrency,
}: UseTaxAmountParams) {
const {getCurrencyDecimals} = useCurrencyListActions();
@@ -61,8 +66,15 @@ function useTaxAmount({
const previousDefaultTaxCode = getDefaultTaxCode(policy, transaction, previousTransactionCurrency);
const shouldKeepCurrentTaxSelection = hasTaxRateWithMatchingValue(policy, transaction) && transaction?.taxCode !== previousDefaultTaxCode;
+ const commuterExclusionData = isDistanceRequest
+ ? DistanceRequestUtils.getCommuterExclusionDisplayData(
+ transaction?.comment?.customUnit,
+ transaction?.comment?.customUnit?.distanceUnit ?? distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ )
+ : null;
+ const taxableDistance = commuterExclusionData ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, commuterExclusionData.distanceUnit) : distance;
// Calculate and set tax amount in transaction draft
- const taxableAmount = isDistanceRequest ? DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, distance) : Math.abs(transaction?.amount ?? 0);
+ const taxableAmount = isDistanceRequest ? DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, taxableDistance) : Math.abs(transaction?.amount ?? 0);
// First we'll try to get the tax value from the chosen policy and if not found, we'll try to get it from the policy for moving expenses (only if the transaction is moving from track expense)
const taxPercentage =
getTaxValue(policy, transaction, transaction?.taxCode ?? defaultTaxCode) ??
diff --git a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx
index 8f67fe0d06a5..da252fdca4df 100644
--- a/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/DistanceField.tsx
@@ -10,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';
@@ -17,7 +18,6 @@ type DistanceFieldProps = {
hasRoute: boolean;
distance: number;
unit: Unit | undefined;
- rate: number | undefined;
isManualDistanceRequest: boolean;
isOdometerDistanceRequest: boolean;
isGPSDistanceRequest: boolean;
@@ -28,13 +28,13 @@ type DistanceFieldProps = {
iouType: Exclude;
reportID: string;
reportActionID: string | undefined;
+ customUnit?: TransactionCustomUnit;
};
function DistanceField({
hasRoute,
distance,
unit,
- rate,
isManualDistanceRequest,
isOdometerDistanceRequest,
isGPSDistanceRequest,
@@ -45,15 +45,22 @@ function DistanceField({
iouType,
reportID,
reportActionID,
+ customUnit,
}: DistanceFieldProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const displayUnit = unit ?? customUnit?.distanceUnit;
+ const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionDisplayData(customUnit, displayUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES);
+ 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/MoneyRequestConfirmationList/sections/RateField.tsx b/src/components/MoneyRequestConfirmationList/sections/RateField.tsx
index a649cdfe4d2f..daae42f3fcd6 100644
--- a/src/components/MoneyRequestConfirmationList/sections/RateField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/RateField.tsx
@@ -28,7 +28,6 @@ type RateFieldProps = {
distanceRateName: string | undefined;
distanceRateCurrency: string;
unit: Unit | undefined;
- rate: number | undefined;
mileageRate: MileageRate;
expenseDate: string | undefined;
customUnitRateID: string | undefined;
@@ -51,7 +50,6 @@ function RateField({
distanceRateName,
distanceRateCurrency,
unit,
- rate,
mileageRate,
expenseDate,
customUnitRateID,
@@ -94,6 +92,7 @@ function RateField({
: '';
const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK;
+ const rate = mileageRate.rate;
const isRateInteractive = !!rate && !isReadOnly && iouType !== CONST.IOU.TYPE.SPLIT;
const {isSearchRouterDisplayed} = useSearchRouterState();
diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts b/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts
index 0834cadeacf4..55f6c24ffaee 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 = {
@@ -15,13 +16,13 @@ type DistanceData = {
distance: number;
hasRoute: boolean;
unit: Unit | undefined;
- rate: number | undefined;
distanceRateName: string | undefined;
distanceRateCurrency: string;
mileageRate: MileageRate;
expenseDate: string | undefined;
customUnitRateID: string | undefined;
shouldShowRateAutoUpdatedTooltip?: boolean;
+ 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 fa9a352ad678..57cfa55d1f23 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}
@@ -154,6 +153,7 @@ function TransactionDetailsFields({
iouType={iouType}
reportID={reportID}
reportActionID={reportActionID}
+ customUnit={distanceData.customUnit}
/>
)}
@@ -162,7 +162,6 @@ function TransactionDetailsFields({
distanceRateName={distanceData.distanceRateName}
distanceRateCurrency={distanceData.distanceRateCurrency}
unit={distanceData.unit}
- rate={distanceData.rate}
mileageRate={distanceData.mileageRate}
expenseDate={distanceData.expenseDate}
customUnitRateID={distanceData.customUnitRateID}
diff --git a/src/components/ParticipantPicker/BaseParticipantPicker.tsx b/src/components/ParticipantPicker/BaseParticipantPicker.tsx
index 65366e0fccf9..190091d5b6f4 100644
--- a/src/components/ParticipantPicker/BaseParticipantPicker.tsx
+++ b/src/components/ParticipantPicker/BaseParticipantPicker.tsx
@@ -22,6 +22,7 @@ function BaseParticipantPicker({
onParticipantsAdded,
onFinish,
onClose,
+ shouldBlockParticipantSelection,
}: ParticipantPickerProps) {
const {translate} = useLocalize();
const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT;
@@ -43,6 +44,7 @@ function BaseParticipantPicker({
onCloseParticipantPicker={onClose}
initiallySelectedReportID={selectedParticipant?.reportID}
shouldMoveSelectedToTop
+ shouldBlockParticipantSelection={shouldBlockParticipantSelection}
/>
);
diff --git a/src/components/ParticipantPicker/types.ts b/src/components/ParticipantPicker/types.ts
index d95d859b2e0f..1d08eff7fecd 100644
--- a/src/components/ParticipantPicker/types.ts
+++ b/src/components/ParticipantPicker/types.ts
@@ -38,6 +38,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;
};
// eslint-disable-next-line import/prefer-default-export
diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx
index a6a6ba9ad494..bcb514e35d1d 100644
--- a/src/components/ReportActionItem/MoneyRequestView.tsx
+++ b/src/components/ReportActionItem/MoneyRequestView.tsx
@@ -609,7 +609,14 @@ function MoneyRequestView({
getCurrencySymbol,
isOffline,
);
- const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate, undefined, isManualDistanceRequest);
+
+ const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES;
+ const commuterExclusionData = transaction?.comment?.originalTransactionID
+ ? null
+ : DistanceRequestUtils.getCommuterExclusionDisplayData(transaction?.comment?.customUnit, distanceUnitValue);
+ const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, translate, true, isManualDistanceRequest, commuterExclusionData);
+ const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, distanceUnitValue, translate);
+
let merchantTitle = isEmptyMerchant ? '' : transactionMerchant;
let amountTitle = formattedTransactionAmount?.toString() || '';
if (isTransactionScanning) {
@@ -946,8 +953,9 @@ function MoneyRequestView({
<>
;
+
+const policiesWithCommuterExclusionsSelector = (policies: OnyxCollection): PoliciesWithCommuterExclusions =>
+ Object.values(policies ?? {}).reduce((acc, policy) => {
+ if (isCommuterExclusionEnabled(policy)) {
+ acc[policy.id] = true;
+ }
+ return acc;
+ }, {});
+
+/**
+ * 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({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 useCallback(
+ (...policyIDsToCheck: [string?]) => {
+ const policyIDToCheck = policyIDsToCheck.length > 0 ? policyIDsToCheck[0] : policyID;
+ if (!isManualDistanceRequest && !isOdometerDistanceRequest) {
+ return false;
+ }
+
+ if (!policyIDToCheck || !policiesWithCommuterExclusions?.[policyIDToCheck]) {
+ 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,
+ image: illustrations.HouseWithMap,
+ shouldUseSuccessStyleForConfirm: true,
+ shouldFitImageToContainer: true,
+ imageStyles: styles.commuterExclusionStaticIllustration,
+ });
+
+ return true;
+ },
+ [policyID, isManualDistanceRequest, isOdometerDistanceRequest, policiesWithCommuterExclusions, showConfirmModal, translate, styles, illustrations.HouseWithMap],
+ );
+}
+
+export default useCommuterExclusionGuard;
diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts
index b7c0021208ac..e99e56887f94 100644
--- a/src/hooks/useExpenseActions.ts
+++ b/src/hooks/useExpenseActions.ts
@@ -35,6 +35,7 @@ import {
getDeleteExpenseTitle,
getOriginalTransactionWithSplitInfo,
hasCustomUnitOutOfPolicyViolation as hasCustomUnitOutOfPolicyViolationTransactionUtils,
+ hasAppliedCommuterExclusion,
isDistanceRequest,
isPerDiemRequest,
isTransactionPendingDelete,
@@ -128,6 +129,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);
@@ -460,7 +462,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/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts
index 86728bf5b1c8..a7a3fef88955 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 {useCurrencyListActions} from './useCurrencyList';
import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails';
import useLocalize from './useLocalize';
@@ -103,6 +104,10 @@ function useParticipantSubmission({
// explicit useMemo is needed here.
const transactionIDs = draftTransactions?.map((transaction) => transaction.transactionID);
const [transactions] = useTransactionsByID(transactionIDs);
+ const blockManualOrOdometerDistanceRequestIfNeeded = useCommuterExclusionGuard({
+ isManualDistanceRequest: isManualDistanceRequest(initialTransaction),
+ isOdometerDistanceRequest: isOdometerDistanceRequest(initialTransaction),
+ });
const isActivePolicyRequest =
iouType === CONST.IOU.TYPE.CREATE &&
@@ -239,6 +244,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 (blockManualOrOdometerDistanceRequestIfNeeded(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/languages/de.ts b/src/languages/de.ts
index 280e644354c5..ad8194ef215c 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -495,6 +495,7 @@ const translations: TranslationDeepObject = {
tryAgain: 'Erneut versuchen',
tagGLCode: 'GL-Code taggen',
off: 'Aus',
+ commuter: 'Pendler',
noResultsFoundSubtitle: 'Keine Ergebnisse. Bitte passen Sie Ihre Filter oder Suchanfrage an',
unableToDisplayChart: 'Diagram kann nicht angezeigt werden',
webGLNotSupported: 'Ihr Browser unterstützt WebGL nicht. Bitte aktivieren Sie es oder wechseln Sie den Browser.',
@@ -8651,20 +8652,11 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc
addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `„${prohibitedExpense}“ zu verbotenen Ausgaben hinzugefügt`,
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`;
- },
- 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',
+ changedToFixedDistance: 'Ausschluss von Arbeitswegen in eine feste Entfernung pro Abrechnung geändert',
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `feste Entfernungsausschlussgrenze von ${formattedDistance} pro Abrechnung festlegen`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `Feste Streckenausschlussgrenze auf ${formattedNewDistance} pro Antrag geändert (zuvor ${formattedOldDistance})`,
+ disabled: 'Pendlerfahrten für Entfernungsvergütung ausschließen deaktiviert',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
`Erstattungmethode zu „${newReimbursementChoice}“ geändert (vorher „${oldReimbursementChoice}“)`,
@@ -9530,6 +9522,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 Entfernungskosten.',
+ },
},
odometer: {
startReading: 'Mit dem Lesen beginnen',
@@ -9546,6 +9542,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: ({formattedDistance}: {formattedDistance: string}) => `Original: ${formattedDistance}`,
+ removedCommuterDistance: ({distance, unit}: {distance: string; unit: string}) => `${distance} Pendel-${unit} entfernt`,
+ systemMessage: ({distance, unit, workspaceDistanceSettingsLink}: {distance: string; unit: string; workspaceDistanceSettingsLink: string}) =>
+ `${distance} Pendl${unit} basierend auf ${workspaceDistanceSettingsLink ? `Entfernungseinstellungen des Arbeitsbereichs` : 'Arbeitsbereich-Entfernungseinstellungen'} entfernt.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/el.ts b/src/languages/el.ts
index bc3ee09d41b0..8b4401813512 100644
--- a/src/languages/el.ts
+++ b/src/languages/el.ts
@@ -500,6 +500,7 @@ const translations: TranslationDeepObject = {
expand: 'Ανάπτυξη',
dialogOpened: 'διάλογος',
downloadReceipts: 'Λήψη αποδείξεων',
+ commuter: 'επιβάτης καθημερινών μετακινήσεων',
},
socials: {
podcast: 'Ακολουθήστε μας στο Podcast',
@@ -8794,20 +8795,11 @@ ${reportName}`,
addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `προστέθηκε το "${prohibitedExpense}" στις απαγορευμένες δαπάνες`,
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} ανά αίτημα`;
- },
- changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) =>
- `άλλαξε τον αποκλεισμό σταθερής απόστασης σε ${newDistance} ${unit} ανά απαίτηση (προηγουμένως ${oldDistance} ${unit})`,
- disabled: 'απενεργοποιήθηκε ο αποκλεισμός μετακινήσεων από/προς εργασία για χιλιομετρικές αποζημιώσεις',
+ changedToFixedDistance: 'άλλαξε την εξαίρεση μετακινήσεων σε σταθερή απόσταση ανά αίτημα',
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `ορίστε σταθερό αποκλεισμό απόστασης σε ${formattedDistance} ανά αίτημα`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `άλλαξε τον αποκλεισμό σταθερής απόστασης σε ${formattedNewDistance} ανά αίτημα (προηγουμένως ${formattedOldDistance})`,
+ disabled: 'απενεργοποιήθηκε ο αποκλεισμός μετακινήσεων από και προς την εργασία για χιλιομετρικές αποζημιώσεις',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
`άλλαξε τη μέθοδο αποζημίωσης σε «${newReimbursementChoice}» (προηγουμένως «${oldReimbursementChoice}»)`,
@@ -9748,6 +9740,10 @@ ${reportName}`,
},
error: {
selectSuggestedAddress: 'Παρακαλείστε να επιλέξετε μια προτεινόμενη διεύθυνση ή να χρησιμοποιήσετε την τρέχουσα τοποθεσία',
+ mapOrGpsDistanceRequired: {
+ title: 'Απαιτείται απόσταση από χάρτη ή GPS',
+ description: 'Αυτός ο χώρος εργασίας απαιτεί έξοδα απόστασης είτε με βάση χάρτη είτε με παρακολούθηση GPS.',
+ },
},
odometer: {
startReading: 'Ξεκινήστε την ανάγνωση',
@@ -9764,6 +9760,12 @@ ${reportName}`,
snapPhotoStart: 'Βγάλτε μια φωτογραφία το χιλιομετρητή σας στην αρχή του ταξιδιού σας.',
snapPhotoEnd: 'Βγάλτε μια φωτογραφία του χιλιομετρητή σας στο τέλος του ταξιδιού σας.',
},
+ commuterExclusion: {
+ original: ({formattedDistance}: {formattedDistance: string}) => `Αρχικό: ${formattedDistance}`,
+ removedCommuterDistance: ({distance, unit}: {distance: string; unit: string}) => `Αφαιρέθηκαν ${distance} διαδρομές εκτός έδρας (${unit})`,
+ systemMessage: ({distance, unit, workspaceDistanceSettingsLink}: {distance: string; unit: string; workspaceDistanceSettingsLink: string}) =>
+ `Αφαιρέθηκαν ${distance} διαδρομές μετακίνησης ${unit} βάσει του ${workspaceDistanceSettingsLink ? `ρυθμίσεις απόστασης χώρου εργασίας` : 'ρυθμίσεις απόστασης χώρου εργασίας'}.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 8eaeafdc11f9..fc28063cc197 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -330,6 +330,7 @@ const translations = {
miles: 'miles',
kilometer: 'kilometer',
kilometers: 'kilometers',
+ commuter: 'commuter',
recent: 'Recent',
all: 'All',
am: 'AM',
@@ -8709,18 +8710,9 @@ 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`;
- },
- changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) =>
- `changed fixed distance exclusion to ${newDistance} ${unit} per claim (previously ${oldDistance} ${unit})`,
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `set fixed distance exclusion to ${formattedDistance} per claim`,
+ 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) =>
@@ -9661,6 +9653,16 @@ const translations = {
},
error: {
selectSuggestedAddress: 'Please select a suggested address or use current location',
+ mapOrGpsDistanceRequired: {
+ title: 'Map or GPS distance required',
+ description: 'This workspace requires either map-based or GPS-tracked distance expenses.',
+ },
+ },
+ commuterExclusion: {
+ original: ({formattedDistance}: {formattedDistance: string}) => `Original: ${formattedDistance}`,
+ removedCommuterDistance: ({distance, unit}: {distance: string; unit: string}) => `Removed ${distance} commuter ${unit}`,
+ systemMessage: ({distance, unit, workspaceDistanceSettingsLink}: {distance: string; unit: string; workspaceDistanceSettingsLink: string}) =>
+ `Removed ${distance} commuter ${unit} based on ${workspaceDistanceSettingsLink ? `workspace distance settings` : 'workspace distance settings'}.`,
},
odometer: {
startReading: 'Start reading',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 12fa8294a544..f8a3cb4326e8 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -481,6 +481,7 @@ const translations: TranslationDeepObject = {
tryAgain: 'Inténtalo de nuevo',
tagGLCode: 'Etiquetar código GL',
off: 'Desactivado',
+ commuter: 'viajero diario',
noResultsFoundSubtitle: 'Sin resultados. Intenta ajustar tus filtros o la búsqueda.',
exportsTo: 'Exporta a',
},
@@ -8431,20 +8432,11 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`,
addedProhibitedExpense: ({prohibitedExpense}) => `añadió "${prohibitedExpense}" a los gastos prohibidos`,
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`;
- },
- 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',
+ changedToFixedDistance: 'cambió la exclusión de desplazamientos a una distancia fija por solicitud',
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `establecer exclusión de distancia fija en ${formattedDistance} por reclamación`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `cambió la exclusión de distancia fija a ${formattedNewDistance} por reclamación (previamente ${formattedOldDistance})`,
+ disabled: 'se deshabilitó la exclusión de los desplazamientos al trabajo para las tarifas por distancia',
},
updatedReimbursementChoice: (newReimbursementChoice, oldReimbursementChoice) =>
`cambió el método de reembolso a "${newReimbursementChoice}" (previamente "${oldReimbursementChoice}")`,
@@ -9812,6 +9804,10 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`,
},
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 con seguimiento por GPS.',
+ },
},
odometer: {
startReading: 'Lectura inicial',
@@ -9828,6 +9824,12 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`,
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: ({formattedDistance}: {formattedDistance: string}) => `Original: ${formattedDistance}`,
+ removedCommuterDistance: ({distance, unit}: {distance: string; unit: string}) => `Se eliminaron ${distance} ${unit} de viaje diario`,
+ systemMessage: ({distance, unit, workspaceDistanceSettingsLink}: {distance: string; unit: string; workspaceDistanceSettingsLink: string}) =>
+ `Se eliminaron ${distance} ${unit} de desplazamiento en base a ${workspaceDistanceSettingsLink ? `configuración de distancias del espacio de trabajo` : 'ajustes 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 af4f2f0ab3d9..5574b20ec55f 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -495,6 +495,7 @@ const translations: TranslationDeepObject = {
tryAgain: 'Réessayer',
tagGLCode: 'Taguer le code GL',
off: 'Désactivé',
+ commuter: 'navetteur',
noResultsFoundSubtitle: 'Aucun résultat. Veuillez essayer de modifier vos filtres ou votre requête de recherche',
unableToDisplayChart: 'Impossible d’afficher le graphique',
webGLNotSupported: 'Votre navigateur ne prend pas en charge WebGL. Veuillez l’activer ou changer de navigateur.',
@@ -8684,19 +8685,10 @@ 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`;
- },
- 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',
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `définir l’exclusion de distance fixe à ${formattedDistance} par demande`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `a modifié l'exclusion de distance fixe à ${formattedNewDistance} par demande (auparavant ${formattedOldDistance})`,
+ disabled: 'exclure les trajets domicile-travail pour les taux de distance désactivé',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
`a modifié le mode de remboursement en « ${newReimbursementChoice} » (auparavant « ${oldReimbursementChoice} »)`,
@@ -9563,6 +9555,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 de carte ou GPS requise',
+ description: 'Cet espace de travail requiert des dépenses kilométriques calculées soit sur la base d’une carte, soit suivies par GPS.',
+ },
},
odometer: {
startReading: 'Commencer la lecture',
@@ -9580,6 +9576,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: ({formattedDistance}: {formattedDistance: string}) => `Original : ${formattedDistance}`,
+ removedCommuterDistance: ({distance, unit}: {distance: string; unit: string}) => `${distance} unité(s) de trajet domicile-travail ${unit} supprimée(s)`,
+ systemMessage: ({distance, unit, workspaceDistanceSettingsLink}: {distance: string; unit: string; workspaceDistanceSettingsLink: string}) =>
+ `${distance} ${unit} de trajet domicile-travail supprimé(s) en fonction de ${workspaceDistanceSettingsLink ? `paramètres de distance de l’espace de travail` : 'paramètres de distance de l’espace de travail'}.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 25457e7ca966..f0d9c0332683 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -495,6 +495,7 @@ const translations: TranslationDeepObject = {
tryAgain: 'Riprova',
tagGLCode: 'Tag codice GL',
off: 'Disattivato',
+ commuter: 'pendolare',
noResultsFoundSubtitle: 'Nessun risultato. Prova a modificare i filtri o la ricerca',
unableToDisplayChart: 'Impossibile visualizzare il grafico',
webGLNotSupported: 'Il tuo browser non supporta WebGL. Abilitalo oppure passa a un altro browser.',
@@ -8620,20 +8621,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`,
addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `ha aggiunto "${prohibitedExpense}" alle spese vietate`,
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`;
- },
- 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',
+ changedToFixedDistance: 'ha modificato "escludi tragitti casa-lavoro" in "distanza fissa per richiesta"',
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `imposta un'esclusione fissa di distanza di ${formattedDistance} per richiesta`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `ha modificato l’esclusione a distanza fissa a ${formattedNewDistance} per richiesta (in precedenza ${formattedOldDistance})`,
+ disabled: 'esclusione tragitti casa-lavoro per tariffe chilometriche disattivata',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
`ha cambiato il metodo di rimborso in "${newReimbursementChoice}" (in precedenza "${oldReimbursementChoice}")`,
@@ -9502,6 +9494,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 richiesta',
+ description: 'Questo spazio di lavoro richiede spese chilometriche basate su mappa o tracciate tramite GPS.',
+ },
},
odometer: {
startReading: 'Inizia a leggere',
@@ -9518,6 +9514,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: ({formattedDistance}: {formattedDistance: string}) => `Originale: ${formattedDistance}`,
+ removedCommuterDistance: ({distance, unit}: {distance: string; unit: string}) => `Rimossi ${distance} spostamenti pendolari in ${unit}`,
+ systemMessage: ({distance, unit, workspaceDistanceSettingsLink}: {distance: string; unit: string; workspaceDistanceSettingsLink: string}) =>
+ `Rimossi ${distance} ${unit} per il pendolarismo in base a ${workspaceDistanceSettingsLink ? `impostazioni distanza spazio di lavoro` : 'impostazioni distanza spazio di lavoro'}.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index d6245c36bf46..d1589c38b8c7 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -494,6 +494,7 @@ const translations: TranslationDeepObject = {
tryAgain: '再試行',
tagGLCode: 'GL コードにタグを付ける',
off: 'オフ',
+ commuter: '通勤者',
noResultsFoundSubtitle: '結果がありません。フィルターや検索キーワードを調整してお試しください',
unableToDisplayChart: 'グラフを表示できません',
webGLNotSupported: 'お使いのブラウザは WebGL に対応していません。有効にするか、別のブラウザに切り替えてください。',
@@ -8514,20 +8515,11 @@ ${reportName}`,
addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `禁止経費に「${prohibitedExpense}」を追加しました`,
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}に設定します`;
- },
- changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) =>
- `1件あたりの固定距離除外を${oldDistance} ${unit}から${newDistance} ${unit}に変更しました`,
- disabled: '距離レートで通勤を除外する設定を無効にしました',
+ changedToFixedDistance: '通勤分を除外する設定を、申請ごとの固定距離に変更しました',
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `請求ごとに固定距離の除外を ${formattedDistance} に設定します`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `1件あたりの固定距離控除を${formattedNewDistance}に変更しました(以前は${formattedOldDistance})`,
+ disabled: '距離レートの通勤除外を無効にしました',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
`精算方法を「${newReimbursementChoice}」(以前は「${oldReimbursementChoice}」)に変更しました`,
@@ -9379,6 +9371,10 @@ ${reportName}`,
},
error: {
selectSuggestedAddress: '候補の住所を選択するか、現在地を使用してください',
+ mapOrGpsDistanceRequired: {
+ title: '地図またはGPSによる距離の入力が必要です',
+ description: 'このワークスペースでは、地図に基づく距離精算または GPS で追跡された距離精算のいずれかが必要です。',
+ },
},
odometer: {
startReading: '読み始める',
@@ -9395,6 +9391,12 @@ ${reportName}`,
snapPhotoStart: '移動を開始するときに、走行距離計の写真を撮影してください。',
snapPhotoEnd: '走行の終了時に、オドメーターの写真を撮影してください。',
},
+ commuterExclusion: {
+ original: ({formattedDistance}: {formattedDistance: string}) => `元の距離: ${formattedDistance}`,
+ removedCommuterDistance: ({distance, unit}: {distance: string; unit: string}) => `${distance} の通勤${unit}を削除しました`,
+ systemMessage: ({distance, unit, workspaceDistanceSettingsLink}: {distance: string; unit: string; workspaceDistanceSettingsLink: string}) =>
+ `${workspaceDistanceSettingsLink ? `ワークスペースの距離設定` : 'ワークスペース距離設定'} に基づき、通勤距離 ${distance} ${unit} を削除しました。`,
+ },
},
gps: {
error: {
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index efe6f501bc98..ece440956eb7 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -494,6 +494,7 @@ const translations: TranslationDeepObject = {
tryAgain: 'Probeer het opnieuw',
tagGLCode: 'GL-code labelen',
off: 'Uit',
+ commuter: 'forens',
noResultsFoundSubtitle: 'Geen resultaten. Probeer je filters of zoekopdracht aan te passen',
unableToDisplayChart: 'Grafiek kan niet worden weergegeven',
webGLNotSupported: 'Je browser ondersteunt WebGL niet. Schakel het in of gebruik een andere browser.',
@@ -8596,20 +8597,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`,
addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `heeft ‘${prohibitedExpense}’ toegevoegd aan verboden uitgaven`,
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`;
- },
- changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) =>
- `heeft de vaste afstandsuitsluiting gewijzigd naar ${newDistance} ${unit} per declaratie (voorheen ${oldDistance} ${unit})`,
- disabled: 'uitschakelen woon-werkverkeer uitsluiten voor afstandstarieven',
+ changedToFixedDistance: 'wijzigde woon-werkverkeer uitsluiten naar een vaste afstand per declaratie',
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `stel vaste afstandsuitsluiting in op ${formattedDistance} per declaratie`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `vaste afstandsuitsluiting gewijzigd naar ${formattedNewDistance} per declaratie (voorheen ${formattedOldDistance})`,
+ disabled: 'woon-werkverkeer uitsluiten voor afstandstarieven uitgeschakeld',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
`vergoedingsmethode gewijzigd naar ‘${newReimbursementChoice}’ (voorheen ‘${oldReimbursementChoice}’)`,
@@ -9474,6 +9466,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 van kaart of GPS vereist',
+ description: 'Deze workspace vereist afstandsdeclaraties die zijn gebaseerd op een kaart of via GPS worden bijgehouden.',
+ },
},
odometer: {
startReading: 'Begin met lezen',
@@ -9490,6 +9486,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: ({formattedDistance}: {formattedDistance: string}) => `Origineel: ${formattedDistance}`,
+ removedCommuterDistance: ({distance, unit}: {distance: string; unit: string}) => `${distance} woon-werk${unit} verwijderd`,
+ systemMessage: ({distance, unit, workspaceDistanceSettingsLink}: {distance: string; unit: string; workspaceDistanceSettingsLink: string}) =>
+ `${distance} woon-werk-${unit} verwijderd op basis van ${workspaceDistanceSettingsLink ? `werkruimte-afstandsinstellingen` : 'instelling voor werkruimte-afstand'}.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index a4f8fc43ba77..1a1f17bd817f 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -494,6 +494,7 @@ const translations: TranslationDeepObject = {
tryAgain: 'Spróbuj ponownie',
tagGLCode: 'Oznacz kod GL',
off: 'Wyłączone',
+ commuter: 'dojazdy do pracy',
noResultsFoundSubtitle: 'Brak wyników. Spróbuj zmienić filtry lub zapytanie wyszukiwania',
unableToDisplayChart: 'Nie można wyświetlić wykresu',
webGLNotSupported: 'Twoja przeglądarka nie obsługuje WebGL. Włącz ją albo zmień przeglądarkę.',
@@ -8574,20 +8575,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`,
addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `dodano „${prohibitedExpense}” do zabronionych wydatków`,
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})`,
- disabled: 'wyłączono wykluczanie dojazdów dla stawek za odległość',
+ changedToFixedDistance: 'zmieniono wykluczanie dojazdów na stałą odległość na zgłoszenie',
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `ustaw stałe wyłączenie dystansu na ${formattedDistance} na zgłoszenie`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `zmieniono stałe wykluczenie odległości na ${formattedNewDistance} na zgłoszenie (wcześniej ${formattedOldDistance})`,
+ disabled: 'wyłączono wykluczanie dojazdów dla stawek za przejechany dystans',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
`zmieniono metodę zwrotu kosztów na „${newReimbursementChoice}” (wcześniej „${oldReimbursementChoice}”)`,
@@ -9451,6 +9443,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: 'W tym obszarze roboczym wymagane są wydatki za przejazdy oparte na mapie lub śledzone za pomocą GPS.',
+ },
},
odometer: {
startReading: 'Zacznij czytać',
@@ -9467,6 +9463,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: ({formattedDistance}: {formattedDistance: string}) => `Oryginał: ${formattedDistance}`,
+ removedCommuterDistance: ({distance, unit}: {distance: string; unit: string}) => `Usunięto ${distance} dojazdów (${unit})`,
+ systemMessage: ({distance, unit, workspaceDistanceSettingsLink}: {distance: string; unit: string; workspaceDistanceSettingsLink: string}) =>
+ `Usunięto ${distance} dojazdowych ${unit} na podstawie ${workspaceDistanceSettingsLink ? `ustawienia odległości w przestrzeni roboczej` : 'ustawienia odległości w przestrzeni roboczej'}.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index b403375cdf44..f27324714fac 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -493,6 +493,7 @@ const translations: TranslationDeepObject = {
tryAgain: 'Tentar novamente',
tagGLCode: 'Marcar código GL',
off: 'Desligado',
+ commuter: 'usuário de transporte coletivo',
noResultsFoundSubtitle: 'Nenhum resultado. Tente ajustar seus filtros ou termo de pesquisa',
unableToDisplayChart: 'Não foi possível exibir o gráfico',
webGLNotSupported: 'Seu navegador não é compatível com WebGL. Ative-o ou mude de navegador.',
@@ -8588,20 +8589,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`,
addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `adicionou "${prohibitedExpense}" às despesas proibidas`,
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})`,
- disabled: 'excluiu deslocamentos diários para tarifas por distância',
+ changedToFixedDistance: 'alterou a exclusão de deslocamentos para uma distância fixa por solicitação',
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `definir exclusão de distância fixa para ${formattedDistance} por solicitação`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `alterou a exclusão de distância fixa para ${formattedNewDistance} por reembolso (antes ${formattedOldDistance})`,
+ disabled: 'desativado excluir trajetos de deslocamento para taxas por distância',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
`alterou o método de reembolso para "${newReimbursementChoice}" (antes "${oldReimbursementChoice}")`,
@@ -9467,6 +9459,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 espaço de trabalho exige despesas de distância baseadas em mapa ou rastreadas por GPS.',
+ },
},
odometer: {
startReading: 'Começar a ler',
@@ -9483,6 +9479,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: ({formattedDistance}: {formattedDistance: string}) => `Original: ${formattedDistance}`,
+ removedCommuterDistance: ({distance, unit}: {distance: string; unit: string}) => `Removeu ${distance} deslocamento ${unit}`,
+ systemMessage: ({distance, unit, workspaceDistanceSettingsLink}: {distance: string; unit: string; workspaceDistanceSettingsLink: string}) =>
+ `Removidos ${distance} ${unit} de deslocamento diário com base em ${workspaceDistanceSettingsLink ? `configurações de distância do workspace` : 'configurações de distância do workspace'}.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 9ffa58b108d1..9f6be9b3c3bc 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -490,6 +490,7 @@ const translations: TranslationDeepObject = {
tryAgain: '重试',
tagGLCode: '标记总账代码',
off: '关',
+ commuter: '通勤者',
noResultsFoundSubtitle: '无结果。请尝试调整筛选条件或搜索内容',
unableToDisplayChart: '无法显示图表',
webGLNotSupported: '您的浏览器不支持 WebGL。请启用该功能或更换浏览器。',
@@ -8297,20 +8298,11 @@ ${reportName}`,
addedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `已将“${prohibitedExpense}”添加到禁止报销的费用中`,
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}`;
- },
- changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) =>
- `已将固定距离排除调整为每笔报销 ${newDistance} ${unit}(之前为 ${oldDistance} ${unit})`,
- disabled: '已停用“排除通勤距离费率”',
+ changedToFixedDistance: '已将“排除通勤”更改为“每次报销按固定距离计算”',
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `将每次报销的固定距离排除设置为 ${formattedDistance}`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `已将固定距离免除调整为每笔报销 ${formattedNewDistance}(之前为 ${formattedOldDistance})`,
+ disabled: '已禁用按距离费率排除通勤',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => `将报销方式更改为“${newReimbursementChoice}”(原为“${oldReimbursementChoice}”)`,
setAutoJoin: ({enabled}: {enabled: boolean}) => `${enabled ? '已启用' : '已禁用'} 预先批准加入工作区的请求`,
@@ -9138,6 +9130,10 @@ ${reportName}`,
},
error: {
selectSuggestedAddress: '请选择一个推荐地址或使用当前位置',
+ mapOrGpsDistanceRequired: {
+ title: '需要提供地图或 GPS 距离',
+ description: '此工作区要求里程报销必须基于地图或通过 GPS 进行轨迹跟踪。',
+ },
},
odometer: {
startReading: '开始阅读',
@@ -9154,6 +9150,12 @@ ${reportName}`,
snapPhotoStart: '在行程开始时拍一张里程表照片。',
snapPhotoEnd: '在行程结束时拍一张里程表的照片。',
},
+ commuterExclusion: {
+ original: ({formattedDistance}: {formattedDistance: string}) => `原始:${formattedDistance}`,
+ removedCommuterDistance: ({distance, unit}: {distance: string; unit: string}) => `已移除 ${distance} ${unit} 通勤距离`,
+ systemMessage: ({distance, unit, workspaceDistanceSettingsLink}: {distance: string; unit: string; workspaceDistanceSettingsLink: string}) =>
+ `已根据 ${workspaceDistanceSettingsLink ? `工作区距离设置` : '工作区距离设置'} 移除 ${distance} ${unit} 的通勤距离。`,
+ },
},
gps: {
error: {
diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts
index 5d715ffccfec..24ccd46fc718 100644
--- a/src/libs/DebugUtils.ts
+++ b/src/libs/DebugUtils.ts
@@ -1096,6 +1096,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,
diff --git a/src/libs/DistanceDisplayUtils.ts b/src/libs/DistanceDisplayUtils.ts
new file mode 100644
index 000000000000..25c8d3956f5b
--- /dev/null
+++ b/src/libs/DistanceDisplayUtils.ts
@@ -0,0 +1,26 @@
+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): string {
+ const roundedDistance = distanceInUnits.toFixed(CONST.DISTANCE_DECIMAL_PLACES);
+ const unitLabel = useShortFormUnit ? unit : getDistanceUnitLabel(distanceInUnits, unit, translate);
+ return `${roundedDistance} ${unitLabel}`;
+}
+
+export {getDistanceUnitLabel, getFormattedDistanceInUnits};
diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts
index badd38c8ff20..9dcb62d0e705 100644
--- a/src/libs/DistanceRequestUtils.ts
+++ b/src/libs/DistanceRequestUtils.ts
@@ -6,6 +6,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';
@@ -13,10 +14,12 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {format, parseISO} from 'date-fns';
import DateUtils from './DateUtils';
+import {getDistanceUnitLabel, getFormattedDistanceInUnits} from './DistanceDisplayUtils';
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';
+import {getUnitTranslationKey} from './WorkspacesSettingsUtils';
type MileageRate = {
customUnitRateID?: string;
@@ -30,6 +33,8 @@ type MileageRate = {
endDate?: string | null;
};
+type CommuterExclusionData = Required>;
+
const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters
const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter
const DEFAULT_P2P_RATE_CENTS_PER_MILE = 67;
@@ -207,35 +212,30 @@ function getRateForExpenseDisplay(
* @param rate Expensable amount allowed per unit
* @param translate Translate function
* @param useShortFormUnit If true, the unit will be returned in short form (e.g., "mi", "km").
+ * @param isZeroDistanceAllowed If true, a zero distance is formatted instead of returning an empty string.
+ * @param commuterExclusionData When provided, the reimbursable distance (after the commuter exclusion) is displayed instead of the full route distance.
* @returns A string that describes the distance traveled
*/
function getDistanceForDisplay(
hasRoute: boolean,
distanceInMeters: number,
unit: Unit | undefined,
- rate: number | undefined,
translate: LocaleContextProps['translate'],
useShortFormUnit?: boolean,
isZeroDistanceAllowed?: boolean,
+ commuterExclusionData?: CommuterExclusionData | null,
): string {
- if (!hasRoute || !unit) {
+ const displayUnit = unit ?? commuterExclusionData?.distanceUnit;
+ if (!hasRoute || !displayUnit) {
return translate('iou.fieldPending');
}
- if (!distanceInMeters && !isZeroDistanceAllowed) {
+ const distanceToDisplayInMeters = commuterExclusionData ? convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, displayUnit) : distanceInMeters;
+ if (!distanceToDisplayInMeters && !isZeroDistanceAllowed && !commuterExclusionData) {
return '';
}
- const distanceInUnits = getRoundedDistanceInUnits(distanceInMeters, unit);
- if (useShortFormUnit) {
- return `${distanceInUnits} ${unit}`;
- }
-
- 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 = Number(distanceInUnits) === 1 ? singularDistanceUnit : distanceUnit;
-
- return `${distanceInUnits} ${unitString}`;
+ return getFormattedDistanceInUnits(convertDistanceUnit(distanceToDisplayInMeters, displayUnit), displayUnit, translate, useShortFormUnit);
}
function getDistanceForDisplayLabel(distanceInMeters: number, unit: Unit): string {
@@ -251,6 +251,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(
@@ -263,16 +264,17 @@ function getDistanceMerchant(
toLocaleDigit: LocaleContextProps['toLocaleDigit'],
getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'],
isZeroDistanceAllowed?: boolean,
+ commuterExclusionData?: CommuterExclusionData | null,
): string {
if (!hasRoute || !rate) {
return translate('iou.fieldPending');
}
- if (!distanceInMeters && !isZeroDistanceAllowed) {
+ if (!distanceInMeters && !isZeroDistanceAllowed && !commuterExclusionData) {
return '';
}
- const distanceInUnits = getDistanceForDisplay(hasRoute, distanceInMeters, unit, rate, translate, true, isZeroDistanceAllowed);
+ 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}`;
@@ -321,6 +323,149 @@ function getDistanceRequestAmount(distance: number, unit: Unit, rate: number): n
return roundDistanceAmount(convertDistanceUnit(distance, unit), rate);
}
+function getCommuterExclusionDisplayData(customUnit: TransactionCustomUnit | undefined, distanceUnit: Unit): CommuterExclusionData | null {
+ const commuterExclusion = customUnit?.commuterExclusion;
+ if (typeof commuterExclusion !== 'number' || commuterExclusion <= 0) {
+ return null;
+ }
+
+ return {
+ commuterExclusion,
+ reimbursableDistance: typeof customUnit?.reimbursableDistance === 'number' ? customUnit.reimbursableDistance : Math.max(0, (customUnit?.quantity ?? 0) - commuterExclusion),
+ distanceUnit: customUnit?.distanceUnit ?? distanceUnit,
+ };
+}
+
+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;
+}): (Pick & {modifiedAmount: number; customUnit: TransactionCustomUnit}) | 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;
+ }
+
+ 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;
+ 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;
+ }
+
+ 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 (fixedDistanceInRequestUnit <= 0) {
+ return;
+ }
+
+ 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 {
+ 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,
+ },
+ };
+}
+
+/**
+ * Returns the description label and hint text for a distance field, incorporating commuter exclusion details when present.
+ *
+ * @param commuterExclusionData - The commuter exclusion breakdown, or null/undefined when not applicable
+ * @param distanceUnit - The unit to use for display formatting (falls back to data's unit or miles)
+ * @param translate - Translate function
+ */
+function getDistanceDisplayDetailsWithCommuter(
+ commuterExclusionData: CommuterExclusionData | null | undefined,
+ distanceUnit: Unit | undefined,
+ translate: LocaleContextProps['translate'],
+): {distanceToDisplayDescription: string; distanceToDisplayHintText: string | undefined} {
+ const baseLabel = translate('common.distance');
+
+ if (!commuterExclusionData) {
+ return {
+ distanceToDisplayDescription: baseLabel,
+ distanceToDisplayHintText: undefined,
+ };
+ }
+
+ const unitToUse = distanceUnit ?? commuterExclusionData.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES;
+ const {commuterExclusion, reimbursableDistance} = commuterExclusionData;
+ const originalDistance = reimbursableDistance + commuterExclusion;
+ const originalDistanceFormatted = getFormattedDistanceInUnits(originalDistance, unitToUse, translate, true);
+ const commuterDistance = commuterExclusion.toFixed(CONST.DISTANCE_DECIMAL_PLACES);
+ const commuterUnit = translate(getUnitTranslationKey(unitToUse));
+
+ return {
+ distanceToDisplayDescription: `${baseLabel} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original', {formattedDistance: originalDistanceFormatted})}`,
+ distanceToDisplayHintText: translate('distance.commuterExclusion.removedCommuterDistance', {distance: commuterDistance, unit: commuterUnit}),
+ };
+}
+
/**
* Converts the distance from kilometers or miles to meters.
*
@@ -702,15 +847,20 @@ export default {
getDefaultMileageRate,
getDistanceMerchant,
getDistanceRequestAmount,
+ getCommuterExclusionDisplayData,
+ getTransactionCommuterExclusionData,
+ getDistanceDisplayDetailsWithCommuter,
getFormattedRateValue,
getMileageRates,
getDistanceForDisplay,
+ getFormattedDistanceInUnits,
getRoundedDistanceInUnits,
getRateForP2P,
getCustomUnitRateID,
convertToDistanceInMeters,
getTaxableAmount,
getDistanceUnit,
+ getDistanceUnitLabel,
getUpdatedDistanceUnit,
getRate,
getRateByCustomUnitRateID,
diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts
index 32a2a629b03a..0e429ce02bd7 100644
--- a/src/libs/MoneyRequestUtils.ts
+++ b/src/libs/MoneyRequestUtils.ts
@@ -6,6 +6,7 @@ import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import {convertToBackendAmount, convertToFrontendAmountAsInteger} from './CurrencyUtils';
+import replaceAllDigits from './replaceAllDigits';
import {isInvoiceReport, isIOUReport} from './ReportUtils';
import StringUtils from './StringUtils';
import {doesMoneyRequestDraftHaveUserInput, haveWaypointAddressesChanged, isExpenseUnreported} from './TransactionUtils';
@@ -93,23 +94,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/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts
index 04f258d96780..ee5f7bd8ef03 100644
--- a/src/libs/PolicyDistanceRatesUtils.ts
+++ b/src/libs/PolicyDistanceRatesUtils.ts
@@ -3,6 +3,7 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {Policy} from '@src/types/onyx';
import type {CustomUnit, Rate, RateAttributes} from '@src/types/onyx/Policy';
import type {OnyxData} from '@src/types/onyx/Request';
@@ -203,4 +204,16 @@ function isGovernmentRateUnmodified(rate: Rate): boolean {
return isRateAmountMatching && (rate.startDate ?? undefined) === governmentRate.startDate && (rate.endDate ?? undefined) === governmentRate.endDate;
}
-export {validateRateValue, validateTaxClaimableValue, validateCreateDistanceRateForm, buildOnyxDataForPolicyDistanceRateUpdates, getRateStatus, isGovernmentRateUnmodified};
+function isCommuterExclusionEnabled(policy: Policy | null | undefined): policy is Policy & {id: string; commuterExclusions: NonNullable} {
+ return !!policy?.id && !!policy.commuterExclusions;
+}
+
+export {
+ validateRateValue,
+ validateTaxClaimableValue,
+ validateCreateDistanceRateForm,
+ buildOnyxDataForPolicyDistanceRateUpdates,
+ getRateStatus,
+ isCommuterExclusionEnabled,
+ isGovernmentRateUnmodified,
+};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 1075959c211c..34b463086e80 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -57,6 +57,7 @@ import {getBankName, isCardPendingActivate} from './CardUtils';
import {getDecodedCategoryName} from './CategoryUtils';
import {convertAmountToDisplayString, convertToBackendAmount, convertToDisplayStringWithExplicitCurrency, convertToShortDisplayString} from './CurrencyUtils';
import DateUtils from './DateUtils';
+import {getFormattedDistanceInUnits} from './DistanceDisplayUtils';
import {getEnvironmentURL, getOldDotEnvironmentURL} from './Environment/Environment';
import getBase62ReportID from './getBase62ReportID';
import {isReportMessageAttachment} from './isReportMessageAttachment';
@@ -72,7 +73,7 @@ import {arePersonalDetailsMissing, getEffectiveDisplayName, getPersonalDetailByE
import stripFollowupListFromHtml from './ReportActionFollowupUtils/stripFollowupListFromHtml';
import StringUtils from './StringUtils';
import {getReportFieldTypeTranslationKey} from './WorkspaceReportFieldUtils';
-import {getWorkspaceAddressStreetLines} from './WorkspacesSettingsUtils';
+import {getUnitTranslationKey, getWorkspaceAddressStreetLines} from './WorkspacesSettingsUtils';
type LastVisibleMessage = {
lastMessageText: string;
@@ -4283,26 +4284,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) ?? {};
- 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 = getFormattedDistanceInUnits(Number(newValue ?? 0), distanceUnit, translate);
- if (originalMessage?.oldValue == null) {
- return translate('workspaceActions.commuterExclusions.setFixedDistance', {distance: newValue, unit});
+ if (oldValue == null) {
+ 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 = getFormattedDistanceInUnits(Number(oldValue ?? 0), distanceUnit, translate);
+ return translate('workspaceActions.commuterExclusions.changedFixedDistance', {formattedOldDistance, formattedNewDistance: formattedDistance});
}
if (updatedField === CONST.POLICY.COMMUTER_EXCLUSION_TYPE.DISABLED) {
@@ -4774,6 +4771,26 @@ function getPlaidBalanceFailureMessage(translate: LocalizedTranslate, action: On
});
}
+function getCommuterExclusionMessage(translate: LocalizedTranslate, action: OnyxEntry, policyID?: string): string {
+ 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 commuterDistance = distanceValue.toFixed(CONST.DISTANCE_DECIMAL_PLACES);
+ const commuterUnit = translate(getUnitTranslationKey(unitValue));
+
+ const workspaceDistanceSettingsLink = policyID ? `${environmentURL}/${ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID)}` : '';
+
+ return translate('distance.commuterExclusion.systemMessage', {
+ distance: commuterDistance,
+ unit: commuterUnit,
+ workspaceDistanceSettingsLink,
+ });
+}
+
function getManagerOnVacation(action: OnyxEntry): string | undefined {
if (!isApprovedAction(action)) {
return;
@@ -5062,6 +5079,7 @@ export {
isReopenedAction,
isRetractedAction,
getIntegrationSyncFailedMessage,
+ getCommuterExclusionMessage,
getCompanyCardConnectionBrokenMessage,
getPlaidBalanceFailureMessage,
getPolicyChangeLogDefaultReimbursableMessage,
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index 3ae18a965a8f..6515ec97af2e 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -99,6 +99,7 @@ import getDistanceInMeters from './getDistanceInMeters';
type TransactionParams = {
amount: number;
modifiedAmount?: number;
+ modifiedMerchant?: string;
currency: string;
reportID: string | undefined;
comment?: string;
@@ -186,6 +187,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 +
@@ -332,6 +337,7 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T
const {
amount,
modifiedAmount,
+ modifiedMerchant,
currency,
reportID,
distance,
@@ -418,11 +424,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);
+ }
}
}
@@ -462,6 +473,7 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T
taxAmount,
taxValue,
modifiedAmount,
+ modifiedMerchant,
billable,
reimbursable,
inserted: DateUtils.getDBTime(),
@@ -774,7 +786,23 @@ function getUpdatedTransaction({
const {unit, rate} = updatedMileageRate;
const distanceInMeters = getDistanceInMeters(updatedTransaction, unit);
- const amount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0);
+
+ // The commuter exclusion is expressed in distance units, so it has to be re-derived against the new
+ // rate's unit and the converted quantity. The amount and merchant then describe the reimbursable
+ // distance, matching what the backend stores for modifiedAmount/modifiedMerchant.
+ const commuterExclusionTransactionData = hasAppliedCommuterExclusion(updatedTransaction)
+ ? DistanceRequestUtils.getTransactionCommuterExclusionData({
+ transaction: updatedTransaction,
+ policy,
+ personalPolicyOutputCurrency,
+ })
+ : undefined;
+
+ if (commuterExclusionTransactionData) {
+ lodashSet(updatedTransaction, 'comment.customUnit', commuterExclusionTransactionData.customUnit);
+ }
+
+ const amount = commuterExclusionTransactionData?.modifiedAmount ?? DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0);
const updatedAmount = isFromExpenseReport || isUnReportedExpense ? -amount : amount;
const updatedCurrency = updatedMileageRate.currency ?? CONST.CURRENCY.USD;
const updatedMerchant = DistanceRequestUtils.getDistanceMerchant(
@@ -787,6 +815,7 @@ function getUpdatedTransaction({
(digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit),
getCurrencySymbol,
isManualDistanceRequest(transaction),
+ DistanceRequestUtils.getCommuterExclusionDisplayData(commuterExclusionTransactionData?.customUnit, unit),
);
updatedTransaction.amount = updatedAmount;
@@ -870,7 +899,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(
@@ -883,6 +924,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
@@ -3266,6 +3308,7 @@ export {
isGPSDistanceRequest,
isManualDistanceRequest,
isOdometerDistanceRequest,
+ hasAppliedCommuterExclusion,
isDistanceExpenseType,
isFetchingWaypointsFromServer,
hasLocallyKnownDistance,
diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts
index 4c3f592490ab..bffe674960b6 100644
--- a/src/libs/actions/IOU/MoneyRequest.ts
+++ b/src/libs/actions/IOU/MoneyRequest.ts
@@ -1,4 +1,4 @@
-import type {LocalizedTranslate} from '@components/LocaleContextProvider';
+import type {LocalizedTranslate, LocaleContextProps} from '@components/LocaleContextProvider';
import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList';
@@ -103,6 +103,19 @@ type CreateTransactionParams = {
delegateAccountID: number | 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,
@@ -778,6 +791,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.
@@ -1025,6 +1093,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 1e4f24ddaa8a..6bf908bdcce5 100644
--- a/src/libs/actions/IOU/MoneyRequestBuilder.ts
+++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts
@@ -43,6 +43,7 @@ import {
buildOptimisticTransaction,
getAmount,
getCurrency,
+ hasAppliedCommuterExclusion,
hasSubmissionBlockingViolationInReport,
isDistanceRequest as isDistanceRequestTransactionUtils,
isManualDistanceRequest as isManualDistanceRequestTransactionUtils,
@@ -1284,6 +1285,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma
amount,
distance,
modifiedAmount,
+ modifiedMerchant,
comment = '',
currency,
source = '',
@@ -1385,23 +1387,27 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma
let didUpdateOptimisticTotal = false;
+ 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.
@@ -1415,18 +1421,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;
}
didUpdateOptimisticTotal = true;
}
@@ -1435,15 +1441,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
@@ -1463,6 +1469,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma
amount: shouldNegateAmount ? -amount : amount,
distance,
...(modifiedAmount !== undefined && {modifiedAmount: shouldNegateAmount ? -modifiedAmount : modifiedAmount}),
+ ...(modifiedMerchant !== undefined && {modifiedMerchant}),
currency,
reportID: transactionReportID,
comment,
@@ -1551,6 +1558,9 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma
if (isSplitExpense && existingTransaction) {
const {convertedAmount: originalConvertedAmount, ...existingTransactionWithoutConvertedAmount} = existingTransaction;
optimisticTransaction = fastMerge(existingTransactionWithoutConvertedAmount, optimisticTransaction, false);
+ if (customUnit && optimisticTransaction.comment) {
+ optimisticTransaction.comment.customUnit = customUnit;
+ }
// Calculate proportional convertedAmount for the split based on the original conversion rate
@@ -1578,7 +1588,10 @@ 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;
+ let iouActionAmount = shouldUseCommuterModifiedValues ? Math.abs(reportAmount) : amount;
+ if (isSplitExpense && modifiedAmount !== undefined) {
+ iouActionAmount = Math.abs(modifiedAmount);
+ }
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 efef5ad1d75d..bba490186c3a 100644
--- a/src/libs/actions/IOU/Split.ts
+++ b/src/libs/actions/IOU/Split.ts
@@ -2008,6 +2008,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest
taxValue,
merchant,
modifiedAmount,
+ modifiedMerchant,
billable,
reimbursable,
validWaypoints,
@@ -2147,6 +2148,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest
},
transactionParams: {
amount,
+ modifiedAmount,
+ modifiedMerchant,
distance: modifiedDistance ?? distance,
currency,
comment,
diff --git a/src/libs/actions/IOU/SplitExpenseItems.ts b/src/libs/actions/IOU/SplitExpenseItems.ts
index 050bd866d86f..b585aca5c865 100644
--- a/src/libs/actions/IOU/SplitExpenseItems.ts
+++ b/src/libs/actions/IOU/SplitExpenseItems.ts
@@ -193,6 +193,13 @@ function initSplitExpenseItemData(
}: {amount?: number; transactionID?: string; reportID?: string; created?: string; merchant?: string; customUnit?: TransactionCustomUnit; isManuallyEdited?: boolean} = {},
): SplitExpense {
const transactionDetails = getTransactionDetails(transaction);
+ const sourceCustomUnit = customUnit ?? transaction?.comment?.customUnit;
+ const splitCustomUnit = sourceCustomUnit ? {...sourceCustomUnit} : undefined;
+ if (splitCustomUnit) {
+ delete splitCustomUnit.commuterExclusion;
+ delete splitCustomUnit.reimbursableDistance;
+ delete splitCustomUnit.commuterExclusionMethod;
+ }
return {
transactionID: transactionID ?? transactionDetails?.transactionID ?? String(CONST.DEFAULT_NUMBER_ID),
@@ -206,7 +213,7 @@ function initSplitExpenseItemData(
reportID: reportID ?? transaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID),
reimbursable: transactionDetails?.reimbursable,
billable: transactionDetails?.billable,
- customUnit: customUnit ?? transaction?.comment?.customUnit ?? undefined,
+ customUnit: splitCustomUnit,
waypoints: transaction?.comment?.waypoints ?? undefined,
odometerStart: transaction?.comment?.odometerStart ?? undefined,
odometerEnd: transaction?.comment?.odometerEnd ?? undefined,
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/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts
index d36b5df82d5b..6224389366f7 100644
--- a/src/libs/actions/Report/index.ts
+++ b/src/libs/actions/Report/index.ts
@@ -87,6 +87,7 @@ import {prunePagesToNewestWindow} from '@libs/PaginationUtils';
import Parser from '@libs/Parser';
import {getParsedMessageWithShortMentions} from '@libs/ParsingUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import {isCommuterExclusionEnabled} from '@libs/PolicyDistanceRatesUtils';
import {
getDefaultApprover,
getMemberAccountIDsForWorkspace,
@@ -169,7 +170,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';
@@ -7917,6 +7928,13 @@ function buildOptimisticChangePolicyData({
return {optimisticData, successData, failureData, optimisticReportPreviewAction, optimisticMovedReportAction};
}
+function shouldBlockChangeReportPolicyForCommuterExclusion(reportTransactions: Transaction[], policy: Policy): boolean {
+ const hasTargetPolicyCommuterExclusions = isCommuterExclusionEnabled(policy);
+ return reportTransactions.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.
*/
@@ -7934,6 +7952,7 @@ function changeReportPolicy({
isReportLastVisibleArchived = false,
reportPreviewAction,
isTrackIntentUser,
+ reportTransactions,
}: {
report: Report;
parentReport: OnyxEntry;
@@ -7948,8 +7967,9 @@ function changeReportPolicy({
isReportLastVisibleArchived?: boolean;
reportPreviewAction: OnyxEntry;
isTrackIntentUser: boolean | undefined;
+ reportTransactions: Transaction[];
}) {
- if (!report || !policy || report.policyID === policy.id || !isExpenseReport(report)) {
+ if (!report || !policy || report.policyID === policy.id || !isExpenseReport(report) || shouldBlockChangeReportPolicyForCommuterExclusion(reportTransactions, policy)) {
return;
}
@@ -7999,6 +8019,7 @@ function changeReportPolicyAndInviteSubmitter({
reportActionsList,
reportPreviewAction,
isTrackIntentUser,
+ reportTransactions,
}: {
report: Report;
parentReport: OnyxEntry;
@@ -8014,8 +8035,17 @@ function changeReportPolicyAndInviteSubmitter({
reportActionsList: OnyxCollection;
reportPreviewAction: OnyxEntry;
isTrackIntentUser: boolean | undefined;
+ reportTransactions: Transaction[];
}) {
- 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(reportTransactions, policy)
+ ) {
return;
}
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;
diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx
index 2679e07f05b2..957f5acf0593 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';
@@ -37,6 +38,7 @@ import {
isWorkspaceEligibleForReportChange,
} from '@libs/ReportUtils';
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
+import {hasAppliedCommuterExclusion, isManualDistanceRequest, isOdometerDistanceRequest} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -98,6 +100,13 @@ 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 blockManualOrOdometerDistanceRequestIfNeeded = useCommuterExclusionGuard({
+ isManualDistanceRequest: hasManualDistanceRequest,
+ isOdometerDistanceRequest: hasOdometerDistanceRequest,
+ });
const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector});
const selectPolicy = (policyID?: string) => {
@@ -105,6 +114,9 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace
if (!policyID || !policy) {
return;
}
+ if (blockManualOrOdometerDistanceRequestIfNeeded(policyID)) {
+ return;
+ }
if (shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriods, amountOwed, currentUserPersonalDetails.accountID)) {
Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id));
return;
@@ -151,6 +163,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace
reportActionsList: filteredReportActions,
reportPreviewAction,
isTrackIntentUser,
+ reportTransactions,
});
return;
}
@@ -169,6 +182,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace
isReportLastVisibleArchived,
reportPreviewAction,
isTrackIntentUser,
+ reportTransactions,
});
};
@@ -196,7 +210,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace
headerMessage: shouldShowNoResultsFoundMessage ? translate('common.noResultsFound') : '',
};
- if (!isMoneyRequestReport(report) || isMoneyRequestReportPendingDeletion(report)) {
+ if (!isMoneyRequestReport(report) || isMoneyRequestReportPendingDeletion(report) || hasCommuterExclusionDistanceRequest) {
return ;
}
diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx
index b1ee345f63d6..491f1865e841 100644
--- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx
+++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx
@@ -16,6 +16,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {
getChangedApproverActionMessage,
+ getCommuterExclusionMessage,
getCompanyCardConnectionBrokenMessage,
getForwardedReportActionMessage,
getIOUReportIDFromReportActionPreview,
@@ -477,6 +478,13 @@ function ActionContentRouter({
);
}
+ if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.COMMUTER_EXCLUSION)) {
+ return (
+
+ ${getCommuterExclusionMessage(translate, action, report?.policyID)}`} />
+
+ );
+ }
if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.CREATED) && isHarvestCreatedExpenseReport) {
return ;
}
diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/SplitExpenseEditPage.tsx
index b7405e478b05..8533ee387ba2 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);
diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx
index 724cb095b3ac..fc5bc344dfc5 100644
--- a/src/pages/iou/request/DistanceRequestStartPage.tsx
+++ b/src/pages/iou/request/DistanceRequestStartPage.tsx
@@ -3,6 +3,8 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import TabSelector from '@components/TabSelector/TabSelector';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
+import useDefaultParticipants from '@hooks/useDefaultParticipants';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import usePolicyForTransaction from '@hooks/usePolicyForTransaction';
@@ -13,7 +15,9 @@ 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 {getPayeeName} from '@libs/ReportUtils';
+import {isCommuterExclusionEnabled} from '@libs/PolicyDistanceRatesUtils';
+import {getActivePolicies, isGroupPolicy} from '@libs/PolicyUtils';
+import {getPayeeName, isPolicyExpenseChat} from '@libs/ReportUtils';
import {endSpan} from '@libs/telemetry/activeSpans';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
@@ -54,8 +58,20 @@ function DistanceRequestStartPage({
const {policy} = usePolicyForTransaction({transaction, reportPolicyID: report?.policyID, action, iouType});
const [selectedTab, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.DISTANCE_REQUEST_TYPE}`);
const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE);
+ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const currentUserPersonalDetails = useCurrentUserPersonalDetails();
+ const {participants} = useDefaultParticipants({sourceReport: report, transaction, iouType});
const isLoadingSelectedTab = isLoadingOnyxValue(selectedTabResult);
const isTrackDistanceExpense = iouType === CONST.IOU.TYPE.TRACK;
+ const activeGroupPolicies = getActivePolicies(policies ?? null, currentUserPersonalDetails.login).filter(isGroupPolicy);
+ const onlyActivePolicy = activeGroupPolicies.length === 1 ? activeGroupPolicies.at(0) : undefined;
+ const targetParticipant = participants.find((participant) => participant.isPolicyExpenseChat);
+ const isOnlyWorkspaceTheTarget = onlyActivePolicy?.id === targetParticipant?.policyID;
+ let targetPolicy = isOnlyWorkspaceTheTarget ? onlyActivePolicy : undefined;
+ if (isPolicyExpenseChat(report)) {
+ targetPolicy = policy;
+ }
+ const shouldHideManualAndOdometerTabs = isCommuterExclusionEnabled(targetPolicy);
const tabTitles = {
[CONST.IOU.TYPE.REQUEST]: translate('iou.trackDistance'),
@@ -149,16 +165,18 @@ function DistanceRequestStartPage({
)}
-
- {() => (
-
-
-
- )}
-
+ {!shouldHideManualAndOdometerTabs && (
+
+ {() => (
+
+
+
+ )}
+
+ )}
{() => (
@@ -169,16 +187,18 @@ function DistanceRequestStartPage({
)}
-
- {() => (
-
-
-
- )}
-
+ {!shouldHideManualAndOdometerTabs && (
+
+ {() => (
+
+
+
+ )}
+
+ )}
diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx
index f39dc906e065..98a934bc20f3 100644
--- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx
+++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx
@@ -60,6 +60,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;
};
@@ -84,6 +90,7 @@ function MoneyRequestParticipantsSelector({
shouldMoveSelectedToTop = false,
onRestrictedParticipantSelected,
onCloseParticipantPicker,
+ shouldBlockParticipantSelection,
ref,
}: MoneyRequestParticipantsSelectorProps) {
const styles = useThemeStyles();
@@ -125,6 +132,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 0174e642e8dd..5b0592036aef 100644
--- a/src/pages/iou/request/ParticipantSearchResults.tsx
+++ b/src/pages/iou/request/ParticipantSearchResults.tsx
@@ -32,7 +32,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';
@@ -120,6 +120,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({
@@ -141,6 +147,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;
@@ -472,15 +479,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 bd2e0eed67f4..a02db22301e2 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 {useCurrencyListActions} from '@hooks/useCurrencyList';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
@@ -262,6 +263,11 @@ function IOURequestStepConfirmation({
const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction);
const isManualRequest = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL;
const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction);
+ const blockManualOrOdometerDistanceRequestIfNeeded = 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);
@@ -385,6 +391,10 @@ function IOURequestStepConfirmation({
return;
}
const selectedParticipant = participantsList.at(0);
+ const selectedPolicyID = selectedParticipant?.policyID ?? (selectedParticipant?.reportID ? getReportOrDraftReport(selectedParticipant.reportID)?.policyID : undefined);
+ if (blockManualOrOdometerDistanceRequestIfNeeded(selectedPolicyID)) {
+ 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
@@ -481,6 +491,7 @@ function IOURequestStepConfirmation({
lastSelectedDistanceRates,
transaction,
personalPolicy?.outputCurrency,
+ blockManualOrOdometerDistanceRequestIfNeeded,
mappedPolicies,
getCurrencyDecimals,
policyID,
@@ -1018,6 +1029,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={blockManualOrOdometerDistanceRequestIfNeeded}
/>
)}
diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts
index 869267eabbd1..273545b6d434 100644
--- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts
+++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts
@@ -23,7 +23,7 @@ import {roundToTwoDecimalPlaces} from '@libs/NumberUtils';
import {getPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils';
import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy';
import {cancelSpan} from '@libs/telemetry/activeSpans';
-import {getDefaultTaxCode, getDistanceRequestType, getIsFromGlobalCreate, getValidWaypoints} from '@libs/TransactionUtils';
+import {getDefaultTaxCode, getDistanceRequestType, getIsFromGlobalCreate, getValidWaypoints, hasAppliedCommuterExclusion} from '@libs/TransactionUtils';
import {setTransactionReport} from '@userActions/Transaction';
@@ -286,6 +286,7 @@ function handleMoneyRequestStepDistanceNavigation({
const distanceDefaultTaxCode = getDefaultTaxCode(policy, transaction);
const distanceTaxCode = (transaction?.taxCode ? transaction.taxCode : distanceDefaultTaxCode) ?? '';
const distanceTaxAmount = transaction?.taxAmount ?? 0;
+ const shouldIncludeCommuterExclusionOverrides = hasAppliedCommuterExclusion(transaction);
if (isCreatingTrackExpense && participant) {
submitWithDismissFirst({
@@ -383,6 +384,8 @@ function handleMoneyRequestStepDistanceNavigation({
existingTransaction: transaction,
transactionParams: {
amount,
+ ...(shouldIncludeCommuterExclusionOverrides && typeof transaction?.modifiedAmount === 'number' && {modifiedAmount: transaction.modifiedAmount}),
+ ...(shouldIncludeCommuterExclusionOverrides && transaction?.modifiedMerchant && {modifiedMerchant: transaction.modifiedMerchant}),
distance,
modifiedDistance: gpsModifiedDistance,
comment: '',
diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx
index 28ab8a8bd547..7a859dcc4fca 100644
--- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx
+++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx
@@ -5,6 +5,7 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
+import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy';
import useDelegateAccountID from '@hooks/useDelegateAccountID';
@@ -145,6 +146,11 @@ function IOURequestStepDistanceManual({
ownerBillingGracePeriodEnd,
currentUserAccountIDParam,
);
+ const shouldAutoReportToDefaultWorkspace = shouldUseDefaultExpensePolicy && (!!defaultExpensePolicy?.autoReporting || !!personalPolicy?.autoReporting);
+ const blockManualOrOdometerDistanceRequestIfNeeded = useCommuterExclusionGuard({
+ policyID: report?.policyID ?? (shouldAutoReportToDefaultWorkspace ? defaultExpensePolicy?.id : undefined),
+ isManualDistanceRequest: true,
+ });
// to make sure the correct distance amount and unit will be shown we use distance unit
// from defaultExpensePolicy or current report's policy instead of from transaction and
@@ -318,6 +324,10 @@ function IOURequestStepDistanceManual({
};
const submitAndNavigateToNextPage = () => {
+ if (blockManualOrOdometerDistanceRequestIfNeeded()) {
+ 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 6a0a60695fb6..6ee200a51521 100644
--- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx
+++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx
@@ -9,6 +9,7 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
+import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy';
import useDelegateAccountID from '@hooks/useDelegateAccountID';
@@ -158,6 +159,11 @@ function IOURequestStepDistanceOdometer({
() => shouldUseDefaultExpensePolicyUtil(iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, currentUserAccountIDParam),
[iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, currentUserAccountIDParam],
);
+ const shouldAutoReportToDefaultWorkspace = shouldUseDefaultExpensePolicy && (!!defaultExpensePolicy?.autoReporting || !!personalPolicy?.autoReporting);
+ const blockManualOrOdometerDistanceRequestIfNeeded = useCommuterExclusionGuard({
+ policyID: report?.policyID ?? (shouldAutoReportToDefaultWorkspace ? defaultExpensePolicy?.id : undefined),
+ isOdometerDistanceRequest: true,
+ });
const mileageRate = DistanceRequestUtils.getRate({
transaction: currentTransaction,
@@ -478,6 +484,10 @@ function IOURequestStepDistanceOdometer({
// Handle form submission with validation
const handleNext = () => {
+ if (blockManualOrOdometerDistanceRequestIfNeeded()) {
+ return;
+ }
+
// Validation: Start and end readings must not be empty
if (!startReading || !endReading) {
setFormError(translate('iou.error.invalidReadings'));
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
index e299e3f78e5e..89ad860f87e2 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,15 @@ 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, isSplitChildTransaction, isTimeRequest as isTimeRequestUtil} from '@libs/TransactionUtils';
+import {
+ getRequestType,
+ isFromCreditCardImport,
+ isManualDistanceRequest,
+ isOdometerDistanceRequest,
+ isPerDiemRequest,
+ isSplitChildTransaction,
+ isTimeRequest as isTimeRequestUtil,
+} from '@libs/TransactionUtils';
import MoneyRequestParticipantsSelector from '@pages/iou/request/MoneyRequestParticipantsSelector';
@@ -80,6 +89,10 @@ function IOURequestStepParticipants({
isMovingTransactionFromTrackExpense,
isFocused,
});
+ const blockManualOrOdometerDistanceRequestIfNeeded = useCommuterExclusionGuard({
+ isManualDistanceRequest: isManualDistanceRequest(initialTransaction),
+ isOdometerDistanceRequest: isOdometerDistanceRequest(initialTransaction),
+ });
const hasEndedSpan = useRef(false);
useEffect(() => {
@@ -167,6 +180,7 @@ function IOURequestStepParticipants({
shouldExcludeP2P={(initialTransaction?.amount ?? 0) < 0}
initiallySelectedReportID={selectedParticipant?.reportID}
shouldMoveSelectedToTop
+ shouldBlockParticipantSelection={blockManualOrOdometerDistanceRequestIfNeeded}
/>
);
diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts
index 2cccb6ed70a6..bfa9855b498c 100644
--- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts
+++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts
@@ -48,6 +48,7 @@ import {
getRateID,
getTaxValue,
getValidWaypoints,
+ hasAppliedCommuterExclusion,
isDistanceRequest as isDistanceRequestTransactionUtils,
isGPSDistanceRequest as isGPSDistanceRequestTransactionUtils,
isManualDistanceRequest as isManualDistanceRequestTransactionUtils,
@@ -813,6 +814,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) {
// Otherwise the builder mints a different ID and the screen hangs waiting on a report that never materializes.
const isBrandNewP2PRecipient = !report && !participant.isPolicyExpenseChat && !participant.reportID;
const optimisticChatReportID = isBrandNewP2PRecipient && !!transaction.reportID && transaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? transaction.reportID : undefined;
+ const shouldIncludeCommuterExclusionOverrides = hasAppliedCommuterExclusion(transaction);
const {chatReportID: distanceChatReportID, transactionID: distanceTransactionID} = createDistanceRequestIOUActions({
report,
@@ -831,6 +833,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) {
},
transactionParams: {
amount: transaction.amount,
+ ...(shouldIncludeCommuterExclusionOverrides && typeof transaction.modifiedAmount === 'number' && {modifiedAmount: transaction.modifiedAmount}),
+ ...(shouldIncludeCommuterExclusionOverrides && transaction.modifiedMerchant && {modifiedMerchant: transaction.modifiedMerchant}),
comment: trimmedComment,
distance: originalTransactionDistance,
modifiedDistance: modifiedTransactionDistance,
diff --git a/src/styles/index.ts b/src/styles/index.ts
index aca992d9e613..db37611ada02 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -6470,6 +6470,11 @@ const staticStyles = (theme: ThemeColors) =>
width: 112,
height: 160,
},
+ commuterExclusionStaticIllustration: {
+ width: 160,
+ height: 140,
+ alignSelf: 'center',
+ },
helpStaticIllustration: {
width: 174,
height: 156,
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index e0133d728807..75c356be9b5e 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -1542,6 +1542,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
*/
@@ -1805,6 +1818,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;
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index 7cf2b3189093..303455455d31 100644
--- a/src/types/onyx/Transaction.ts
+++ b/src/types/onyx/Transaction.ts
@@ -212,6 +212,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 */
diff --git a/tests/actions/IOU/GetMoneyRequestInformationTest.ts b/tests/actions/IOU/GetMoneyRequestInformationTest.ts
index dd76fa00547b..b76a4428ede6 100644
--- a/tests/actions/IOU/GetMoneyRequestInformationTest.ts
+++ b/tests/actions/IOU/GetMoneyRequestInformationTest.ts
@@ -2,7 +2,7 @@ import {getMoneyRequestInformation} from '@libs/actions/IOU/MoneyRequestBuilder'
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Beta, PolicyTagLists, Report} from '@src/types/onyx';
+import type {Beta, PolicyTagLists, Report, Transaction} from '@src/types/onyx';
import Onyx from 'react-native-onyx';
@@ -228,4 +228,47 @@ describe('getMoneyRequestInformation', () => {
);
});
});
+
+ it('does not copy commuter exclusion data to an optimistic split', () => {
+ const customUnit = {
+ name: CONST.CUSTOM_UNITS.NAME_DISTANCE,
+ customUnitID: 'distance-unit',
+ customUnitRateID: 'rate-123',
+ distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ quantity: 2.24,
+ } as const;
+ const existingTransaction: Transaction = {
+ transactionID: 'original-transaction',
+ reportID: 'expense-report',
+ amount: -280,
+ currency: CONST.CURRENCY.USD,
+ created: '2024-01-01',
+ merchant: '4.48 mi @ $0.625 / mi',
+ iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE,
+ comment: {
+ customUnit: {
+ ...customUnit,
+ quantity: 6.48,
+ commuterExclusion: 2,
+ reimbursableDistance: 4.48,
+ commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE,
+ },
+ },
+ };
+
+ const result = getMoneyRequestInformation({
+ ...baseParams,
+ existingTransaction,
+ isSplitExpense: true,
+ transactionParams: {
+ ...baseParams.transactionParams,
+ amount: 140,
+ modifiedAmount: 140,
+ originalTransactionID: existingTransaction.transactionID,
+ customUnit,
+ },
+ });
+
+ expect(result.transaction.comment?.customUnit).toEqual(customUnit);
+ });
});
diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts
index 172c9d118a7d..5126d435e905 100644
--- a/tests/actions/IOU/MoneyRequestTest.ts
+++ b/tests/actions/IOU/MoneyRequestTest.ts
@@ -926,6 +926,38 @@ describe('MoneyRequest', () => {
expect(Split.createDistanceRequest).not.toHaveBeenCalled();
});
+ it.each([
+ ['omits stale', 0, false],
+ ['forwards commuter exclusion', 2, true],
+ ])('%s modified fields when creating a distance request', (_description, commuterExclusion, shouldForwardModifiedFields) => {
+ const transaction = {
+ ...fakeTransaction,
+ iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL,
+ modifiedAmount: 80,
+ modifiedMerchant: '8 mi @ $0.50 / mi',
+ comment: {comment: '', customUnit: {commuterExclusion, reimbursableDistance: 8, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}},
+ };
+
+ handleMoneyRequestStepDistanceNavigation({
+ ...baseParams,
+ transaction,
+ manualDistance: 10,
+ shouldSkipConfirmation: true,
+ iouType: CONST.IOU.TYPE.SUBMIT,
+ draftTransactionIDs: [baseParams.transactionID],
+ delegateAccountID: undefined,
+ getCurrencySymbol,
+ });
+
+ const transactionParams = jest.mocked(Split.createDistanceRequest).mock.calls.at(-1)?.at(0)?.transactionParams;
+ if (shouldForwardModifiedFields) {
+ expect(transactionParams).toEqual(expect.objectContaining({modifiedAmount: 80, modifiedMerchant: '8 mi @ $0.50 / mi'}));
+ } else {
+ expect(transactionParams).not.toHaveProperty('modifiedAmount');
+ expect(transactionParams).not.toHaveProperty('modifiedMerchant');
+ }
+ });
+
it('should pass the existing tracked transaction ID (not the optimistic id) to cleanup for a move-from-track distance submission', async () => {
const EXISTING_TRACKED_TRANSACTION_ID = 'tracked-transaction-88';
const linkedTrackedExpenseReportAction = {
diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts
index f01c7f5b2a93..a636b97d61ea 100644
--- a/tests/actions/IOUTest/SplitTest.ts
+++ b/tests/actions/IOUTest/SplitTest.ts
@@ -8143,7 +8143,7 @@ describe('initSplitExpenseItemData', () => {
expect(splitExpense.reportID).toBe('456');
});
- it('should use provided parameters over transaction data', () => {
+ it('should use provided parameters without inheriting commuter exclusion data', () => {
const transaction: Transaction = {
transactionID: '123',
amount: -100,
@@ -8167,6 +8167,9 @@ describe('initSplitExpenseItemData', () => {
customUnitRateID: 'rate-123',
distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
quantity: 50,
+ commuterExclusion: 2,
+ reimbursableDistance: 48,
+ commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE,
};
const transactionReport: Report = {
@@ -8190,7 +8193,13 @@ describe('initSplitExpenseItemData', () => {
expect(splitExpense.reportID).toBe('888');
expect(splitExpense.created).toBe('2024-01-01');
expect(splitExpense.merchant).toBe('Custom Merchant');
- expect(splitExpense.customUnit).toEqual(customUnit);
+ expect(splitExpense.customUnit).toEqual({
+ name: CONST.CUSTOM_UNITS.NAME_DISTANCE,
+ customUnitID: 'distance-unit',
+ customUnitRateID: 'rate-123',
+ distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ quantity: 50,
+ });
});
it('should handle transaction with waypoints and odometer readings', () => {
diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts
index 2b9940670e1c..f3d468e9f88e 100644
--- a/tests/actions/ReportTest.ts
+++ b/tests/actions/ReportTest.ts
@@ -3552,6 +3552,46 @@ describe('actions/Report', () => {
});
describe('changeReportPolicy', () => {
+ it('blocks manual distance using the provided transaction list', async () => {
+ const expenseReport: OnyxTypes.Report = {
+ ...createRandomReport(1, undefined),
+ type: CONST.REPORT.TYPE.EXPENSE,
+ policyID: 'sourcePolicy',
+ };
+ const targetPolicy = {
+ ...createRandomPolicy(2),
+ commuterExclusions: {
+ method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE,
+ fixedDistance: 1,
+ fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ },
+ };
+ const manualDistanceTransaction = {
+ ...createRandomTransaction(1),
+ reportID: expenseReport.reportID,
+ iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL,
+ };
+
+ Report.changeReportPolicy({
+ report: expenseReport,
+ parentReport: undefined,
+ policy: targetPolicy,
+ currentUserAccountID: 1,
+ email: '',
+ managerLogin: '',
+ hasViolationsParam: false,
+ isChangePolicyTrainingModalDismissed: false,
+ ownerLogin: undefined,
+ isASAPSubmitBetaEnabled: false,
+ reportPreviewAction: undefined,
+ isTrackIntentUser: false,
+ reportTransactions: [manualDistanceTransaction],
+ });
+ await waitForBatchedUpdates();
+
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.CHANGE_REPORT_POLICY, 0);
+ });
+
it('should unarchive the expense report', async () => {
// Given an archived expense report
const expenseReport: OnyxTypes.Report = {
@@ -3580,6 +3620,7 @@ describe('actions/Report', () => {
isASAPSubmitBetaEnabled: false,
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -3640,6 +3681,7 @@ describe('actions/Report', () => {
isASAPSubmitBetaEnabled: false,
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -3710,6 +3752,7 @@ describe('actions/Report', () => {
isASAPSubmitBetaEnabled: false,
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -3806,6 +3849,7 @@ describe('actions/Report', () => {
isASAPSubmitBetaEnabled: false,
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -3890,6 +3934,7 @@ describe('actions/Report', () => {
isASAPSubmitBetaEnabled: false,
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -3946,6 +3991,7 @@ describe('actions/Report', () => {
reportActionsList: {},
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -4035,6 +4081,7 @@ describe('actions/Report', () => {
reportActionsList: {},
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -4079,6 +4126,7 @@ describe('actions/Report', () => {
reportActionsList: {},
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -4109,6 +4157,7 @@ describe('actions/Report', () => {
reportActionsList: {},
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -4138,6 +4187,7 @@ describe('actions/Report', () => {
reportActionsList: {},
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -4167,6 +4217,7 @@ describe('actions/Report', () => {
reportActionsList: {},
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -4198,6 +4249,7 @@ describe('actions/Report', () => {
reportActionsList: {},
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
@@ -4250,6 +4302,7 @@ describe('actions/Report', () => {
reportActionsList: {},
reportPreviewAction: undefined,
isTrackIntentUser: false,
+ reportTransactions: [],
});
await waitForBatchedUpdates();
diff --git a/tests/ui/DistanceRequestStartPageTest.tsx b/tests/ui/DistanceRequestStartPageTest.tsx
index ec1511f7c045..7c096838cf9d 100644
--- a/tests/ui/DistanceRequestStartPageTest.tsx
+++ b/tests/ui/DistanceRequestStartPageTest.tsx
@@ -1,5 +1,6 @@
-import {act, render} from '@testing-library/react-native';
+import {act, render, screen} from '@testing-library/react-native';
+import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
import OnyxListItemProvider from '@components/OnyxListItemProvider';
@@ -8,7 +9,7 @@ import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types';
import DistanceRequestStartPage from '@pages/iou/request/DistanceRequestStartPage';
-import type {IOURequestType} from '@src/CONST';
+import type {IOURequestType, IOUType} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
@@ -33,7 +34,14 @@ jest.mock('@rnmapbox/maps', () => ({
}));
jest.mock('react-native-tab-view', () => ({
- TabView: 'TabView',
+ TabView: ({navigationState}: {navigationState: {routes: Array<{name: string}>}}) => {
+ const React2 = jest.requireActual('react');
+ return React2.createElement(
+ React2.Fragment,
+ null,
+ navigationState.routes.map((route) => React2.createElement('TabRoute', {key: route.name, testID: `tab-${route.name}`})),
+ );
+ },
SceneMap: jest.fn(),
TabBar: 'TabBar',
}));
@@ -43,6 +51,8 @@ jest.mock('react-native-vision-camera', () => ({
}));
const REPORT_ID = '1';
+const ACCOUNT_ID = 1;
+const ACCOUNT_LOGIN = 'test@user.com';
const PERSONAL_POLICY_ID = 'personalPolicy1';
type DistanceCreateScreenProps = PlatformStackScreenProps;
@@ -68,26 +78,28 @@ async function setUpOnyx({selectedTab, lastDistanceExpenseType}: {selectedTab?:
});
}
-async function renderPage(defaultSelectedTab: SelectedTabRequest) {
+async function renderPage(defaultSelectedTab: SelectedTabRequest, iouType: IOUType = CONST.IOU.TYPE.TRACK) {
render(
-
-
- ({
- params: {
- iouType: CONST.IOU.TYPE.TRACK,
- reportID: REPORT_ID,
- transactionID: '',
- },
- })}
- report={undefined}
- reportDraft={undefined}
- navigation={createMock({})}
- defaultSelectedTab={defaultSelectedTab}
- />
-
-
+
+
+
+ ({
+ params: {
+ iouType,
+ reportID: REPORT_ID,
+ transactionID: '',
+ },
+ })}
+ report={undefined}
+ reportDraft={undefined}
+ navigation={createMock({})}
+ defaultSelectedTab={defaultSelectedTab}
+ />
+
+
+
,
);
@@ -174,4 +186,64 @@ describe('DistanceRequestStartPage', () => {
// Then the draft falls back to the map type
await expect(getDraftRequestType()).resolves.toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MAP);
});
+
+ it('keeps manual distance available for a self-DM expense with a personal policy when every workspace has commuter exclusions', async () => {
+ await setUpOnyx({selectedTab: CONST.TAB_REQUEST.DISTANCE_MANUAL});
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {
+ reportID: REPORT_ID,
+ policyID: PERSONAL_POLICY_ID,
+ chatType: CONST.REPORT.CHAT_TYPE.SELF_DM,
+ });
+ await Onyx.set(
+ `${ONYXKEYS.COLLECTION.POLICY}workspacePolicy`,
+ createMock({
+ id: 'workspacePolicy',
+ type: CONST.POLICY.TYPE.TEAM,
+ name: 'Workspace',
+ role: CONST.POLICY.ROLE.USER,
+ commuterExclusions: {
+ method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE,
+ fixedDistance: 1,
+ fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ },
+ }),
+ );
+
+ await renderPage(CONST.TAB_REQUEST.DISTANCE_MANUAL);
+
+ expect(screen.getByTestId(`tab-${CONST.TAB_REQUEST.DISTANCE_MANUAL}`)).toBeOnTheScreen();
+ expect(screen.getByTestId(`tab-${CONST.TAB_REQUEST.DISTANCE_ODOMETER}`)).toBeOnTheScreen();
+ });
+
+ it('hides manual and odometer distance when the only workspace is the auto-report target', async () => {
+ await setUpOnyx({selectedTab: CONST.TAB_REQUEST.DISTANCE_MAP});
+ await Onyx.set(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID, email: ACCOUNT_LOGIN});
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}workspaceReport`, {
+ reportID: 'workspaceReport',
+ policyID: 'workspacePolicy',
+ ownerAccountID: ACCOUNT_ID,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ });
+ await Onyx.set(
+ `${ONYXKEYS.COLLECTION.POLICY}workspacePolicy`,
+ createMock({
+ id: 'workspacePolicy',
+ type: CONST.POLICY.TYPE.TEAM,
+ name: 'Workspace',
+ role: CONST.POLICY.ROLE.USER,
+ isPolicyExpenseChatEnabled: true,
+ autoReporting: true,
+ commuterExclusions: {
+ method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE,
+ fixedDistance: 1,
+ fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ },
+ }),
+ );
+
+ await renderPage(CONST.TAB_REQUEST.DISTANCE_MAP, CONST.IOU.TYPE.CREATE);
+
+ expect(screen.queryByTestId(`tab-${CONST.TAB_REQUEST.DISTANCE_MANUAL}`)).not.toBeOnTheScreen();
+ expect(screen.queryByTestId(`tab-${CONST.TAB_REQUEST.DISTANCE_ODOMETER}`)).not.toBeOnTheScreen();
+ });
});
diff --git a/tests/unit/DistanceRequestUtilsTest.ts b/tests/unit/DistanceRequestUtilsTest.ts
index 47e566ae7bc8..c1161c84358f 100644
--- a/tests/unit/DistanceRequestUtilsTest.ts
+++ b/tests/unit/DistanceRequestUtilsTest.ts
@@ -370,16 +370,96 @@ 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')}`);
+ });
+
+ 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,
+ translateLocal,
+ false,
+ false,
+ commuterExclusionData,
+ );
+
expect(result).toBe(`0.00 ${translateLocal('common.miles')}`);
});
});
+ 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_MILES,
+ },
+ };
+
+ const result = DistanceRequestUtils.getTransactionCommuterExclusionData({
+ transaction,
+ policy,
+ translate: translateLocal,
+ toLocaleDigit: toLocaleDigitMock,
+ getCurrencySymbol: getCurrencySymbolMock,
+ });
+
+ 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);
+ });
+ });
+
describe('getRate', () => {
it('returns the rate from policyForMovingExpenses if an unreported transaction rate belongs to it', () => {
const transaction = {...createRandomTransaction(1), reportID: '0', comment: {customUnit: {customUnitRateID: 'EE75E6DBC6FF8'}}};
@@ -492,6 +572,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', () => {
diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts
index a25de4f4969e..9a9ae7e6f604 100644
--- a/tests/unit/TransactionUtilsTest.ts
+++ b/tests/unit/TransactionUtilsTest.ts
@@ -470,6 +470,7 @@ describe('TransactionUtils', () => {
},
};
const transaction = generateTransaction({
+ iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP,
comment: {
customUnit: {
distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
@@ -671,6 +672,134 @@ describe('TransactionUtils', () => {
expect(updatedTransaction.modifiedAmount).toBe(CONST.IOU.DEFAULT_AMOUNT);
});
+ 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({
+ iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP,
+ 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,
+ getCurrencyDecimals,
+ getCurrencySymbol,
+ });
+
+ 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('converts commuter exclusion data when the distance rate unit is changed', () => {
+ // Given a policy with a 3 mile fixed distance commuter exclusion and a kilometer rate
+ const fakePolicy: Policy = {
+ ...createRandomPolicy(0),
+ commuterExclusions: {
+ method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE,
+ fixedDistance: 3,
+ fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ },
+ customUnits: {
+ distance: {
+ name: CONST.CUSTOM_UNITS.NAME_DISTANCE,
+ customUnitID: 'distance',
+ rates: {
+ // getMileageRates keys its result by the rates map key, so it must match customUnitRateID
+ ID1: {
+ customUnitRateID: '1',
+ currency: CONST.CURRENCY.EUR,
+ rate: 10,
+ },
+ ID2: {
+ customUnitRateID: '2',
+ currency: CONST.CURRENCY.EUR,
+ rate: 30,
+ },
+ },
+ attributes: {
+ unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS,
+ },
+ },
+ },
+ };
+
+ // And a 10 mile expense whose reimbursable distance is 7 miles after the exclusion
+ const transaction = generateTransaction({
+ iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP,
+ comment: {
+ customUnit: {
+ customUnitRateID: 'ID1',
+ 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,
+ });
+
+ // When the rate is changed
+ const updatedTransaction = TransactionUtils.getUpdatedTransaction({
+ transaction,
+ isFromExpenseReport: false,
+ policy: fakePolicy,
+ transactionChanges: {customUnitRateID: 'ID2'},
+ personalPolicyOutputCurrency: undefined,
+ getCurrencyDecimals,
+ getCurrencySymbol,
+ });
+
+ // Then the original distance and commuter exclusion are converted to kilometers
+ expect(updatedTransaction.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS);
+ expect(updatedTransaction.comment?.customUnit?.quantity).toBe(16.09);
+ expect(updatedTransaction.comment?.customUnit?.commuterExclusion).toBeCloseTo(4.83);
+ expect(updatedTransaction.comment?.customUnit?.reimbursableDistance).toBeCloseTo(11.26);
+
+ // And the amount and merchant use the converted reimbursable distance at the kilometer rate
+ expect(updatedTransaction.modifiedAmount).toBe(338);
+ expect(updatedTransaction.modifiedMerchant).toBe('11.26 km @ EUR 0.30 / km');
+ });
+
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
new file mode 100644
index 000000000000..81640fb4a545
--- /dev/null
+++ b/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx
@@ -0,0 +1,118 @@
+import {render} from '@testing-library/react-native';
+
+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();
+const mockSetMoneyRequestCommuterExclusionFields = 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);
+ },
+ setMoneyRequestCommuterExclusionFields: (...args: unknown[]) => {
+ mockSetMoneyRequestCommuterExclusionFields(...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('updates the base distance merchant and delegates commuter fields to the commuter action', () => {
+ render(
+ ,
+ );
+
+ 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,
+ }),
+ );
+ });
+});
diff --git a/tests/unit/hooks/useCommuterExclusionGuard.test.ts b/tests/unit/hooks/useCommuterExclusionGuard.test.ts
new file mode 100644
index 000000000000..af4dbd5958f5
--- /dev/null
+++ b/tests/unit/hooks/useCommuterExclusionGuard.test.ts
@@ -0,0 +1,140 @@
+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',
+ areDistanceRatesEnabled: true,
+ 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 fall back to the current workspace when selecting a policy-less participant', 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({
+ policyID: 'policy_forced',
+ isManualDistanceRequest: true,
+ }),
+ );
+
+ expect(result.current(undefined)).toBe(false);
+ expect(mockShowConfirmModal).not.toHaveBeenCalled();
+ });
+
+ it('blocks selecting a workspace with commuter exclusions even when distance rates are disabled', async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy_disabled_rates`, {
+ id: 'policy_disabled_rates',
+ name: 'Disabled rates workspace',
+ areDistanceRatesEnabled: false,
+ commuterExclusions: {
+ method: 'fixedDistance',
+ fixedDistance: 1,
+ fixedDistanceUnit: 'mi',
+ },
+ });
+ await waitForBatchedUpdates();
+
+ const {result} = renderHook(() =>
+ useCommuterExclusionGuard({
+ isManualDistanceRequest: true,
+ }),
+ );
+
+ expect(result.current('policy_disabled_rates')).toBe(true);
+ expect(mockShowConfirmModal).toHaveBeenCalledTimes(1);
+ });
+
+ 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',
+ areDistanceRatesEnabled: true,
+ commuterExclusions: {
+ method: 'fixedDistance',
+ fixedDistance: 1,
+ fixedDistanceUnit: 'mi',
+ },
+ });
+ await waitForBatchedUpdates();
+
+ const {result} = renderHook(() => useCommuterExclusionGuard({}));
+
+ expect(result.current('policy_forced')).toBe(false);
+ expect(mockShowConfirmModal).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/hooks/useDistanceRequestState.test.ts b/tests/unit/hooks/useDistanceRequestState.test.ts
index 9e7c96a4fd47..b1cafe854135 100644
--- a/tests/unit/hooks/useDistanceRequestState.test.ts
+++ b/tests/unit/hooks/useDistanceRequestState.test.ts
@@ -10,18 +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),
+ 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,
@@ -39,6 +50,49 @@ describe('useDistanceRequestState', () => {
expect(result.current.distanceRequestAmount).toBe(500); // 10 * 0.5 * 100
});
+ it('recalculates only when the reimbursable distance changes', () => {
+ const transaction = baseParams.transaction;
+ if (!transaction) {
+ throw new Error('Expected a transaction');
+ }
+
+ let reimbursableDistance = 8;
+ const {result, rerender} = renderHook(() =>
+ useDistanceRequestState({
+ ...baseParams,
+ transaction: {
+ ...transaction,
+ transactionID: 'txn1',
+ comment: {
+ ...transaction.comment,
+ customUnit: {
+ ...transaction.comment?.customUnit,
+ routeDistanceMeters: 10,
+ quantity: 10,
+ distanceUnit: 'mi',
+ commuterExclusion: 2,
+ reimbursableDistance,
+ },
+ },
+ },
+ iouAmount: 500,
+ }),
+ );
+
+ expect(result.current.distance).toBe(10);
+ expect(result.current.distanceRequestAmount).toBe(400);
+ expect(result.current.shouldCalculateDistanceAmount).toBe(false);
+
+ reimbursableDistance = 7;
+ rerender(undefined);
+
+ expect(result.current.distanceRequestAmount).toBe(350);
+ expect(result.current.shouldCalculateDistanceAmount).toBe(true);
+
+ rerender(undefined);
+ expect(result.current.shouldCalculateDistanceAmount).toBe(false);
+ });
+
it('isDistanceRequestWithPendingRoute is true when transaction has no route', () => {
const {result} = renderHook(() =>
useDistanceRequestState({
diff --git a/tests/unit/hooks/useExpenseSubmission.test.ts b/tests/unit/hooks/useExpenseSubmission.test.ts
index c0397ff93694..0e7784ce8532 100644
--- a/tests/unit/hooks/useExpenseSubmission.test.ts
+++ b/tests/unit/hooks/useExpenseSubmission.test.ts
@@ -9,12 +9,16 @@ import type {Policy, PolicyCategories, Report, ReportAction, Transaction} from '
import Onyx from 'react-native-onyx';
+import type * as Split from '../../../src/libs/actions/IOU/Split';
+
import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct';
const mockRequestMoneyAction = jest.fn();
const mockTrackExpenseAction = jest.fn();
const mockSubmitPerDiemExpenseAction = jest.fn();
const mockSubmitPerDiemExpenseForSelfDMAction = jest.fn();
+type CreateDistanceRequest = typeof Split.createDistanceRequest;
+const mockCreateDistanceRequestAction = jest.fn, Parameters>();
const mockCleanupAfterExpenseCreate = jest.fn();
const mockCleanupAndNavigateAfterExpenseCreate = jest.fn();
const mockResolveChatTargetForSubmitCleanup = jest.fn();
@@ -30,6 +34,13 @@ jest.mock('@userActions/IOU/PerDiem', () => ({
submitPerDiemExpenseForSelfDM: (...args: unknown[]) => mockSubmitPerDiemExpenseForSelfDMAction(...args),
}));
+jest.mock('@userActions/IOU/Split', () => ({
+ createDistanceRequest: (...args: Parameters) => mockCreateDistanceRequestAction(...args),
+ splitBill: jest.fn(),
+ splitBillAndOpenReport: jest.fn(),
+ startSplitBill: jest.fn(),
+}));
+
jest.mock('@userActions/IOU/SendInvoice', () => ({
sendInvoice: (...args: unknown[]) => mockSendInvoiceAction(...args),
getReceiverType: jest.fn(),
@@ -197,6 +208,7 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => {
jest.clearAllMocks();
await Onyx.clear();
mockRequestMoneyAction.mockReturnValue({iouReport: {reportID: 'iou-1'}});
+ mockCreateDistanceRequestAction.mockReturnValue({iouReport: {reportID: 'distance-iou-1'}, chatReportID: 'distance-chat-1', transactionID: 'distance-transaction-1'});
mockResolveChatTargetForSubmitCleanup.mockReturnValue({report: {reportID: REPORT_ID}, chatReportID: 'fallback-id', optimisticChatReportID: undefined});
});
@@ -340,6 +352,44 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => {
});
});
+ describe('distance request path', () => {
+ it.each([
+ ['omits stale', 0, false],
+ ['forwards commuter exclusion', 2, true],
+ ])('%s modified fields', async (_description, commuterExclusion, shouldForwardModifiedFields) => {
+ const distanceTransaction = buildTransaction({
+ iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL,
+ modifiedAmount: 80,
+ modifiedMerchant: '8 mi @ $0.50 / mi',
+ comment: {comment: '', customUnit: {commuterExclusion, reimbursableDistance: 8, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}},
+ });
+ const {result} = renderHook(() =>
+ useExpenseSubmission(
+ buildParams({
+ transaction: distanceTransaction,
+ transactions: [distanceTransaction],
+ requestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL,
+ isDistanceRequest: true,
+ isManualDistanceRequest: true,
+ }),
+ ),
+ );
+ await waitForBatchedUpdatesWithAct();
+
+ await act(async () => {
+ result.current.createTransaction(false, true);
+ });
+
+ const transactionParams = mockCreateDistanceRequestAction.mock.calls.at(-1)?.at(0)?.transactionParams;
+ if (shouldForwardModifiedFields) {
+ expect(transactionParams).toEqual(expect.objectContaining({modifiedAmount: 80, modifiedMerchant: '8 mi @ $0.50 / mi'}));
+ } else {
+ expect(transactionParams).not.toHaveProperty('modifiedAmount');
+ expect(transactionParams).not.toHaveProperty('modifiedMerchant');
+ }
+ });
+ });
+
describe('trackExpense path', () => {
it('calls cleanupAfterExpenseCreate and skips cleanupAndNavigateAfterExpenseCreate when shouldHandleNavigation=false (orchestrator pre-navigated)', async () => {
const {result} = renderHook(() => useExpenseSubmission(buildParams({iouType: CONST.IOU.TYPE.TRACK})));
diff --git a/tests/unit/hooks/useTaxAmount.test.ts b/tests/unit/hooks/useTaxAmount.test.ts
index f0b7b8ea23e0..5067e9994a28 100644
--- a/tests/unit/hooks/useTaxAmount.test.ts
+++ b/tests/unit/hooks/useTaxAmount.test.ts
@@ -2,7 +2,7 @@ import {renderHook} from '@testing-library/react-native';
import useTaxAmount from '@components/MoneyRequestConfirmationList/hooks/useTaxAmount';
-import type * as OnyxTypes from '@src/types/onyx';
+import DistanceRequestUtils from '@libs/DistanceRequestUtils';
jest.mock('@hooks/useCurrencyList', () => ({
useCurrencyListActions: () => ({getCurrencyDecimals: () => 2}),
@@ -15,7 +15,9 @@ jest.mock('@libs/CurrencyUtils', () => ({
jest.mock('@libs/DistanceRequestUtils', () => ({
__esModule: true,
default: {
- getTaxableAmount: () => 100,
+ convertToDistanceInMeters: jest.fn(),
+ getCommuterExclusionDisplayData: jest.fn(),
+ getTaxableAmount: jest.fn(),
},
}));
@@ -31,18 +33,38 @@ jest.mock('@libs/TransactionUtils', () => ({
type Params = Parameters[0];
+const mockConvertToDistanceInMeters = jest.mocked(DistanceRequestUtils.convertToDistanceInMeters);
+const mockGetCommuterExclusionDisplayData = jest.mocked(DistanceRequestUtils.getCommuterExclusionDisplayData);
+const mockGetTaxableAmount = jest.mocked(DistanceRequestUtils.getTaxableAmount);
+
+const baseTransaction = {
+ transactionID: 'txn1',
+ amount: 1000,
+ created: '2026-07-22',
+ currency: 'USD',
+ merchant: 'Merchant',
+ reportID: 'report1',
+} satisfies Params['transaction'];
+
const baseParams: Params = {
- transaction: {transactionID: 'txn1', amount: 1000, currency: 'USD'} as unknown as OnyxTypes.Transaction,
+ transaction: baseTransaction,
policy: undefined,
policyForMovingExpenses: undefined,
isDistanceRequest: false,
isMovingTransactionFromTrackExpense: false,
customUnitRateID: '',
distance: 0,
+ distanceUnit: undefined,
previousTransactionCurrency: 'USD',
};
describe('useTaxAmount', () => {
+ beforeEach(() => {
+ mockConvertToDistanceInMeters.mockImplementation((distance: number) => distance);
+ mockGetCommuterExclusionDisplayData.mockReturnValue(null);
+ mockGetTaxableAmount.mockReturnValue(100);
+ });
+
it('returns the default tax code and value from policy resolution', () => {
const {result} = renderHook(() => useTaxAmount(baseParams));
expect(result.current.defaultTaxCode).toBe('tax_default');
@@ -61,6 +83,74 @@ describe('useTaxAmount', () => {
expect(result.current.taxAmountInSmallestCurrencyUnits).toBe(1000);
});
+ it('uses reimbursable distance for distance tax when commuter exclusion applies', () => {
+ const transaction = {
+ ...baseTransaction,
+ comment: {
+ customUnit: {
+ commuterExclusion: 1,
+ reimbursableDistance: 3,
+ distanceUnit: 'mi',
+ },
+ },
+ } satisfies Params['transaction'];
+
+ mockGetCommuterExclusionDisplayData.mockReturnValue({
+ commuterExclusion: 1,
+ reimbursableDistance: 3,
+ distanceUnit: 'mi',
+ });
+
+ renderHook(() =>
+ useTaxAmount({
+ ...baseParams,
+ transaction,
+ isDistanceRequest: true,
+ distance: 4,
+ }),
+ );
+
+ expect(mockGetTaxableAmount).toHaveBeenCalledWith(undefined, '', 3);
+ });
+
+ it('falls back to the active mileage unit for commuter exclusion tax', () => {
+ const transaction = {
+ ...baseTransaction,
+ comment: {
+ customUnit: {
+ commuterExclusion: 1,
+ reimbursableDistance: 3,
+ },
+ },
+ } satisfies Params['transaction'];
+
+ mockGetCommuterExclusionDisplayData.mockReturnValue({
+ commuterExclusion: 1,
+ reimbursableDistance: 3,
+ distanceUnit: 'km',
+ });
+
+ renderHook(() =>
+ useTaxAmount({
+ ...baseParams,
+ transaction,
+ isDistanceRequest: true,
+ distance: 4,
+ distanceUnit: 'km',
+ }),
+ );
+
+ expect(mockGetCommuterExclusionDisplayData).toHaveBeenCalledWith(
+ {
+ commuterExclusion: 1,
+ reimbursableDistance: 3,
+ },
+ 'km',
+ );
+ expect(mockConvertToDistanceInMeters).toHaveBeenCalledWith(3, 'km');
+ expect(mockGetTaxableAmount).toHaveBeenCalledWith(undefined, '', 3);
+ });
+
it('shouldKeepCurrentTaxSelection is false when policy has no matching tax rate', () => {
const {result} = renderHook(() => useTaxAmount(baseParams));
expect(result.current.shouldKeepCurrentTaxSelection).toBe(false);