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, }, }));