diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 8e3549c4cacb..abf6d894318a 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -849,6 +849,25 @@ function getCurrency(transaction: OnyxInputOrEntry): string { return transaction?.currency ?? CONST.CURRENCY.USD; } +/** + * Determines if a transaction's convertedAmount should be cleared when moving to a different currency workspace. + * The convertedAmount is calculated for the source workspace's currency, so it becomes stale when: + * 1. Source and destination workspace currencies differ, AND + * 2. The transaction's currency doesn't match the destination currency + * + * Transactions that match the destination currency can keep their convertedAmount since no conversion is needed. + */ +function shouldClearConvertedAmount(transaction: OnyxInputOrEntry, sourceCurrency: string | undefined, destinationCurrency: string | undefined): boolean { + if (!sourceCurrency || !destinationCurrency || sourceCurrency === destinationCurrency) { + return false; + } + + const transactionCurrency = getCurrency(transaction); + const transactionMatchesDestination = transactionCurrency === destinationCurrency; + + return !transactionMatchesDestination; +} + /** * Return the original currency field from the transaction. */ @@ -2280,6 +2299,7 @@ export { getTaxAmount, getTaxCode, getCurrency, + shouldClearConvertedAmount, getDistanceInMeters, getCardID, getOriginalCurrency, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 5d7b08d21d3b..4cf13e79eac1 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -170,7 +170,7 @@ import { import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import type {ArchivedReportsIDSet} from '@libs/SearchUIUtils'; import playSound, {SOUNDS} from '@libs/Sound'; -import {hasValidModifiedAmount, isOnHold} from '@libs/TransactionUtils'; +import {hasValidModifiedAmount, isOnHold, shouldClearConvertedAmount} from '@libs/TransactionUtils'; import addTrailingForwardSlash from '@libs/UrlUtils'; import Visibility from '@libs/Visibility'; import CONFIG from '@src/CONFIG'; @@ -6009,6 +6009,43 @@ function buildOptimisticChangePolicyData( }, }); + // 6. Handle transactions when moving to a workspace with a different currency + // Clear convertedAmount (which is calculated for the old workspace currency) and add pendingAction for proper UI feedback + // Only clear for transactions that don't match the destination currency - matching transactions can keep their values + const sourceCurrency = report.currency; + const destinationCurrency = policy.outputCurrency; + const transactions = getReportTransactions(reportID); + + for (const transaction of transactions) { + if (!shouldClearConvertedAmount(transaction, sourceCurrency, destinationCurrency)) { + continue; + } + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + convertedAmount: null, + }, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + pendingAction: null, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + pendingAction: transaction.pendingAction ?? null, + convertedAmount: transaction.convertedAmount, + }, + }); + } + return {optimisticData, successData, failureData, optimisticReportPreviewAction, optimisticMovedReportAction}; } diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index ab1eb7581e98..c81ba1b0e64e 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -28,7 +28,7 @@ import { hasViolations as hasViolationsReportUtils, shouldEnableNegative, } from '@libs/ReportUtils'; -import {isManagedCardTransaction, isOnHold, waypointHasValidAddress} from '@libs/TransactionUtils'; +import {isManagedCardTransaction, isOnHold, shouldClearConvertedAmount, waypointHasValidAddress} from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -808,6 +808,9 @@ function changeTransactionsReport( const policyTagList = getPolicyTagsData(policy?.id); const policyHasDependentTags = hasDependentTags(policy, policyTagList); + // Determine the destination currency for convertedAmount clearing logic + const destinationCurrency = newReport?.currency ?? policy?.outputCurrency; + for (const transaction of transactions) { const isUnreportedExpense = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; @@ -822,6 +825,8 @@ function changeTransactionsReport( const oldReportID = isUnreportedExpense ? CONST.REPORT.UNREPORTED_REPORT_ID : transaction.reportID; const oldReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${oldReportID}`]; + const sourceCurrency = oldReport?.currency; + const shouldClearAmount = shouldClearConvertedAmount(transaction, sourceCurrency, destinationCurrency); // 1. Optimistically change the reportID on the passed transactions optimisticData.push({ @@ -829,9 +834,11 @@ function changeTransactionsReport( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { reportID, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, comment: { hold: null, }, + ...(shouldClearAmount && {convertedAmount: null}), }, }); @@ -840,6 +847,7 @@ function changeTransactionsReport( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { reportID, + pendingAction: null, }, }); @@ -848,9 +856,11 @@ function changeTransactionsReport( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { reportID: transaction.reportID, + pendingAction: transaction.pendingAction ?? null, comment: { hold: transaction.comment?.hold, }, + ...(shouldClearAmount && {convertedAmount: transaction.convertedAmount}), }, }); diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 02e9b055f3ca..508ab6a7469b 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -2551,6 +2551,178 @@ describe('actions/Report', () => { predictedNextStatus: CONST.REPORT.STATUS_NUM.SUBMITTED, }); }); + + it('should set pendingAction and clear convertedAmount when moving to workspace with different currency', async () => { + const reportID = 'testReport123'; + const transactionID = 'testTransaction456'; + const transaction = { + ...createRandomTransaction(1), + transactionID, + reportID, + currency: 'AUD', + convertedAmount: 15000, // Has a converted amount from old workspace + }; + + const report: OnyxTypes.Report = { + ...createRandomReport(1, undefined), + reportID, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + type: CONST.REPORT.TYPE.EXPENSE, + currency: 'AUD', // Source report currency + }; + + const policy = { + ...createRandomPolicy(Number(1)), + outputCurrency: CONST.CURRENCY.USD, // Destination currency is different + }; + + // Set up the transaction in Onyx so getReportTransactions can find it + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await waitForBatchedUpdates(); + + const {optimisticData, successData, failureData} = Report.buildOptimisticChangePolicyData(report, policy, 1, '', false, true, undefined); + + // Find the transaction optimistic data + const transactionOptimisticData = optimisticData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const transactionSuccessData = successData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const transactionFailureData = failureData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + + // Should have pendingAction set to UPDATE + expect((transactionOptimisticData?.value as OnyxTypes.Transaction)?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + // Should have convertedAmount cleared + expect((transactionOptimisticData?.value as OnyxTypes.Transaction)?.convertedAmount).toBeNull(); + + // Success data should clear pendingAction + expect((transactionSuccessData?.value as OnyxTypes.Transaction)?.pendingAction).toBeNull(); + + // Failure data should restore original values + expect((transactionFailureData?.value as OnyxTypes.Transaction)?.pendingAction).toBe(transaction.pendingAction ?? null); + expect((transactionFailureData?.value as OnyxTypes.Transaction)?.convertedAmount).toBe(transaction.convertedAmount); + }); + + it('should NOT clear convertedAmount when source and destination currencies are the same', async () => { + const reportID = 'testReport789'; + const transactionID = 'testTransaction012'; + const transaction = { + ...createRandomTransaction(1), + transactionID, + reportID, + currency: 'EUR', + convertedAmount: 15000, + }; + + const report: OnyxTypes.Report = { + ...createRandomReport(1, undefined), + reportID, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + type: CONST.REPORT.TYPE.EXPENSE, + currency: CONST.CURRENCY.USD, // Source report currency + }; + + const policy = { + ...createRandomPolicy(Number(1)), + outputCurrency: CONST.CURRENCY.USD, // Same as source + }; + + // Set up the transaction in Onyx + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await waitForBatchedUpdates(); + + const {optimisticData} = Report.buildOptimisticChangePolicyData(report, policy, 1, '', false, true, undefined); + + // Should NOT find transaction optimistic data when currencies are the same + const transactionOptimisticData = optimisticData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transactionOptimisticData).toBeUndefined(); + }); + + it('should NOT clear convertedAmount when transaction matches destination currency', async () => { + const reportID = 'testReport345'; + const transactionID = 'testTransaction678'; + const transaction = { + ...createRandomTransaction(1), + transactionID, + reportID, + currency: CONST.CURRENCY.USD, // Transaction is in destination currency + convertedAmount: 15000, + }; + + const report: OnyxTypes.Report = { + ...createRandomReport(1, undefined), + reportID, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + type: CONST.REPORT.TYPE.EXPENSE, + currency: 'AUD', // Source report currency is different + }; + + const policy = { + ...createRandomPolicy(Number(1)), + outputCurrency: CONST.CURRENCY.USD, // Matches transaction currency + }; + + // Set up the transaction in Onyx + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await waitForBatchedUpdates(); + + const {optimisticData} = Report.buildOptimisticChangePolicyData(report, policy, 1, '', false, true, undefined); + + // Should NOT find transaction optimistic data when transaction matches destination currency + const transactionOptimisticData = optimisticData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transactionOptimisticData).toBeUndefined(); + }); + + it('should only clear convertedAmount for non-matching transactions in mixed currency scenario', async () => { + const reportID = 'testReport999'; + const matchingTransactionID = 'matchingTransaction'; + const nonMatchingTransactionID = 'nonMatchingTransaction'; + + // Transaction that matches destination currency (USD) + const matchingTransaction = { + ...createRandomTransaction(1), + transactionID: matchingTransactionID, + reportID, + currency: CONST.CURRENCY.USD, + convertedAmount: 10000, + }; + + // Transaction that doesn't match destination currency (AUD != USD) + const nonMatchingTransaction = { + ...createRandomTransaction(2), + transactionID: nonMatchingTransactionID, + reportID, + currency: 'AUD', + convertedAmount: 15000, + }; + + const report: OnyxTypes.Report = { + ...createRandomReport(1, undefined), + reportID, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + type: CONST.REPORT.TYPE.EXPENSE, + currency: 'AUD', // Source report currency + }; + + const policy = { + ...createRandomPolicy(Number(1)), + outputCurrency: CONST.CURRENCY.USD, // Destination currency + }; + + // Set up both transactions in Onyx + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${matchingTransactionID}`, matchingTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${nonMatchingTransactionID}`, nonMatchingTransaction); + await waitForBatchedUpdates(); + + const {optimisticData} = Report.buildOptimisticChangePolicyData(report, policy, 1, '', false, true, undefined); + + // Should NOT find optimistic data for the matching transaction (USD matches USD destination) + const matchingOptimisticData = optimisticData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${matchingTransactionID}`); + expect(matchingOptimisticData).toBeUndefined(); + + // Should find optimistic data for the non-matching transaction (AUD doesn't match USD destination) + const nonMatchingOptimisticData = optimisticData.find((data) => data.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${nonMatchingTransactionID}`); + expect(nonMatchingOptimisticData).toBeDefined(); + expect((nonMatchingOptimisticData?.value as OnyxTypes.Transaction)?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + expect((nonMatchingOptimisticData?.value as OnyxTypes.Transaction)?.convertedAmount).toBeNull(); + }); }); describe('searchInServer', () => {