diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 01ea745e5731..9624408b5dbb 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -25,7 +25,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; -import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; +import useDisplayTransaction from '@hooks/useDisplayTransaction'; import useEnvironment from '@hooks/useEnvironment'; import useHasMultipleSplitChildren from '@hooks/useHasMultipleSplitChildren'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -66,7 +66,6 @@ import { canShowReimbursableToggleForPolicy, canSubmitPerDiemExpenseFromWorkspace, findVendorByID, - getDistanceRateCustomUnitRate, getLengthOfTag, getPerDiemCustomUnit, getPolicyByCustomUnitID, @@ -106,7 +105,6 @@ import { getCurrency, getDescription, getDetailedExpenseTypeTranslationKey, - getDisplayTransactionWithoutInvalidCommuterExclusion, getDistanceInMeters, getFormattedCreated, getOriginalAmountForDisplay, @@ -253,9 +251,9 @@ function MoneyRequestView({ const isPerDiemRequest = isPerDiemRequestTransactionUtils(transaction); const perDiemOriginalPolicy = getPolicyByCustomUnitID(transaction, policiesWithPerDiem); - const customUnitRateID = isDistanceRequestTransactionUtils(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; - const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(expensePolicy, customUnitRateID); - const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); + const [reportPolicyType] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${parentReport?.policyID}`, {selector: policyTypeSelector}); + // Collect/Control (group) policies are always tied to an expense chat, so a group policy type means this is a policy expense chat. + const isPolicyExpenseChat = isGroupPolicyByType(reportPolicyType); let policy; let policyID; @@ -272,6 +270,8 @@ function MoneyRequestView({ policyID = parentReport?.policyID; } + const {displayTransaction, distanceOriginalPolicy} = useDisplayTransaction(transaction, isPolicyExpenseChat, policy, expensePolicy); + // Use the report's real policy, not `policy` above (swapped to an unrelated workspace for // unreported expenses), else self-DM split editing wrongly redirects to RESTRICTED_ACTION. const restrictedActionPolicyID = useRestrictedActionPolicyID(expensePolicy); @@ -303,10 +303,6 @@ function MoneyRequestView({ const isApproved = isReportApproved({report: moneyRequestReport}); const isInvoice = isInvoiceReport(moneyRequestReport); const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(transactionThreadReport, moneyRequestReport, parentReportAction); - const [reportPolicyType] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport?.policyID}`, {selector: policyTypeSelector}); - // Collect/Control (group) policies are always tied to an expense chat, so a group policy type means this is a policy expense chat. - const isPolicyExpenseChat = isGroupPolicyByType(reportPolicyType); - let iouType: ValueOf; if (isTrackExpense) { iouType = CONST.IOU.TYPE.TRACK; @@ -317,14 +313,6 @@ function MoneyRequestView({ } const allowNegativeAmount = shouldEnableNegative(parentReport, policy, iouType); - const displayTransaction = getDisplayTransactionWithoutInvalidCommuterExclusion({ - transaction, - isPolicyExpenseChat, - policy: distanceOriginalPolicy ?? policy, - translate, - getCurrencySymbol, - }); - const { created: transactionDate, amount: transactionAmount, diff --git a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx index 66b34ce94dd5..13561695126e 100644 --- a/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx +++ b/src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx @@ -62,6 +62,7 @@ function TransactionPreviewContent({ report, policy, transaction, + displayTransaction, violations, transactionRawAmount, offlineWithFeedbackOnClose, @@ -86,8 +87,8 @@ function TransactionPreviewContent({ const {environmentURL} = useEnvironment(); const isParentPolicyExpenseChat = isPolicyExpenseChat(chatReport); const transactionDetails = useMemo>( - () => getTransactionDetails(transaction, undefined, policy, isParentPolicyExpenseChat) ?? {}, - [transaction, policy, isParentPolicyExpenseChat], + () => getTransactionDetails(displayTransaction, undefined, policy, isParentPolicyExpenseChat) ?? {}, + [displayTransaction, policy, isParentPolicyExpenseChat], ); const {amount, comment: requestComment, merchant, category, currency: requestCurrency} = transactionDetails; const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`); diff --git a/src/components/ReportActionItem/TransactionPreview/index.tsx b/src/components/ReportActionItem/TransactionPreview/index.tsx index b1850100660e..2294aab2a76d 100644 --- a/src/components/ReportActionItem/TransactionPreview/index.tsx +++ b/src/components/ReportActionItem/TransactionPreview/index.tsx @@ -3,6 +3,7 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeed import {showContextMenuForReport, useShowContextMenuActions, useShowContextMenuState} from '@components/ShowContextMenuContext'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useDisplayTransaction from '@hooks/useDisplayTransaction'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useTransactionViolations from '@hooks/useTransactionViolations'; @@ -11,6 +12,7 @@ import ControlSelection from '@libs/ControlSelection'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; +import {isGroupPolicyByType} from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction as isMoneyRequestActionReportActionsUtils} from '@libs/ReportActionsUtils'; import {getTransactionDetails} from '@libs/ReportUtils'; import {getReviewNavigationRoute} from '@libs/TransactionPreviewUtils'; @@ -67,7 +69,10 @@ function TransactionPreview(props: TransactionPreviewProps) { const sessionAccountID = session?.accountID; const areThereDuplicates = allDuplicateIDs.length > 0 && duplicates.length > 0 && allDuplicateIDs.length === duplicates.length; - const transactionDetails = getTransactionDetails(transaction); + const transactionPreview = onPreviewPressed ? transaction : (originalTransaction ?? transaction); + const isParentPolicyExpenseChat = isGroupPolicyByType(policy?.type); + const {displayTransaction} = useDisplayTransaction(transactionPreview, isParentPolicyExpenseChat, policy); + const transactionDetails = getTransactionDetails(displayTransaction); const {amount: requestAmount, currency: requestCurrency} = transactionDetails ?? {}; const contextMenuReportID = contextAction ? chatReportID : reportID; @@ -88,8 +93,6 @@ function TransactionPreview(props: TransactionPreviewProps) { const navigateToReviewFields = () => Navigation.navigate(getReviewNavigationRoute(Navigation.getActiveRoute(), route.params?.reportID, transaction, duplicates, policy, policyCategories, policyTags ?? {}, report)); - const transactionPreview = transaction; - const {isBillSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); // See description of `transactionRawAmount` prop for more context @@ -118,6 +121,7 @@ function TransactionPreview(props: TransactionPreviewProps) { chatReport={chatReport} personalDetails={personalDetails} transaction={transactionPreview} + displayTransaction={displayTransaction} transactionRawAmount={transactionRawAmount} report={report} policy={policy} @@ -142,7 +146,8 @@ function TransactionPreview(props: TransactionPreviewProps) { isBillSplit={isBillSplit} chatReport={chatReport} personalDetails={personalDetails} - transaction={originalTransaction ?? transaction} + transaction={transactionPreview} + displayTransaction={displayTransaction} transactionRawAmount={transactionRawAmount} report={report} policy={policy} diff --git a/src/components/ReportActionItem/TransactionPreview/types.ts b/src/components/ReportActionItem/TransactionPreview/types.ts index 9bec44e4b950..42b28a70faac 100644 --- a/src/components/ReportActionItem/TransactionPreview/types.ts +++ b/src/components/ReportActionItem/TransactionPreview/types.ts @@ -82,6 +82,9 @@ type TransactionPreviewContentProps = { transaction: OnyxEntry; + /** Transaction normalized for display-only values. */ + displayTransaction: OnyxEntry; + /** The amount of the transaction saved in the database. This is used to deduce who is the sender and who is the receiver of the money request * In case of Splits the property `transaction` is actually an original transaction (for the whole split) and it does not have the data required to deduce who is the sender */ transactionRawAmount: number; diff --git a/src/hooks/useDisplayTransaction.ts b/src/hooks/useDisplayTransaction.ts new file mode 100644 index 000000000000..65225633f8b0 --- /dev/null +++ b/src/hooks/useDisplayTransaction.ts @@ -0,0 +1,46 @@ +import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; +import {getDisplayTransactionWithoutInvalidCommuterExclusion, isDistanceRequest} from '@libs/TransactionUtils'; + +import type {Policy, Transaction} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useCurrencyListActions} from './useCurrencyList'; +import useDistanceRateOriginalPolicy from './useDistanceRateOriginalPolicy'; +import useLocalize from './useLocalize'; + +type UseDisplayTransactionResult = { + displayTransaction: OnyxEntry; + distanceOriginalPolicy: OnyxEntry; +}; + +/** + * Normalizes a transaction for display, keeping commuter-reduced values in policy + * expense chats and restoring full-route values elsewhere when possible. + * + * @param policy Fallback policy used for display normalization when no original policy is found. + * @param rateLookupPolicy Policy checked for the selected rate before searching other policies. Defaults to policy. + */ +function useDisplayTransaction( + transaction: OnyxEntry, + isPolicyExpenseChat: boolean, + policy?: OnyxEntry, + rateLookupPolicy: OnyxEntry = policy, +): UseDisplayTransactionResult { + const {translate} = useLocalize(); + const {getCurrencySymbol} = useCurrencyListActions(); + const customUnitRateID = isDistanceRequest(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; + const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(rateLookupPolicy, customUnitRateID); + const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); + const displayTransaction = getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat, + policy: distanceOriginalPolicy ?? policy, + translate, + getCurrencySymbol, + }); + + return {displayTransaction, distanceOriginalPolicy}; +} + +export default useDisplayTransaction; diff --git a/src/libs/TransactionUtils/getUpdatedTransaction.ts b/src/libs/TransactionUtils/getUpdatedTransaction.ts index 36c51421678b..d2568add1404 100644 --- a/src/libs/TransactionUtils/getUpdatedTransaction.ts +++ b/src/libs/TransactionUtils/getUpdatedTransaction.ts @@ -135,6 +135,7 @@ function getUpdatedTransaction({ policy = undefined, policies = undefined, isSplitTransaction = false, + shouldApplyCommuterExclusion = true, personalPolicyOutputCurrency, getCurrencyDecimals, getCurrencySymbol, @@ -146,6 +147,7 @@ function getUpdatedTransaction({ policy?: OnyxEntry; policies?: OnyxCollection; isSplitTransaction?: boolean; + shouldApplyCommuterExclusion?: boolean; personalPolicyOutputCurrency: string | undefined; getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; @@ -228,15 +230,16 @@ function getUpdatedTransaction({ lodashSet(updatedTransaction, 'comment.customUnit.routeDistanceMeters', distanceInMeters); } - const commuterExclusionTransactionData = hasAppliedCommuterExclusion(updatedTransaction) - ? DistanceRequestUtils.getTransactionCommuterExclusionData({ - transaction: updatedTransaction, - policy, - storedCustomUnit: transaction?.comment?.customUnit, - personalPolicyOutputCurrency, - hasTripChanged: waypointsActuallyChanged, - }) - : undefined; + const commuterExclusionTransactionData = + shouldApplyCommuterExclusion && hasAppliedCommuterExclusion(updatedTransaction) + ? DistanceRequestUtils.getTransactionCommuterExclusionData({ + transaction: updatedTransaction, + policy, + storedCustomUnit: transaction?.comment?.customUnit, + personalPolicyOutputCurrency, + hasTripChanged: waypointsActuallyChanged, + }) + : undefined; if (commuterExclusionTransactionData) { lodashSet(updatedTransaction, 'comment.customUnit', commuterExclusionTransactionData.customUnit); @@ -347,14 +350,15 @@ function getUpdatedTransaction({ // The commuter exclusion is expressed in distance units, so it has to be re-derived against the new // rate's unit and the converted quantity. The amount and merchant then describe the reimbursable // distance, matching what the backend stores for modifiedAmount/modifiedMerchant. - const commuterExclusionTransactionData = hasAppliedCommuterExclusion(updatedTransaction) - ? DistanceRequestUtils.getTransactionCommuterExclusionData({ - transaction: updatedTransaction, - policy, - storedCustomUnit: transaction?.comment?.customUnit, - personalPolicyOutputCurrency, - }) - : undefined; + const commuterExclusionTransactionData = + shouldApplyCommuterExclusion && hasAppliedCommuterExclusion(updatedTransaction) + ? DistanceRequestUtils.getTransactionCommuterExclusionData({ + transaction: updatedTransaction, + policy, + storedCustomUnit: transaction?.comment?.customUnit, + personalPolicyOutputCurrency, + }) + : undefined; if (commuterExclusionTransactionData) { lodashSet(updatedTransaction, 'comment.customUnit', commuterExclusionTransactionData.customUnit); @@ -373,7 +377,9 @@ function getUpdatedTransaction({ DistanceRequestUtils.getCommuterExclusionDisplayData(commuterExclusionTransactionData?.customUnit, unit), ); - updatedTransaction.amount = updatedAmount; + if (!commuterExclusionTransactionData) { + updatedTransaction.amount = updatedAmount; + } updatedTransaction.modifiedAmount = updatedAmount; updatedTransaction.modifiedMerchant = updatedMerchant; updatedTransaction.modifiedCurrency = updatedCurrency; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 2c8630740931..96d329708dd4 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -60,6 +60,7 @@ import getDistanceInMeters from './getDistanceInMeters'; import getSelectedRouteKey from './getSelectedRouteKey'; // eslint-disable-next-line import/no-cycle import {getClearedPendingFields, getDistanceMerchantForTransaction, getUpdatedTransaction} from './getUpdatedTransaction'; +import parseDistanceMerchant, {getDistanceMerchantPrefix} from './parseDistanceMerchant'; // eslint-disable-next-line import/no-cycle import { calculateTaxAmount, @@ -179,18 +180,198 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ return transaction; } + const modifiedAmount = hasValidModifiedAmount(transaction) ? Number(transaction.modifiedAmount) : undefined; + const storedUnit = customUnit?.distanceUnit; + const reducedDistance = customUnit?.reimbursableDistance; + const storedAmount = modifiedAmount ?? transaction.amount; + const storedMerchant = transaction.modifiedMerchant ?? transaction.merchant; const mileageRate = DistanceRequestUtils.getRateByCustomUnitRateIDAcrossPolicies({customUnitRateID: customUnit?.customUnitRateID, policy, policies}); const rate = mileageRate?.rate; const unit = customUnit?.distanceUnit ?? mileageRate?.unit; - if (!unit || !rate) { + // A rate edit can leave the preferred merchant describing the full route. Keep its saved rate + // and amount, and only clear the stale commuter fields. + if (parseDistanceMerchant({merchant: storedMerchant, distance: fullDistance, unit: storedUnit})) { + return { + ...transaction, + amount: storedAmount, + convertedAmount: undefined, + modifiedAmount: undefined, + merchant: storedMerchant, + modifiedMerchant: undefined, + }; + } + + // Base fields can preserve the full route while modified fields contain the commuter-reduced + // snapshot. Matching rate text confirms both snapshots use the same historical rate. + if ( + storedUnit && + typeof reducedDistance === 'number' && + reducedDistance >= 0 && + reducedDistance < fullDistance && + (!transaction.modifiedCurrency || transaction.modifiedCurrency === transaction.currency) + ) { + const snapshotCurrency = getCurrency(transaction); + const parsedFullMerchant = parseDistanceMerchant({ + merchant: transaction.merchant, + distance: fullDistance, + unit: storedUnit, + currency: snapshotCurrency, + currencySymbol: getCurrencySymbol(snapshotCurrency), + }); + const fullRateText = parsedFullMerchant?.rateText; + const reducedRateText = parseDistanceMerchant({merchant: transaction.modifiedMerchant, distance: reducedDistance, unit: storedUnit})?.rateText; + if (fullRateText && fullRateText === reducedRateText && Number.isFinite(transaction.amount) && (modifiedAmount === undefined || Number.isFinite(modifiedAmount))) { + const roundedFullDistance = Number(fullDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); + const roundedReducedDistance = Number(reducedDistance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); + if (roundedFullDistance > 0) { + // The stored total is rounded to the nearest minor currency unit, so its unrounded + // value lies within +/-0.5 of that total. Dividing those bounds by distance gives + // the possible historical-rate range; if the whole range reproduces modifiedAmount, + // it was an automatic commuter reduction rather than a manual edit. + const lowerRate = Math.max(0, (Math.abs(transaction.amount) - 0.5) / roundedFullDistance); + const upperRate = (Math.abs(transaction.amount) + 0.5) / roundedFullDistance; + const calculatedReducedAmount = Math.round(roundedReducedDistance * lowerRate); + let isCalculatedAmount = + modifiedAmount === undefined || (calculatedReducedAmount === Math.round(roundedReducedDistance * upperRate) && calculatedReducedAmount === Math.abs(modifiedAmount)); + if (!isCalculatedAmount && modifiedAmount !== undefined) { + const savedRate = parsedFullMerchant?.rate; + // Validate both snapshots with their historical rate before consulting a mutable policy rate. + if (savedRate !== undefined) { + const fullDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(roundedFullDistance, storedUnit); + const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(roundedReducedDistance, storedUnit); + isCalculatedAmount = + Math.abs(transaction.amount) === DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, storedUnit, savedRate) && + Math.abs(modifiedAmount) === DistanceRequestUtils.getDistanceRequestAmount(reducedDistanceInMeters, storedUnit, savedRate); + } + } + if (!isCalculatedAmount && modifiedAmount !== undefined && typeof rate === 'number' && unit === storedUnit) { + const resolvedCurrency = mileageRate?.currency ?? getCurrency(transaction); + const savedCurrency = getCurrency(transaction); + if (resolvedCurrency === savedCurrency) { + const fullDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(roundedFullDistance, unit); + const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(roundedReducedDistance, unit); + const fullMerchantAtResolvedRate = getDistanceMerchantForTransaction({ + transaction, + distanceInMeters: fullDistanceInMeters, + unit, + rate, + currency: resolvedCurrency, + translate, + getCurrencySymbol, + }); + const reducedMerchantAtResolvedRate = getDistanceMerchantForTransaction({ + transaction, + distanceInMeters: reducedDistanceInMeters, + unit, + rate, + currency: resolvedCurrency, + translate, + getCurrencySymbol, + }); + isCalculatedAmount = + transaction.merchant === fullMerchantAtResolvedRate && + transaction.modifiedMerchant === reducedMerchantAtResolvedRate && + Math.abs(transaction.amount) === DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, rate) && + Math.abs(modifiedAmount) === DistanceRequestUtils.getDistanceRequestAmount(reducedDistanceInMeters, unit, rate); + } + } + const amountToDisplay = isCalculatedAmount ? Math.abs(transaction.amount) : Math.abs(modifiedAmount ?? transaction.amount); + return { + ...transaction, + amount: (modifiedAmount ?? transaction.amount) < 0 ? -amountToDisplay : amountToDisplay, + convertedAmount: undefined, + modifiedAmount: undefined, + modifiedMerchant: undefined, + }; + } + } + } + + if (!unit) { return transaction; } const fullDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(fullDistance, unit); + const storedReducedMerchant = transaction.modifiedMerchant ?? transaction.merchant; + const storedReducedAmount = modifiedAmount ?? transaction.amount; + const savedCurrencyForRate = getCurrency(transaction); + const parsedReducedMerchant = parseDistanceMerchant({ + merchant: storedReducedMerchant, + distance: reducedDistance, + unit: storedUnit, + currency: savedCurrencyForRate, + currencySymbol: getCurrencySymbol(savedCurrencyForRate), + }); + // Only allow proportional restoration when both modified fields are available and the merchant + // identifies the saved reduced distance; otherwise the full route cannot be reconstructed safely. + const canRestoreSavedReducedValues = + modifiedAmount !== undefined && + !!transaction.modifiedMerchant && + !!storedUnit && + typeof reducedDistance === 'number' && + reducedDistance > 0 && + reducedDistance < fullDistance && + Number.isFinite(storedReducedAmount) && + !!parsedReducedMerchant; + function restoreSavedReducedValues(): OnyxEntry { + if (!transaction || !unit || !storedUnit || typeof fullDistance !== 'number' || !canRestoreSavedReducedValues || !parsedReducedMerchant || typeof reducedDistance !== 'number') { + return transaction; + } + + const rateText = parsedReducedMerchant.rateText.trim(); + let fullAmount = Math.round((Math.abs(storedReducedAmount ?? 0) * fullDistance) / reducedDistance); + const savedRate = parsedReducedMerchant.rate; + if (savedRate !== undefined) { + const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(reducedDistance, unit); + if (DistanceRequestUtils.getDistanceRequestAmount(reducedDistanceInMeters, unit, savedRate) === Math.abs(storedReducedAmount)) { + fullAmount = DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, savedRate); + } + } + return { + ...transaction, + amount: (storedReducedAmount ?? 0) < 0 ? -fullAmount : fullAmount, + convertedAmount: undefined, + modifiedAmount: undefined, + merchant: `${getDistanceMerchantPrefix(fullDistance, storedUnit)}${rateText}`, + modifiedMerchant: undefined, + }; + } + + // When the rate cannot be resolved from policy data, recover from the available reduced snapshot + // instead of leaving commuter values in a personal report. + if (!rate) { + return restoreSavedReducedValues(); + } + const fullDistanceAmount = DistanceRequestUtils.getDistanceRequestAmount(fullDistanceInMeters, unit, rate); - const storedAmount = hasValidModifiedAmount(transaction) ? Number(transaction.modifiedAmount) : (transaction.amount ?? 0); - const normalizedAmount = storedAmount < 0 ? -fullDistanceAmount : fullDistanceAmount; + const normalizedAmount = (storedAmount ?? 0) < 0 ? -fullDistanceAmount : fullDistanceAmount; const currency = mileageRate?.currency ?? getCurrency(transaction); + const savedCurrency = getCurrency(transaction); + // Identical symbols cannot prove that the expense used the newly resolved currency, so avoid + // a potentially incorrect conversion. + if (currency !== savedCurrency && getCurrencySymbol(currency) === getCurrencySymbol(savedCurrency)) { + return transaction; + } + + // Verify the live policy rate against the saved reduced merchant. If they do not match, preserve + // the historical snapshot instead of risking a reprice from changed or differently formatted data. + if (canRestoreSavedReducedValues && typeof reducedDistance === 'number') { + const reducedDistanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(reducedDistance, unit); + const merchantAtCurrentRate = getDistanceMerchantForTransaction({ + transaction, + distanceInMeters: reducedDistanceInMeters, + unit, + rate, + currency, + translate, + getCurrencySymbol, + }); + // Rate values use a locale-dependent decimal separator, while the saved merchant keeps the locale used at edit time. + if (storedReducedMerchant.replaceAll(',', '.') !== merchantAtCurrentRate.replaceAll(',', '.')) { + return restoreSavedReducedValues(); + } + } + const normalizedMerchant = getDistanceMerchantForTransaction({ transaction, distanceInMeters: fullDistanceInMeters, diff --git a/src/libs/TransactionUtils/parseDistanceMerchant.ts b/src/libs/TransactionUtils/parseDistanceMerchant.ts new file mode 100644 index 000000000000..1a1e807427c3 --- /dev/null +++ b/src/libs/TransactionUtils/parseDistanceMerchant.ts @@ -0,0 +1,56 @@ +import CONST from '@src/CONST'; +import type {Unit} from '@src/types/onyx/Policy'; + +function getDistanceMerchantPrefix(distance: number, unit: Unit): string { + return `${distance.toFixed(CONST.DISTANCE_DECIMAL_PLACES)} ${unit} ${CONST.DISTANCE_MERCHANT_SEPARATOR} `; +} + +/** + * Compatibility parser for historical distance expenses without a structured rate snapshot. + * Distance, unit, and currency must come from the transaction, not from the label or live policy. + * This mirrors getDistanceMerchantForTransaction's label format; round-trip tests guard that contract. + * An unrecognized rate leaves rate undefined so callers retain their existing fallback behavior. + */ +function parseDistanceMerchant({ + merchant, + distance, + unit, + currency, + currencySymbol, +}: { + merchant: string | null | undefined; + distance: number | null | undefined; + unit: Unit | undefined; + currency?: string; + currencySymbol?: string; +}): {rateText: string; rate: number | undefined} | undefined { + if (typeof distance !== 'number' || !unit) { + return; + } + + const prefix = getDistanceMerchantPrefix(distance, unit); + if (!merchant?.startsWith(prefix)) { + return; + } + + const rateText = merchant.slice(prefix.length); + const trimmedRateText = rateText.trim(); + const currencyPrefix = [currencySymbol, currency ? `${currency} ` : undefined].find((candidate) => !!candidate && trimmedRateText.startsWith(candidate)); + const unitSuffix = ` / ${unit}`; + let rate: number | undefined; + if (currencyPrefix && trimmedRateText.endsWith(unitSuffix)) { + const numericRateText = trimmedRateText.slice(currencyPrefix.length, -unitSuffix.length); + // Rate labels have no grouping separators and at most four decimal places. + if (/^\d+(?:[.,]\d{1,4})?$/.test(numericRateText)) { + const parsedRate = Number(numericRateText.replace(',', '.')) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET; + if (Number.isFinite(parsedRate) && parsedRate > 0) { + rate = parsedRate; + } + } + } + + return {rateText, rate}; +} + +export {getDistanceMerchantPrefix}; +export default parseDistanceMerchant; diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 121d2995f934..a8d3889eb71a 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -2606,6 +2606,7 @@ function getUpdateTrackExpenseParams( transaction, transactionChanges, isFromExpenseReport: false, + shouldApplyCommuterExclusion: false, policy: policyForTransaction, personalPolicyOutputCurrency, getCurrencyDecimals, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 83098bd75204..69d1ad234925 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -56,6 +56,7 @@ import { shouldClearConvertedAmount, waypointHasValidAddress, } from '@libs/TransactionUtils'; +import parseDistanceMerchant from '@libs/TransactionUtils/parseDistanceMerchant'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import CONST from '@src/CONST'; @@ -1388,6 +1389,83 @@ function getChangeTransactionsReportOnyxData({ ...optimisticValue, }; } + } else if (transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID && !isFetchingWaypointsFromServer(transaction)) { + const customUnit = transaction.comment?.customUnit; + const currency = transaction.modifiedCurrency || transaction.currency; + const fullMerchant = transaction.modifiedMerchant ?? transaction.merchant; + const savedRate = parseDistanceMerchant({ + merchant: fullMerchant, + distance: customUnit?.quantity, + unit: customUnit?.distanceUnit, + currency, + currencySymbol: getCurrencySymbol(currency), + })?.rate; + const selectedRate = DistanceRequestUtils.getRateByCustomUnitRateID({customUnitRateID: currentRateID, policy}); + const storedAmount = Number(transaction.modifiedAmount ?? transaction.amount); + // Only restore a saved deduction for a verified full-route calculation. Do not reprice + // historical rates or replace amounts that differ from the selected rate's calculation. + if ( + customUnit?.distanceUnit && + typeof customUnit.quantity === 'number' && + (customUnit.commuterExclusion ?? 0) > 0 && + savedRate !== undefined && + selectedRate?.rate !== undefined && + Math.abs(savedRate - selectedRate.rate) < 1e-8 && + currency === selectedRate.currency && + customUnit.distanceUnit === selectedRate.unit && + Math.abs(storedAmount) === + DistanceRequestUtils.getDistanceRequestAmount( + DistanceRequestUtils.convertToDistanceInMeters(customUnit.quantity, customUnit.distanceUnit), + customUnit.distanceUnit, + selectedRate.rate, + ) + ) { + const exclusionData = DistanceRequestUtils.getTransactionCommuterExclusionData({ + transaction, + policy, + storedCustomUnit: customUnit, + // eslint-disable-next-line @typescript-eslint/no-deprecated + translate: translateLocal, + toLocaleDigit: (digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit), + getCurrencySymbol, + personalPolicyOutputCurrency, + }); + if (exclusionData && (exclusionData.customUnit.commuterExclusion ?? 0) > 0) { + const exclusionFields = { + commuterExclusion: exclusionData.customUnit.commuterExclusion, + reimbursableDistance: exclusionData.customUnit.reimbursableDistance, + commuterExclusionMethod: exclusionData.customUnit.commuterExclusionMethod, + }; + const optimisticValue = { + merchant: fullMerchant, + modifiedAmount: isExpenseReport(newReport) ? -exclusionData.modifiedAmount : exclusionData.modifiedAmount, + modifiedMerchant: exclusionData.modifiedMerchant, + comment: {customUnit: exclusionFields}, + }; + optimisticData.push({onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: optimisticValue}); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + merchant: transaction.merchant, + modifiedAmount: transaction.modifiedAmount ?? null, + modifiedMerchant: transaction.modifiedMerchant ?? null, + comment: { + customUnit: { + commuterExclusion: customUnit.commuterExclusion ?? null, + reimbursableDistance: customUnit.reimbursableDistance ?? null, + commuterExclusionMethod: customUnit.commuterExclusionMethod ?? null, + }, + }, + }, + }); + transactionForViolations = { + ...transaction, + ...optimisticValue, + comment: {...transaction.comment, customUnit: {...customUnit, ...exclusionFields}}, + }; + } + } } } diff --git a/src/stories/MoneyRequestReportPreview.stories.tsx b/src/stories/MoneyRequestReportPreview.stories.tsx index 512d635f9034..73e58c019374 100755 --- a/src/stories/MoneyRequestReportPreview.stories.tsx +++ b/src/stories/MoneyRequestReportPreview.stories.tsx @@ -145,6 +145,7 @@ function Template(props: MoneyRequestReportPreviewContentProps, {parameters}: {p report={iouReportR14932} policy={undefined} transaction={item} + displayTransaction={item} transactionRawAmount={item.amount} violations={item.errors ? violationsR14932 : []} offlineWithFeedbackOnClose={() => undefined} diff --git a/src/stories/TransactionPreviewContent.stories.tsx b/src/stories/TransactionPreviewContent.stories.tsx index fce7bd86897e..afa630b72980 100644 --- a/src/stories/TransactionPreviewContent.stories.tsx +++ b/src/stories/TransactionPreviewContent.stories.tsx @@ -48,6 +48,7 @@ const disabledProperties = [ 'walletTermsErrors', 'personalDetails', 'chatReport', + 'displayTransaction', ].reduce>((disabledArgTypes, property) => { // eslint-disable-next-line no-param-reassign disabledArgTypes[property] = { @@ -136,7 +137,10 @@ function Template(props: TransactionPreviewContentProps) { - + diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index ef04c2a94672..1a3102ee7d8f 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -2292,6 +2292,79 @@ describe('actions/IOU/UpdateMoneyRequest', () => { }); describe('getUpdateTrackExpenseParams', () => { + it('uses the full distance when optimistically changing a tracked expense rate in Self DM', async () => { + const transactionID = 'track_distance_rate'; + const transactionThreadReportID = 'track_distance_rate_thread'; + const selfDMReportID = 'track_distance_rate_self_dm'; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + amount: 3, + modifiedAmount: 1, + currency: CONST.CURRENCY.USD, + merchant: '1.97 mi @ $0.015 / mi', + modifiedMerchant: '0.97 mi @ $0.015 / mi', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitRateID: 'custom', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 1.97, + commuterExclusion: 1, + reimbursableDistance: 0.97, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }, + }, + }; + const fakeThreadReport = createMock({ + reportID: transactionThreadReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID: selfDMReportID, + }); + const fakeSelfDMReport = createRandomReport(2, CONST.REPORT.CHAT_TYPE.SELF_DM); + fakeSelfDMReport.reportID = selfDMReportID; + const fakePolicy: Policy = { + ...createRandomPolicy(1), + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + custom: {customUnitRateID: 'custom', currency: CONST.CURRENCY.USD, rate: 0.02}, + default: {customUnitRateID: 'default', currency: CONST.CURRENCY.USD, rate: 1.5}, + }, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, fakeThreadReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`, fakeSelfDMReport); + await waitForBatchedUpdates(); + + const {onyxData} = getUpdateTrackExpenseParams({ + transactionID, + transaction: fakeTransaction, + transactionThreadReportID, + transactionChanges: {customUnitRateID: 'default'}, + policy: fakePolicy, + delegateAccountID: undefined, + currencyContext: {getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal}, + shouldBuildOptimisticModifiedExpenseReportAction: false, + }); + const optimisticTransactionUpdate = onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + + expect(optimisticTransactionUpdate?.value).toMatchObject({ + amount: -3, + modifiedAmount: -3, + modifiedMerchant: '1.97 mi @ $0.015 / mi', + comment: {customUnit: {customUnitRateID: 'default'}}, + }); + }); + it('preserves full-precision distance in API params (#90561 — mirror of getUpdateMoneyRequestParams)', async () => { // Given a self-DM track expense whose stored quantity is at 2-decimal precision const transactionID = 'track_distance_precision'; diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts index eb4938eea084..3abf64bd6488 100644 --- a/tests/actions/TransactionTest.ts +++ b/tests/actions/TransactionTest.ts @@ -8,8 +8,9 @@ import {putOnHold} from '@libs/actions/IOU/Hold'; import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {createWorkspace, generatePolicyID, setWorkspaceApprovalMode} from '@libs/actions/Policy/Policy'; -import '@libs/actions/IOU/MoneyRequest'; import {createNewReport} from '@libs/actions/Report'; +import '@libs/actions/IOU/MoneyRequest'; +import * as API from '@libs/API'; import type * as PolicyUtils from '@libs/PolicyUtils'; import {getOriginalMessage, isDeletedAction, isMoneyRequestAction, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReportAction, getReportOrDraftReport} from '@libs/ReportUtils'; @@ -767,6 +768,118 @@ describe('actions/Transaction', () => { expect(updated?.modifiedMerchant).toContain('mi'); }); + it.each([ + {distance: 56.66, amount: -11332, savedRateText: '2.00', liveRate: 200, expectedAmount: -11132}, + {distance: 13.53, amount: -2706, savedRateText: '2.00', liveRate: 200, expectedAmount: -2506}, + {distance: 56.66, amount: -5000, savedRateText: '2.00', liveRate: 200, expectedAmount: -5000}, + {distance: 56.66, amount: -11332, savedRateText: '2.00', liveRate: 300, expectedAmount: -11332}, + {distance: 13.53, amount: -392, savedRateText: '0.29', liveRate: 29, expectedAmount: -363}, + {distance: 13.53, amount: -392, savedRateText: '0.29', liveRate: 30, expectedAmount: -392}, + {distance: 1.5, amount: -44, savedRateText: '0.29', liveRate: 29, expectedAmount: -15}, + {distance: 1.5, amount: -43, savedRateText: '0.29', liveRate: 29, expectedAmount: -43}, + ])('moves a tracked distance expense offline at $distance miles with amount $amount and live rate $liveRate', async ({distance, amount, savedRateText, liveRate, expectedAmount}) => { + // Given a Self DM rate edit with full-route values and retained commuter metadata + const policyID = 'commuter-move-policy'; + const mockPolicy: Policy = { + ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), + id: policyID, + outputCurrency: CONST.CURRENCY.USD, + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, fixedDistance: 1, fixedDistanceUnit: 'mi'}, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + enabled: true, + attributes: {unit: 'mi'}, + rates: {selected: {customUnitRateID: 'selected', rate: liveRate, currency: 'USD', enabled: true}}, + }, + }, + }; + const reducedDistance = Number((distance - 1).toFixed(2)); + const transaction: Transaction = { + transactionID: 'commuter-move-transaction', + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + amount, + modifiedAmount: amount, + currency: 'USD', + modifiedCurrency: 'USD', + merchant: `${distance.toFixed(2)} mi @ $1.00 / mi`, + modifiedMerchant: `${distance.toFixed(2)} mi @ $${savedRateText} / mi`, + created: '2026-10-23', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitRateID: 'selected', + quantity: distance, + distanceUnit: 'mi', + commuterExclusion: 1, + reimbursableDistance: reducedDistance, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }, + }, + }; + const report: Report = { + reportID: 'commuter-move-report', + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + currency: 'USD', + total: 0, + chatReportID: 'commuter-move-chat', + ownerAccountID: RORY_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + await Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: true}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, mockPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + await waitForBatchedUpdates(); + + // When moving to the workspace without a server response to reconcile its values + const writeSpy = jest.spyOn(API, 'write'); + changeTransactionsReport({ + isVendorMatchingBetaEnabled: false, + transactionIDs: [transaction.transactionID], + isASAPSubmitBetaEnabled: false, + accountID: RORY_ACCOUNT_ID, + email: RORY_EMAIL, + newReport: report, + policy: mockPolicy, + allTransactions: {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction}, + policyTagList: {}, + transactionViolations: {}, + reports: {[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report}, + isTrackIntentUser: false, + cardList: undefined, + }); + const failureData = writeSpy.mock.calls.at(-1)?.[2]?.failureData; + writeSpy.mockRestore(); + await waitForBatchedUpdates(); + + // Then a verified automatic amount receives the deduction, but overrides and historical rates are untouched + const updated = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`); + expect(updated?.modifiedAmount).toBe(expectedAmount); + expect(updated?.amount).toBe(amount); + expect(updated?.comment?.customUnit).toEqual(transaction.comment?.customUnit); + expect(updated?.modifiedMerchant).toBe(expectedAmount === amount ? transaction.modifiedMerchant : `${reducedDistance.toFixed(2)} mi @ $${savedRateText} / mi`); + const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`); + expect(updatedReport?.total).toBe(expectedAmount); + + // When the request fails, its rollback restores the original Self DM fields and report total + expect(failureData).toBeDefined(); + await Onyx.update(failureData ?? []); + await waitForBatchedUpdates(); + const restored = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`); + expect(restored?.reportID).toBe(transaction.reportID); + expect(restored?.amount).toBe(transaction.amount); + expect(restored?.modifiedAmount).toBe(transaction.modifiedAmount); + expect(restored?.merchant).toBe(transaction.merchant); + expect(restored?.modifiedMerchant).toBe(transaction.modifiedMerchant); + expect(restored?.comment?.customUnit).toEqual(transaction.comment?.customUnit); + expect((await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`))?.total).toBe(0); + }); + it('does not save Search row display fields on the moved transaction', async () => { // Given an unreported expense selected from Search, which hands over the whole row item with its display-only fields const policyID = generatePolicyID(); diff --git a/tests/ui/TransactionPreviewContentTest.tsx b/tests/ui/TransactionPreviewContentTest.tsx index 64ffdeda82cb..6230beb29167 100644 --- a/tests/ui/TransactionPreviewContentTest.tsx +++ b/tests/ui/TransactionPreviewContentTest.tsx @@ -2,12 +2,15 @@ import {act, render, screen, waitFor} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import TransactionPreview from '@components/ReportActionItem/TransactionPreview'; import TransactionPreviewContent from '@components/ReportActionItem/TransactionPreview/TransactionPreviewContent'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Policy, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx'; +import type * as ReactNavigation from '@react-navigation/native'; + import React from 'react'; import Onyx from 'react-native-onyx'; @@ -24,6 +27,14 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ }), })); +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + useRoute: () => ({key: 'report', name: 'Report', params: {}}), + }; +}); + // Expose the canEdit translation parameter in the rendered output so canEdit-dependent messages can be asserted. // The message must stay under CONST.REPORT_VIOLATIONS.RBR_MESSAGE_MAX_CHARACTERS_FOR_PREVIEW or the preview // swaps it for the generic "review required" message. @@ -60,6 +71,26 @@ const corporatePolicy = { }, } as Policy; +const distancePolicy = { + ...corporatePolicy, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + rate1: { + customUnitRateID: 'rate1', + currency: CONST.CURRENCY.USD, + rate: 76, + }, + }, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }, +} as Policy; + const expenseReport = { reportID: expenseReportID, type: CONST.REPORT.TYPE.EXPENSE, @@ -94,6 +125,40 @@ const transaction = { comment: {}, } as Transaction; +const commuterDistanceTransaction = { + ...transaction, + amount: 415, + modifiedAmount: 415, + merchant: '5.46 mi @ $0.76 / mi', + modifiedMerchant: '5.46 mi @ $0.76 / mi', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: 6.46, + reimbursableDistance: 5.46, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, +} as Transaction; + +const policyExpenseChat = { + reportID: 'policy_expense_chat_tpc_test', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID, +} as Report; + +const selfDMReportID = 'self_dm_tpc_test'; +const selfDMReport = { + reportID: selfDMReportID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + participants: { + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, +} as Report; + const smartscanFailedViolation: TransactionViolation = { name: CONST.VIOLATIONS.SMARTSCAN_FAILED, type: CONST.VIOLATION_TYPES.VIOLATION, @@ -105,19 +170,30 @@ const personalDetails: PersonalDetailsList = { [approverAccountID]: {accountID: approverAccountID, login: approverEmail, displayName: 'Approver'}, }; -const renderTransactionPreviewContent = () => +const renderTransactionPreviewContent = ({ + transactionToRender = transaction, + displayTransaction = transactionToRender, + chatReport, + violations = [smartscanFailedViolation], +}: { + transactionToRender?: Transaction; + displayTransaction?: Transaction; + chatReport?: Report; + violations?: TransactionViolation[]; +} = {}) => render( {}} containerStyles={[]} @@ -133,6 +209,22 @@ const renderTransactionPreviewContent = () => , ); +const renderTransactionPreview = ({action = moneyRequestAction, chatReport = policyExpenseChat}: {action?: ReportAction; chatReport?: Report} = {}) => + render( + + + , + ); + describe('TransactionPreviewContent', () => { beforeAll(() => { Onyx.init({ @@ -147,14 +239,17 @@ describe('TransactionPreviewContent', () => { }); }); - const seedOnyx = async (reportActions: Record) => { + const seedOnyx = async ( + reportActions: Record, + {report = expenseReport, transactionToSeed = transaction}: {report?: Report; transactionToSeed?: Transaction} = {}, + ) => { await act(async () => { await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID, email: currentUserEmail}); await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, corporatePolicy); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, reportActions); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, distancePolicy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transactionToSeed); }); await waitForBatchedUpdatesWithAct(); }; @@ -187,4 +282,32 @@ describe('TransactionPreviewContent', () => { expect(screen.getByText('smartscan#false')).toBeOnTheScreen(); }); }); + + it('shows full-route values for a self-DM distance expense with commuter exclusion metadata', async () => { + // Given a removed distance expense whose stored values still include a commuter exclusion + const selfDMAction = {...moneyRequestAction, reportID: selfDMReportID}; + const selfDMTransaction = {...commuterDistanceTransaction, reportID: selfDMReportID}; + await seedOnyx({[selfDMAction.reportActionID]: selfDMAction}, {report: selfDMReport, transactionToSeed: selfDMTransaction}); + + // When the transaction preview derives the display context from the personal report policy + renderTransactionPreview({action: selfDMAction, chatReport: selfDMReport}); + await waitForBatchedUpdatesWithAct(); + + // Then it renders the full-route merchant instead of the commuter-adjusted merchant + expect(screen.getByText(/6\.46 mi/)).toBeOnTheScreen(); + expect(screen.queryByText('5.46 mi @ $0.76 / mi')).not.toBeOnTheScreen(); + }); + + it('keeps commuter-adjusted values for a distance expense in a policy expense chat', async () => { + // Given a distance expense that still belongs to a policy expense chat + await seedOnyx({[moneyRequestAction.reportActionID]: moneyRequestAction}, {transactionToSeed: commuterDistanceTransaction}); + + // When the transaction preview derives the display context from the group policy + renderTransactionPreview(); + await waitForBatchedUpdatesWithAct(); + + // Then it preserves the commuter-adjusted merchant instead of showing the full route + expect(screen.getByText('5.46 mi @ $0.76 / mi')).toBeOnTheScreen(); + expect(screen.queryByText('6.46 mi @ $0.76 / mi')).not.toBeOnTheScreen(); + }); }); diff --git a/tests/unit/ParseDistanceMerchantTest.ts b/tests/unit/ParseDistanceMerchantTest.ts new file mode 100644 index 000000000000..85d5a1a140e9 --- /dev/null +++ b/tests/unit/ParseDistanceMerchantTest.ts @@ -0,0 +1,76 @@ +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import {translate as translateWithLocale} from '@libs/Localize'; +import {getDistanceMerchantForTransaction} from '@libs/TransactionUtils/getUpdatedTransaction'; +import parseDistanceMerchant, {getDistanceMerchantPrefix} from '@libs/TransactionUtils/parseDistanceMerchant'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; + +describe('parseDistanceMerchant', () => { + beforeAll(async () => { + await IntlStore.load(CONST.LOCALES.EN); + await IntlStore.load(CONST.LOCALES.FR); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe.each([CONST.LOCALES.EN, CONST.LOCALES.FR])('formatter contract in %s', (locale) => { + it.each([ + {rate: 50, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, symbol: '$'}, + {rate: 1.5, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, symbol: '$'}, + {rate: 123450, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, symbol: '$'}, + {rate: 50, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, symbol: undefined}, + ])('round-trips rate $rate and unit $unit with symbol $symbol', ({rate, unit, symbol}) => { + // Given a label generated by the production formatter, without relying on a policy lookup + jest.spyOn(IntlStore, 'getCurrentLocale').mockReturnValue(locale); + const distance = 1.03; + const merchant = getDistanceMerchantForTransaction({ + transaction: undefined, + distanceInMeters: DistanceRequestUtils.convertToDistanceInMeters(distance, unit), + unit, + rate, + currency: CONST.CURRENCY.USD, + translate: (path, ...parameters) => translateWithLocale(locale, path, ...parameters), + getCurrencySymbol: () => symbol, + }); + + // When the historical label is parsed with the saved structured distance, unit and currency + const parsed = parseDistanceMerchant({merchant, distance, unit, currency: CONST.CURRENCY.USD, currencySymbol: symbol}); + + // Then the exact rate survives the formatter/parser boundary, including fractional and large rates + expect(parsed?.rate).toBeCloseTo(rate, 8); + expect(`${getDistanceMerchantPrefix(distance, unit)}${parsed?.rateText}`).toBe(merchant); + }); + }); + + it.each(['UNKNOWN0.50 / mi', '$1,234.50 / mi', '$0.50 extra / mi', '$0.50 / km'])('does not infer a numeric rate from %s', (rateText) => { + // Given a historical label with an unsupported currency, number format or unit + const merchant = `1.03 mi @ ${rateText}`; + + // When parsing the complete rate value + const parsed = parseDistanceMerchant({ + merchant, + distance: 1.03, + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + currency: CONST.CURRENCY.USD, + currencySymbol: '$', + }); + + // Then the raw snapshot remains available but the caller cannot treat the text as a verified numeric rate + expect(parsed?.rateText).toBe(rateText); + expect(parsed?.rate).toBeUndefined(); + }); + + it('rejects a label that does not match the saved distance', () => { + // Given a stale merchant from before the route was edited + const merchant = '10.82 mi @ $0.50 / mi'; + + // When it is checked against the new structured distance + const parsed = parseDistanceMerchant({merchant, distance: 1.03, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}); + + // Then it cannot be mistaken for a snapshot of the new route + expect(parsed).toBeUndefined(); + }); +}); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index a0a4655bbc11..74f08da895e3 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -15,7 +15,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {Card, Policy, Report, ReviewDuplicates, Transaction} from '../../src/types/onyx'; -import type {Routes} from '../../src/types/onyx/Transaction'; +import type {Routes, TransactionChanges} from '../../src/types/onyx/Transaction'; import type {TransactionViolation} from '../../src/types/onyx/TransactionViolation'; import * as TransactionUtils from '../../src/libs/TransactionUtils'; @@ -1024,6 +1024,122 @@ describe('TransactionUtils', () => { }, ); + it('does not apply a saved commuter exclusion when changing a distance rate in Self DM', () => { + const fakePolicy: Policy = { + ...createRandomPolicy(0), + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + custom: {customUnitRateID: 'custom', currency: CONST.CURRENCY.USD, rate: 0.02}, + default: {customUnitRateID: 'default', currency: CONST.CURRENCY.USD, rate: 1.5}, + }, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }, + }, + }; + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + customUnit: { + customUnitRateID: 'custom', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 1.97, + commuterExclusion: 1, + reimbursableDistance: 0.97, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }, + }, + currency: CONST.CURRENCY.USD, + }); + + const updatedTransaction = TransactionUtils.getUpdatedTransaction({ + transaction, + isFromExpenseReport: false, + shouldApplyCommuterExclusion: false, + policy: fakePolicy, + transactionChanges: {customUnitRateID: 'default'}, + personalPolicyOutputCurrency: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + expect(updatedTransaction.modifiedAmount).toBe(3); + expect(updatedTransaction.modifiedMerchant).toBe('1.97 mi @ $0.015 / mi'); + }); + + it('preserves the full amount through offline workspace distance and rate edits', () => { + // Given a workspace expense with a one-mile commuter exclusion and separate full/reduced snapshots + const fakePolicy: Policy = { + ...createRandomPolicy(0), + commuterExclusions: { + method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + fixedDistance: 1, + fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + rateA: {customUnitRateID: 'rateA', currency: CONST.CURRENCY.USD, rate: 100}, + rateB: {customUnitRateID: 'rateB', currency: CONST.CURRENCY.USD, rate: 200}, + }, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }, + }, + }; + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: -1015, + modifiedAmount: -915, + currency: CONST.CURRENCY.USD, + merchant: '10.15 mi @ $1.00 / mi', + modifiedMerchant: '9.15 mi @ $1.00 / mi', + comment: { + customUnit: { + customUnitRateID: 'rateA', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 10.15, + commuterExclusion: 1, + reimbursableDistance: 9.15, + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }, + }, + }); + const updateWorkspaceTransaction = (transactionToUpdate: Transaction, transactionChanges: TransactionChanges) => + TransactionUtils.getUpdatedTransaction({ + transaction: transactionToUpdate, + isFromExpenseReport: true, + policy: fakePolicy, + transactionChanges, + personalPolicyOutputCurrency: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // When offline optimistic edits change the distance away and back, then switch rate A to B and back to A + const longerTransaction = updateWorkspaceTransaction(transaction, {distance: 11.15}); + const originalDistanceTransaction = updateWorkspaceTransaction(longerTransaction, {distance: 10.15}); + const rateBTransaction = updateWorkspaceTransaction(originalDistanceTransaction, {customUnitRateID: 'rateB'}); + const restoredRateATransaction = updateWorkspaceTransaction(rateBTransaction, {customUnitRateID: 'rateA'}); + const personalDisplayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: restoredRateATransaction, + isPolicyExpenseChat: false, + policy: fakePolicy, + translate: (path, ...parameters) => translateWithLocale(CONST.LOCALES.EN, path, ...parameters), + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the workspace keeps its reduced amount while Self DM can restore the preserved full snapshot + expect(rateBTransaction.amount).toBe(-1015); + expect(rateBTransaction.modifiedAmount).toBe(-1830); + expect(restoredRateATransaction.modifiedAmount).toBe(-915); + expect(personalDisplayTransaction?.amount).toBe(-1015); + expect(personalDisplayTransaction?.merchant).toBe('10.15 mi @ $1.00 / mi'); + }); + it('does not apply a newly enabled commuter exclusion when a historical manual distance is changed', () => { const fakePolicy: Policy = { ...createRandomPolicy(0), @@ -1365,7 +1481,11 @@ describe('TransactionUtils', () => { } }); - it('preserves the saved commuter exclusion when recalculating edited waypoints', () => { + it.each([ + {context: 'with commuter exclusion enabled by default', shouldApplyCommuterExclusion: undefined, expectedAmount: 170, expectedDistance: 17}, + {context: 'in Self DM with commuter exclusion disabled', shouldApplyCommuterExclusion: false, expectedAmount: 200, expectedDistance: 20}, + ])('recalculates edited waypoints $context', ({shouldApplyCommuterExclusion, expectedAmount, expectedDistance}) => { + // Given a saved three-mile deduction and a policy that still enables commuter exclusion const fakePolicy: Policy = { ...createRandomPolicy(0), commuterExclusions: { @@ -1397,16 +1517,24 @@ describe('TransactionUtils', () => { }, }); + // When new waypoints and route geometry replace the original ten-mile trip with a twenty-mile trip const updatedTransaction = TransactionUtils.getUpdatedTransaction({ transaction, isFromExpenseReport: false, + shouldApplyCommuterExclusion, policy: fakePolicy, transactionChanges: { waypoints: {waypoint0: {address: 'New A'}, waypoint1: {address: 'New B'}}, routes: { route0: { distance: 32186.88, - geometry: {coordinates: [[0, 0]], type: 'LineString'}, + geometry: { + coordinates: [ + [0, 0], + [0.1, 0.1], + ], + type: 'LineString', + }, }, }, }, @@ -1415,8 +1543,18 @@ describe('TransactionUtils', () => { getCurrencySymbol: getCurrencySymbolLocal, }); - expect(updatedTransaction.comment?.customUnit).toMatchObject({quantity: 20, commuterExclusion: 3, reimbursableDistance: 17}); - expect(updatedTransaction.modifiedAmount).toBe(170); + // Then Self DM restores the entire new trip and clears deductions; the default behavior preserves the saved exclusion + expect(updatedTransaction.comment?.customUnit?.quantity).toBe(20); + expect(updatedTransaction.comment?.customUnit?.routeDistanceMeters).toBe(32186.88); + expect(updatedTransaction.modifiedAmount).toBe(expectedAmount); + expect(updatedTransaction.modifiedMerchant).toBe(`${expectedDistance.toFixed(2)} mi @ $0.10 / mi`); + if (shouldApplyCommuterExclusion === false) { + expect(updatedTransaction.comment?.customUnit?.commuterExclusion).toBeNull(); + expect(updatedTransaction.comment?.customUnit?.reimbursableDistance).toBeNull(); + expect(updatedTransaction.comment?.customUnit?.commuterExclusionMethod).toBeNull(); + } else { + expect(updatedTransaction.comment?.customUnit).toMatchObject({commuterExclusion: 3, reimbursableDistance: 17}); + } }); it('does not carry a home and office exclusion onto edited waypoints', () => { @@ -1808,6 +1946,24 @@ describe('TransactionUtils', () => { }, }; + const historicalTransaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: -12158, + modifiedAmount: -11758, + currency: 'AFN', + merchant: '60.79 mi @ Af2.00 / mi', + modifiedMerchant: '58.79 mi @ Af2.00 / mi', + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: 60.79, + reimbursableDistance: 58.79, + commuterExclusion: 2, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + it('rebuilds full-route amount and merchant for personal distance expenses with commuter metadata', () => { const transaction = generateTransaction({ iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, @@ -1849,6 +2005,431 @@ describe('TransactionUtils', () => { expect(displayTransaction.currency).toBe(CONST.CURRENCY.USD); }); + it('rebuilds a rounded full-route amount when a low rate makes historical rate inference ambiguous', () => { + // Given a low-rate expense whose full and commuter-reduced totals both lose precision when rounded to cents + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: 3, + modifiedAmount: 1, + currency: CONST.CURRENCY.USD, + merchant: '1.97 mi @ $0.015 / mi', + modifiedMerchant: '0.97 mi @ $0.015 / mi', + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: 1.97, + reimbursableDistance: 0.97, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + const lowRatePolicy: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + rate1: { + customUnitRateID: 'rate1', + currency: CONST.CURRENCY.USD, + rate: 1.5, + }, + }, + }, + }, + }; + + // When the commuter exclusion is removed for display in a personal report + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: lowRatePolicy, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the exact resolved rate identifies the reduced cent as automatic and restores the rounded full total + expect(displayTransaction?.amount).toBe(3); + expect(displayTransaction?.merchant).toBe('1.97 mi @ $0.015 / mi'); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + expect(displayTransaction?.modifiedMerchant).toBeUndefined(); + }); + + it.each([ + {fromLocale: CONST.LOCALES.EN, toLocale: CONST.LOCALES.FR, savedRateText: '$0.50', liveRate: 50}, + {fromLocale: CONST.LOCALES.FR, toLocale: CONST.LOCALES.EN, savedRateText: '$0,50', liveRate: 50}, + {fromLocale: CONST.LOCALES.EN, toLocale: CONST.LOCALES.FR, savedRateText: '$0.50', liveRate: 100}, + {fromLocale: CONST.LOCALES.FR, toLocale: CONST.LOCALES.EN, savedRateText: '$0,50', liveRate: 100}, + ])('keeps the full amount from $fromLocale to $toLocale with live rate $liveRate after a manual distance edit', ({fromLocale, toLocale, savedRateText, liveRate}) => { + // Given a saved expense whose manual distance edit left an old base merchant and a rounded commuter amount + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: 541, + modifiedAmount: -2, + currency: CONST.CURRENCY.USD, + modifiedCurrency: CONST.CURRENCY.USD, + merchant: `10.82 mi @ ${savedRateText} / mi`, + modifiedMerchant: `0.03 mi @ ${savedRateText} / mi`, + comment: { + customUnit: { + customUnitRateID: 'custom', + quantity: 1.03, + reimbursableDistance: 0.03, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + const customRatePolicy: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: {custom: {customUnitRateID: 'custom', currency: CONST.CURRENCY.USD, rate: liveRate}}, + }, + }, + }; + const currentLocale = jest.spyOn(IntlStore, 'getCurrentLocale'); + + // When Self DM renders the saved transaction before and after switching languages + currentLocale.mockReturnValue(fromLocale); + const originalDisplay = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: customRatePolicy, + translate: (path, ...parameters) => translateWithLocale(fromLocale, path, ...parameters), + getCurrencySymbol: () => '$', + }); + currentLocale.mockReturnValue(toLocale); + const translatedDisplay = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: customRatePolicy, + translate: (path, ...parameters) => translateWithLocale(toLocale, path, ...parameters), + getCurrencySymbol: () => '$', + }); + currentLocale.mockRestore(); + + // Then both locales use the selected rate, not a ratio of the already rounded two cents + expect(originalDisplay?.amount).toBe(-52); + expect(translatedDisplay?.amount).toBe(-52); + if (liveRate !== 50) { + expect(originalDisplay?.merchant).toBe(`1.03 mi @ ${savedRateText} / mi`); + expect(translatedDisplay?.merchant).toBe(`1.03 mi @ ${savedRateText} / mi`); + } + }); + + it('preserves the restored amount for a saved ISO currency prefix when currency data loads', () => { + // Given a reduced snapshot saved with an ISO currency prefix and a rounded two-cent amount + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: 541, + modifiedAmount: -2, + currency: CONST.CURRENCY.USD, + merchant: '10.82 mi @ USD 0.50 / mi', + modifiedMerchant: '0.03 mi @ USD 0.50 / mi', + comment: { + customUnit: { + customUnitRateID: 'custom', + quantity: 1.03, + reimbursableDistance: 0.03, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + + // When the same saved transaction renders before and after its currency symbol loads + const displays = [undefined, '$'].map((symbol) => + TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + translate: (path, ...parameters) => translateWithLocale(CONST.LOCALES.EN, path, ...parameters), + getCurrencySymbol: () => symbol, + }), + ); + + // Then loading currency data does not change the restored total or historical rate + for (const display of displays) { + expect(display?.amount).toBe(-52); + expect(display?.merchant).toBe('1.03 mi @ USD 0.50 / mi'); + } + }); + + it('keeps the saved transaction when the rate currency has the same symbol', () => { + // Given a saved CNY distance expense whose current policy rate uses JPY, which shares the same currency symbol + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount: 415, + modifiedAmount: 415, + currency: 'CNY', + merchant: '5.46 mi @ ¥0.76 / mi', + modifiedMerchant: '5.46 mi @ ¥0.76 / mi', + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: 6.46, + reimbursableDistance: 5.46, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + const policyWithMatchingCurrencySymbol: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + rate1: { + customUnitRateID: 'rate1', + currency: 'JPY', + rate: 76, + }, + }, + }, + }, + }; + + // When the personal-report display attempts to rebuild the full-route values + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy: policyWithMatchingCurrencySymbol, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then it keeps the saved transaction to avoid silently replacing CNY values with JPY values + expect(displayTransaction).toBe(transaction); + }); + + it.each([ + {fullDistance: 2.06, reducedDistance: 1.06, amount: 3, modifiedAmount: -2, liveRate: 2, expectedAmount: -3}, + {fullDistance: 56.64, reducedDistance: 55.64, amount: 85, modifiedAmount: -83, liveRate: 2, expectedAmount: -85}, + {fullDistance: 2.06, reducedDistance: 1.06, amount: 3, modifiedAmount: -2, liveRate: 100, expectedAmount: -3}, + {fullDistance: 2.06, reducedDistance: 1.06, amount: 3, modifiedAmount: -50, liveRate: 2, expectedAmount: -50}, + {fullDistance: 2.06, reducedDistance: 1.06, amount: -2, modifiedAmount: -2, liveRate: 2, expectedAmount: -2}, + ])( + 'validates paired snapshots at $fullDistance miles with live rate $liveRate and modified amount $modifiedAmount', + ({fullDistance, reducedDistance, amount, modifiedAmount, liveRate, expectedAmount}) => { + // Given matching historical rate labels, including manual-override and pre-sync reduced-only controls + const transaction = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + amount, + modifiedAmount, + currency: CONST.CURRENCY.USD, + merchant: `${fullDistance.toFixed(2)} mi @ $0.015 / mi`, + modifiedMerchant: `${reducedDistance.toFixed(2)} mi @ $0.015 / mi`, + comment: { + customUnit: { + customUnitRateID: 'rate1', + quantity: fullDistance, + reimbursableDistance: reducedDistance, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }); + const policy: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: {rate1: {customUnitRateID: 'rate1', currency: CONST.CURRENCY.USD, rate: liveRate}}, + }, + }, + }; + + // When Self DM normalizes the expense after the workspace rate changes + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction, + isPolicyExpenseChat: false, + policy, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then only verified full/reduced pairs restore the full amount; other saved amounts remain protected + expect(displayTransaction?.amount).toBe(expectedAmount); + expect(displayTransaction?.merchant).toBe(transaction.merchant); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + }, + ); + + it('preserves the saved rate when the workspace rate changes', () => { + // Given a personal distance expense saved at Af2 per mile before its workspace rate changed to Af3 + const policyWithChangedRate: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + rate1: { + customUnitRateID: 'rate1', + currency: 'AFN', + rate: 300, + }, + }, + }, + }, + }; + + // When the commuter exclusion is removed for display in a personal report + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: historicalTransaction, + isPolicyExpenseChat: false, + policy: policyWithChangedRate, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the full route uses the saved Af2 rate instead of repricing the expense at Af3 + expect(displayTransaction?.amount).toBe(-12158); + expect(displayTransaction?.merchant).toBe('60.79 mi @ Af2.00 / mi'); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + expect(displayTransaction?.modifiedMerchant).toBeUndefined(); + }); + + it('preserves a selected custom rate after the live custom rate changes', () => { + // Given an expense whose base fields use the old default rate while its modified fields contain the selected custom rate + const transactionAfterSelectingCustomRate = generateTransaction({ + ...historicalTransaction, + amount: -18237, + merchant: '60.79 mi @ Af3.00 / mi', + modifiedAmount: -4468, + modifiedMerchant: '58.79 mi @ Af0.76 / mi', + comment: { + customUnit: { + ...historicalTransaction.comment?.customUnit, + customUnitRateID: 'customRate', + }, + }, + }); + const policyWithChangedCustomRate: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + customRate: { + customUnitRateID: 'customRate', + currency: 'AFN', + rate: 900, + }, + }, + }, + }, + }; + + // When the personal-report display is rebuilt after the live custom rate changes from Af0.76 to Af9 + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: transactionAfterSelectingCustomRate, + isPolicyExpenseChat: false, + policy: policyWithChangedCustomRate, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the full route retains the selected Af0.76 rate instead of using the live Af9 rate + expect(displayTransaction?.amount).toBe(-4620); + expect(displayTransaction?.merchant).toBe('60.79 mi @ Af0.76 / mi'); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + expect(displayTransaction?.modifiedMerchant).toBeUndefined(); + }); + + it('preserves the reassigned default rate when saved fields already contain the full route', () => { + // Given an expense reassigned to the default rate after its custom rate was deleted, leaving full-route values alongside commuter metadata + const transactionReassignedToDefaultRate = generateTransaction({ + ...historicalTransaction, + amount: -18237, + modifiedAmount: -18237, + merchant: '60.79 mi @ Af3.00 / mi', + modifiedMerchant: '60.79 mi @ Af3.00 / mi', + comment: { + customUnit: { + ...historicalTransaction.comment?.customUnit, + customUnitRateID: 'defaultRate', + }, + }, + }); + const policyWithUpdatedDefaultRate: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: { + defaultRate: { + customUnitRateID: 'defaultRate', + currency: 'AFN', + rate: 100, + }, + }, + }, + }, + }; + + // When the expense returns to the personal report and the live default rate has changed from Af3 to Af1 + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: transactionReassignedToDefaultRate, + isPolicyExpenseChat: false, + policy: policyWithUpdatedDefaultRate, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the already-saved full-route Af3 values remain authoritative + expect(displayTransaction?.amount).toBe(-18237); + expect(displayTransaction?.merchant).toBe('60.79 mi @ Af3.00 / mi'); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + expect(displayTransaction?.modifiedMerchant).toBeUndefined(); + }); + + it('restores the saved full-route values when the workspace rate was deleted', () => { + // Given a personal distance expense whose original workspace rate no longer exists + const policyWithoutRate: Policy = { + ...policyWithDistanceRate, + customUnits: { + distance: { + ...policyWithDistanceRate.customUnits?.distance, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance', + rates: {}, + }, + }, + }; + + // When the commuter exclusion is removed for display in a personal report + const displayTransaction = TransactionUtils.getDisplayTransactionWithoutInvalidCommuterExclusion({ + transaction: historicalTransaction, + isPolicyExpenseChat: false, + policy: policyWithoutRate, + translate, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + // Then the saved full-route amount and merchant are restored without the missing policy rate + expect(displayTransaction?.amount).toBe(-12158); + expect(displayTransaction?.merchant).toBe('60.79 mi @ Af2.00 / mi'); + expect(displayTransaction?.modifiedAmount).toBeUndefined(); + expect(displayTransaction?.modifiedMerchant).toBeUndefined(); + }); + it('returns an ordinary transaction unchanged when no commuter exclusion is applied', () => { const transaction = generateTransaction({ iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, diff --git a/tests/unit/hooks/useDisplayTransaction.test.ts b/tests/unit/hooks/useDisplayTransaction.test.ts new file mode 100644 index 000000000000..4846cd39be0f --- /dev/null +++ b/tests/unit/hooks/useDisplayTransaction.test.ts @@ -0,0 +1,110 @@ +import {act, renderHook, waitFor} from '@testing-library/react-native'; + +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useDisplayTransaction from '@hooks/useDisplayTransaction'; +import useLocalize from '@hooks/useLocalize'; + +import {translate as translateWithLocale} from '@libs/Localize'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Transaction} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../../utils/collections/policies'; +import createMock from '../../utils/createMock'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +jest.mock('@hooks/useLocalize'); +jest.mock('@hooks/useCurrencyList'); + +describe('useDisplayTransaction', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + await IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(async () => { + await Onyx.clear(); + jest.mocked(useLocalize).mockReturnValue( + createMock({ + translate: (path, ...parameters) => translateWithLocale(CONST.LOCALES.EN, path, ...parameters), + }), + ); + jest.mocked(useCurrencyListActions).mockReturnValue( + createMock>({ + getCurrencySymbol: () => '$', + }), + ); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('uses the original policy when the supplied policy does not contain the distance rate', async () => { + // Given a reduced expense without modified snapshots, so restoration requires a policy rate + const suppliedPolicy: Policy = { + ...createRandomPolicy(1), + id: 'display-policy-without-rate', + customUnits: {}, + }; + const originalPolicy: Policy = { + ...createRandomPolicy(2), + id: 'display-original-policy', + customUnits: { + distance: { + customUnitID: 'distance', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: { + selectedRate: {customUnitRateID: 'selectedRate', rate: 76, currency: CONST.CURRENCY.USD, enabled: true}, + }, + }, + }, + }; + const transaction: Transaction = { + transactionID: 'display-distance-transaction', + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + amount: 415, + currency: CONST.CURRENCY.USD, + created: '2026-10-20', + merchant: '5.46 mi @ $0.76 / mi', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + customUnit: { + customUnitRateID: 'selectedRate', + quantity: 6.46, + reimbursableDistance: 5.46, + commuterExclusion: 1, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${suppliedPolicy.id}`, suppliedPolicy); + await waitForBatchedUpdates(); + const {result} = renderHook(() => useDisplayTransaction(transaction, false, suppliedPolicy)); + await waitForBatchedUpdates(); + expect(result.current.distanceOriginalPolicy).toBeUndefined(); + expect(result.current.displayTransaction?.amount).toBe(415); + + // When the actual cross-policy selector discovers the policy owning the selected rate + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${originalPolicy.id}`, originalPolicy); + }); + + // Then the hook uses that policy, rather than the rate-less fallback, to restore the full route + await waitFor(() => { + expect(result.current.distanceOriginalPolicy?.id).toBe(originalPolicy.id); + expect(result.current.displayTransaction?.amount).toBe(491); + expect(result.current.displayTransaction?.merchant).toBe('6.46 mi @ $0.76 / mi'); + }); + }); +});