From 54504da4f9646f8bb5827526c6b7831bcabdb27f Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:06:19 +0100 Subject: [PATCH 01/12] Add optimistic comment migration for RevertSplitTransaction When reverting a split, user comments in the chosen split's transaction thread are migrated to the restored original transaction's thread by the backend. This adds matching frontend optimistic data so the UI reflects the migration immediately, and passes the optimistic thread IDs to the backend so both sides use the same report IDs. --- src/libs/actions/IOU/Split.ts | 77 ++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 81bc3e5bcdcb9..72126d78f2357 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -21,7 +21,7 @@ import * as NumberUtils from '@libs/NumberUtils'; import Parser from '@libs/Parser'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; -import {getAllReportActions, getOriginalMessage, getReportAction, getReportActionHtml, getReportActionText, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getAllReportActions, getOriginalMessage, getReportAction, getReportActionHtml, getReportActionText, isDeletedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import { buildOptimisticChatReport, buildOptimisticCreatedReportAction, @@ -1080,6 +1080,26 @@ function updateSplitTransactions({ const isReverseSplitOperation = splitExpenses.length === 1 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft && allChildTransactions.length === originalChildTransactions.length; + let splitThreadUserComments: 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 = Object.values(revertSplitReportActions).find((action) => { + const IOUTransactionID = isMoneyRequestAction(action) ? (getOriginalMessage(action)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; + return IOUTransactionID === revertSplitTransactionID; + }); + splitTransactionThreadReportID = revertSplitIOUAction?.childReportID; + if (splitTransactionThreadReportID) { + const splitTransactionThreadActions = getAllReportActions(splitTransactionThreadReportID); + splitThreadUserComments = Object.values(splitTransactionThreadActions).filter( + (action): action is OnyxTypes.ReportAction => action.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && !isDeletedAction(action) && (action.actorAccountID ?? 0) > 0, + ); + } + } + let changesInReportTotal = 0; // Validate custom unit rate before proceeding with split const customUnitRateID = originalTransaction?.comment?.customUnit?.customUnitRateID; @@ -1265,7 +1285,7 @@ function updateSplitTransactions({ currentUserEmailParam: currentUserPersonalDetails?.login ?? '', transactionViolations, quickAction, - shouldGenerateTransactionThreadReport: !isReverseSplitOperation, + shouldGenerateTransactionThreadReport: !isReverseSplitOperation || splitThreadUserComments.length > 0, policyRecentlyUsedCurrencies, betas, personalDetails, @@ -1572,6 +1592,59 @@ function updateSplitTransactions({ }); } } + + const originalTransactionThreadReportID = splits.at(0)?.transactionThreadReportID; + if (splitThreadUserComments.length > 0 && originalTransactionThreadReportID && splitTransactionThreadReportID) { + const optimisticMovedComments: Record = {}; + const optimisticRemovedComments: Record = {}; + const successMovedComments: Record> = {}; + const failureMovedCommentsRemoval: Record = {}; + const failureRestoredComments: Record = {}; + + for (const comment of splitThreadUserComments) { + 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; + } + + 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, + }, + ); + + 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, + }, + ); + } } if (isReverseSplitOperation) { From 0e8295dd5ad8f90bb869e40fc8f5f5473d22e9a2 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:23:03 +0100 Subject: [PATCH 02/12] Update child comments metadata on IOU action after migrating split thread comments When reverting a split, user comments are moved from the split's transaction thread to the restored original transaction's thread. The parent IOU action on the expense report needs its childVisibleActionCount, childCommenterCount, childLastVisibleActionCreated, and childOldestFourAccountIDs updated to reflect the migrated comments, otherwise the thread reply indicator shows 0 replies. On failure, these fields are reset since the comments remain in the split thread. --- src/libs/actions/IOU/Split.ts | 39 +++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 72126d78f2357..fbb22f80931fb 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'; @@ -1594,13 +1594,17 @@ function updateSplitTransactions({ } const originalTransactionThreadReportID = splits.at(0)?.transactionThreadReportID; - if (splitThreadUserComments.length > 0 && originalTransactionThreadReportID && splitTransactionThreadReportID) { + const iouActionReportActionID = splits.at(0)?.splitReportActionID; + if (splitThreadUserComments.length > 0 && originalTransactionThreadReportID && splitTransactionThreadReportID && iouActionReportActionID && expenseReportID) { const optimisticMovedComments: Record = {}; const optimisticRemovedComments: Record = {}; const successMovedComments: Record> = {}; const failureMovedCommentsRemoval: Record = {}; const failureRestoredComments: Record = {}; + const commenterAccountIDs = new Set(); + let latestCommentCreated = ''; + for (const comment of splitThreadUserComments) { optimisticMovedComments[comment.reportActionID] = { ...comment, @@ -1611,6 +1615,13 @@ function updateSplitTransactions({ 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( @@ -1624,6 +1635,18 @@ function updateSplitTransactions({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitTransactionThreadReportID}`, value: optimisticRemovedComments, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, + value: { + [iouActionReportActionID]: { + childVisibleActionCount: splitThreadUserComments.length, + childCommenterCount: commenterAccountIDs.size, + childLastVisibleActionCreated: latestCommentCreated, + childOldestFourAccountIDs: [...commenterAccountIDs].slice(0, 4).join(','), + }, + }, + }, ); onyxData.successData?.push({ @@ -1643,6 +1666,18 @@ function updateSplitTransactions({ 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: '', + }, + }, + }, ); } } From d291155fd97b2809ee38e443ed242b4bc3f6c72b Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:23:23 +0100 Subject: [PATCH 03/12] Refactor successMovedComments type in updateSplitTransactions function Updated the type of successMovedComments from a Record to OnyxCollection for better type safety and clarity in handling migrated comments during split transactions. --- src/libs/actions/IOU/Split.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index fbb22f80931fb..0381c80ba5ab4 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -1598,7 +1598,7 @@ function updateSplitTransactions({ if (splitThreadUserComments.length > 0 && originalTransactionThreadReportID && splitTransactionThreadReportID && iouActionReportActionID && expenseReportID) { const optimisticMovedComments: Record = {}; const optimisticRemovedComments: Record = {}; - const successMovedComments: Record> = {}; + const successMovedComments: OnyxCollection> = {}; const failureMovedCommentsRemoval: Record = {}; const failureRestoredComments: Record = {}; From ebb1fd100813fc607407f37725271f683b379dd9 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:42:07 +0100 Subject: [PATCH 04/12] Refactor IOU action retrieval in updateSplitTransactions function Updated the logic for retrieving the revertSplitIOUAction to use getIOUActionForTransactionID. Additionally, refactored the filtering of splitThreadUserComments to utilize isActionOfType. --- src/libs/actions/IOU/Split.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 0381c80ba5ab4..fc0f9ca2f7613 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -21,7 +21,17 @@ import * as NumberUtils from '@libs/NumberUtils'; import Parser from '@libs/Parser'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; -import {getAllReportActions, getOriginalMessage, getReportAction, getReportActionHtml, getReportActionText, isDeletedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import { + getAllReportActions, + getIOUActionForTransactionID, + getOriginalMessage, + getReportAction, + getReportActionHtml, + getReportActionText, + isActionOfType, + isDeletedAction, + isMoneyRequestAction, +} from '@libs/ReportActionsUtils'; import { buildOptimisticChatReport, buildOptimisticCreatedReportAction, @@ -1087,15 +1097,12 @@ function updateSplitTransactions({ const revertSplitTransactionID = splitExpenses.at(0)?.transactionID; const revertSplitTransaction = allTransactionsList?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${revertSplitTransactionID}`]; const revertSplitReportActions = getAllReportActions(revertSplitTransaction?.reportID); - const revertSplitIOUAction = Object.values(revertSplitReportActions).find((action) => { - const IOUTransactionID = isMoneyRequestAction(action) ? (getOriginalMessage(action)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; - return IOUTransactionID === revertSplitTransactionID; - }); + const revertSplitIOUAction = revertSplitTransactionID ? getIOUActionForTransactionID(Object.values(revertSplitReportActions ?? {}), revertSplitTransactionID) : undefined; splitTransactionThreadReportID = revertSplitIOUAction?.childReportID; if (splitTransactionThreadReportID) { const splitTransactionThreadActions = getAllReportActions(splitTransactionThreadReportID); splitThreadUserComments = Object.values(splitTransactionThreadActions).filter( - (action): action is OnyxTypes.ReportAction => action.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && !isDeletedAction(action) && (action.actorAccountID ?? 0) > 0, + (action): action is OnyxTypes.ReportAction => isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) && !isDeletedAction(action) && (action.actorAccountID ?? 0) > 0, ); } } From 2856d9caa4ee44ecffb2e0db91aaa69b2d061341 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:44:17 +0100 Subject: [PATCH 05/12] Renamed variable splitThreadUserComments to splitThreadComments --- src/libs/actions/IOU/Split.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index fc0f9ca2f7613..1a2a5300d1993 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -1090,7 +1090,7 @@ function updateSplitTransactions({ const isReverseSplitOperation = splitExpenses.length === 1 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft && allChildTransactions.length === originalChildTransactions.length; - let splitThreadUserComments: OnyxTypes.ReportAction[] = []; + let splitThreadComments: OnyxTypes.ReportAction[] = []; let splitTransactionThreadReportID: string | undefined; if (isReverseSplitOperation) { @@ -1101,7 +1101,7 @@ function updateSplitTransactions({ splitTransactionThreadReportID = revertSplitIOUAction?.childReportID; if (splitTransactionThreadReportID) { const splitTransactionThreadActions = getAllReportActions(splitTransactionThreadReportID); - splitThreadUserComments = Object.values(splitTransactionThreadActions).filter( + splitThreadComments = Object.values(splitTransactionThreadActions).filter( (action): action is OnyxTypes.ReportAction => isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) && !isDeletedAction(action) && (action.actorAccountID ?? 0) > 0, ); } @@ -1292,7 +1292,7 @@ function updateSplitTransactions({ currentUserEmailParam: currentUserPersonalDetails?.login ?? '', transactionViolations, quickAction, - shouldGenerateTransactionThreadReport: !isReverseSplitOperation || splitThreadUserComments.length > 0, + shouldGenerateTransactionThreadReport: !isReverseSplitOperation || splitThreadComments.length > 0, policyRecentlyUsedCurrencies, betas, personalDetails, @@ -1602,7 +1602,7 @@ function updateSplitTransactions({ const originalTransactionThreadReportID = splits.at(0)?.transactionThreadReportID; const iouActionReportActionID = splits.at(0)?.splitReportActionID; - if (splitThreadUserComments.length > 0 && originalTransactionThreadReportID && splitTransactionThreadReportID && iouActionReportActionID && expenseReportID) { + if (splitThreadComments.length > 0 && originalTransactionThreadReportID && splitTransactionThreadReportID && iouActionReportActionID && expenseReportID) { const optimisticMovedComments: Record = {}; const optimisticRemovedComments: Record = {}; const successMovedComments: OnyxCollection> = {}; @@ -1612,7 +1612,7 @@ function updateSplitTransactions({ const commenterAccountIDs = new Set(); let latestCommentCreated = ''; - for (const comment of splitThreadUserComments) { + for (const comment of splitThreadComments) { optimisticMovedComments[comment.reportActionID] = { ...comment, reportID: originalTransactionThreadReportID, @@ -1647,7 +1647,7 @@ function updateSplitTransactions({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, value: { [iouActionReportActionID]: { - childVisibleActionCount: splitThreadUserComments.length, + childVisibleActionCount: splitThreadComments.length, childCommenterCount: commenterAccountIDs.size, childLastVisibleActionCreated: latestCommentCreated, childOldestFourAccountIDs: [...commenterAccountIDs].slice(0, 4).join(','), From 001b34685248eb05f5970f07de1d9a1272fa8606 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:24:20 +0100 Subject: [PATCH 06/12] fix lint error --- src/libs/actions/IOU/Split.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 1a2a5300d1993..baae504d06c96 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -1102,7 +1102,8 @@ function updateSplitTransactions({ 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 ?? 0) > 0, + (action): action is OnyxTypes.ReportAction => + isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) && !isDeletedAction(action) && (action.actorAccountID ?? CONST.DEFAULT_NUMBER_ID) > 0, ); } } From e134049a9831cb19a1083d9782152d753f344e7e Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:21:29 +0100 Subject: [PATCH 07/12] Add test for split thread comment migration during revert Verifies that when reverting a split transaction (isReverseSplitOperation), user comments from the chosen split's transaction thread are migrated to the restored original transaction's new thread. The test creates a workspace expense, splits it into two, injects a comment on one split's thread, then reverts the split and asserts the comment appears in the new transaction thread with correct data. --- tests/actions/IOUTest/SplitTest.ts | 287 ++++++++++++++++++++++++++++- 1 file changed, 286 insertions(+), 1 deletion(-) diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index e7e34fbde6fb2..07c9f040112f5 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -9,7 +9,7 @@ import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {createWorkspace, generatePolicyID, setWorkspaceApprovalMode} from '@libs/actions/Policy/Policy'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {rand64} from '@libs/NumberUtils'; -import {getOriginalMessage, isActionOfType, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, isActionOfType, isDeletedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReportAction, getReportOrDraftReport} from '@libs/ReportUtils'; import { addSplitExpenseField, @@ -44,6 +44,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'; @@ -2268,6 +2269,290 @@ 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, + }); + setWorkspaceApprovalMode(policyID, 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(); + + // Verify the new IOU action + 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, + ); + }, + }); + + const newThreadReportID = newIOUAction?.childReportID; + expect(newThreadReportID).toBeDefined(); + expect(newThreadReportID).not.toBe(splitThreadReportID); + + // Step 4: Verify the new transaction thread report exists + const newThreadReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newThreadReportID}`); + expect(newThreadReport).toBeDefined(); + expect(newThreadReport?.reportID).toBe(newThreadReportID); + + // Step 5: 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); + }); + it('should set navigate-back URL and navigate to parent chat 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'); From c4e70f18015ce439bdba9135fc191c15566f0967 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:22:42 +0100 Subject: [PATCH 08/12] Assert split thread comments are removed after revert Verify that after reverting a split, the migrated comment is no longer accessible from the old split transaction thread (which is deleted during the revert operation). --- tests/actions/IOUTest/SplitTest.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 07c9f040112f5..160edc4a28ed1 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2551,6 +2551,10 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { 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 navigate to parent chat when reverse-split deletes the last transaction in expense report', async () => { From de345602a766164cbe1cf651ffd8f0001e5ca058 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:24:27 +0100 Subject: [PATCH 09/12] Assert transactions data state after split revert Verify the original transaction is restored with correct amount and currency, the chosen split is force-deleted, and the other split is marked with pendingAction: delete. --- tests/actions/IOUTest/SplitTest.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 160edc4a28ed1..aad795732b93f 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2520,7 +2520,21 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { }); await waitForBatchedUpdates(); - // Verify the new IOU action + // 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); + const revertExpenseReportID = reports.expenseReport?.reportID; let newIOUAction: ReportAction | undefined; await getOnyxData({ From 9e2c91a3c4e6131d7db0dba4e8d0f35f6f019053 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:26:00 +0100 Subject: [PATCH 10/12] Assert IOU action properties after split revert Verify the new IOU action has correct actionName, IOUTransactionID, amount, currency, and type. --- tests/actions/IOUTest/SplitTest.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index aad795732b93f..a686c4b877343 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2520,7 +2520,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { }); await waitForBatchedUpdates(); - // Verify the original transaction is restored with correct data + // 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); @@ -2535,6 +2535,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { 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({ @@ -2548,16 +2549,24 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { }, }); + 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); + const newThreadReportID = newIOUAction?.childReportID; expect(newThreadReportID).toBeDefined(); expect(newThreadReportID).not.toBe(splitThreadReportID); - // Step 4: Verify the new transaction thread report exists + // 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 5: Verify the comment was migrated to the new thread + // 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); From 3181a9d865342ce70671de395b7fc5f5b1f1c681 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:26:33 +0100 Subject: [PATCH 11/12] Assert IOU action child comment counts after split revert Verify the new IOU action has correct childVisibleActionCount, childCommenterCount, childOldestFourAccountIDs, and childLastVisibleActionCreated reflecting the migrated comments. --- tests/actions/IOUTest/SplitTest.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index a686c4b877343..4176d6f8dcfb8 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2557,6 +2557,12 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { 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); From 1ec553c7eddecf0553e59cd35ca6e2ade057ab9d Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:32:40 +0100 Subject: [PATCH 12/12] fix type errors --- tests/actions/IOUTest/SplitTest.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 9dc513098d7ed..2d83e2c792584 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -9,7 +9,7 @@ import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {createWorkspace, generatePolicyID, setWorkspaceApprovalMode} from '@libs/actions/Policy/Policy'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {rand64} from '@libs/NumberUtils'; -import {getOriginalMessage, isActionOfType, isDeletedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, isActionOfType, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReportAction, getReportOrDraftReport} from '@libs/ReportUtils'; import { addSplitExpenseField, @@ -2288,7 +2288,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { isSelfTourViewed: false, hasActiveAdminPolicies: false, }); - setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); + const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); await waitForBatchedUpdates(); await getOnyxData({