diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 1845a1b2edc7d..6d4e78d662fac 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -1,6 +1,6 @@ import {eachDayOfInterval, format, parse} from 'date-fns'; import {InteractionManager} from 'react-native'; -import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {SearchActionsContextValue, SearchStateContextValue} from '@components/Search/types'; @@ -24,11 +24,13 @@ import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; import { getAllReportActions, getIOUActionForReportID, + getIOUActionForTransactionID, getLastVisibleAction, getOriginalMessage, getReportAction, getReportActionHtml, getReportActionText, + isActionOfType, isAddCommentAction, isDeletedAction, isMoneyRequestAction, @@ -1097,6 +1099,24 @@ function updateSplitTransactions({ const isReverseSplitOperation = splitExpenses.length === 1 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft && allChildTransactions.length === originalChildTransactions.length; + let splitThreadComments: OnyxTypes.ReportAction[] = []; + let splitTransactionThreadReportID: string | undefined; + + if (isReverseSplitOperation) { + const revertSplitTransactionID = splitExpenses.at(0)?.transactionID; + const revertSplitTransaction = allTransactionsList?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${revertSplitTransactionID}`]; + const revertSplitReportActions = getAllReportActions(revertSplitTransaction?.reportID); + const revertSplitIOUAction = revertSplitTransactionID ? getIOUActionForTransactionID(Object.values(revertSplitReportActions ?? {}), revertSplitTransactionID) : undefined; + splitTransactionThreadReportID = revertSplitIOUAction?.childReportID; + if (splitTransactionThreadReportID) { + const splitTransactionThreadActions = getAllReportActions(splitTransactionThreadReportID); + splitThreadComments = Object.values(splitTransactionThreadActions).filter( + (action): action is OnyxTypes.ReportAction => + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) && !isDeletedAction(action) && (action.actorAccountID ?? CONST.DEFAULT_NUMBER_ID) > 0, + ); + } + } + let changesInReportTotal = 0; // Validate custom unit rate before proceeding with split const customUnitRateID = originalTransaction?.comment?.customUnit?.customUnitRateID; @@ -1967,6 +1987,94 @@ function updateSplitTransactions({ }); } } + + const originalTransactionThreadReportID = splits.at(0)?.transactionThreadReportID; + const iouActionReportActionID = splits.at(0)?.splitReportActionID; + if (splitThreadComments.length > 0 && originalTransactionThreadReportID && splitTransactionThreadReportID && iouActionReportActionID && expenseReportID) { + const optimisticMovedComments: Record = {}; + const optimisticRemovedComments: Record = {}; + const successMovedComments: OnyxCollection> = {}; + const failureMovedCommentsRemoval: Record = {}; + const failureRestoredComments: Record = {}; + + const commenterAccountIDs = new Set(); + let latestCommentCreated = ''; + + for (const comment of splitThreadComments) { + optimisticMovedComments[comment.reportActionID] = { + ...comment, + reportID: originalTransactionThreadReportID, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + optimisticRemovedComments[comment.reportActionID] = null; + successMovedComments[comment.reportActionID] = {pendingAction: null}; + failureMovedCommentsRemoval[comment.reportActionID] = null; + failureRestoredComments[comment.reportActionID] = comment; + + if (comment.actorAccountID && comment.actorAccountID > 0) { + commenterAccountIDs.add(comment.actorAccountID); + } + if (comment.created && comment.created > latestCommentCreated) { + latestCommentCreated = comment.created; + } + } + + onyxData.optimisticData?.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalTransactionThreadReportID}`, + value: optimisticMovedComments, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitTransactionThreadReportID}`, + value: optimisticRemovedComments, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, + value: { + [iouActionReportActionID]: { + childVisibleActionCount: splitThreadComments.length, + childCommenterCount: commenterAccountIDs.size, + childLastVisibleActionCreated: latestCommentCreated, + childOldestFourAccountIDs: [...commenterAccountIDs].slice(0, 4).join(','), + }, + }, + }, + ); + + onyxData.successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalTransactionThreadReportID}`, + value: successMovedComments, + }); + + onyxData.failureData?.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalTransactionThreadReportID}`, + value: failureMovedCommentsRemoval, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitTransactionThreadReportID}`, + value: failureRestoredComments, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, + value: { + [iouActionReportActionID]: { + childVisibleActionCount: 0, + childCommenterCount: 0, + childLastVisibleActionCreated: '', + childOldestFourAccountIDs: '', + }, + }, + }, + ); + } } if (isReverseSplitOperation) { diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index bcfd200f711ff..d53823e19395b 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -46,6 +46,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import currencyList from '../../unit/currencyList.json'; import createPersonalDetails from '../../utils/collections/personalDetails'; import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; +import createRandomReportAction from '../../utils/collections/reportActions'; import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; import getOnyxValue from '../../utils/getOnyxValue'; @@ -2271,6 +2272,324 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { expect(isDeleted).toBe(true); }); + it('should migrate split thread comments to the original transaction thread when reverting a split', async () => { + const amount = 10000; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let originalTransactionID: string | undefined; + + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + isSelfTourViewed: false, + hasActiveAdminPolicies: false, + }); + const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }); + + requestMoney({ + report: chatReport, + betas: [CONST.BETAS.ALL], + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'TestMerchant', + comment: 'test comment', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + isSelfTourViewed: false, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: {}, + }); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportActions) => { + const iouActions = Object.values(allReportActions ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; + originalTransactionID = originalMessage?.IOUTransactionID; + }, + }); + + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + const originalReportID = originalTransaction?.reportID; + + // Step 1: Split into 2 child transactions + const splitTransactionID1 = rand64(); + const splitTransactionID2 = rand64(); + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + + const reportID = originalReportID ?? String(CONST.DEFAULT_NUMBER_ID); + const policyTags = await getPolicyTags(reportID); + let reports = getTransactionAndExpenseReports(reportID); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + betas: [CONST.BETAS.ALL], + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID, + originalTransactionID: originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: [ + {transactionID: splitTransactionID1, amount: amount / 2, created: DateUtils.getDBTime()}, + {transactionID: splitTransactionID2, amount: amount / 2, created: DateUtils.getDBTime()}, + ], + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + policyTags, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: reports.transactionReport, + expenseReport: reports.expenseReport, + }); + await waitForBatchedUpdates(); + + // Verify child transactions were created + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + const childTxs = Object.values(allTransactions ?? {}).filter((tx) => tx?.comment?.originalTransactionID === originalTransactionID); + expect(childTxs.length).toBeGreaterThan(0); + + // Step 2: Find the split's transaction thread and add a comment to it + let splitThreadReportID: string | undefined; + const splitTx1 = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID1}`]; + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitTx1?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportActions) => { + const iouAction = Object.values(allReportActions ?? {}).find( + (action): action is ReportAction => + isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUTransactionID === splitTransactionID1, + ); + splitThreadReportID = iouAction?.childReportID; + }, + }); + + expect(splitThreadReportID).toBeDefined(); + + const commentReportActionID = rand64(); + const commentCreated = DateUtils.getDBTime(); + + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + reportActionID: commentReportActionID, + reportID: splitThreadReportID, + actorAccountID: RORY_ACCOUNT_ID, + created: commentCreated, + message: [{type: 'COMMENT', html: 'Test comment to preserve', text: 'Test comment to preserve'}], + originalMessage: {html: 'Test comment to preserve', whisperedTo: []}, + shouldShow: true, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitThreadReportID}`, { + [commentReportActionID]: reportAction, + }); + await waitForBatchedUpdates(); + + // Verify comment was written to the split thread + const splitThreadActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitThreadReportID}`); + expect(splitThreadActions?.[commentReportActionID]).toBeDefined(); + expect(splitThreadActions?.[commentReportActionID]?.actionName).toBe(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + + // Step 3: Revert to 1 split (triggers isReverseSplitOperation) + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + + reports = getTransactionAndExpenseReports(reportID); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + betas: [CONST.BETAS.ALL], + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID, + originalTransactionID: originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: [{transactionID: splitTransactionID1, amount, created: DateUtils.getDBTime()}], + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + policyTags, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: reports.transactionReport, + expenseReport: reports.expenseReport, + }); + await waitForBatchedUpdates(); + + // Step 4: Verify the original transaction is restored with correct data + const restoredTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + expect(restoredTransaction).toBeDefined(); + expect(restoredTransaction?.transactionID).toBe(originalTransactionID); + expect(restoredTransaction?.amount).toBe(-amount); + expect(restoredTransaction?.currency).toBe(CONST.CURRENCY.USD); + + // The chosen split (split1) is force-deleted + const deletedSplit1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID1}`); + expect(deletedSplit1).toBeFalsy(); + + // The other split (split2) is marked pendingAction: delete + const deletedSplit2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID2}`); + expect(deletedSplit2?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + + // Step 5: Verify the new IOU action for the restored original transaction + const revertExpenseReportID = reports.expenseReport?.reportID; + let newIOUAction: ReportAction | undefined; + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${revertExpenseReportID}`, + waitForCollectionCallback: false, + callback: (allReportActions) => { + newIOUAction = Object.values(allReportActions ?? {}).findLast( + (action): action is ReportAction => + isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUTransactionID === originalTransactionID, + ); + }, + }); + + expect(newIOUAction).toBeDefined(); + expect(newIOUAction?.actionName).toBe(CONST.REPORT.ACTIONS.TYPE.IOU); + const newIOUOriginalMessage = getOriginalMessage(newIOUAction as ReportAction); + expect(newIOUOriginalMessage?.IOUTransactionID).toBe(originalTransactionID); + expect(newIOUOriginalMessage?.amount).toBe(amount); + expect(newIOUOriginalMessage?.currency).toBe(CONST.CURRENCY.USD); + expect(newIOUOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + + // Verify the IOU action child counts + expect(newIOUAction?.childCommenterCount).toBe(1); + expect(newIOUAction?.childOldestFourAccountIDs).toBe(`${RORY_ACCOUNT_ID}`); + expect(newIOUAction?.childVisibleActionCount).toBe(1); + expect(newIOUAction?.childLastVisibleActionCreated).toBe(commentCreated); + + const newThreadReportID = newIOUAction?.childReportID; + expect(newThreadReportID).toBeDefined(); + expect(newThreadReportID).not.toBe(splitThreadReportID); + + // Step 6: Verify the new transaction thread report exists + const newThreadReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newThreadReportID}`); + expect(newThreadReport).toBeDefined(); + expect(newThreadReport?.reportID).toBe(newThreadReportID); + + // Step 7: Verify the comment was migrated to the new thread + const newThreadActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${newThreadReportID}`); + expect(newThreadActions?.[commentReportActionID]).toBeDefined(); + expect(newThreadActions?.[commentReportActionID]?.reportID).toBe(newThreadReportID); + expect(newThreadActions?.[commentReportActionID]?.actionName).toBe(CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + expect(newThreadActions?.[commentReportActionID]?.actorAccountID).toBe(RORY_ACCOUNT_ID); + expect(newThreadActions?.[commentReportActionID]?.created).toBe(commentCreated); + expect(newThreadActions?.[commentReportActionID]?.message).toEqual(reportAction.message); + + // The comment should no longer be accessible from the old split thread (thread is deleted during revert) + const updatedSplitThreadActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitThreadReportID}`); + expect(updatedSplitThreadActions?.[commentReportActionID] ?? null).toBeFalsy(); + }); + it('should set navigate-back URL and use backward navigation pattern when reverse-split deletes the last transaction in expense report', async () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const Navigation = jest.requireMock('@src/libs/Navigation/Navigation');