diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index c41e65cd11f3..79d2dd15db97 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -636,6 +636,22 @@ function hasValidModifiedAmount(transaction: OnyxEntry | null): boo return transaction?.modifiedAmount !== undefined && transaction?.modifiedAmount !== null && transaction?.modifiedAmount !== ''; } +/** + * Builds the optimistic transaction used when an IOU report is converted to an expense report. + * + * Expense reports store amounts with the opposite sign of IOU reports (see `getAmount`/`getConvertedAmount`), + * so `amount`, `modifiedAmount` and `convertedAmount` are negated to match the expense-report convention. + * Absent converted values are not added so they keep being derived from the amount. + */ +function getNegatedAmountTransaction(transaction: Transaction): Transaction { + return { + ...transaction, + amount: -transaction.amount, + modifiedAmount: hasValidModifiedAmount(transaction) ? -Number(transaction.modifiedAmount) : '', + ...(transaction.convertedAmount != null && {convertedAmount: -transaction.convertedAmount}), + }; +} + function isCreatedMissing(transaction: OnyxEntry) { if (!transaction) { return true; @@ -2991,6 +3007,7 @@ export { hasPendingRTERViolation, hasAnyPendingRTERViolation, hasValidModifiedAmount, + getNegatedAmountTransaction, allHavePendingRTERViolation, hasPendingUI, getWaypointIndex, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 909e7274448c..eb97c1360fc0 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -95,7 +95,7 @@ import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; import {getCustomUnitsForDuplication, getMemberAccountIDsForWorkspace, goBackWhenEnableFeature, isControlPolicy, navigateToExpensifyCardPage} from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import {hasValidModifiedAmount} from '@libs/TransactionUtils'; +import {getNegatedAmountTransaction} from '@libs/TransactionUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import type {Feature} from '@pages/OnboardingInterestedFeatures/types'; import * as PaymentMethods from '@userActions/PaymentMethods'; @@ -4461,11 +4461,7 @@ function createWorkspaceFromIOUPayment( const transactionsOptimisticData: Record = {}; const transactionFailureData: Record = {}; for (const transaction of reportTransactions) { - transactionsOptimisticData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { - ...transaction, - amount: -transaction.amount, - modifiedAmount: hasValidModifiedAmount(transaction) ? -Number(transaction.modifiedAmount) : '', - }; + transactionsOptimisticData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = getNegatedAmountTransaction(transaction); transactionFailureData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; } diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index d667f59fd4c1..832d521935ab 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -169,7 +169,7 @@ import { } from '@libs/ReportUtils'; import {buildOptimisticSnapshotData, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import playSound, {SOUNDS} from '@libs/Sound'; -import {getAmount, getCurrency, hasValidModifiedAmount, isOnHold, recalculateUnreportedTransactionDetails, shouldClearConvertedAmount} from '@libs/TransactionUtils'; +import {getAmount, getCurrency, getNegatedAmountTransaction, isOnHold, recalculateUnreportedTransactionDetails, shouldClearConvertedAmount} from '@libs/TransactionUtils'; import addTrailingForwardSlash from '@libs/UrlUtils'; import Visibility from '@libs/Visibility'; import {cacheAttachment, removeCachedAttachment} from '@userActions/Attachment'; @@ -6781,11 +6781,7 @@ function convertIOUReportToExpenseReport(iouReport: Report, policy: Policy, poli const transactionsOptimisticData: Record = {}; const transactionFailureData: Record = {}; for (const transaction of reportTransactions) { - transactionsOptimisticData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { - ...transaction, - amount: -transaction.amount, - modifiedAmount: hasValidModifiedAmount(transaction) ? -Number(transaction.modifiedAmount) : '', - }; + transactionsOptimisticData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = getNegatedAmountTransaction(transaction); transactionFailureData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; } diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 848b12b86f2a..1cf620943c7d 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -11,10 +11,11 @@ import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import {askToJoinPolicy, joinAccessiblePolicy} from '@src/libs/actions/Policy/Member'; import * as Policy from '@src/libs/actions/Policy/Policy'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Onboarding, PolicyJoinMember, PolicyReportField, Policy as PolicyType, Report, ReportAction, ReportActions, TransactionViolations} from '@src/types/onyx'; +import type {Onboarding, PolicyJoinMember, PolicyReportField, Policy as PolicyType, Report, ReportAction, ReportActions, Transaction, TransactionViolations} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/Report'; import createRandomPolicy from '../utils/collections/policies'; import {createRandomReport} from '../utils/collections/reports'; +import createRandomTransaction from '../utils/collections/transaction'; import getOnyxValue from '../utils/getOnyxValue'; import * as TestHelper from '../utils/TestHelper'; import type {MockFetch} from '../utils/TestHelper'; @@ -7093,5 +7094,54 @@ describe('actions/Policy', () => { apiWriteSpy.mockRestore(); isIOUReportUsingReportSpy.mockRestore(); }); + + it('should negate the converted transaction amounts on the optimistic data so the expense-report table total stays positive', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); + + const employeeAccountID = 400; + const iouReportOwnerEmail = 'employee@example.com'; + + const iouReport: Report = { + ...createRandomReport(1, undefined), + reportID: '900', + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: employeeAccountID, + chatReportID: '901', + policyID: 'oldPolicyID', + currency: CONST.CURRENCY.USD, + total: 5000, + }; + + const transaction: Transaction = { + ...createRandomTransaction(900), + transactionID: 'transaction900', + reportID: iouReport.reportID, + amount: 5000, + modifiedAmount: '', + convertedAmount: 6000, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + await waitForBatchedUpdates(); + + const mockTranslate = ((key: string) => key) as unknown as Parameters[8]; + Policy.createWorkspaceFromIOUPayment(iouReport, undefined, ESH_ACCOUNT_ID, ESH_EMAIL, iouReportOwnerEmail, undefined, CONST.CURRENCY.USD, undefined, mockTranslate, {}); + await waitForBatchedUpdates(); + + // Optimistic merge: read the stored transaction from Onyx + const optimisticTransaction: OnyxEntry = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + expect(optimisticTransaction?.amount).toBe(-5000); + expect(optimisticTransaction?.convertedAmount).toBe(-6000); + }); }); }); diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 5c99b6d11603..4f328b095867 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -4070,6 +4070,7 @@ describe('actions/Report', () => { reportID: iouReport.reportID, amount: 5000, modifiedAmount: 6000, + convertedAmount: 6000, }; await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { @@ -4097,6 +4098,7 @@ describe('actions/Report', () => { }); expect(updatedTransaction?.amount).toBe(-5000); expect(updatedTransaction?.modifiedAmount).toBe(-6000); + expect(updatedTransaction?.convertedAmount).toBe(-6000); }); it('should convert IOU report to expense report with correct policyID when reportTransactions are provided', async () => {