Skip to content
9 changes: 8 additions & 1 deletion src/libs/TransactionThreadNavigationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Comment thread
grgia marked this conversation as resolved.

// 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.
Expand Down
18 changes: 12 additions & 6 deletions src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -185,20 +186,25 @@ 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;
}
// Unreported expenses have no parent report to resolve ownership from, but always belong to the user.
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)));

Expand All @@ -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);

Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Report> = {};
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);
}
Expand Down Expand Up @@ -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})]);
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/libs/TransactionThreadNavigationUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -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();
Expand Down
Loading