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 7d37ca62ac30..f8455c2bd4af 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -1603,6 +1603,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 1cde69896a3d..fead9ec86d77 100644
--- a/src/components/Icon/chunks/illustrations.chunk.ts
+++ b/src/components/Icon/chunks/illustrations.chunk.ts
@@ -153,6 +153,7 @@ import HeadSet from '@assets/images/simple-illustrations/simple-illustration__he
import Hourglass from '@assets/images/simple-illustrations/simple-illustration__hourglass.svg';
import HouseMoney from '@assets/images/simple-illustrations/simple-illustration__house-money.svg';
import House from '@assets/images/simple-illustrations/simple-illustration__house.svg';
+import HouseWithMap from '@assets/images/simple-illustrations/simple-illustration__housewithmap.svg';
import InvoiceBlue from '@assets/images/simple-illustrations/simple-illustration__invoice.svg';
import LifeRing from '@assets/images/simple-illustrations/simple-illustration__lifering.svg';
import Lightbulb from '@assets/images/simple-illustrations/simple-illustration__lightbulb.svg';
@@ -408,6 +409,7 @@ const Illustrations = {
HeadSet,
Hourglass,
House,
+ HouseWithMap,
HouseMoney,
LifeRing,
Lightbulb,
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index 3ec4d336bef8..0683fb86a041 100644
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -1,4 +1,5 @@
import useAttendees from '@hooks/useAttendees';
+import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
import useLocalize from '@hooks/useLocalize';
@@ -254,6 +255,11 @@ function MoneyRequestConfirmationList({
const isDistanceRequest = isDistanceRequestUtil(transaction);
const isManualDistanceRequest = isManualDistanceRequestUtil(transaction);
const isGPSDistanceRequest = isGPSDistanceRequestUtil(transaction);
+ const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({
+ policyID: policy?.id,
+ isManualDistanceRequest,
+ isOdometerDistanceRequest,
+ });
const iouAmount = hasValidModifiedAmount(transaction) ? Number(transaction?.modifiedAmount) : (transaction?.amount ?? 0);
const iouCurrencyCode = getCurrency(transaction);
@@ -500,7 +506,12 @@ function MoneyRequestConfirmationList({
setFormError,
setDidConfirmSplit,
showDelegateNoAccessModal,
- onConfirm,
+ onConfirm: () => {
+ if (shouldBlockManualOrOdometerDistanceRequest()) {
+ return;
+ }
+ onConfirm?.();
+ },
onSendMoney,
});
@@ -561,6 +572,7 @@ function MoneyRequestConfirmationList({
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 c931c4b51319..4527c36ae77c 100644
--- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
+++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
@@ -303,7 +303,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/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/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts b/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts
index 0834cadeacf4..c79910e444e5 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 = {
@@ -22,6 +23,7 @@ type DistanceData = {
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..e15895fb25ec 100644
--- a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx
+++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx
@@ -143,7 +143,6 @@ function TransactionDetailsFields({
hasRoute={distanceData.hasRoute}
distance={distanceData.distance}
unit={distanceData.unit}
- rate={distanceData.rate}
isManualDistanceRequest={isManualDistanceRequest}
isOdometerDistanceRequest={isOdometerDistanceRequest}
isGPSDistanceRequest={isGPSDistanceRequest}
@@ -154,6 +153,7 @@ function TransactionDetailsFields({
iouType={iouType}
reportID={reportID}
reportActionID={reportActionID}
+ customUnit={distanceData.customUnit}
/>
)}
diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx
index 76c4a32a6b91..6bc853e7deb5 100644
--- a/src/components/ReportActionItem/MoneyRequestView.tsx
+++ b/src/components/ReportActionItem/MoneyRequestView.tsx
@@ -605,7 +605,12 @@ 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 = 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) {
@@ -936,8 +941,9 @@ function MoneyRequestView({
<>
;
+
+const policiesWithCommuterExclusionsSelector = (policies: OnyxCollection): PoliciesWithCommuterExclusions =>
+ Object.values(policies ?? {}).reduce((acc, policy) => {
+ if (policy?.id && policy.commuterExclusions) {
+ acc[policy.id] = true;
+ }
+ return acc;
+ }, {});
+
+/**
+ * Returns a guard function that blocks manual/odometer distance 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 (policyIDToCheck = 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;
+ };
+}
+
+export default useCommuterExclusionGuard;
diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts
index d4b0b7f1906a..ad1938144246 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);
@@ -447,7 +449,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/languages/de.ts b/src/languages/de.ts
index ab7159789b5f..71c9bc4fabb4 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -521,6 +521,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.',
@@ -8421,18 +8422,9 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc
removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `„${prohibitedExpense}“ aus verbotenen Ausgaben entfernt`,
commuterExclusions: {
changedToFixedDistance: 'hat das Ausschließen von Pendelstrecken in eine feste Entfernung pro Antrag geändert',
- setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => {
- const isSingular = distance === 1;
- let unitLabel: string;
- if (unit === 'mi') {
- unitLabel = isSingular ? 'Meile' : 'Meilen';
- } else {
- unitLabel = isSingular ? 'Kilometer' : 'Kilometer';
- }
- return `feste Distanz von ${distance} ${unitLabel} pro Antrag ausschließen`;
- },
- changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) =>
- `feste Entfernungsausschlussgrenze auf ${newDistance} ${unit} pro Antrag geändert (zuvor ${oldDistance} ${unit})`,
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `feste Distanz von ${formattedDistance} pro Antrag ausschließen`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `feste Entfernungsausschlussgrenze auf ${formattedNewDistance} pro Antrag geändert (zuvor ${formattedOldDistance})`,
disabled: 'Ausschluss von Pendelstrecken für Distanzsätze deaktiviert',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
@@ -9279,6 +9271,10 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc
},
error: {
selectSuggestedAddress: 'Bitte wählen Sie eine vorgeschlagene Adresse aus oder verwenden Sie den aktuellen Standort',
+ mapOrGpsDistanceRequired: {
+ title: 'Karten- oder GPS-Entfernung erforderlich',
+ description: 'Dieser Workspace erfordert entweder kartenbasierte oder GPS-verfolgte Entfernungsspesen.',
+ },
},
odometer: {
startReading: 'Mit dem Lesen beginnen',
@@ -9295,6 +9291,12 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc
snapPhotoStart: 'Machen Sie zu Beginn Ihrer Fahrt ein Foto von Ihrem Kilometerzähler.',
snapPhotoEnd: 'Machen Sie ein Foto von Ihrem Kilometerzähler am Ende Ihrer Fahrt.',
},
+ commuterExclusion: {
+ original: 'Original',
+ removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `${formattedDistance} entfernt`,
+ systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) =>
+ `${formattedDistance} basierend auf den Entfernungseinstellungen des Arbeitsbereichs entfernt.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/en.ts b/src/languages/en.ts
index b38453141ed1..c588d045fd61 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -361,6 +361,7 @@ const translations = {
miles: 'miles',
kilometer: 'kilometer',
kilometers: 'kilometers',
+ commuter: 'commuter',
recent: 'Recent',
all: 'All',
am: 'AM',
@@ -8455,18 +8456,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) =>
@@ -9381,6 +9373,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: 'Original',
+ removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Removed ${formattedDistance}`,
+ systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) =>
+ `Removed ${formattedDistance} based on workspace distance settings.`,
},
odometer: {
startReading: 'Start reading',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 80f5e64f2d4c..e076550fa919 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -473,6 +473,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.',
},
socials: {
@@ -8147,18 +8148,9 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`,
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})`,
+ 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: 'desactivó la opción de excluir desplazamientos para las tarifas por distancia',
},
updatedReimbursementChoice: (newReimbursementChoice, oldReimbursementChoice) =>
@@ -9513,6 +9505,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 rastreados por GPS.',
+ },
},
odometer: {
startReading: 'Lectura inicial',
@@ -9529,6 +9525,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: 'Original',
+ removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Eliminó ${formattedDistance}`,
+ systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) =>
+ `Se eliminaron ${formattedDistance} según la configuración de distancia del espacio de trabajo.`,
+ },
},
reportCardLostOrDamaged: {
screenTitle: 'Notificar la pérdida o deterioro de la tarjeta',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 2c80ad56187f..374e86157530 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -521,6 +521,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.',
@@ -8456,18 +8457,9 @@ 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})`,
+ 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: 'désactivé exclure les trajets domicile-travail pour les taux de distance',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
@@ -9315,6 +9307,10 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e
},
error: {
selectSuggestedAddress: 'Veuillez sélectionner une adresse suggérée ou utiliser la position actuelle',
+ mapOrGpsDistanceRequired: {
+ title: 'Distance carte ou GPS requise',
+ description: 'Cet espace de travail requiert des dépenses de distance soit basées sur une carte, soit suivies par GPS.',
+ },
},
odometer: {
startReading: 'Commencer la lecture',
@@ -9332,6 +9328,12 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e
snapPhotoStart: 'Prenez une photo de votre compteur kilométrique au début de votre trajet.',
snapPhotoEnd: 'Prenez une photo de votre compteur kilométrique à la fin de votre trajet.',
},
+ commuterExclusion: {
+ original: 'Original',
+ removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Supprimé ${formattedDistance}`,
+ systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) =>
+ `${formattedDistance} supprimé en fonction des paramètres de distance de l'espace de travail.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/it.ts b/src/languages/it.ts
index c6cce232c27a..3e75b72af8bb 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -521,6 +521,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.',
@@ -8408,18 +8409,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`,
removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `ha rimosso "${prohibitedExpense}" dalle spese vietate`,
commuterExclusions: {
changedToFixedDistance: 'ha modificato l’esclusione dei tragitti casa-lavoro in una distanza fissa per rimborso',
- setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => {
- const isSingular = distance === 1;
- let unitLabel: string;
- if (unit === 'mi') {
- unitLabel = isSingular ? 'miglio' : 'miglia';
- } else {
- unitLabel = isSingular ? 'chilometro' : 'chilometri';
- }
- return `imposta un'esclusione di distanza fissa di ${distance} ${unitLabel} per richiesta`;
- },
- 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})`,
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `imposta un'esclusione di distanza fissa 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 delle tratte casa-lavoro per le tariffe a distanza disattivata',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
@@ -9268,6 +9260,10 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`,
},
error: {
selectSuggestedAddress: 'Seleziona un indirizzo suggerito o usa la posizione attuale',
+ mapOrGpsDistanceRequired: {
+ title: 'Distanza da mappa o GPS obbligatoria',
+ description: 'Questo spazio di lavoro richiede spese chilometriche basate su mappa o tracciate tramite GPS.',
+ },
},
odometer: {
startReading: 'Inizia a leggere',
@@ -9284,6 +9280,12 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`,
snapPhotoStart: "Scatta una foto del contachilometri all'inizio del viaggio.",
snapPhotoEnd: 'Scatta una foto del contachilometri alla fine del viaggio.',
},
+ commuterExclusion: {
+ original: 'Originale',
+ removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Rimossi ${formattedDistance}`,
+ systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) =>
+ `Rimossi ${formattedDistance} in base alle impostazioni della distanza dello spazio di lavoro.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index cd0d1dd46a70..c9912bd06441 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -520,6 +520,7 @@ const translations: TranslationDeepObject = {
tryAgain: '再試行',
tagGLCode: 'GL コードにタグを付ける',
off: 'オフ',
+ commuter: '通勤者',
noResultsFoundSubtitle: '結果がありません。フィルターや検索キーワードを調整してお試しください',
unableToDisplayChart: 'グラフを表示できません',
webGLNotSupported: 'お使いのブラウザは WebGL に対応していません。有効にするか、別のブラウザに切り替えてください。',
@@ -8294,18 +8295,9 @@ ${reportName}`,
removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `禁止経費から「${prohibitedExpense}」を削除しました`,
commuterExclusions: {
changedToFixedDistance: '通勤分の除外方法を、申請ごとの固定距離に変更しました',
- setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => {
- const isSingular = distance === 1;
- let unitLabel: string;
- if (unit === 'mi') {
- unitLabel = isSingular ? 'マイル' : 'マイル';
- } else {
- unitLabel = isSingular ? 'キロメートル' : 'キロメートル';
- }
- return `1件の経費申請につき固定除外距離を${distance} ${unitLabel}に設定します`;
- },
- changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) =>
- `1件あたりの固定距離除外を${oldDistance} ${unit}から${newDistance} ${unit}に変更しました`,
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `1件の経費申請につき固定除外距離を${formattedDistance}に設定します`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `1件あたりの固定距離除外を${formattedOldDistance}から${formattedNewDistance}に変更しました`,
disabled: '距離レートで通勤を除外する設定を無効にしました',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
@@ -9138,6 +9130,10 @@ ${reportName}`,
},
error: {
selectSuggestedAddress: '候補の住所を選択するか、現在地を使用してください',
+ mapOrGpsDistanceRequired: {
+ title: '地図または GPS の距離が必要です',
+ description: 'このワークスペースでは、地図ベースまたは GPS 追跡による距離経費のいずれかが必要です。',
+ },
},
odometer: {
startReading: '読み始める',
@@ -9154,6 +9150,12 @@ ${reportName}`,
snapPhotoStart: '移動を開始するときに、走行距離計の写真を撮影してください。',
snapPhotoEnd: '走行の終了時に、オドメーターの写真を撮影してください。',
},
+ commuterExclusion: {
+ original: 'オリジナル',
+ removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `${formattedDistance} を削除しました`,
+ systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) =>
+ `ワークスペースの距離設定に基づき、${formattedDistance} を削除しました。`,
+ },
},
gps: {
error: {
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 983cfb709e44..40a1ed511c1e 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -520,6 +520,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.',
@@ -8380,18 +8381,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`,
removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `heeft „${prohibitedExpense}” verwijderd uit verboden uitgaven`,
commuterExclusions: {
changedToFixedDistance: 'heeft ‘woon-werkverkeer uitsluiten’ gewijzigd naar een vaste afstand per declaratie',
- setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => {
- const isSingular = distance === 1;
- let unitLabel: string;
- if (unit === 'mi') {
- unitLabel = isSingular ? 'mijl' : 'mijlen';
- } else {
- unitLabel = isSingular ? 'kilometer' : 'kilometers';
- }
- return `stel vaste afstandsuitsluiting in op ${distance} ${unitLabel} per declaratie`;
- },
- changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) =>
- `heeft de vaste afstandsuitsluiting gewijzigd naar ${newDistance} ${unit} per declaratie (voorheen ${oldDistance} ${unit})`,
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `stel vaste afstandsuitsluiting in op ${formattedDistance} per declaratie`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `heeft de vaste afstandsuitsluiting gewijzigd naar ${formattedNewDistance} per declaratie (voorheen ${formattedOldDistance})`,
disabled: 'uitschakelen woon-werkverkeer uitsluiten voor afstandstarieven',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
@@ -9237,6 +9229,10 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`,
},
error: {
selectSuggestedAddress: 'Selecteer een voorgesteld adres of gebruik de huidige locatie',
+ mapOrGpsDistanceRequired: {
+ title: 'Afstand op kaart of via GPS vereist',
+ description: 'Deze workspace vereist afstandskosten die zijn gebaseerd op een kaart of met GPS zijn gevolgd.',
+ },
},
odometer: {
startReading: 'Begin met lezen',
@@ -9253,6 +9249,12 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`,
snapPhotoStart: 'Maak een foto van je kilometerteller aan het begin van je rit.',
snapPhotoEnd: 'Maak een foto van je kilometerteller aan het einde van je rit.',
},
+ commuterExclusion: {
+ original: 'Origineel',
+ removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `${formattedDistance} verwijderd`,
+ systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) =>
+ `${formattedDistance} verwijderd op basis van werkruimte-instellingen voor afstand.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 8ba7155c1df9..5fd3c64450db 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -520,6 +520,7 @@ const translations: TranslationDeepObject = {
tryAgain: 'Spróbuj ponownie',
tagGLCode: 'Oznacz kod GL',
off: 'Wyłączone',
+ commuter: 'dojeżdżający',
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ę.',
@@ -8358,18 +8359,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`,
removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `usunięto „${prohibitedExpense}” z wydatków zabronionych`,
commuterExclusions: {
changedToFixedDistance: 'zmienił wykluczanie dojazdów na stały dystans na zgłoszenie',
- setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => {
- const isSingular = distance === 1;
- let unitLabel: string;
- if (unit === 'mi') {
- unitLabel = isSingular ? 'mile' : 'mile';
- } else {
- unitLabel = isSingular ? 'kilometr' : 'kilometry';
- }
- return `ustaw stałe wykluczenie dystansu na ${distance} ${unitLabel} na zgłoszenie`;
- },
- changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) =>
- `zmienił stałe wykluczenie dystansu na ${newDistance} ${unit} na zgłoszenie (wcześniej ${oldDistance} ${unit})`,
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `ustaw stałe wykluczenie dystansu na ${formattedDistance} na zgłoszenie`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `zmienił stałe wykluczenie dystansu na ${formattedNewDistance} na zgłoszenie (wcześniej ${formattedOldDistance})`,
disabled: 'wyłączono wykluczanie dojazdów dla stawek za odległość',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
@@ -9215,6 +9207,10 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`,
},
error: {
selectSuggestedAddress: 'Wybierz sugerowany adres lub użyj bieżącej lokalizacji',
+ mapOrGpsDistanceRequired: {
+ title: 'Wymagana odległość z mapy lub GPS',
+ description: 'Toje miejsce pracy wymaga wydatków za przejazdy obliczanych na podstawie mapy lub śledzonych za pomocą GPS.',
+ },
},
odometer: {
startReading: 'Zacznij czytać',
@@ -9231,6 +9227,12 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`,
snapPhotoStart: 'Zrób zdjęcie licznika kilometrów na początku podróży.',
snapPhotoEnd: 'Zrób zdjęcie licznika kilometrów na koniec swojej podróży.',
},
+ commuterExclusion: {
+ original: 'Oryginał',
+ removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Usunięto ${formattedDistance}`,
+ systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) =>
+ `Usunięto ${formattedDistance} na podstawie ustawień odległości w przestrzeni roboczej.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 6e1b8d8a3984..f14b42c40670 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -519,6 +519,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.',
@@ -8367,18 +8368,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`,
removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `removeu "${prohibitedExpense}" das despesas proibidas`,
commuterExclusions: {
changedToFixedDistance: 'alterou "excluir trajetos casa-trabalho" para uma distância fixa por solicitação',
- setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => {
- const isSingular = distance === 1;
- let unitLabel: string;
- if (unit === 'mi') {
- unitLabel = isSingular ? 'milha' : 'milhas';
- } else {
- unitLabel = isSingular ? 'quilômetro' : 'quilômetros';
- }
- return `definir exclusão de distância fixa de ${distance} ${unitLabel} por solicitação`;
- },
- changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) =>
- `alterou a exclusão de distância fixa para ${newDistance} ${unit} por solicitação (antes ${oldDistance} ${unit})`,
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `definir exclusão de distância fixa de ${formattedDistance} por solicitação`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `alterou a exclusão de distância fixa para ${formattedNewDistance} por solicitação (antes ${formattedOldDistance})`,
disabled: 'excluiu deslocamentos diários para tarifas por distância',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) =>
@@ -9225,6 +9217,10 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`,
},
error: {
selectSuggestedAddress: 'Selecione um endereço sugerido ou use a localização atual',
+ mapOrGpsDistanceRequired: {
+ title: 'Distância de mapa ou GPS obrigatória',
+ description: 'Este workspace exige despesas de distância baseadas em mapa ou rastreadas por GPS.',
+ },
},
odometer: {
startReading: 'Começar a ler',
@@ -9241,6 +9237,12 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`,
snapPhotoStart: 'Tire uma foto do odômetro no início da sua viagem.',
snapPhotoEnd: 'Tire uma foto do odômetro no final da sua viagem.',
},
+ commuterExclusion: {
+ original: 'Original',
+ removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `Removeu ${formattedDistance}`,
+ systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) =>
+ `Removido ${formattedDistance} com base nas configurações de distância do workspace.`,
+ },
},
gps: {
error: {
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 7ad0e99a0c82..4ed6af7cbe8f 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -516,6 +516,7 @@ const translations: TranslationDeepObject = {
tryAgain: '重试',
tagGLCode: '标记总账代码',
off: '关',
+ commuter: '通勤者',
noResultsFoundSubtitle: '无结果。请尝试调整筛选条件或搜索内容',
unableToDisplayChart: '无法显示图表',
webGLNotSupported: '您的浏览器不支持 WebGL。请启用该功能或更换浏览器。',
@@ -8123,18 +8124,9 @@ ${reportName}`,
removedProhibitedExpense: ({prohibitedExpense}: {prohibitedExpense: string}) => `已从禁用报销类别中移除“${prohibitedExpense}”`,
commuterExclusions: {
changedToFixedDistance: '已将“排除通勤”更改为“按每次报销固定距离”',
- setFixedDistance: ({distance, unit}: {distance: number; unit: string}) => {
- const isSingular = distance === 1;
- let unitLabel: string;
- if (unit === 'mi') {
- unitLabel = isSingular ? '英里' : '英里';
- } else {
- unitLabel = isSingular ? '公里' : '千米';
- }
- return `将每次报销的固定排除距离设置为 ${distance} ${unitLabel}`;
- },
- changedFixedDistance: ({newDistance, oldDistance, unit}: {newDistance: number; oldDistance: number; unit: string}) =>
- `已将固定距离排除调整为每笔报销 ${newDistance} ${unit}(之前为 ${oldDistance} ${unit})`,
+ setFixedDistance: ({formattedDistance}: {formattedDistance: string}) => `将每次报销的固定排除距离设置为 ${formattedDistance}`,
+ changedFixedDistance: ({formattedOldDistance, formattedNewDistance}: {formattedOldDistance: string; formattedNewDistance: string}) =>
+ `已将固定距离排除调整为每笔报销 ${formattedNewDistance}(之前为 ${formattedOldDistance})`,
disabled: '已停用“排除通勤距离费率”',
},
updatedReimbursementChoice: (newReimbursementChoice: string, oldReimbursementChoice: string) => `将报销方式更改为“${newReimbursementChoice}”(原为“${oldReimbursementChoice}”)`,
@@ -8946,6 +8938,10 @@ ${reportName}`,
},
error: {
selectSuggestedAddress: '请选择一个推荐地址或使用当前位置',
+ mapOrGpsDistanceRequired: {
+ title: '需要地图或 GPS 距离',
+ description: '此工作区要求使用基于地图或 GPS 轨迹的里程报销。',
+ },
},
odometer: {
startReading: '开始阅读',
@@ -8962,6 +8958,12 @@ ${reportName}`,
snapPhotoStart: '在行程开始时拍一张里程表照片。',
snapPhotoEnd: '在行程结束时拍一张里程表的照片。',
},
+ commuterExclusion: {
+ original: '原始',
+ removedCommuterDistance: ({formattedDistance}: {formattedDistance: string}) => `已移除 ${formattedDistance}`,
+ systemMessage: ({formattedDistance, workspaceDistanceSettingsLink}: {formattedDistance: string; workspaceDistanceSettingsLink: string}) =>
+ `已根据工作区距离设置移除 ${formattedDistance}。`,
+ },
},
gps: {
error: {
diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts
index 0578bfd8dc70..89af411e584d 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..8faf706446c8
--- /dev/null
+++ b/src/libs/DistanceDisplayUtils.ts
@@ -0,0 +1,30 @@
+import type {LocaleContextProps} from '@components/LocaleContextProvider';
+
+import CONST from '@src/CONST';
+import type {Unit} from '@src/types/onyx/Policy';
+
+/**
+ * Returns the translated distance unit label based on the distance value (for singular/plural).
+ */
+function getDistanceUnitLabel(distance: number, unit: Unit, translate: LocaleContextProps['translate']): string {
+ const isSingular = distance === 1;
+ if (unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES) {
+ return translate(isSingular ? 'common.mile' : 'common.miles');
+ }
+ return translate(isSingular ? 'common.kilometer' : 'common.kilometers');
+}
+
+/**
+ * Formats a distance that is already expressed in display units (mi/km) into a localized string (e.g. "12.34 miles").
+ */
+function getFormattedDistanceInUnits(distanceInUnits: number, unit: Unit, translate: LocaleContextProps['translate'], useShortFormUnit?: boolean, isCommuterDistance?: boolean): string {
+ const roundedDistance = distanceInUnits.toFixed(CONST.DISTANCE_DECIMAL_PLACES);
+ const unitLabel = useShortFormUnit ? unit : getDistanceUnitLabel(distanceInUnits, unit, translate);
+ if (isCommuterDistance) {
+ return `${roundedDistance} ${translate('common.commuter')} ${unitLabel}`;
+ }
+
+ return `${roundedDistance} ${unitLabel}`;
+}
+
+export {getDistanceUnitLabel, getFormattedDistanceInUnits};
diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts
index 8a1fc433dbdf..9d994ea5de1f 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 {OnyxEntry} from 'react-native-onyx';
@@ -13,9 +14,10 @@ import type {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';
type MileageRate = {
@@ -30,6 +32,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 +211,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 = distanceInUnits === '1' ? singularDistanceUnit : distanceUnit;
-
- return `${distanceInUnits} ${unitString}`;
+ return getFormattedDistanceInUnits(convertDistanceUnit(distanceToDisplayInMeters, displayUnit), displayUnit, translate, useShortFormUnit);
}
function getDistanceForDisplayLabel(distanceInMeters: number, unit: Unit): string {
@@ -251,6 +250,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 +263,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 +322,148 @@ 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 formattedDistance = getFormattedDistanceInUnits(commuterExclusion, unitToUse, translate, false, true);
+
+ return {
+ distanceToDisplayDescription: `${baseLabel} ${CONST.DOT_SEPARATOR} ${translate('distance.commuterExclusion.original')}: ${originalDistanceFormatted}`,
+ distanceToDisplayHintText: translate('distance.commuterExclusion.removedCommuterDistance', {formattedDistance}),
+ };
+}
+
/**
* Converts the distance from kilometers or miles to meters.
*
@@ -684,15 +827,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/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index ce9465e3d98f..f9d57464d14d 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -58,6 +58,7 @@ import {getBankName, isCardPendingActivate} from './CardUtils';
import {getDecodedCategoryName} from './CategoryUtils';
import {convertAmountToDisplayString, convertToBackendAmount, convertToDisplayString, 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';
@@ -4223,26 +4224,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) {
+ 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) {
@@ -4693,6 +4690,24 @@ 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 formattedDistance = getFormattedDistanceInUnits(distanceValue, unitValue, translate, false, true);
+
+ const workspaceDistanceSettingsLink = policyID ? `${environmentURL}/${ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID)}` : '';
+
+ return translate('distance.commuterExclusion.systemMessage', {
+ formattedDistance,
+ workspaceDistanceSettingsLink,
+ });
+}
+
function getManagerOnVacation(action: OnyxEntry): string | undefined {
if (!isApprovedAction(action)) {
return;
@@ -4976,6 +4991,7 @@ export {
isReopenedAction,
isRetractedAction,
getIntegrationSyncFailedMessage,
+ getCommuterExclusionMessage,
getCompanyCardConnectionBrokenMessage,
getPlaidBalanceFailureMessage,
getPolicyChangeLogDefaultReimbursableMessage,
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index a740216af029..9d912cb367cc 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -98,6 +98,7 @@ import getDistanceInMeters from './getDistanceInMeters';
type TransactionParams = {
amount: number;
modifiedAmount?: number;
+ modifiedMerchant?: string;
currency: string;
reportID: string | undefined;
comment?: string;
@@ -185,6 +186,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 +
@@ -331,6 +336,7 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T
const {
amount,
modifiedAmount,
+ modifiedMerchant,
currency,
reportID,
distance,
@@ -417,11 +423,16 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T
if (customUnit) {
lodashSet(commentJSON, 'customUnit', customUnit);
} else {
+ const routeDistanceMeters = routes?.route0?.distance ?? existingTransaction?.routes?.route0?.distance;
+ lodashSet(commentJSON, 'customUnit', existingTransaction?.comment?.customUnit ?? {});
// Set the distance unit, which comes from the policy distance unit or the P2P rate data
lodashSet(commentJSON, 'customUnit.distanceUnit', DistanceRequestUtils.getUpdatedDistanceUnit({transaction: existingTransaction, policy}));
lodashSet(commentJSON, 'customUnit.quantity', distance);
lodashSet(commentJSON, 'customUnit.customUnitRateID', customUnitRateID);
lodashSet(commentJSON, 'customUnit.name', existingTransaction?.comment?.customUnit?.name ?? CONST.CUSTOM_UNITS.NAME_DISTANCE);
+ if (typeof routeDistanceMeters === 'number') {
+ lodashSet(commentJSON, 'customUnit.routeDistanceMeters', routeDistanceMeters);
+ }
}
}
@@ -461,6 +472,7 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T
taxAmount,
taxValue,
modifiedAmount,
+ modifiedMerchant,
billable,
reimbursable,
inserted: DateUtils.getDBTime(),
@@ -838,7 +850,19 @@ function getUpdatedTransaction({
}
const distanceInMeters = getDistanceInMeters(updatedTransaction, unit);
- let amount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0);
+ const commuterExclusionTransactionData = hasAppliedCommuterExclusion(updatedTransaction)
+ ? DistanceRequestUtils.getTransactionCommuterExclusionData({
+ transaction: updatedTransaction,
+ policy,
+ personalPolicyOutputCurrency,
+ })
+ : undefined;
+
+ if (commuterExclusionTransactionData) {
+ lodashSet(updatedTransaction, 'comment.customUnit', commuterExclusionTransactionData.customUnit);
+ }
+
+ let amount = commuterExclusionTransactionData?.modifiedAmount ?? DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0);
amount = isFromExpenseReport || isUnReportedExpense ? -amount : amount;
const updatedCurrency = updatedMileageRate.currency ?? CONST.CURRENCY.USD;
const updatedMerchant = DistanceRequestUtils.getDistanceMerchant(
@@ -851,6 +875,7 @@ function getUpdatedTransaction({
(digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit),
getCurrencySymbol,
isManualDistanceRequest(transaction),
+ DistanceRequestUtils.getCommuterExclusionDisplayData(commuterExclusionTransactionData?.customUnit, unit),
);
// No locally resolvable rate (e.g. track expense without policy loaded) → scale the previous
@@ -3177,6 +3202,7 @@ export {
isGPSDistanceRequest,
isManualDistanceRequest,
isOdometerDistanceRequest,
+ hasAppliedCommuterExclusion,
isDistanceExpenseType,
isFetchingWaypointsFromServer,
hasPendingDistanceReceiptRegeneration,
diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts
index f5e26f4eb45e..3f16db810a74 100644
--- a/src/libs/actions/IOU/MoneyRequest.ts
+++ b/src/libs/actions/IOU/MoneyRequest.ts
@@ -1,4 +1,5 @@
-import type {LocalizedTranslate} from '@components/LocaleContextProvider';
+import type {CurrencyListActionsContextType} from '@components/CurrencyListContextProvider';
+import type {LocalizedTranslate, LocaleContextProps} from '@components/LocaleContextProvider';
import {WRITE_COMMANDS} from '@libs/API/types';
import DateUtils from '@libs/DateUtils';
@@ -102,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,
@@ -768,6 +782,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.
@@ -1013,6 +1082,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 cb0d841ee694..a4071f870fb0 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,
@@ -1300,6 +1301,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma
amount,
distance,
modifiedAmount,
+ modifiedMerchant,
comment = '',
currency,
source = '',
@@ -1399,23 +1401,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.
@@ -1429,18 +1435,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;
}
@@ -1449,15 +1455,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
@@ -1477,6 +1483,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma
amount: shouldNegateAmount ? -amount : amount,
distance,
...(modifiedAmount !== undefined && {modifiedAmount: shouldNegateAmount ? -modifiedAmount : modifiedAmount}),
+ ...(modifiedMerchant !== undefined && {modifiedMerchant}),
currency,
reportID: transactionReportID,
comment,
@@ -1566,7 +1573,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma
// For selfDM split: use TRACK type, split amount, and isPersonalTrackingExpense to match BE response
// (linkedTrackedExpenseReportAction is undefined for new splits - we search by new transaction ID which doesn't exist yet)
const iouActionType = isSelfDMSplit ? CONST.IOU.REPORT_ACTION_TYPE.TRACK : CONST.IOU.REPORT_ACTION_TYPE.CREATE;
- const iouActionAmount = isSplitExpense && modifiedAmount !== undefined ? Math.abs(modifiedAmount) : amount;
+ const iouActionAmount = Math.abs(reportAmount);
const [optimisticCreatedActionForChat, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] =
buildOptimisticMoneyRequestEntities({
iouReport,
diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts
index 697fa680bbc9..e9a05ebcb6cc 100644
--- a/src/libs/actions/IOU/Split.ts
+++ b/src/libs/actions/IOU/Split.ts
@@ -1976,6 +1976,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest
taxValue,
merchant,
modifiedAmount,
+ modifiedMerchant,
billable,
reimbursable,
validWaypoints,
@@ -2115,6 +2116,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest
},
transactionParams: {
amount,
+ modifiedAmount,
+ modifiedMerchant,
distance,
currency,
comment,
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 6a11688e9c1d..78b0c03feed3 100644
--- a/src/libs/actions/Report/index.ts
+++ b/src/libs/actions/Report/index.ts
@@ -167,7 +167,17 @@ import {
} from '@libs/ReportUtils';
import {buildOptimisticSnapshotData, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import playSound, {SOUNDS} from '@libs/Sound';
-import {getAmount, getCurrency, getNegatedAmountTransaction, isOnHold, recalculateUnreportedTransactionDetails, shouldClearConvertedAmount} from '@libs/TransactionUtils';
+import {
+ getAmount,
+ getCurrency,
+ getNegatedAmountTransaction,
+ hasAppliedCommuterExclusion,
+ isOnHold,
+ isManualDistanceRequest,
+ isOdometerDistanceRequest,
+ recalculateUnreportedTransactionDetails,
+ shouldClearConvertedAmount,
+} from '@libs/TransactionUtils';
import {getSearchParamFromPath} from '@libs/Url';
import {buildSecureDownloadURL} from '@libs/UrlUtils';
import Visibility from '@libs/Visibility';
@@ -7713,6 +7723,13 @@ function buildOptimisticChangePolicyData({
return {optimisticData, successData, failureData, optimisticReportPreviewAction, optimisticMovedReportAction};
}
+function shouldBlockChangeReportPolicyForCommuterExclusion(reportID: string | undefined, policy: Policy): boolean {
+ const hasTargetPolicyCommuterExclusions = !!policy.commuterExclusions;
+ return getReportTransactions(reportID).some(
+ (transaction) => hasAppliedCommuterExclusion(transaction) || (hasTargetPolicyCommuterExclusions && (isManualDistanceRequest(transaction) || isOdometerDistanceRequest(transaction))),
+ );
+}
+
/**
* Changes the policy of a report and all its child reports, and moves the report to the new policy's expense chat.
*/
@@ -7747,7 +7764,7 @@ function changeReportPolicy({
reportPreviewAction: OnyxEntry;
isTrackIntentUser: boolean | undefined;
}) {
- if (!report || !policy || report.policyID === policy.id || !isExpenseReport(report)) {
+ if (!report || !policy || report.policyID === policy.id || !isExpenseReport(report) || shouldBlockChangeReportPolicyForCommuterExclusion(report.reportID, policy)) {
return;
}
@@ -7820,7 +7837,15 @@ function changeReportPolicyAndInviteSubmitter({
reportPreviewAction: OnyxEntry;
isTrackIntentUser: boolean | undefined;
}) {
- if (!report.reportID || !policy?.id || report.policyID === policy.id || !isExpenseReport(report) || !report.ownerAccountID || !submitterLogin) {
+ if (
+ !report.reportID ||
+ !policy?.id ||
+ report.policyID === policy.id ||
+ !isExpenseReport(report) ||
+ !report.ownerAccountID ||
+ !submitterLogin ||
+ shouldBlockChangeReportPolicyForCommuterExclusion(report.reportID, policy)
+ ) {
return;
}
diff --git a/src/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 7f6870fa51a6..1edfc79b06dc 100644
--- a/src/pages/DynamicReportChangeWorkspacePage.tsx
+++ b/src/pages/DynamicReportChangeWorkspacePage.tsx
@@ -6,6 +6,7 @@ import SelectionList from '@components/SelectionList';
import type {WorkspaceListItemType} from '@components/SelectionList/ListItem/types';
import UserListItem from '@components/SelectionList/ListItem/UserListItem';
+import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useDebouncedState from '@hooks/useDebouncedState';
import useDynamicBackPath from '@hooks/useDynamicBackPath';
@@ -35,6 +36,7 @@ import {
isWorkspaceEligibleForReportChange,
} from '@libs/ReportUtils';
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
+import {hasAppliedCommuterExclusion, isManualDistanceRequest, isOdometerDistanceRequest} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -91,6 +93,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 shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({
+ isManualDistanceRequest: hasManualDistanceRequest,
+ isOdometerDistanceRequest: hasOdometerDistanceRequest,
+ });
const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector});
const selectPolicy = (policyID?: string) => {
@@ -98,6 +107,9 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace
if (!policyID || !policy) {
return;
}
+ if (shouldBlockManualOrOdometerDistanceRequest(policyID)) {
+ return;
+ }
if (shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriods, amountOwed, currentUserPersonalDetails.accountID)) {
Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id));
return;
@@ -194,7 +206,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 ed2eb4ba9015..175965b9d667 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,
@@ -468,6 +469,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/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx
index 78e511430331..aa1123911b13 100644
--- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx
+++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx
@@ -186,7 +186,7 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps)
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 1f2202d02864..fc0fe88a416a 100644
--- a/src/pages/iou/request/DistanceRequestStartPage.tsx
+++ b/src/pages/iou/request/DistanceRequestStartPage.tsx
@@ -3,6 +3,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import TabSelector from '@components/TabSelector/TabSelector';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import usePolicyForTransaction from '@hooks/usePolicyForTransaction';
@@ -13,6 +14,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import Navigation from '@libs/Navigation/Navigation';
import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator';
+import {getActivePolicies, isGroupPolicy} from '@libs/PolicyUtils';
import {getPayeeName} from '@libs/ReportUtils';
import {endSpan} from '@libs/telemetry/activeSpans';
@@ -56,6 +58,14 @@ function DistanceRequestStartPage({
const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE);
const isLoadingSelectedTab = isLoadingOnyxValue(selectedTabResult);
const isTrackDistanceExpense = iouType === CONST.IOU.TYPE.TRACK;
+ const {login} = useCurrentUserPersonalDetails();
+
+ const [shouldHideManualAndOdometerTabs] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {
+ selector: (allPolicies) => {
+ const activeGroupPolicies = getActivePolicies(allPolicies ?? null, login).filter(isGroupPolicy);
+ return activeGroupPolicies.length > 0 && activeGroupPolicies.every((activePolicy) => !!activePolicy.commuterExclusions);
+ },
+ });
const tabTitles = {
[CONST.IOU.TYPE.REQUEST]: translate('iou.trackDistance'),
@@ -146,16 +156,18 @@ function DistanceRequestStartPage({
)}
-
- {() => (
-
-
-
- )}
-
+ {!shouldHideManualAndOdometerTabs && (
+
+ {() => (
+
+
+
+ )}
+
+ )}
{() => (
@@ -166,16 +178,18 @@ function DistanceRequestStartPage({
)}
-
- {() => (
-
-
-
- )}
-
+ {!shouldHideManualAndOdometerTabs && (
+
+ {() => (
+
+
+
+ )}
+
+ )}
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index 3446132b55c7..b23727096426 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -10,6 +10,7 @@ import ParticipantPicker from '@components/ParticipantPicker';
import PrevNextButtons from '@components/PrevNextButtons';
import ScreenWrapper from '@components/ScreenWrapper';
+import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard';
import useConfirmModal from '@hooks/useConfirmModal';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useDefaultParticipants from '@hooks/useDefaultParticipants';
@@ -233,6 +234,11 @@ function IOURequestStepConfirmation({
const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction);
const isManualRequest = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL;
const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction);
+ const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({
+ policyID: policy?.id,
+ isManualDistanceRequest,
+ isOdometerDistanceRequest,
+ });
const isTimeRequest = requestType === CONST.IOU.REQUEST_TYPE.TIME;
const [lastLocationPermissionPrompt] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT);
const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES);
@@ -331,6 +337,9 @@ function IOURequestStepConfirmation({
return;
}
const selectedParticipant = participantsList.at(0);
+ if (shouldBlockManualOrOdometerDistanceRequest(selectedParticipant?.policyID)) {
+ return;
+ }
// P2P chats don't support negative amounts. When a negative amount was entered before a participant
// was selected (e.g. "Submit it to someone" from a self DM), assigning it to a P2P participant would
// fail at submit, so keep the expense on the self DM (its default) instead of assigning the P2P
@@ -398,6 +407,7 @@ function IOURequestStepConfirmation({
lastSelectedDistanceRates,
transaction,
personalPolicy?.outputCurrency,
+ shouldBlockManualOrOdometerDistanceRequest,
],
);
diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts
index 4244211a0834..249602b7fad5 100644
--- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts
+++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts
@@ -367,6 +367,8 @@ function handleMoneyRequestStepDistanceNavigation({
existingTransaction: transaction,
transactionParams: {
amount,
+ ...(typeof transaction?.modifiedAmount === 'number' && {modifiedAmount: transaction.modifiedAmount}),
+ ...(transaction?.modifiedMerchant && {modifiedMerchant: transaction.modifiedMerchant}),
distance,
comment: '',
created: transaction?.created ?? '',
diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx
index 1fc2ae19097f..7e58d52ade9d 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 useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy';
import useDelegateAccountID from '@hooks/useDelegateAccountID';
import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation';
@@ -90,6 +91,11 @@ function IOURequestStepDistanceManual({
const {policyForMovingExpenses} = usePolicyForMovingExpenses();
const reportAttributesDerived = useReportAttributes();
+ const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({
+ policyID: policy?.id ?? defaultExpensePolicy?.id,
+ isManualDistanceRequest: true,
+ });
+
const [selectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.DISTANCE_REQUEST_TYPE}`);
const distanceOriginalPolicy = useDistanceRateOriginalPolicy(transaction?.comment?.customUnit?.customUnitRateID);
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`);
@@ -300,6 +306,10 @@ function IOURequestStepDistanceManual({
};
const submitAndNavigateToNextPage = () => {
+ if (shouldBlockManualOrOdometerDistanceRequest()) {
+ 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 3002e5689eff..37d973a42529 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 useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy';
import useDelegateAccountID from '@hooks/useDelegateAccountID';
import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation';
@@ -128,6 +129,10 @@ function IOURequestStepDistanceOdometer({
const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`);
const personalPolicy = usePersonalPolicy();
const defaultExpensePolicy = useDefaultExpensePolicy();
+ const shouldBlockManualOrOdometerDistanceRequest = useCommuterExclusionGuard({
+ policyID: policy?.id ?? defaultExpensePolicy?.id,
+ isOdometerDistanceRequest: true,
+ });
const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED);
const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END);
const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END);
@@ -468,6 +473,10 @@ function IOURequestStepDistanceOdometer({
// Handle form submission with validation
const handleNext = () => {
+ if (shouldBlockManualOrOdometerDistanceRequest()) {
+ 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/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts
index 4e40e78e941b..9ec2b839e361 100644
--- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts
+++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts
@@ -764,6 +764,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) {
},
transactionParams: {
amount: transaction.amount,
+ ...(typeof transaction.modifiedAmount === 'number' && {modifiedAmount: transaction.modifiedAmount}),
+ ...(transaction.modifiedMerchant && {modifiedMerchant: transaction.modifiedMerchant}),
comment: trimmedComment,
distance: transactionDistance,
created: transaction.created,
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 167081fda20a..47ba5c7dee64 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -6268,6 +6268,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 e666e9690f4f..103e9872cec2 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -1497,6 +1497,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
*/
@@ -1745,6 +1758,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 c3b1caf12d6b..f9cbe6e24e68 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/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 f6ee3ae78ec5..483b1c0194bd 100644
--- a/tests/unit/TransactionUtilsTest.ts
+++ b/tests/unit/TransactionUtilsTest.ts
@@ -447,6 +447,7 @@ describe('TransactionUtils', () => {
},
};
const transaction = generateTransaction({
+ iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP,
comment: {
customUnit: {
distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
@@ -487,6 +488,61 @@ describe('TransactionUtils', () => {
});
});
+ it('recalculates commuter exclusion data when distance is changed', () => {
+ const fakePolicy: Policy = {
+ ...createRandomPolicy(0),
+ commuterExclusions: {
+ method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE,
+ fixedDistance: 3,
+ fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ },
+ customUnits: {
+ distance: {
+ name: CONST.CUSTOM_UNITS.NAME_DISTANCE,
+ customUnitID: 'distance',
+ rates: {
+ default: {
+ customUnitRateID: '1',
+ currency: CONST.CURRENCY.USD,
+ rate: 1,
+ },
+ },
+ attributes: {
+ unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ },
+ },
+ },
+ };
+ const transaction = generateTransaction({
+ 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,
+ });
+
+ expect(updatedTransaction.comment?.customUnit?.quantity).toBe(20);
+ expect(updatedTransaction.comment?.customUnit?.commuterExclusion).toBe(3);
+ expect(updatedTransaction.comment?.customUnit?.reimbursableDistance).toBe(17);
+ expect(updatedTransaction.modifiedAmount).toBe(17);
+ expect(updatedTransaction.modifiedMerchant).toContain('17');
+ expect(updatedTransaction.modifiedMerchant).not.toContain('20');
+ });
+
it('threads personalPolicyOutputCurrency into the recalculated rate for a P2P distance expense with no policy', async () => {
// A P2P distance expense (FAKE_P2P_ID) has no policy rate, so the mileage currency comes from the
// resolved personal-policy currency that getUpdatedTransaction forwards to getRate. getRateForP2P only
diff --git a/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/DistanceRequestController.test.tsx
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/useDistanceRequestState.test.ts b/tests/unit/hooks/useDistanceRequestState.test.ts
index 9e7c96a4fd47..ee9fb84be75b 100644
--- a/tests/unit/hooks/useDistanceRequestState.test.ts
+++ b/tests/unit/hooks/useDistanceRequestState.test.ts
@@ -10,6 +10,8 @@ jest.mock('@libs/DistanceRequestUtils', () => ({
getDefaultMileageRate: () => undefined,
getRate: () => ({rate: 0.5, unit: 'mi', currency: 'USD'}),
getDistanceRequestAmount: (distance: number, _unit: string, rate: number): number => Math.round(distance * rate * 100),
+ getCommuterExclusionData: () => null,
+ convertToDistanceInMeters: (distance: number): number => distance,
},
}));