diff --git a/src/components/AddUnreportedExpenseFooter.tsx b/src/components/AddUnreportedExpenseFooter.tsx index 308eca3049d82..23663bd2f3d7e 100644 --- a/src/components/AddUnreportedExpenseFooter.tsx +++ b/src/components/AddUnreportedExpenseFooter.tsx @@ -36,7 +36,7 @@ type AddUnreportedExpenseFooterProps = { }; function AddUnreportedExpenseFooter({selectedIds, report, reportToConfirm, reportNextStep, policy, policyCategories, errorMessage, setErrorMessage}: AddUnreportedExpenseFooterProps) { - const {translate} = useLocalize(); + const {translate, toLocaleDigit} = useLocalize(); const styles = useThemeStyles(); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -80,6 +80,8 @@ function AddUnreportedExpenseFooter({selectedIds, report, reportToConfirm, repor reportNextStep, policyCategories, allTransactions, + translate, + toLocaleDigit, }); } }); diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 5ceaa98907a4a..6f46d91bcfd30 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -268,6 +268,32 @@ function getRateForP2P(currency: string, transaction: OnyxEntry): M }; } +function getRateForP2PInUnit(currency: string, transaction: OnyxEntry, unit: Unit): number { + const mileageRate = getRateForP2P(currency, transaction); + + if (!mileageRate.rate) { + return 0; + } + + // If requested unit matches the currency's native unit, return the rate directly + if (unit === mileageRate.unit) { + return mileageRate.rate; + } + + // If units don't match, convert between km and miles + if (unit === 'km' && mileageRate.unit === 'mi') { + // Convert miles rate to km rate: km rate = mile rate / CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS + return mileageRate.rate / CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS; + } + if (unit === 'mi' && mileageRate.unit === 'km') { + // Convert km rate to miles rate: mile rate = km rate * CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS + return mileageRate.rate * CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS; + } + + // Fallback: return rate as-is (shouldn't happen with valid units) + return mileageRate.rate; +} + /** * Calculates the expense amount based on distance, unit, and rate. * @@ -427,6 +453,7 @@ export default { getDistanceForDisplay, getRoundedDistanceInUnits, getRateForP2P, + getRateForP2PInUnit, getCustomUnitRateID, convertToDistanceInMeters, getTaxableAmount, diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index ffa0471c21860..d5c0d96054e21 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -153,10 +153,12 @@ function getPolicyTagsData(policyID: string | undefined) { return allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; } +function hasCustomUnit(transaction: OnyxEntry | Partial): boolean { + return transaction?.comment?.type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && !!transaction?.comment?.customUnit; +} + function hasDistanceCustomUnit(transaction: OnyxEntry | Partial): boolean { - const type = transaction?.comment?.type; - const customUnitName = transaction?.comment?.customUnit?.name; - return type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && customUnitName === CONST.CUSTOM_UNITS.NAME_DISTANCE; + return transaction?.comment?.type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && transaction?.comment?.customUnit?.name === CONST.CUSTOM_UNITS.NAME_DISTANCE; } function isDistanceRequest(transaction: OnyxEntry): boolean { @@ -2759,6 +2761,7 @@ export { didReceiptScanSucceed, getValidWaypoints, getValidDuplicateTransactionIDs, + hasCustomUnit, isDistanceRequest, isMapDistanceRequest, isGPSDistanceRequest, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index f8b49c7c995f0..9a87dbfc937aa 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -2,6 +2,7 @@ import {getUnixTime} from 'date-fns'; import lodashClone from 'lodash/clone'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import * as API from '@libs/API'; import type { ChangeTransactionsReportParams, @@ -13,7 +14,9 @@ import type { } from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CollectionUtils from '@libs/CollectionUtils'; +import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils'; import * as NumberUtils from '@libs/NumberUtils'; import {rand64} from '@libs/NumberUtils'; @@ -33,7 +36,17 @@ import { hasViolations as hasViolationsReportUtils, shouldEnableNegative, } from '@libs/ReportUtils'; -import {isManagedCardTransaction, isOnHold, shouldClearConvertedAmount, waypointHasValidAddress} from '@libs/TransactionUtils'; +import { + getCurrency, + getDistanceInMeters, + hasCustomUnit, + isDistanceRequest, + isManagedCardTransaction, + isManualDistanceRequest, + isOnHold, + shouldClearConvertedAmount, + waypointHasValidAddress, +} from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -53,7 +66,7 @@ import type { import type {OriginalMessageIOU, OriginalMessageModifiedExpense} from '@src/types/onyx/OriginalMessage'; import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; -import type {Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; +import type {Comment, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type TransactionState from '@src/types/utils/TransactionStateType'; import {getPolicyTags} from './IOU/index'; @@ -762,6 +775,8 @@ type ChangeTransactionsReportProps = { reportNextStep?: OnyxEntry; policyCategories?: OnyxEntry; allTransactions: OnyxCollection; + translate: LocaleContextProps['translate']; + toLocaleDigit: LocaleContextProps['toLocaleDigit']; }; function changeTransactionsReport({ @@ -774,6 +789,8 @@ function changeTransactionsReport({ reportNextStep, policyCategories, allTransactions, + translate, + toLocaleDigit, }: ChangeTransactionsReportProps) { const reportID = newReport?.reportID ?? CONST.REPORT.UNREPORTED_REPORT_ID; @@ -951,6 +968,45 @@ function changeTransactionsReport({ created: oldIOUAction?.created ?? DateUtils.getDBTime(), }; + let comment: NullishDeep | undefined; + let modifiedAmount: number | undefined; + let modifiedMerchant: string | undefined; + if (isUnreported) { + // If the transaction is on hold, we need to unhold it because unreported transactions (on selfDM) should never remain on hold. + comment = { + hold: null, + }; + + // If the transaction has a custom unit then update it to `_FAKE_P2P_ID_` so it's no longer tied to the policy's rate which would cause the "Rate out of policy" violation to appear. + if (hasCustomUnit(transaction)) { + comment.customUnit = { + customUnitID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, + customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, + }; + + // For distance requests we also need to set the defaultP2PRate and update the amount and merchant + if (isDistanceRequest(transaction)) { + const currency = getCurrency(transaction); + const unit = transaction?.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const distanceInMeters = getDistanceInMeters(transaction, unit); + const defaultP2PRate = DistanceRequestUtils.getRateForP2PInUnit(currency, transaction, unit); + comment.customUnit.defaultP2PRate = defaultP2PRate; + modifiedAmount = -DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, defaultP2PRate); + modifiedMerchant = DistanceRequestUtils.getDistanceMerchant( + true, + distanceInMeters, + unit, + defaultP2PRate, + currency, + translate, + toLocaleDigit, + getCurrencySymbol, + isManualDistanceRequest(transaction), + ); + } + } + } + // 1. Optimistically change the reportID on the passed transactions // Only set pendingAction for transactions that need convertedAmount recalculation optimisticData.push({ @@ -958,12 +1014,10 @@ function changeTransactionsReport({ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { reportID, + comment, + modifiedAmount, + modifiedMerchant, ...(shouldClearAmount && {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), - ...(isUnreported && { - comment: { - hold: null, - }, - }), ...(shouldClearAmount && {convertedAmount: null}), ...(oldIOUAction ? {linkedTrackedExpenseReportAction: newIOUAction} : {}), }, @@ -983,16 +1037,16 @@ function changeTransactionsReport({ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { reportID: transaction.reportID, + comment: transaction.comment, + modifiedAmount: transaction.modifiedAmount, + modifiedMerchant: transaction.modifiedMerchant, ...(shouldClearAmount && {pendingAction: transaction.pendingAction ?? null}), - comment: { - hold: transaction.comment?.hold, - }, ...(shouldClearAmount && {convertedAmount: transaction.convertedAmount}), }, }); // Optimistically clear all violations for the transaction when moving to self DM report - if (reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + if (isUnreported) { const duplicateViolation = currentTransactionViolations?.[transaction.transactionID]?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION); const duplicateTransactionIDs = duplicateViolation?.data?.duplicates; if (duplicateTransactionIDs) { diff --git a/src/pages/NewReportWorkspaceSelectionPage.tsx b/src/pages/NewReportWorkspaceSelectionPage.tsx index 773272370328d..24d491757b612 100644 --- a/src/pages/NewReportWorkspaceSelectionPage.tsx +++ b/src/pages/NewReportWorkspaceSelectionPage.tsx @@ -56,7 +56,7 @@ function NewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelectionPag const {selectedTransactions, selectedTransactionIDs, clearSelectedTransactions} = useSearchContext(); const styles = useThemeStyles(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - const {translate, localeCompare} = useLocalize(); + const {translate, localeCompare, toLocaleDigit} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [allReportNextSteps] = useOnyx(ONYXKEYS.COLLECTION.NEXT_STEP, {canBeMissing: true}); const isRHPOnReportInSearch = isRHPOnSearchMoneyRequestReportPage(); @@ -141,6 +141,8 @@ function NewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelectionPag reportNextStep, policyCategories: undefined, allTransactions, + translate, + toLocaleDigit, }); // eslint-disable-next-line rulesdir/no-default-id-values @@ -175,6 +177,8 @@ function NewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelectionPag activePolicyID, clearSelectedTransactions, betas, + translate, + toLocaleDigit, ], ); diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 093d0970dfc0b..cfd0056dc49dc 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -6,6 +6,7 @@ import {useSearchContext} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionListWithSections/types'; import useConditionalCreateEmptyReportConfirmation from '@hooks/useConditionalCreateEmptyReportConfirmation'; import useHasPerDiemTransactions from '@hooks/useHasPerDiemTransactions'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -27,6 +28,7 @@ type TransactionGroupListItem = ListItem & { }; function SearchTransactionsChangeReport() { + const {translate, toLocaleDigit} = useLocalize(); const {selectedTransactions, clearSelectedTransactions} = useSearchContext(); const selectedTransactionsKeys = useMemo(() => Object.keys(selectedTransactions), [selectedTransactions]); const transactions = useMemo( @@ -111,6 +113,8 @@ function SearchTransactionsChangeReport() { reportNextStep, policyCategories: allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyForMovingExpensesID}`], allTransactions: transactions, + translate, + toLocaleDigit, }); clearSelectedTransactions(); }); @@ -153,6 +157,8 @@ function SearchTransactionsChangeReport() { reportNextStep, policyCategories: allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${item.policyID}`], allTransactions: transactions, + translate, + toLocaleDigit, }); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { @@ -172,6 +178,8 @@ function SearchTransactionsChangeReport() { accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, email: session?.email ?? '', allTransactions: transactions, + translate, + toLocaleDigit, }); clearSelectedTransactions(); Navigation.goBack(); diff --git a/src/pages/iou/request/step/IOURequestEditReport.tsx b/src/pages/iou/request/step/IOURequestEditReport.tsx index 07bad2d716960..0ad631b0c058e 100644 --- a/src/pages/iou/request/step/IOURequestEditReport.tsx +++ b/src/pages/iou/request/step/IOURequestEditReport.tsx @@ -5,6 +5,7 @@ import {useSearchContext} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionListWithSections/types'; import useConditionalCreateEmptyReportConfirmation from '@hooks/useConditionalCreateEmptyReportConfirmation'; import useHasPerDiemTransactions from '@hooks/useHasPerDiemTransactions'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -34,6 +35,7 @@ type IOURequestEditReportProps = WithWritableReportOrNotFoundProps): ReportAction[ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { const {backTo, action, iouType, transactionID, reportID: reportIDFromRoute, reportActionID} = route.params; + const {translate, toLocaleDigit} = useLocalize(); const [allReports] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}`, {canBeMissing: false}); const isUnreported = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const transactionReport = Object.values(allReports ?? {}).find((report) => report?.reportID === transaction?.reportID); @@ -168,6 +170,8 @@ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { reportNextStep: undefined, policyCategories: allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${item.policyID}`], allTransactions, + translate, + toLocaleDigit, }); removeTransaction(transaction.transactionID); } @@ -210,6 +214,8 @@ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { accountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, email: session?.email ?? '', allTransactions, + translate, + toLocaleDigit, }); removeTransaction(transaction.transactionID); }); diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index aa06457cc93a1..eb9b638beaf8d 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -117,7 +117,7 @@ import createRandomTransaction from '../utils/collections/transaction'; import getOnyxValue from '../utils/getOnyxValue'; import PusherHelper from '../utils/PusherHelper'; import type {MockFetch} from '../utils/TestHelper'; -import {getGlobalFetchMock, getOnyxData, localeCompare, setPersonalDetails, signInWithTestUser, translateLocal} from '../utils/TestHelper'; +import {getGlobalFetchMock, getOnyxData, localeCompare, setPersonalDetails, signInWithTestUser, toLocaleDigit, translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; import waitForNetworkPromises from '../utils/waitForNetworkPromises'; @@ -11621,6 +11621,8 @@ describe('actions/IOU', () => { email: CARLOS_EMAIL, newReport: result.current.report, allTransactions, + translate: translateLocal, + toLocaleDigit, }); let updatedTransaction: OnyxEntry; diff --git a/tests/unit/TransactionTest.ts b/tests/unit/TransactionTest.ts index be7871abfb346..b470bf65059f7 100644 --- a/tests/unit/TransactionTest.ts +++ b/tests/unit/TransactionTest.ts @@ -126,6 +126,8 @@ describe('Transaction', () => { email: 'test@example.com', newReport: report, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); const reportActions = await new Promise>((resolve) => { @@ -173,6 +175,8 @@ describe('Transaction', () => { email: 'test@example.com', newReport: report, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); const reportActions = await new Promise>((resolve) => { @@ -235,6 +239,8 @@ describe('Transaction', () => { policy: undefined, reportNextStep: mockReportNextStep, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); @@ -299,6 +305,8 @@ describe('Transaction', () => { policy: undefined, reportNextStep: mockReportNextStep, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); @@ -351,6 +359,8 @@ describe('Transaction', () => { policy: undefined, reportNextStep: undefined, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); @@ -414,6 +424,8 @@ describe('Transaction', () => { email: 'test@example.com', newReport: report, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); @@ -469,6 +481,8 @@ describe('Transaction', () => { email: 'test@example.com', newReport: report, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); @@ -521,6 +535,8 @@ describe('Transaction', () => { email: customEmail, newReport: report, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); @@ -579,6 +595,8 @@ describe('Transaction', () => { email: 'test@example.com', newReport: expenseReport, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); const report = await new Promise>((resolve) => { @@ -637,6 +655,8 @@ describe('Transaction', () => { email: 'test@example.com', newReport: expenseReport, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); const report = await new Promise>((resolve) => { @@ -702,6 +722,8 @@ describe('Transaction', () => { email: 'test@example.com', newReport: newExpenseReport, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); const report = await new Promise>((resolve) => { @@ -766,6 +788,8 @@ describe('Transaction', () => { email: 'test@example.com', newReport: newExpenseReport, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); const report = await new Promise>((resolve) => { @@ -823,6 +847,8 @@ describe('Transaction', () => { email: 'test@example.com', newReport: fakeReport, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); @@ -882,6 +908,8 @@ describe('Transaction', () => { email: 'test@example.com', newReport: fakeReport, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); @@ -936,6 +964,8 @@ describe('Transaction', () => { reportNextStep: undefined, policyCategories, allTransactions, + translate: TestHelper.translateLocal, + toLocaleDigit: TestHelper.toLocaleDigit, }); await waitForBatchedUpdates(); diff --git a/tests/utils/TestHelper.ts b/tests/utils/TestHelper.ts index 09515f43e709c..5776eedc64339 100644 --- a/tests/utils/TestHelper.ts +++ b/tests/utils/TestHelper.ts @@ -5,6 +5,7 @@ import Onyx from 'react-native-onyx'; import type {ConnectOptions, OnyxEntry, OnyxKey} from 'react-native-onyx/dist/types'; import type {ApiCommand, ApiRequestCommandParameters} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; +import {toLocaleDigit as toLocaleDigitUtil} from '@libs/LocaleDigitUtils'; import {formatPhoneNumberWithCountryCode} from '@libs/LocalePhoneNumber'; import {translate} from '@libs/Localize'; import Pusher from '@libs/Pusher'; @@ -436,6 +437,11 @@ function localeCompare(a: string, b: string): number { return customCollator.compare(a, b); } +function toLocaleDigit(digit: string): string { + const currentLocale = IntlStore.getCurrentLocale(); + return toLocaleDigitUtil(currentLocale, digit); +} + export type {MockFetch, FormData}; export { translateLocal, @@ -458,4 +464,5 @@ export { localeCompare, STRIPE_CUSTOMER_ID, getNvpDismissedProductTraining, + toLocaleDigit, };