diff --git a/src/components/AddExistingExpenseFooter.tsx b/src/components/AddExistingExpenseFooter.tsx index c48c6ea07a5a..e2c700109d02 100644 --- a/src/components/AddExistingExpenseFooter.tsx +++ b/src/components/AddExistingExpenseFooter.tsx @@ -1,6 +1,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsByID from '@hooks/useTransactionsByID'; @@ -49,6 +50,7 @@ function AddExistingExpenseFooter({selectedIds, report, reportToConfirm, reportN const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const session = useSession(); const personalDetails = usePersonalDetails(); + const personalPolicy = usePersonalPolicy(); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); @@ -97,6 +99,7 @@ function AddExistingExpenseFooter({selectedIds, report, reportToConfirm, reportN transactions, allTransactionViolation: transactionViolations, allReports, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, }); } }, diff --git a/src/hooks/useUndeleteTransactions.ts b/src/hooks/useUndeleteTransactions.ts index a2bf5d366395..8f9003889871 100644 --- a/src/hooks/useUndeleteTransactions.ts +++ b/src/hooks/useUndeleteTransactions.ts @@ -31,6 +31,7 @@ function useUndeleteTransactions() { transactions, allTransactionViolation: transactionViolations, allReports, + personalPolicyOutputCurrency: policy?.outputCurrency, }); }; } diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 1ff25f94b6dd..80a6739299fe 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -839,6 +839,7 @@ type ChangeTransactionsReportProps = { transactions: Transaction[]; allTransactionViolation?: OnyxCollection; allReports: OnyxCollection; + personalPolicyOutputCurrency: string | undefined; }; function changeTransactionsReport({ @@ -854,6 +855,7 @@ function changeTransactionsReport({ transactions, allTransactionViolation = {}, allReports: allReportsParam, + personalPolicyOutputCurrency, }: ChangeTransactionsReportProps) { const reports = allReportsParam ?? allReports; const reportID = newReport?.reportID ?? CONST.REPORT.UNREPORTED_REPORT_ID; @@ -1231,7 +1233,7 @@ function changeTransactionsReport({ }; if (!isFetchingWaypointsFromServer(transaction)) { - const updatedMileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy, useTransactionDistanceUnit: false}); + const updatedMileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy, useTransactionDistanceUnit: false, personalPolicyOutputCurrency}); const {unit, rate} = updatedMileageRate; const distanceInMeters = getDistanceInMeters(updatedTransaction, unit); const calculatedAmount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0); diff --git a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx index 57f2c8d1b52e..eaeddf09a1dd 100644 --- a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx +++ b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx @@ -17,6 +17,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsByID from '@hooks/useTransactionsByID'; @@ -84,6 +85,7 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalPolicy = usePersonalPolicy(); const selectedTransactionsKeys = Object.keys(selectedTransactions); const transactionIDs = selectedTransactionsKeys.length ? selectedTransactionsKeys : selectedTransactionIDs; @@ -135,6 +137,7 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec transactions, allTransactionViolation: transactionViolations, allReports, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, }); // eslint-disable-next-line rulesdir/no-default-id-values diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 4b98dd9dc802..231b4a1a52a1 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -6,6 +6,7 @@ import useConditionalCreateEmptyReportConfirmation from '@hooks/useConditionalCr import useHasPerDiemTransactions from '@hooks/useHasPerDiemTransactions'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import {createNewReport} from '@libs/actions/Report'; @@ -45,6 +46,7 @@ function SearchTransactionsChangeReport() { const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allPolicyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}`); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); + const personalPolicy = usePersonalPolicy(); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); @@ -159,6 +161,7 @@ function SearchTransactionsChangeReport() { transactions, allTransactionViolation: transactionViolations, allReports, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, }); clearSelectedTransactions(); }); @@ -237,6 +240,7 @@ function SearchTransactionsChangeReport() { transactions, allTransactionViolation: transactionViolations, allReports, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, }); Navigation.goBack(undefined, {afterTransition: clearSelectedTransactions}); }; @@ -256,6 +260,7 @@ function SearchTransactionsChangeReport() { transactions, allTransactionViolation: transactionViolations, allReports, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, }); clearSelectedTransactions(); Navigation.goBack(); diff --git a/src/pages/iou/request/step/IOURequestEditReport.tsx b/src/pages/iou/request/step/IOURequestEditReport.tsx index c6ea9c6b5042..b4622891f17a 100644 --- a/src/pages/iou/request/step/IOURequestEditReport.tsx +++ b/src/pages/iou/request/step/IOURequestEditReport.tsx @@ -7,6 +7,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useHasPerDiemTransactions from '@hooks/useHasPerDiemTransactions'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useTransactionsByID from '@hooks/useTransactionsByID'; @@ -53,6 +54,7 @@ function IOURequestEditReport({route}: IOURequestEditReportProps) { const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalPolicy = usePersonalPolicy(); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); @@ -103,6 +105,7 @@ function IOURequestEditReport({route}: IOURequestEditReportProps) { transactions, allTransactionViolation: transactionViolations, allReports, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, }); turnOffMobileSelectionMode(); clearSelectedTransactions(true); @@ -126,6 +129,7 @@ function IOURequestEditReport({route}: IOURequestEditReportProps) { transactions, allTransactionViolation: transactionViolations, allReports, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, }); if (shouldTurnOffSelectionMode) { turnOffMobileSelectionMode(); diff --git a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts index 8f85e493e531..7b5d0b1226e2 100644 --- a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts +++ b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts @@ -210,6 +210,7 @@ function useReportSelectionActions({ transactions: targetTransactions, allTransactionViolation: transactionViolations, allReports, + personalPolicyOutputCurrency: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${personalPolicyID}`]?.outputCurrency, }); removeTransaction(transaction.transactionID); } @@ -234,6 +235,7 @@ function useReportSelectionActions({ transactions: targetTransactions, allTransactionViolation: transactionViolations, allReports, + personalPolicyOutputCurrency: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${personalPolicyID}`]?.outputCurrency, }); removeTransaction(transaction.transactionID); }, diff --git a/src/pages/iou/request/step/IOURequestStepUpgrade.tsx b/src/pages/iou/request/step/IOURequestStepUpgrade.tsx index 2524dd5c0cf1..7574dc04e577 100644 --- a/src/pages/iou/request/step/IOURequestStepUpgrade.tsx +++ b/src/pages/iou/request/step/IOURequestStepUpgrade.tsx @@ -147,6 +147,8 @@ function IOURequestStepUpgrade({ transactions, allTransactionViolation: transactionViolations, allReports, + // Expenses move to the upgraded workspace (newPolicy), whose currency drives any distance calculation, so the personal-policy currency is never read here. + personalPolicyOutputCurrency: undefined, }); clearSelectedTransactions(); diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts index 270bdf2ed1fb..3a51ade8ae84 100644 --- a/tests/actions/TransactionTest.ts +++ b/tests/actions/TransactionTest.ts @@ -38,14 +38,15 @@ import getOnyxValue from '../utils/getOnyxValue'; import {getGlobalFetchMock, getOnyxData} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -type LegacyChangeTransactionsReportProps = Omit[0], 'transactions' | 'allTransactionViolation'> & { +type LegacyChangeTransactionsReportProps = Omit[0], 'transactions' | 'allTransactionViolation' | 'personalPolicyOutputCurrency'> & { allTransactions: OnyxCollection; transactionViolations: Parameters[0]['allTransactionViolation']; + personalPolicyOutputCurrency?: string; }; -function changeTransactionsReport({allTransactions, transactionIDs, transactionViolations, ...rest}: LegacyChangeTransactionsReportProps) { +function changeTransactionsReport({allTransactions, transactionIDs, transactionViolations, personalPolicyOutputCurrency, ...rest}: LegacyChangeTransactionsReportProps) { const transactions = transactionIDs.map((id) => allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]).filter((transaction): transaction is Transaction => !!transaction); - changeTransactionsReportAction({transactionIDs, transactions, allTransactionViolation: transactionViolations, ...rest}); + changeTransactionsReportAction({transactionIDs, transactions, allTransactionViolation: transactionViolations, personalPolicyOutputCurrency, ...rest}); } const topMostReportID = '23423423'; @@ -343,6 +344,96 @@ describe('actions/Transaction', () => { expect(updatedExpenseReport?.unheldNonReimbursableTotal).toBe(-amount); }); + it('recomputes a distance expense amount/merchant/currency from the destination workspace rate when moved', async () => { + // Given a destination workspace whose default distance rate is defined in GBP (200/mi) + const policyID = generatePolicyID(); + const distanceCustomUnitID = 'CU_DISTANCE'; + const workspaceRateID = 'RATE_GBP'; + const mockPolicy: Policy = { + ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM, 'Distance Workspace'), + id: policyID, + outputCurrency: CONST.CURRENCY.USD, + customUnits: { + [distanceCustomUnitID]: { + customUnitID: distanceCustomUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: { + [workspaceRateID]: { + customUnitRateID: workspaceRateID, + currency: 'GBP', + rate: 200, + enabled: true, + name: 'Default Rate', + subRates: [], + }, + }, + }, + }, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, mockPolicy); + + // And a 10-mile distance expense in USD whose current rate does not exist on that workspace, + // which forces changeTransactionsReport to reselect the workspace's default rate and calculation. + const transactionID = 'txn-distance-move'; + const transaction: Transaction = { + transactionID, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + amount: -1000, + currency: CONST.CURRENCY.USD, + merchant: '10.00 mi @ $1.00 / mi', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitRateID: 'rate-not-on-destination-workspace', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 10, + }, + }, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + + const expenseReport = { + reportID: 'dest-expense-report', + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + chatReportID: 'dest-chat-report', + ownerAccountID: RORY_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + } as Report; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + await waitForBatchedUpdates(); + + // When the expense is moved to the workspace report + changeTransactionsReport({ + transactionIDs: [transactionID], + isASAPSubmitBetaEnabled: false, + accountID: RORY_ACCOUNT_ID, + email: RORY_EMAIL, + newReport: expenseReport, + policy: mockPolicy, + allTransactions: {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction}, + policyTagList: undefined, + transactionViolations: {}, + allReports: undefined, + personalPolicyOutputCurrency: 'EUR', + }); + await waitForBatchedUpdates(); + + // Then it adopts the workspace's default rate and recomputes amount/merchant/currency from it: + // 10 mi × 200 = 2000, currency GBP (from the rate, not the expense's original USD). + const updated = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(updated?.comment?.customUnit?.customUnitRateID).toBe(workspaceRateID); + expect(updated?.modifiedCurrency).toBe('GBP'); + expect(Math.abs(Number(updated?.modifiedAmount ?? 0))).toBe(2000); + expect(updated?.modifiedMerchant).toContain('mi'); + }); + describe('updateSplitTransactionsFromSplitExpensesFlow', () => { it("should update split transaction's description correctly ", async () => { const amount = 10000; diff --git a/tests/unit/TransactionTest.ts b/tests/unit/TransactionTest.ts index d349e72fa5b8..bbbd9cfa6fd8 100644 --- a/tests/unit/TransactionTest.ts +++ b/tests/unit/TransactionTest.ts @@ -33,15 +33,16 @@ import getOnyxValue from '../utils/getOnyxValue'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -type LegacyChangeTransactionsReportProps = Omit[0], 'transactions' | 'allTransactionViolation'> & { +type LegacyChangeTransactionsReportProps = Omit[0], 'transactions' | 'allTransactionViolation' | 'personalPolicyOutputCurrency'> & { allTransactions: OnyxCollection; transactionViolations?: OnyxCollection; + personalPolicyOutputCurrency?: string; }; // Wrapper mirroring the pre-refactor signature so existing test call sites compile unchanged. -function changeTransactionsReport({allTransactions, transactionIDs, transactionViolations = {}, ...rest}: LegacyChangeTransactionsReportProps) { +function changeTransactionsReport({allTransactions, transactionIDs, transactionViolations = {}, personalPolicyOutputCurrency, ...rest}: LegacyChangeTransactionsReportProps) { const transactions = transactionIDs.map((id) => allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]).filter((transaction): transaction is Transaction => !!transaction); - changeTransactionsReportAction({transactionIDs, transactions, allTransactionViolation: transactionViolations, ...rest}); + changeTransactionsReportAction({transactionIDs, transactions, allTransactionViolation: transactionViolations, personalPolicyOutputCurrency, ...rest}); } function generateTransaction(values: Partial = {}): Transaction {