diff --git a/src/libs/TransactionThreadNavigationUtils.ts b/src/libs/TransactionThreadNavigationUtils.ts index c609c7572efb..37cdeff1ff5e 100644 --- a/src/libs/TransactionThreadNavigationUtils.ts +++ b/src/libs/TransactionThreadNavigationUtils.ts @@ -3,7 +3,7 @@ import type {IntroSelected, PersonalDetailsList, Report, ReportAction, Transacti import type {OnyxEntry} from 'react-native-onyx'; import {createTransactionThreadReport, setOptimisticTransactionThread} from './actions/Report'; -import {getAllReportActions, getExpenseCreationIOUActionForTransactionID} from './ReportActionsUtils'; +import {getAllReportActions, getExpenseCreationIOUActionForTransactionID, isSentMoneyReportAction} from './ReportActionsUtils'; import {findSelfDMReportID, getReportOrDraftReport} from './ReportUtils'; import {isExpenseUnreported} from './TransactionUtils'; @@ -61,6 +61,13 @@ function getReportIDToOpenForExpense(expense: TransactionThreadNavigationDescrip const {transaction, reportID} = expense; const isUnreported = isExpenseUnreported(transaction); + // A sent-money (pay) action's childReportID is the "marked as paid" system message thread, not the expense. + // Opening the paid expense should land on its report, so send these to the parent report rather than the + // pay action's thread. Mirrors how the Search page navigates single-transaction reports to the report itself. + if (isSentMoneyReportAction(expense.reportAction)) { + return reportID; + } + // Prefer the transaction thread resolved from the Search snapshot. The main reportActions_ collection // may be empty (e.g. right after clearing Onyx) so the local lookup can fail and incorrectly // fall back to the whole parent expense report; the snapshot already carries the correct childReportID. diff --git a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts index 00b9a301afa0..049cb0f05b19 100644 --- a/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts +++ b/src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts @@ -117,6 +117,7 @@ function useRecentlyAddedData(): RecentlyAddedData { // Read by key only, never iterated: the collection holds tens of thousands of entries. const [localTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [pendingTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {selector: pendingTransactionIDsSelector}); + const [localReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); // Holding a just-created expense here keeps it in the slot after `pendingAction` clears on sync but before the // refreshed snapshot arrives (otherwise it briefly disappears and reappears). @@ -185,7 +186,7 @@ function useRecentlyAddedData(): RecentlyAddedData { } } - const filtered = snapshotTransactions.filter((transaction): transaction is Transaction & {reportID: string} => { + const isOwnedByCurrentUser = (transaction: Transaction | undefined, ownerAccountID: number | undefined): transaction is Transaction & {reportID: string} => { if (!transaction?.reportID) { return false; } @@ -193,12 +194,17 @@ function useRecentlyAddedData(): RecentlyAddedData { if (transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { return true; } - const ownerAccountID = reportByReportID.get(transaction.reportID)?.ownerAccountID; return ownerAccountID === undefined || ownerAccountID === accountID; - }); + }; + + const filtered = snapshotTransactions.filter((transaction) => + isOwnedByCurrentUser(transaction, transaction.reportID ? reportByReportID.get(transaction.reportID)?.ownerAccountID : undefined), + ); // Merge in locally-pending expenses, skipping any already in the snapshot so a row never appears twice. - // A local optimistic ADD always belongs to the current user, so no ownership check is needed (unlike the snapshot path). + // A local optimistic ADD doesn't always belong to the current user: sending money (Pay someone) creates it on + // an IOU report owned by the recipient. Apply the same ownership check as the snapshot path, resolved from the + // local report, so such an expense never flashes in the payer's slot. const snapshotTransactionIDs = new Set(snapshotTransactions.map((transaction) => transaction.transactionID)); const nextUnconfirmed = new Set([...unconfirmedTransactionIDs, ...(pendingTransactionIDs?.added ?? [])].filter((transactionID) => !snapshotTransactionIDs.has(transactionID))); @@ -221,7 +227,7 @@ function useRecentlyAddedData(): RecentlyAddedData { // have had its `pendingAction` cleared by a sync. ...[...nextUnconfirmed] .map((transactionID) => getLocalTransaction(localTransactions, transactionID)) - .filter((transaction): transaction is Transaction & {reportID: string} => !!transaction?.reportID), + .filter((transaction) => isOwnedByCurrentUser(transaction, localReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]?.ownerAccountID)), ].filter((transaction) => { const localTransaction = getLocalTransaction(localTransactions, transaction.transactionID); @@ -279,7 +285,7 @@ function useRecentlyAddedData(): RecentlyAddedData { }); return {transactions: transactionsList, nextUnconfirmedTransactionIDs: nextUnconfirmed, nextDeletedTransactionIDs: nextDeleted}; - }, [snapshotData, unconfirmedTransactionIDs, deletedTransactionIDs, accountID, localTransactions, pendingTransactionIDs?.added, pendingTransactionIDs?.deleted, translate]); + }, [snapshotData, unconfirmedTransactionIDs, deletedTransactionIDs, accountID, localTransactions, localReports, pendingTransactionIDs?.added, pendingTransactionIDs?.deleted, translate]); const hasSameUnconfirmedIDs = nextUnconfirmedTransactionIDs.size === unconfirmedTransactionIDs.size && [...nextUnconfirmedTransactionIDs].every((id) => unconfirmedTransactionIDs.has(id)); diff --git a/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts b/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts index 44661ed7e307..309b44785850 100644 --- a/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts +++ b/tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts @@ -176,6 +176,15 @@ function setupLocalTransactions(transactions: Transaction[]) { onyxData[ONYXKEYS.COLLECTION.TRANSACTION] = collection; } +/** Seeds the local `report_` collection (mirrors the optimistic parent reports written alongside new expenses). */ +function setupLocalReports(reports: Report[]) { + const collection: Record = {}; + for (const report of reports) { + collection[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report; + } + onyxData[ONYXKEYS.COLLECTION.REPORT] = collection; +} + function resultTransactionIDs(transactions: RecentlyAddedExpense[]): string[] { return transactions.map((t) => t.transactionID); } @@ -316,6 +325,20 @@ describe('useRecentlyAddedData — locally pending (offline-created) expenses', expect(result.current.transactions.at(0)?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); }); + it('excludes a locally-pending expense whose local parent report is owned by another account (e.g. sent money)', () => { + // Sending money (Pay someone) optimistically creates the expense on an IOU report owned by the recipient. + setupSnapshot([], [makeReport('report_owned', ACCOUNT_ID)]); + setupLocalReports([makeReport('report_owned', ACCOUNT_ID), makeReport('report_sent', OTHER_ACCOUNT_ID, {managerID: ACCOUNT_ID})]); + setupLocalTransactions([ + makeTransaction({transactionID: 'mine', reportID: 'report_owned', inserted: '2026-06-02 10:00:00', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}), + makeTransaction({transactionID: 'sent', reportID: 'report_sent', inserted: '2026-06-03 10:00:00', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}), + ]); + + const {result} = renderHook(() => useRecentlyAddedData()); + + expect(resultTransactionIDs(result.current.transactions)).toEqual(['mine']); + }); + it('does not duplicate an expense present in both the snapshot and the local collection', () => { setupSnapshot([makeTransaction({transactionID: 'shared', inserted: '2026-06-01 10:00:00'})], [makeReport('report_owned', ACCOUNT_ID)]); setupLocalTransactions([makeTransaction({transactionID: 'shared', inserted: '2026-06-01 10:00:00', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD})]); diff --git a/tests/unit/libs/TransactionThreadNavigationUtilsTest.ts b/tests/unit/libs/TransactionThreadNavigationUtilsTest.ts index e6978cc5e679..437e3b55cc0d 100644 --- a/tests/unit/libs/TransactionThreadNavigationUtilsTest.ts +++ b/tests/unit/libs/TransactionThreadNavigationUtilsTest.ts @@ -43,6 +43,13 @@ function buildIOUAction(reportActionID: string, childReportID?: string): ReportA return {...actionR14932, reportActionID, childReportID}; } +function buildSentMoneyAction(reportActionID: string, childReportID?: string): ReportAction { + return { + ...buildIOUAction(reportActionID, childReportID), + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUTransactionID: 'TRANSACTION_ID_R14932', IOUDetails: {amount: 100, currency: 'USD', comment: ''}}, + }; +} + describe('getReportIDToOpenForExpense', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -76,6 +83,31 @@ describe('getReportIDToOpenForExpense', () => { expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); }); + it('returns the parent report (not the pay message thread) for a sent-money (pay) action', () => { + const expense: TransactionThreadNavigationDescriptor = { + reportID: 'parentPay', + transaction: buildTransaction('t1', 'parentPay'), + // The pay action's childReportID is the "marked as paid" system message thread. Opening the paid + // expense must land on the report itself, not that thread. + reportAction: buildSentMoneyAction('a1', 'pay_message_thread'), + }; + + expect(getReportIDToOpenForExpense(expense, CONTEXT)).toBe('parentPay'); + expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); + }); + + it('returns the parent report when only the live action is a sent-money (pay) action', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}parentPayLive`, {a1: buildSentMoneyAction('a1', 'pay_message_thread')}); + await waitForBatchedUpdates(); + + // An optimistic or offline paid expense is absent from the Search snapshot, so it arrives without a + // reportAction and the pay action is only reachable through the main reportActions collection. + const expense: TransactionThreadNavigationDescriptor = {reportID: 'parentPayLive', transaction: buildTransaction(transactionR14932.transactionID, 'parentPayLive')}; + + expect(getReportIDToOpenForExpense(expense, CONTEXT)).toBe('parentPayLive'); + expect(mockCreateTransactionThreadReport).not.toHaveBeenCalled(); + }); + it('resolves the thread from the main reportActions collection when the snapshot has no thread', async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}parent2`, {a1: buildIOUAction('a1', 'main_thread')}); await waitForBatchedUpdates();