From ad5a635fc2e756740c6ae472bae391e4b493b3ee Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 15 Apr 2026 15:42:18 +0430 Subject: [PATCH 01/22] fix(expense): prevent IOU avatar from combining when sending manual and scanned expenses between users --- .../useReportPreviewSenderID.ts | 100 ++++++++++- tests/unit/getReportPreviewSenderIDTest.ts | 166 +++++++++++++++++- 2 files changed, 255 insertions(+), 11 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 1ff56f67abb8..2dc543b442d8 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -6,11 +6,12 @@ import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViol import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import {getOriginalMessage, isMoneyRequestAction, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; +import {getIOUActionForTransactionID, getOriginalMessage, isDeletedParentAction, isMoneyRequestAction, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; import {isDM, isIOUReport} from '@libs/ReportUtils'; +import {isScanRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; +import type {OriginalMessageIOU, Policy, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; function getSplitAuthor(transaction: Transaction, splits?: Array>) { const {originalTransactionID, source} = transaction.comment ?? {}; @@ -38,6 +39,42 @@ const getSplitsSelector = (actions: OnyxEntry): Array getOriginalMessage(act)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT); }; +function getTransactionDirectionSign(transaction: Transaction): number | undefined { + if (transaction.amount !== 0) { + return Math.sign(transaction.amount); + } + + if (isScanRequest(transaction)) { + const modifiedAmount = Number(transaction.modifiedAmount); + + if (Number.isFinite(modifiedAmount) && modifiedAmount !== 0) { + return Math.sign(modifiedAmount); + } + } + + return undefined; +} + +function isExplicitlyDeletedIOUAction(iouAction: ReportAction): boolean { + const originalMessage = getOriginalMessage(iouAction) as OriginalMessageIOU | undefined; + + if (originalMessage?.deleted) { + return true; + } + + if (isDeletedParentAction(iouAction)) { + return true; + } + + const message = iouAction.message; + + if (Array.isArray(message)) { + return message.some((fragment) => !!fragment?.deleted); + } + + return !!message?.deleted; +} + type GetReportPreviewSenderIDParams = { iouReport: OnyxEntry; action: OnyxEntry; @@ -55,17 +92,62 @@ function getReportPreviewSenderID({iouReport, action, chatReport, iouActions, tr if (isOptimisticReportPreview) { return currentUserAccountID; } + const loadedTransactionCount = transactions?.length ?? 0; + const childMoneyRequestCount = action?.childMoneyRequestCount ?? 0; + const activeMoneyRequestCount = iouReport?.transactionCount ?? childMoneyRequestCount; + const activeIOUActions = + iouActions?.filter((iouAction) => { + return !isExplicitlyDeletedIOUAction(iouAction); + }) ?? []; + const uniqueIOUActionActorMap = new Map(); + + for (const iouAction of activeIOUActions) { + const iouTransactionID = (getOriginalMessage(iouAction) as OriginalMessageIOU | undefined)?.IOUTransactionID; + + if (!iouTransactionID || iouAction.actorAccountID === undefined) { + continue; + } - // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative. - // We have to do it this way because there can be a case when actions are not available - // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 + uniqueIOUActionActorMap.set(iouTransactionID, iouAction.actorAccountID); + } - const areAmountsSignsTheSame = new Set(transactions?.map((tr) => Math.sign(tr.amount))).size < 2; + const hasCompleteActionCoverage = activeMoneyRequestCount > 0 && uniqueIOUActionActorMap.size >= activeMoneyRequestCount; + const areAllActiveChildRequestsCreatedByOneActor = new Set(uniqueIOUActionActorMap.values()).size < 2; + const canInferFromIOUActionsDuringPartialHydration = loadedTransactionCount > 0 && hasCompleteActionCoverage && activeIOUActions.length > 0 && areAllActiveChildRequestsCreatedByOneActor; - if (!areAmountsSignsTheSame) { + // After refresh, the preview action can hydrate before all active child transactions. + // Avoid collapsing to one avatar unless the available IOU actions already prove the remaining + // active requests all belong to the same sender. + if (activeMoneyRequestCount > loadedTransactionCount && !canInferFromIOUActionsDuringPartialHydration) { return undefined; } + const transactionActorAccountIDs = transactions?.map((transaction) => getIOUActionForTransactionID(activeIOUActions, transaction.transactionID)?.actorAccountID); + const hasActorAccountIDForEachTransaction = + activeIOUActions.length > 0 && !!transactionActorAccountIDs && transactionActorAccountIDs.length > 0 && transactionActorAccountIDs.every((accountID) => accountID !== undefined); + + // 1. Use actorAccountID when it is available for every transaction. Otherwise, fall back to known transaction direction only. + if (hasActorAccountIDForEachTransaction) { + const areAllTransactionsCreatedByOneActor = new Set(transactionActorAccountIDs).size < 2; + + if (!areAllTransactionsCreatedByOneActor) { + return undefined; + } + } else { + const transactionSigns = transactions?.map((transaction) => getTransactionDirectionSign(transaction)) ?? []; + const hasUnknownDirection = transactionSigns.some((sign) => sign === undefined); + + if (hasUnknownDirection) { + return undefined; + } + + const areAmountsSignsTheSame = new Set(transactionSigns).size < 2; + + if (!areAmountsSignsTheSame) { + return undefined; + } + } + // 2. If there is only one attendee - we check that by counting unique emails converted to account IDs in the attendees list. // This is a fallback added because: https://github.com/Expensify/App/pull/64802#issuecomment-3007906310 @@ -83,13 +165,13 @@ function getReportPreviewSenderID({iouReport, action, chatReport, iouActions, tr } // If the action is a 'Send Money' flow, it will only have one transaction, but the person who sent the money is the child manager account, not the child owner account. - const isSendMoneyFlowBasedOnActions = !!iouActions && iouActions.every(isSentMoneyReportAction); + const isSendMoneyFlowBasedOnActions = activeIOUActions.length > 0 && activeIOUActions.every(isSentMoneyReportAction); // This is used only if there are no IOU actions in the Onyx // eslint-disable-next-line rulesdir/no-negated-variables const isSendMoneyFlowBasedOnTransactions = !!action && action.childMoneyRequestCount === 0 && transactions?.length === 1 && (chatReport ? isDM(chatReport) : policy?.type === CONST.POLICY.TYPE.PERSONAL); - const isSendMoneyFlow = !!iouActions && iouActions?.length > 0 ? isSendMoneyFlowBasedOnActions : isSendMoneyFlowBasedOnTransactions; + const isSendMoneyFlow = activeIOUActions.length > 0 ? isSendMoneyFlowBasedOnActions : isSendMoneyFlowBasedOnTransactions; const singleAvatarAccountID = isSendMoneyFlow ? action?.childManagerAccountID : action?.childOwnerAccountID; diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index 295bc5367409..1eb2a6976c60 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -83,6 +83,74 @@ describe('getReportPreviewSenderID', () => { expect(result).toBe(OWNER_ACCOUNT_ID); }); + it('returns childOwnerAccountID when all transactions map to IOU actions from the same actor', () => { + const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: 'tr-1'}); + const transaction2 = makeTransaction(200, 'user2@test.com', {transactionID: 'tr-2'}); + + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport(), + action: makeAction({childMoneyRequestCount: 2}), + iouActions: [ + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-1', + amount: 100, + currency: 'USD', + }, + }), + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-2', + amount: 200, + currency: 'USD', + }, + }), + ], + transactions: [transaction1, transaction2], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + + it('returns undefined when transactions map to IOU actions from different actors', () => { + const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: 'tr-1'}); + const transaction2 = makeTransaction(200, 'user2@test.com', {transactionID: 'tr-2'}); + + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport(), + action: makeAction({childMoneyRequestCount: 2}), + iouActions: [ + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-1', + amount: 100, + currency: 'USD', + }, + }), + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 20, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-2', + amount: 200, + currency: 'USD', + }, + }), + ], + transactions: [transaction1, transaction2], + }); + + expect(result).toBeUndefined(); + }); + it('returns childManagerAccountID for send money flow (all iouActions are sentMoney)', () => { const sentMoneyAction = makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.PAY, { originalMessage: { @@ -140,6 +208,100 @@ describe('getReportPreviewSenderID', () => { expect(result).toBeUndefined(); }); + it('returns childOwnerAccountID for a zero-amount scan request when modifiedAmount reveals the direction', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport(), + action: makeAction(), + iouActions: [], + transactions: [ + makeTransaction(0, 'user@test.com', { + modifiedAmount: 100, + receipt: {source: 'receipt.jpg'}, + }), + ], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + + it('returns undefined when the report preview has not loaded all child transactions yet', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport(), + action: makeAction({childMoneyRequestCount: 2}), + iouActions: [], + transactions: [makeTransaction(100)], + }); + + expect(result).toBeUndefined(); + }); + + it('returns childOwnerAccountID when a deleted expense keeps childMoneyRequestCount stale', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({transactionCount: 1}), + action: makeAction({childMoneyRequestCount: 2}), + iouActions: [ + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-active', + amount: 100, + currency: 'USD', + }, + }), + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 20, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-deleted', + amount: 100, + currency: 'USD', + deleted: '2026-04-11 07:12:23.697', + }, + message: [{type: 'COMMENT', text: 'Deleted expense', deleted: '2026-04-11 07:12:23.697'}], + }), + ], + transactions: [makeTransaction(100, 'user@test.com', {transactionID: 'tr-active'})], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + + it('returns childOwnerAccountID when iouReport.transactionCount is lower than stale childMoneyRequestCount after deletion', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({transactionCount: 1}), + action: makeAction({childMoneyRequestCount: 5}), + iouActions: [ + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-active', + amount: 100, + currency: 'USD', + }, + }), + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 20, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + deleted: '2026-04-12 04:48:48.212', + amount: 100, + currency: 'USD', + }, + message: [{type: 'COMMENT', text: '', deleted: '2026-04-12 04:48:48.212'}], + }), + ], + transactions: [makeTransaction(100, 'user@test.com', {transactionID: 'tr-active'})], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + it('returns undefined for multi-sender: multiple attendees', () => { // Two transactions with different attendees (different emails resolve to different accountIDs) // Since getPersonalDetailByEmail returns undefined in test (no Onyx), attendeesIDs will be filtered out @@ -238,7 +400,7 @@ describe('getReportPreviewSenderID', () => { expect(result).toBe(OWNER_ACCOUNT_ID); }); - it('returns sender ID when no transactions (empty set has size 0 < 2)', () => { + it('returns undefined when transactions have not loaded yet for a money request preview', () => { const result = getReportPreviewSenderID({ ...baseParams, iouReport: makeIOUReport(), @@ -247,6 +409,6 @@ describe('getReportPreviewSenderID', () => { transactions: [], }); - expect(result).toBe(OWNER_ACCOUNT_ID); + expect(result).toBeUndefined(); }); }); From 1dd2588b7901d37dfde8f210daf0bec2b5ad30f9 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Thu, 16 Apr 2026 08:32:42 +0430 Subject: [PATCH 02/22] fix: use modifiedAmount after cache clear --- .../ReportActionAvatars/useReportPreviewSenderID.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 2dc543b442d8..d53b97e8812a 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -8,7 +8,6 @@ import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getIOUActionForTransactionID, getOriginalMessage, isDeletedParentAction, isMoneyRequestAction, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; import {isDM, isIOUReport} from '@libs/ReportUtils'; -import {isScanRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OriginalMessageIOU, Policy, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; @@ -44,12 +43,10 @@ function getTransactionDirectionSign(transaction: Transaction): number | undefin return Math.sign(transaction.amount); } - if (isScanRequest(transaction)) { - const modifiedAmount = Number(transaction.modifiedAmount); + const modifiedAmount = Number(transaction.modifiedAmount); - if (Number.isFinite(modifiedAmount) && modifiedAmount !== 0) { - return Math.sign(modifiedAmount); - } + if (Number.isFinite(modifiedAmount) && modifiedAmount !== 0) { + return Math.sign(modifiedAmount); } return undefined; From 5b168b70f5513052722f9536464cbdade71654b9 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Thu, 16 Apr 2026 08:51:43 +0430 Subject: [PATCH 03/22] fix: ignore empty-message deleted IOU actions --- .../ReportActionAvatars/useReportPreviewSenderID.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index d53b97e8812a..d1a85836b27a 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -66,10 +66,10 @@ function isExplicitlyDeletedIOUAction(iouAction: ReportAction): boolean { const message = iouAction.message; if (Array.isArray(message)) { - return message.some((fragment) => !!fragment?.deleted); + return message.some((fragment) => !!fragment?.deleted || fragment?.html === ''); } - return !!message?.deleted; + return !!message?.deleted || message?.html === ''; } type GetReportPreviewSenderIDParams = { From 141b87466ab3b12f767af1b1026e6941bbd75902 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Thu, 16 Apr 2026 08:55:16 +0430 Subject: [PATCH 04/22] fix: handle deleted IOU actions safely --- src/components/ReportActionAvatars/useReportPreviewSenderID.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index d1a85836b27a..ec9ca75a21f4 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -131,6 +131,9 @@ function getReportPreviewSenderID({iouReport, action, chatReport, iouActions, tr return undefined; } } else { + // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative. + // We have to do it this way because there can be a case when actions are not available + // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 const transactionSigns = transactions?.map((transaction) => getTransactionDirectionSign(transaction)) ?? []; const hasUnknownDirection = transactionSigns.some((sign) => sign === undefined); From c9ed1ab47507cd15f4c894957ce5493859f244ab Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Thu, 16 Apr 2026 12:23:21 +0430 Subject: [PATCH 05/22] test: cover cache-clear sender fallback --- tests/unit/getReportPreviewSenderIDTest.ts | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index 1eb2a6976c60..f611455561a2 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -225,6 +225,29 @@ describe('getReportPreviewSenderID', () => { expect(result).toBe(OWNER_ACCOUNT_ID); }); + it('returns childOwnerAccountID after cache clear when a receipt-backed transaction is rehydrated as manual but modifiedAmount reveals the direction', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({transactionCount: 2}), + action: makeAction({childMoneyRequestCount: 2}), + iouActions: [], + transactions: [ + makeTransaction(1200, 'user@test.com', {transactionID: 'tr-1'}), + makeTransaction(0, 'user@test.com', { + transactionID: 'tr-2', + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + modifiedAmount: 50000, + modifiedCreated: '2021-03-18 00:00:00', + modifiedCurrency: 'INR', + modifiedMerchant: 'Airtel', + receipt: {source: 'receipt.jpg'}, + }), + ], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + it('returns undefined when the report preview has not loaded all child transactions yet', () => { const result = getReportPreviewSenderID({ ...baseParams, From dbc5e98cf43e132d0035dca3a43d735d60f06980 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Thu, 16 Apr 2026 12:33:52 +0430 Subject: [PATCH 06/22] test: fix spelling in report preview test --- tests/unit/getReportPreviewSenderIDTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index f611455561a2..c585bfb6eac1 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -239,7 +239,7 @@ describe('getReportPreviewSenderID', () => { modifiedAmount: 50000, modifiedCreated: '2021-03-18 00:00:00', modifiedCurrency: 'INR', - modifiedMerchant: 'Airtel', + modifiedMerchant: 'merchant', receipt: {source: 'receipt.jpg'}, }), ], From 99f96c548ab97ab5537a3ff31b240b9b76cd3a86 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Tue, 21 Apr 2026 14:44:34 +0430 Subject: [PATCH 07/22] fix: handle partial IOU actions after cache clear --- .../ReportActionAvatars/useReportPreviewSenderID.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index ec9ca75a21f4..b81a6a21d9f6 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -193,8 +193,16 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx if (!shouldFetchData) { return undefined; } - return getAllNonDeletedTransactions(reportTransactions, iouActions ?? []); - }, [reportTransactions, iouActions, shouldFetchData]); + const activeMoneyRequestCount = iouReport?.transactionCount ?? action?.childMoneyRequestCount ?? 0; + const filteredTransactions = getAllNonDeletedTransactions(reportTransactions, iouActions ?? []); + const allReportTransactions = Object.values(reportTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction); + + if (filteredTransactions.length < allReportTransactions.length && filteredTransactions.length < activeMoneyRequestCount) { + return getAllNonDeletedTransactions(reportTransactions, iouActions ?? [], false, true); + } + + return filteredTransactions; + }, [reportTransactions, iouActions, shouldFetchData, iouReport?.transactionCount, action?.childMoneyRequestCount]); const [splits] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(shouldFetchData ? chatReport?.reportID : undefined)}`, { selector: getSplitsSelector, From c5d45a88403f236e005f3445611f3bc7850f5e5f Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 22 Apr 2026 14:00:39 +0430 Subject: [PATCH 08/22] fix: ignore in-progress scan placeholders --- .../useReportPreviewSenderID.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index b81a6a21d9f6..8dd88cb20293 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -52,6 +52,14 @@ function getTransactionDirectionSign(transaction: Transaction): number | undefin return undefined; } +function hasPendingScanStateAndUnknownDirection(transaction: Transaction): boolean { + return ( + transaction.receipt?.source !== undefined && + (transaction.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_READY || transaction.receipt?.state === CONST.IOU.RECEIPT_STATE.SCANNING) && + getTransactionDirectionSign(transaction) === undefined + ); +} + function isExplicitlyDeletedIOUAction(iouAction: ReportAction): boolean { const originalMessage = getOriginalMessage(iouAction) as OriginalMessageIOU | undefined; @@ -135,13 +143,21 @@ function getReportPreviewSenderID({iouReport, action, chatReport, iouActions, tr // We have to do it this way because there can be a case when actions are not available // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 const transactionSigns = transactions?.map((transaction) => getTransactionDirectionSign(transaction)) ?? []; + const transactionsWithUnknownDirection = (transactions ?? []).filter((transaction, index) => transactionSigns.at(index) === undefined); const hasUnknownDirection = transactionSigns.some((sign) => sign === undefined); + const unknownDirectionComesOnlyFromPendingScans = transactionsWithUnknownDirection.length > 0 && transactionsWithUnknownDirection.every(hasPendingScanStateAndUnknownDirection); + + if (hasUnknownDirection && !unknownDirectionComesOnlyFromPendingScans) { + return undefined; + } + + const knownTransactionSigns = transactionSigns.filter((sign): sign is number => sign !== undefined); - if (hasUnknownDirection) { + if (knownTransactionSigns.length === 0) { return undefined; } - const areAmountsSignsTheSame = new Set(transactionSigns).size < 2; + const areAmountsSignsTheSame = new Set(knownTransactionSigns).size < 2; if (!areAmountsSignsTheSame) { return undefined; From 97fa7141efc1bdb2b2d96c848a0f0c7cc41bcbcc Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 27 Apr 2026 16:24:26 +0430 Subject: [PATCH 09/22] fix: handle legacy deleted IOU actions in report preview sender --- .../useReportPreviewSenderID.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 8dd88cb20293..01604f4db4ce 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -63,18 +63,14 @@ function hasPendingScanStateAndUnknownDirection(transaction: Transaction): boole function isExplicitlyDeletedIOUAction(iouAction: ReportAction): boolean { const originalMessage = getOriginalMessage(iouAction) as OriginalMessageIOU | undefined; - if (originalMessage?.deleted) { - return true; - } - - if (isDeletedParentAction(iouAction)) { + if (originalMessage?.deleted || isDeletedParentAction(iouAction)) { return true; } const message = iouAction.message; if (Array.isArray(message)) { - return message.some((fragment) => !!fragment?.deleted || fragment?.html === ''); + return message.length === 0 || message.some((fragment) => !!fragment?.deleted || fragment?.html === ''); } return !!message?.deleted || message?.html === ''; @@ -117,7 +113,7 @@ function getReportPreviewSenderID({iouReport, action, chatReport, iouActions, tr } const hasCompleteActionCoverage = activeMoneyRequestCount > 0 && uniqueIOUActionActorMap.size >= activeMoneyRequestCount; - const areAllActiveChildRequestsCreatedByOneActor = new Set(uniqueIOUActionActorMap.values()).size < 2; + const areAllActiveChildRequestsCreatedByOneActor = uniqueIOUActionActorMap.size > 0 && new Set(uniqueIOUActionActorMap.values()).size < 2; const canInferFromIOUActionsDuringPartialHydration = loadedTransactionCount > 0 && hasCompleteActionCoverage && activeIOUActions.length > 0 && areAllActiveChildRequestsCreatedByOneActor; // After refresh, the preview action can hydrate before all active child transactions. @@ -210,11 +206,13 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx return undefined; } const activeMoneyRequestCount = iouReport?.transactionCount ?? action?.childMoneyRequestCount ?? 0; - const filteredTransactions = getAllNonDeletedTransactions(reportTransactions, iouActions ?? []); const allReportTransactions = Object.values(reportTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction); + const orphanedInclusiveTransactions = getAllNonDeletedTransactions(reportTransactions, iouActions ?? [], false, true); + const filteredTransactions = + orphanedInclusiveTransactions.length < allReportTransactions.length ? getAllNonDeletedTransactions(reportTransactions, iouActions ?? []) : orphanedInclusiveTransactions; if (filteredTransactions.length < allReportTransactions.length && filteredTransactions.length < activeMoneyRequestCount) { - return getAllNonDeletedTransactions(reportTransactions, iouActions ?? [], false, true); + return orphanedInclusiveTransactions; } return filteredTransactions; From da5ff2a362312e5af15f8c3e4844af29a2e56045 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 1 May 2026 10:48:37 +0430 Subject: [PATCH 10/22] fix: handle stale preview counts after load --- .../useReportPreviewSenderID.ts | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 01604f4db4ce..b90412c0aaba 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -10,6 +10,7 @@ import {getIOUActionForTransactionID, getOriginalMessage, isDeletedParentAction, import {isDM, isIOUReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {hasOnceLoadedReportActionsSelector, isLoadingInitialReportActionsSelector} from '@src/selectors/ReportMetaData'; import type {OriginalMessageIOU, Policy, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; function getSplitAuthor(transaction: Transaction, splits?: Array>) { @@ -85,9 +86,20 @@ type GetReportPreviewSenderIDParams = { splits: Array> | undefined; policy: OnyxEntry; currentUserAccountID: number; + hasFinishedInitialReportActionsLoad?: boolean; }; -function getReportPreviewSenderID({iouReport, action, chatReport, iouActions, transactions, splits, policy, currentUserAccountID}: GetReportPreviewSenderIDParams): number | undefined { +function getReportPreviewSenderID({ + iouReport, + action, + chatReport, + iouActions, + transactions, + splits, + policy, + currentUserAccountID, + hasFinishedInitialReportActionsLoad, +}: GetReportPreviewSenderIDParams): number | undefined { const isOptimisticReportPreview = action?.isOptimisticAction && action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && isIOUReport(iouReport); if (isOptimisticReportPreview) { @@ -119,7 +131,7 @@ function getReportPreviewSenderID({iouReport, action, chatReport, iouActions, tr // After refresh, the preview action can hydrate before all active child transactions. // Avoid collapsing to one avatar unless the available IOU actions already prove the remaining // active requests all belong to the same sender. - if (activeMoneyRequestCount > loadedTransactionCount && !canInferFromIOUActionsDuringPartialHydration) { + if (!hasFinishedInitialReportActionsLoad && activeMoneyRequestCount > loadedTransactionCount && !canInferFromIOUActionsDuringPartialHydration) { return undefined; } @@ -142,18 +154,21 @@ function getReportPreviewSenderID({iouReport, action, chatReport, iouActions, tr const transactionsWithUnknownDirection = (transactions ?? []).filter((transaction, index) => transactionSigns.at(index) === undefined); const hasUnknownDirection = transactionSigns.some((sign) => sign === undefined); const unknownDirectionComesOnlyFromPendingScans = transactionsWithUnknownDirection.length > 0 && transactionsWithUnknownDirection.every(hasPendingScanStateAndUnknownDirection); + const hasOnlyUnknownDirections = transactionSigns.length > 0 && transactionSigns.every((sign) => sign === undefined); + const hasOnlyUnknownNonPendingScanDirections = + hasOnlyUnknownDirections && transactionsWithUnknownDirection.every((transaction) => !hasPendingScanStateAndUnknownDirection(transaction)); - if (hasUnknownDirection && !unknownDirectionComesOnlyFromPendingScans) { + if (hasUnknownDirection && !unknownDirectionComesOnlyFromPendingScans && !hasOnlyUnknownNonPendingScanDirections) { return undefined; } const knownTransactionSigns = transactionSigns.filter((sign): sign is number => sign !== undefined); - if (knownTransactionSigns.length === 0) { + if (knownTransactionSigns.length === 0 && !hasOnlyUnknownNonPendingScanDirections) { return undefined; } - const areAmountsSignsTheSame = new Set(knownTransactionSigns).size < 2; + const areAmountsSignsTheSame = hasOnlyUnknownNonPendingScanDirections || new Set(knownTransactionSigns).size < 2; if (!areAmountsSignsTheSame) { return undefined; @@ -199,6 +214,13 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(shouldFetchData ? iouReport?.reportID : undefined)}`, { selector: getIOUActionsSelector, }); + const [hasOnceLoadedReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(shouldFetchData ? action?.childReportID : undefined)}`, { + selector: hasOnceLoadedReportActionsSelector, + }); + const [isLoadingInitialReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(shouldFetchData ? action?.childReportID : undefined)}`, { + selector: isLoadingInitialReportActionsSelector, + }); + const hasFinishedInitialReportActionsLoad = hasOnceLoadedReportActions === true || isLoadingInitialReportActions === false; const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(shouldFetchData ? action?.childReportID : undefined); const transactions = useMemo(() => { @@ -225,7 +247,7 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(shouldFetchData ? iouReport?.policyID : undefined)}`); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - return getReportPreviewSenderID({iouReport, action, chatReport, iouActions, transactions, splits, policy, currentUserAccountID}); + return getReportPreviewSenderID({iouReport, action, chatReport, iouActions, transactions, splits, policy, currentUserAccountID, hasFinishedInitialReportActionsLoad}); } export default useReportPreviewSenderID; From c897d0fd277bf47ea822c5829c1c849b117b661d Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 7 May 2026 14:12:15 +0430 Subject: [PATCH 11/22] fix: improve report preview sender --- .../useReportPreviewSenderID.ts | 37 ++++++++++-------- tests/unit/getReportPreviewSenderIDTest.ts | 38 +++++++++---------- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index e3713e0ac05b..2c0cad3c5f98 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -55,11 +55,19 @@ function getTransactionDirectionSign(transaction: Transaction): number | undefin } function hasPendingScanStateAndUnknownDirection(transaction: Transaction): boolean { - return ( - transaction.receipt?.source !== undefined && - (transaction.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_READY || transaction.receipt?.state === CONST.IOU.RECEIPT_STATE.SCANNING) && - getTransactionDirectionSign(transaction) === undefined - ); + if (transaction.receipt?.source === undefined) { + return false; + } + + if (transaction.receipt?.state !== CONST.IOU.RECEIPT_STATE.SCAN_READY && transaction.receipt?.state !== CONST.IOU.RECEIPT_STATE.SCANNING) { + return false; + } + + if (getTransactionDirectionSign(transaction) !== undefined) { + return false; + } + + return true; } function isExplicitlyDeletedIOUAction(iouAction: ReportAction): boolean { @@ -109,10 +117,7 @@ function getReportPreviewSenderID({ const loadedTransactionCount = transactions?.length ?? 0; const childMoneyRequestCount = action?.childMoneyRequestCount ?? 0; const activeMoneyRequestCount = iouReport?.transactionCount ?? childMoneyRequestCount; - const activeIOUActions = - iouActions?.filter((iouAction) => { - return !isExplicitlyDeletedIOUAction(iouAction); - }) ?? []; + const activeIOUActions = iouActions?.filter((iouAction) => !isExplicitlyDeletedIOUAction(iouAction)) ?? []; const uniqueIOUActionActorMap = new Map(); for (const iouAction of activeIOUActions) { @@ -140,7 +145,7 @@ function getReportPreviewSenderID({ const hasActorAccountIDForEachTransaction = activeIOUActions.length > 0 && !!transactionActorAccountIDs && transactionActorAccountIDs.length > 0 && transactionActorAccountIDs.every((accountID) => accountID !== undefined); - // 1. Use actorAccountID when it is available for every transaction. Otherwise, fall back to known transaction direction only. + // 1. Use actorAccountID when it is available for every transaction. if (hasActorAccountIDForEachTransaction) { const areAllTransactionsCreatedByOneActor = new Set(transactionActorAccountIDs).size < 2; @@ -148,8 +153,8 @@ function getReportPreviewSenderID({ return undefined; } } else { - // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative. - // We have to do it this way because there can be a case when actions are not available + // 1. If actor data is unavailable, fall back to transaction direction. + // We use amount sign here because there can be cases where actions are not available. // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 const transactionSigns = transactions?.map((transaction) => getTransactionDirectionSign(transaction)) ?? []; const transactionsWithUnknownDirection = (transactions ?? []).filter((transaction, index) => transactionSigns.at(index) === undefined); @@ -232,12 +237,14 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx } const activeMoneyRequestCount = iouReport?.transactionCount ?? action?.childMoneyRequestCount ?? 0; const allReportTransactions = Object.values(reportTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction); - const orphanedInclusiveTransactions = getAllNonDeletedTransactions(reportTransactions, iouActions ?? [], false, true); + const nonDeletedTransactionsIncludingOrphans = getAllNonDeletedTransactions(reportTransactions, iouActions ?? [], false, true); const filteredTransactions = - orphanedInclusiveTransactions.length < allReportTransactions.length ? getAllNonDeletedTransactions(reportTransactions, iouActions ?? []) : orphanedInclusiveTransactions; + nonDeletedTransactionsIncludingOrphans.length < allReportTransactions.length + ? getAllNonDeletedTransactions(reportTransactions, iouActions ?? []) + : nonDeletedTransactionsIncludingOrphans; if (filteredTransactions.length < allReportTransactions.length && filteredTransactions.length < activeMoneyRequestCount) { - return orphanedInclusiveTransactions; + return nonDeletedTransactionsIncludingOrphans; } return filteredTransactions; diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index aeab764507c1..01ac0aa86949 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -84,8 +84,8 @@ describe('getReportPreviewSenderID', () => { }); it('returns childOwnerAccountID when all transactions map to IOU actions from the same actor', () => { - const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: 'tr-1'}); - const transaction2 = makeTransaction(200, 'user2@test.com', {transactionID: 'tr-2'}); + const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: '123'}); + const transaction2 = makeTransaction(200, 'user1@test.com', {transactionID: '321'}); const result = getReportPreviewSenderID({ ...baseParams, @@ -96,7 +96,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-1', + IOUTransactionID: '123', amount: 100, currency: 'USD', }, @@ -105,7 +105,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-2', + IOUTransactionID: '321', amount: 200, currency: 'USD', }, @@ -118,8 +118,8 @@ describe('getReportPreviewSenderID', () => { }); it('returns undefined when transactions map to IOU actions from different actors', () => { - const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: 'tr-1'}); - const transaction2 = makeTransaction(200, 'user2@test.com', {transactionID: 'tr-2'}); + const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: '123'}); + const transaction2 = makeTransaction(200, 'user1@test.com', {transactionID: '321'}); const result = getReportPreviewSenderID({ ...baseParams, @@ -130,7 +130,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-1', + IOUTransactionID: '123', amount: 100, currency: 'USD', }, @@ -139,7 +139,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 20, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-2', + IOUTransactionID: '321', amount: 200, currency: 'USD', }, @@ -156,7 +156,7 @@ describe('getReportPreviewSenderID', () => { originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUDetails: {amount: 100, comment: '', currency: 'USD'}, - IOUTransactionID: 'tr-1', + IOUTransactionID: '123', amount: 100, currency: 'USD', }, @@ -232,9 +232,9 @@ describe('getReportPreviewSenderID', () => { action: makeAction({childMoneyRequestCount: 2}), iouActions: [], transactions: [ - makeTransaction(1200, 'user@test.com', {transactionID: 'tr-1'}), + makeTransaction(1200, 'user@test.com', {transactionID: '123'}), makeTransaction(0, 'user@test.com', { - transactionID: 'tr-2', + transactionID: '321', iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, modifiedAmount: 50000, modifiedCreated: '2021-03-18 00:00:00', @@ -270,7 +270,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-active', + IOUTransactionID: '123', amount: 100, currency: 'USD', }, @@ -279,7 +279,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 20, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-deleted', + IOUTransactionID: '321', amount: 100, currency: 'USD', deleted: '2026-04-11 07:12:23.697', @@ -287,7 +287,7 @@ describe('getReportPreviewSenderID', () => { message: [{type: 'COMMENT', text: 'Deleted expense', deleted: '2026-04-11 07:12:23.697'}], }), ], - transactions: [makeTransaction(100, 'user@test.com', {transactionID: 'tr-active'})], + transactions: [makeTransaction(100, 'user@test.com', {transactionID: '123'})], }); expect(result).toBe(OWNER_ACCOUNT_ID); @@ -303,7 +303,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-active', + IOUTransactionID: '123', amount: 100, currency: 'USD', }, @@ -319,7 +319,7 @@ describe('getReportPreviewSenderID', () => { message: [{type: 'COMMENT', text: '', deleted: '2026-04-12 04:48:48.212'}], }), ], - transactions: [makeTransaction(100, 'user@test.com', {transactionID: 'tr-active'})], + transactions: [makeTransaction(100, 'user@test.com', {transactionID: '123'})], }); expect(result).toBe(OWNER_ACCOUNT_ID); @@ -330,7 +330,7 @@ describe('getReportPreviewSenderID', () => { // Since getPersonalDetailByEmail returns undefined in test (no Onyx), attendeesIDs will be filtered out // and the set size will be 0, which is <= 1, so we need to use splits to create multiple attendees const splitTr1: Transaction = { - transactionID: 'tr-1', + transactionID: '123', amount: 100, comment: { source: CONST.IOU.TYPE.SPLIT, @@ -340,7 +340,7 @@ describe('getReportPreviewSenderID', () => { } as Transaction; const splitTr2: Transaction = { - transactionID: 'tr-2', + transactionID: '321', amount: 100, comment: { source: CONST.IOU.TYPE.SPLIT, @@ -388,7 +388,7 @@ describe('getReportPreviewSenderID', () => { it('returns childOwnerAccountID for split transaction with single author', () => { const splitTr: Transaction = { - transactionID: 'tr-1', + transactionID: '123', amount: 100, comment: { source: CONST.IOU.TYPE.SPLIT, From cf0a7c48c0ef343bf1e462f55589b9b6a593109a Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 11 May 2026 16:30:51 +0430 Subject: [PATCH 12/22] fix: improve report preview sender rehydration --- .../useReportPreviewSenderID.ts | 206 ++++++++- src/libs/ReportUtils.ts | 1 + tests/unit/ReportUtilsTest.ts | 29 ++ tests/unit/getReportPreviewSenderIDTest.ts | 413 ++++++++++++++++++ 4 files changed, 625 insertions(+), 24 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 2c0cad3c5f98..f81cc2b1e02c 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -70,6 +70,131 @@ function hasPendingScanStateAndUnknownDirection(transaction: Transaction): boole return true; } +function hasReceiptBackedUnknownDirection(transaction: Transaction): boolean { + return transaction.receipt?.source !== undefined && getTransactionDirectionSign(transaction) === undefined; +} + +function normalizeAccountID(accountID: number | string | undefined): number | undefined { + const parsedAccountID = Number(accountID); + + if (!Number.isFinite(parsedAccountID) || parsedAccountID <= 0) { + return undefined; + } + + return parsedAccountID; +} + +function getAttendeeIdentifier(attendee: {accountID?: number; email?: string; login?: string; reportID?: string; displayName?: string; text?: string}): number | string | undefined { + if (attendee.accountID !== undefined) { + return attendee.accountID; + } + + if (attendee.email) { + return attendee.email.toLowerCase(); + } + + if (attendee.login) { + return attendee.login.toLowerCase(); + } + + if (attendee.reportID) { + return attendee.reportID; + } + + if (attendee.displayName) { + return attendee.displayName.toLowerCase(); + } + + if (attendee.text) { + return attendee.text.toLowerCase(); + } + + return undefined; +} + +function getAccountIDFromTransactionDirection(transaction: Transaction, action: OnyxEntry, iouReport: OnyxEntry): number | undefined { + const directionSign = getTransactionDirectionSign(transaction); + + if (directionSign === undefined) { + return undefined; + } + + return directionSign > 0 ? normalizeAccountID(action?.childOwnerAccountID ?? iouReport?.ownerAccountID) : normalizeAccountID(action?.childManagerAccountID ?? iouReport?.managerID); +} + +function getLastActorAccountIDForReceiptBackedUnknown( + transaction: Transaction, + action: OnyxEntry, + iouReport: OnyxEntry, + receiptBackedUnknownTransactionCount: number, +): number | undefined { + if (!hasReceiptBackedUnknownDirection(transaction) || receiptBackedUnknownTransactionCount !== 1) { + return undefined; + } + + return normalizeAccountID(action?.childLastActorAccountID ?? iouReport?.lastActorAccountID); +} + +function shouldBackfillReceiptBackedUnknownTransactions( + transactions: Transaction[] | undefined, + transactionActorAccountIDs: Array | undefined, + fallbackActorAccountID: number | undefined, +): fallbackActorAccountID is number { + if (!transactions || !transactionActorAccountIDs || fallbackActorAccountID === undefined) { + return false; + } + + const knownActorAccountIDs = transactionActorAccountIDs.filter((accountID): accountID is number => accountID !== undefined); + if (knownActorAccountIDs.length === 0 || knownActorAccountIDs.some((accountID) => accountID !== fallbackActorAccountID)) { + return false; + } + + const transactionsMissingActor = transactions.filter((transaction, index) => transactionActorAccountIDs.at(index) === undefined); + return transactionsMissingActor.length > 0 && transactionsMissingActor.every(hasReceiptBackedUnknownDirection); +} + +function canInferFromTransactionsDuringPartialHydration( + transactions: Transaction[] | undefined, + transactionActorAccountIDs: Array | undefined, + fallbackActorAccountID: number | undefined, + childRecentReceiptTransactionIDs: Record | undefined, + missingTransactionCount: number, +): boolean { + if (!transactions || !transactionActorAccountIDs || fallbackActorAccountID === undefined || missingTransactionCount < 1) { + return false; + } + + const uniqueKnownActorAccountIDs = new Set(transactionActorAccountIDs.filter((accountID): accountID is number => accountID !== undefined)); + + if (uniqueKnownActorAccountIDs.size !== 1 || uniqueKnownActorAccountIDs.has(fallbackActorAccountID) === false) { + return false; + } + + if (transactions.some((transaction, index) => transactionActorAccountIDs.at(index) === undefined && !hasReceiptBackedUnknownDirection(transaction))) { + return false; + } + + return Object.keys(childRecentReceiptTransactionIDs ?? {}).length >= missingTransactionCount; +} + +function shouldPreferFallbackActorForReceiptBackedUnknownTransactions( + transactions: Transaction[] | undefined, + directionBasedActorAccountIDs: Array | undefined, + fallbackActorAccountID: number | undefined, + hasCompleteActionCoverage: boolean, +): fallbackActorAccountID is number { + if (!transactions || !directionBasedActorAccountIDs || fallbackActorAccountID === undefined || hasCompleteActionCoverage) { + return false; + } + + const knownDirectionActorAccountIDs = transactions + .map((transaction, index) => ({transaction, actorAccountID: directionBasedActorAccountIDs.at(index)})) + .filter(({transaction, actorAccountID}) => !hasReceiptBackedUnknownDirection(transaction) && actorAccountID !== undefined) + .map(({actorAccountID}) => actorAccountID); + + return knownDirectionActorAccountIDs.length > 0 && knownDirectionActorAccountIDs.every((accountID) => accountID === fallbackActorAccountID); +} + function isExplicitlyDeletedIOUAction(iouAction: ReportAction): boolean { const originalMessage = getOriginalMessage(iouAction) as OriginalMessageIOU | undefined; @@ -133,19 +258,55 @@ function getReportPreviewSenderID({ const hasCompleteActionCoverage = activeMoneyRequestCount > 0 && uniqueIOUActionActorMap.size >= activeMoneyRequestCount; const areAllActiveChildRequestsCreatedByOneActor = uniqueIOUActionActorMap.size > 0 && new Set(uniqueIOUActionActorMap.values()).size < 2; const canInferFromIOUActionsDuringPartialHydration = loadedTransactionCount > 0 && hasCompleteActionCoverage && activeIOUActions.length > 0 && areAllActiveChildRequestsCreatedByOneActor; - - // After refresh, the preview action can hydrate before all active child transactions. - // Avoid collapsing to one avatar unless the available IOU actions already prove the remaining - // active requests all belong to the same sender. - if (!hasFinishedInitialReportActionsLoad && activeMoneyRequestCount > loadedTransactionCount && !canInferFromIOUActionsDuringPartialHydration) { + const attendeeIdentifierGroups = + transactions?.map((transaction) => + convertAttendeesToArray(transaction.comment?.attendees) + .map((attendee) => + transaction.comment?.source === CONST.IOU.TYPE.SPLIT + ? getSplitAuthor(transaction, splits) + : (getPersonalDetailByEmail(attendee.email)?.accountID ?? getAttendeeIdentifier(attendee)), + ) + .filter((identifier): identifier is number | string => !!identifier), + ) ?? []; + const hasAttendeeIdentifierForEachTransaction = + transactions === undefined || (attendeeIdentifierGroups.length === transactions.length && attendeeIdentifierGroups.every((identifiers) => identifiers.length > 0)); + const receiptBackedUnknownTransactionCount = transactions?.filter(hasReceiptBackedUnknownDirection).length ?? 0; + const missingTransactionCount = Math.max(activeMoneyRequestCount - loadedTransactionCount, 0); + const receiptBackedUnknownFallbackActorAccountID = normalizeAccountID(action?.childLastActorAccountID ?? iouReport?.lastActorAccountID); + const directionBasedActorAccountIDs = transactions?.map((transaction) => getAccountIDFromTransactionDirection(transaction, action, iouReport)) ?? []; + const shouldPreferFallbackActorForReceiptBackedUnknown = shouldPreferFallbackActorForReceiptBackedUnknownTransactions( + transactions, + directionBasedActorAccountIDs, + receiptBackedUnknownFallbackActorAccountID, + hasCompleteActionCoverage, + ); + const initialTransactionActorAccountIDs = + transactions?.map( + (transaction, index) => + (shouldPreferFallbackActorForReceiptBackedUnknown && hasReceiptBackedUnknownDirection(transaction) + ? receiptBackedUnknownFallbackActorAccountID + : getIOUActionForTransactionID(activeIOUActions, transaction.transactionID)?.actorAccountID) ?? + directionBasedActorAccountIDs.at(index) ?? + getLastActorAccountIDForReceiptBackedUnknown(transaction, action, iouReport, receiptBackedUnknownTransactionCount), + ) ?? []; + const transactionActorAccountIDs = shouldBackfillReceiptBackedUnknownTransactions(transactions, initialTransactionActorAccountIDs, receiptBackedUnknownFallbackActorAccountID) + ? initialTransactionActorAccountIDs.map((accountID) => accountID ?? receiptBackedUnknownFallbackActorAccountID) + : initialTransactionActorAccountIDs; + const hasActorAccountIDForEachTransaction = + !!transactionActorAccountIDs && transactionActorAccountIDs.length > 0 && transactionActorAccountIDs.every((accountID) => accountID !== undefined); + const canInferFromTransactionDataDuringPartialHydration = canInferFromTransactionsDuringPartialHydration( + transactions, + transactionActorAccountIDs, + receiptBackedUnknownFallbackActorAccountID, + action?.childRecentReceiptTransactionIDs, + missingTransactionCount, + ); + + if (!hasFinishedInitialReportActionsLoad && missingTransactionCount > 0 && !canInferFromIOUActionsDuringPartialHydration && !canInferFromTransactionDataDuringPartialHydration) { return undefined; } - const transactionActorAccountIDs = transactions?.map((transaction) => getIOUActionForTransactionID(activeIOUActions, transaction.transactionID)?.actorAccountID); - const hasActorAccountIDForEachTransaction = - activeIOUActions.length > 0 && !!transactionActorAccountIDs && transactionActorAccountIDs.length > 0 && transactionActorAccountIDs.every((accountID) => accountID !== undefined); - - // 1. Use actorAccountID when it is available for every transaction. + // 1. Use the transaction creator when it can be inferred for every transaction. if (hasActorAccountIDForEachTransaction) { const areAllTransactionsCreatedByOneActor = new Set(transactionActorAccountIDs).size < 2; @@ -160,11 +321,15 @@ function getReportPreviewSenderID({ const transactionsWithUnknownDirection = (transactions ?? []).filter((transaction, index) => transactionSigns.at(index) === undefined); const hasUnknownDirection = transactionSigns.some((sign) => sign === undefined); const unknownDirectionComesOnlyFromPendingScans = transactionsWithUnknownDirection.length > 0 && transactionsWithUnknownDirection.every(hasPendingScanStateAndUnknownDirection); + const unknownDirectionComesOnlyFromReceiptBackedTransactions = + hasAttendeeIdentifierForEachTransaction && transactionsWithUnknownDirection.length > 0 && transactionsWithUnknownDirection.every(hasReceiptBackedUnknownDirection); const hasOnlyUnknownDirections = transactionSigns.length > 0 && transactionSigns.every((sign) => sign === undefined); const hasOnlyUnknownNonPendingScanDirections = - hasOnlyUnknownDirections && transactionsWithUnknownDirection.every((transaction) => !hasPendingScanStateAndUnknownDirection(transaction)); + hasOnlyUnknownDirections && + transactionsWithUnknownDirection.every((transaction) => !hasPendingScanStateAndUnknownDirection(transaction)) && + hasAttendeeIdentifierForEachTransaction; - if (hasUnknownDirection && !unknownDirectionComesOnlyFromPendingScans && !hasOnlyUnknownNonPendingScanDirections) { + if (hasUnknownDirection && !unknownDirectionComesOnlyFromPendingScans && !unknownDirectionComesOnlyFromReceiptBackedTransactions && !hasOnlyUnknownNonPendingScanDirections) { return undefined; } @@ -184,16 +349,8 @@ function getReportPreviewSenderID({ // 2. If there is only one attendee - we check that by counting unique emails converted to account IDs in the attendees list. // This is a fallback added because: https://github.com/Expensify/App/pull/64802#issuecomment-3007906310 - const attendeesIDs = transactions - // If the transaction is a split, then attendees are not present as a property so we need to use a helper function. - ?.flatMap((tr) => - convertAttendeesToArray(tr.comment?.attendees).map((att) => - tr.comment?.source === CONST.IOU.TYPE.SPLIT ? getSplitAuthor(tr, splits) : getPersonalDetailByEmail(att.email)?.accountID, - ), - ) - .filter((accountID) => !!accountID); - - const isThereOnlyOneAttendee = new Set(attendeesIDs).size <= 1; + const attendeeIdentifiers = attendeeIdentifierGroups.flatMap((identifiers) => identifiers); + const isThereOnlyOneAttendee = new Set(attendeeIdentifiers).size <= 1; if (!isThereOnlyOneAttendee) { return undefined; @@ -208,8 +365,9 @@ function getReportPreviewSenderID({ const isSendMoneyFlow = activeIOUActions.length > 0 ? isSendMoneyFlowBasedOnActions : isSendMoneyFlowBasedOnTransactions; - const singleAvatarAccountID = isSendMoneyFlow ? action?.childManagerAccountID : action?.childOwnerAccountID; - + const singleAvatarAccountID = isSendMoneyFlow + ? normalizeAccountID(action?.childManagerAccountID ?? iouReport?.managerID) + : normalizeAccountID(action?.childOwnerAccountID ?? iouReport?.ownerAccountID); return singleAvatarAccountID; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 1480bf4a695c..642a580d52b0 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7697,6 +7697,7 @@ function updateReportPreview( }, ], childLastMoneyRequestComment: comment || reportPreviewAction?.childLastMoneyRequestComment, + childLastActorAccountID: isPayRequest ? reportPreviewAction?.childLastActorAccountID : deprecatedCurrentUserAccountID, childMoneyRequestCount: (reportPreviewAction?.childMoneyRequestCount ?? 0) + (isPayRequest ? 0 : 1), childRecentReceiptTransactionIDs: hasReceipt ? { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 6478f908203a..2baee7deeddd 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -160,6 +160,7 @@ import { sortIconsByName, sortOutstandingReportsBySelected, temporary_getMoneyRequestOptions, + updateReportPreview, } from '@libs/ReportUtils'; import {buildOptimisticTransaction} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -10678,6 +10679,34 @@ describe('ReportUtils', () => { expect(reportPreviewAction.childOwnerAccountID).toBe(iouReport.ownerAccountID); expect(reportPreviewAction.childManagerAccountID).toBe(iouReport.managerID); }); + + it('should refresh childLastActorAccountID when adding a new expense to an existing preview', () => { + const chatReport: Report = { + ...createRandomReport(100, undefined), + type: CONST.REPORT.TYPE.CHAT, + }; + + const iouReport: Report = { + ...createRandomReport(200, undefined), + parentReportID: '1', + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: 1, + managerID: 2, + }; + + const initialPreview = { + ...buildOptimisticReportPreview(chatReport, iouReport), + childLastActorAccountID: undefined, + }; + + const updatedPreview = updateReportPreview(iouReport, initialPreview, false, '', { + transactionID: '123', + created: '2026-05-11 10:30:00', + receipt: {source: 'receipt.png'}, + } as Transaction); + + expect(updatedPreview.childLastActorAccountID).toBe(currentUserAccountID); + }); }); describe('compute (Formula.ts for optimistic report names)', () => { diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index 01ac0aa86949..5518bb8c7469 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -248,6 +248,222 @@ describe('getReportPreviewSenderID', () => { expect(result).toBe(OWNER_ACCOUNT_ID); }); + it('returns childOwnerAccountID after cache clear when one sender has a manual expense and one failed receipt without attendees', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({ + transactionCount: 2, + ownerAccountID: OWNER_ACCOUNT_ID, + managerID: MANAGER_ACCOUNT_ID, + lastActorAccountID: OWNER_ACCOUNT_ID, + }), + action: makeAction({ + childMoneyRequestCount: 2, + childLastActorAccountID: undefined, + }), + iouActions: [], + transactions: [ + makeTransaction(1200, 'user@test.com', { + transactionID: '123', + comment: undefined, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '321', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + ], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + + it('returns childOwnerAccountID when the preview action is unavailable and iouReport.lastActorAccountID is rehydrated as a string', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({ + transactionCount: 2, + ownerAccountID: OWNER_ACCOUNT_ID, + managerID: MANAGER_ACCOUNT_ID, + lastActorAccountID: `${OWNER_ACCOUNT_ID}` as unknown as number, + }), + action: undefined, + iouActions: [], + transactions: [ + makeTransaction(1200, 'user@test.com', { + transactionID: '123', + comment: undefined, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '321', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + ], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + + it('returns undefined after cache clear when the failed receipt was created by the other participant', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({ + transactionCount: 2, + ownerAccountID: OWNER_ACCOUNT_ID, + managerID: MANAGER_ACCOUNT_ID, + lastActorAccountID: MANAGER_ACCOUNT_ID, + }), + action: makeAction({ + childMoneyRequestCount: 2, + childLastActorAccountID: undefined, + }), + iouActions: [], + transactions: [ + makeTransaction(1200, 'user@test.com', { + transactionID: '123', + comment: undefined, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '321', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + ], + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when all transactions have failed receipt states and no sender-identifying data', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({transactionCount: 2}), + action: makeAction({childMoneyRequestCount: 2}), + iouActions: [], + transactions: [ + makeTransaction(0, 'user@test.com', { + transactionID: '123', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt-1.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '321', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt-2.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + ], + }); + + expect(result).toBeUndefined(); + }); + + it('returns childOwnerAccountID when multiple failed receipts belong to the same sender and known transactions already anchor that sender', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({ + transactionCount: 3, + ownerAccountID: OWNER_ACCOUNT_ID, + managerID: MANAGER_ACCOUNT_ID, + lastActorAccountID: OWNER_ACCOUNT_ID, + }), + action: makeAction({ + childMoneyRequestCount: 3, + childLastActorAccountID: undefined, + }), + iouActions: [], + transactions: [ + makeTransaction(1200, 'user@test.com', { + transactionID: '123', + comment: undefined, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '321', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt-1.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '456', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt-2.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + ], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + + it('returns undefined when multiple failed receipts exist but the latest actor does not match the known sender anchor', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({ + transactionCount: 3, + ownerAccountID: OWNER_ACCOUNT_ID, + managerID: MANAGER_ACCOUNT_ID, + lastActorAccountID: MANAGER_ACCOUNT_ID, + }), + action: makeAction({ + childMoneyRequestCount: 3, + childLastActorAccountID: undefined, + }), + iouActions: [], + transactions: [ + makeTransaction(1200, 'user@test.com', { + transactionID: '123', + comment: undefined, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '321', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt-1.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '456', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt-2.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + ], + }); + + expect(result).toBeUndefined(); + }); + it('returns undefined when the report preview has not loaded all child transactions yet', () => { const result = getReportPreviewSenderID({ ...baseParams, @@ -260,6 +476,203 @@ describe('getReportPreviewSenderID', () => { expect(result).toBeUndefined(); }); + it('returns childOwnerAccountID during partial hydration when one recent receipt-backed transaction is still missing and the loaded transactions already anchor the same sender', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({ + transactionCount: 4, + ownerAccountID: OWNER_ACCOUNT_ID, + managerID: MANAGER_ACCOUNT_ID, + lastActorAccountID: OWNER_ACCOUNT_ID, + }), + action: makeAction({ + childMoneyRequestCount: 4, + childLastActorAccountID: OWNER_ACCOUNT_ID, + childRecentReceiptTransactionIDs: Object.fromEntries([ + ['321', '2026-05-11 10:30:00'], + ['456', '2026-05-11 10:31:00'], + ]), + }), + hasFinishedInitialReportActionsLoad: false, + iouActions: [], + transactions: [ + makeTransaction(1200, 'user@test.com', { + transactionID: '123', + comment: undefined, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '321', + comment: undefined, + modifiedAmount: 50000, + receipt: { + source: 'receipt-complete.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE, + }, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '456', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt-failed.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + ], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + + it('returns childOwnerAccountID during partial hydration when multiple recent receipt-backed transactions are still missing and the loaded transactions already anchor the same sender', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({ + transactionCount: 4, + ownerAccountID: OWNER_ACCOUNT_ID, + managerID: MANAGER_ACCOUNT_ID, + lastActorAccountID: OWNER_ACCOUNT_ID, + }), + action: makeAction({ + childMoneyRequestCount: 4, + childLastActorAccountID: OWNER_ACCOUNT_ID, + childRecentReceiptTransactionIDs: Object.fromEntries([ + ['321', '2026-05-11 10:30:00'], + ['456', '2026-05-11 10:31:00'], + ]), + }), + hasFinishedInitialReportActionsLoad: false, + iouActions: [], + transactions: [ + makeTransaction(1200, 'user@test.com', { + transactionID: '123', + comment: undefined, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '789', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'loaded-receipt.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + ], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + + it('returns childOwnerAccountID when incomplete IOU action coverage conflicts with anchored direction-based actors for a failed receipt transaction', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({ + transactionCount: 6, + ownerAccountID: OWNER_ACCOUNT_ID, + managerID: MANAGER_ACCOUNT_ID, + }), + action: makeAction({ + childMoneyRequestCount: 6, + childLastActorAccountID: OWNER_ACCOUNT_ID, + childRecentReceiptTransactionIDs: Object.fromEntries([['789', '2026-05-11 10:24:41']]), + }), + iouActions: [ + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: MANAGER_ACCOUNT_ID, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: '789', + amount: 0, + currency: 'USD', + }, + }), + ], + transactions: [ + makeTransaction(0, 'user@test.com', { + transactionID: '111', + comment: undefined, + modifiedAmount: 50000, + receipt: { + source: 'receipt-1.jpg', + state: CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE, + }, + }), + makeTransaction(2300, 'user@test.com', { + transactionID: '222', + comment: undefined, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '333', + comment: undefined, + modifiedAmount: 50000, + receipt: { + source: 'receipt-2.jpg', + state: CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE, + }, + }), + makeTransaction(4300, 'user@test.com', { + transactionID: '444', + comment: undefined, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '555', + comment: undefined, + modifiedAmount: 3848, + receipt: { + source: 'receipt-3.jpg', + state: CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE, + }, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '789', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt-failed.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + ], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + + it('returns undefined during partial hydration when the missing transaction case does not have recent receipt metadata to support sender inference', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({ + transactionCount: 3, + ownerAccountID: OWNER_ACCOUNT_ID, + managerID: MANAGER_ACCOUNT_ID, + lastActorAccountID: OWNER_ACCOUNT_ID, + }), + action: makeAction({ + childMoneyRequestCount: 3, + childLastActorAccountID: OWNER_ACCOUNT_ID, + }), + hasFinishedInitialReportActionsLoad: false, + iouActions: [], + transactions: [ + makeTransaction(1200, 'user@test.com', { + transactionID: '123', + comment: undefined, + }), + makeTransaction(0, 'user@test.com', { + transactionID: '321', + comment: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: { + source: 'receipt-failed.png', + state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + }, + }), + ], + }); + + expect(result).toBeUndefined(); + }); + it('returns childOwnerAccountID when a deleted expense keeps childMoneyRequestCount stale', () => { const result = getReportPreviewSenderID({ ...baseParams, From 3f8d962accd54c15f3d42c0617ea5e43ae475276 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 12 May 2026 15:32:51 +0430 Subject: [PATCH 13/22] test: fix report action avatar fixture --- tests/ui/ReportActionAvatarsTest.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/ui/ReportActionAvatarsTest.tsx b/tests/ui/ReportActionAvatarsTest.tsx index b4cbd2ec44c8..36f2746ef884 100644 --- a/tests/ui/ReportActionAvatarsTest.tsx +++ b/tests/ui/ReportActionAvatarsTest.tsx @@ -187,6 +187,7 @@ const iouTripReport = { const iouActionOne = { ...actionR14932, + actorAccountID: LOGGED_USER_ID, originalMessage: { ...getOriginalMessage(actionR14932), IOUTransactionID: 'TRANSACTION_NUMBER_ONE', @@ -196,6 +197,7 @@ const iouActionOne = { const iouActionTwo = { ...actionR14932, + actorAccountID: SECOND_USER_ID, originalMessage: { ...getOriginalMessage(actionR14932), IOUTransactionID: 'TRANSACTION_NUMBER_TWO', @@ -205,6 +207,7 @@ const iouActionTwo = { const iouActionThree = { ...actionR14932, + actorAccountID: LOGGED_USER_ID, originalMessage: { ...getOriginalMessage(actionR14932), IOUTransactionID: 'TRANSACTION_NUMBER_THREE', @@ -250,11 +253,11 @@ const reportActionCollectionDataSet = { [reportPreviewDMAction.reportActionID]: reportPreviewDMAction, [reportPreviewSingleTransactionDMAction.reportActionID]: reportPreviewSingleTransactionDMAction, }, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportPreviewDMAction.reportID}`]: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouDMReport.reportID}`]: { [iouActionOne.reportActionID]: iouActionOne, [iouActionTwo.reportActionID]: iouActionTwo, }, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportPreviewSingleTransactionDMAction.reportID}`]: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouDMSingleExpenseReport.reportID}`]: { [iouActionThree.reportActionID]: iouActionThree, }, }; From 0ee0bb03e65ba321b64a8d4cfd90d01574ddda51 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 14 May 2026 18:45:42 +0430 Subject: [PATCH 14/22] fix: handle pending scan preview sender --- .../useReportPreviewSenderID.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 0bab7712d19c..eac696b5fd05 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -74,6 +74,10 @@ function hasReceiptBackedUnknownDirection(transaction: Transaction): boolean { return transaction.receipt?.source !== undefined && getTransactionDirectionSign(transaction) === undefined; } +function hasNonPendingReceiptBackedUnknownDirection(transaction: Transaction): boolean { + return hasReceiptBackedUnknownDirection(transaction) && !hasPendingScanStateAndUnknownDirection(transaction); +} + function normalizeAccountID(accountID: number | string | undefined): number | undefined { const parsedAccountID = Number(accountID); @@ -128,7 +132,7 @@ function getLastActorAccountIDForReceiptBackedUnknown( iouReport: OnyxEntry, receiptBackedUnknownTransactionCount: number, ): number | undefined { - if (!hasReceiptBackedUnknownDirection(transaction) || receiptBackedUnknownTransactionCount !== 1) { + if (!hasNonPendingReceiptBackedUnknownDirection(transaction) || receiptBackedUnknownTransactionCount !== 1) { return undefined; } @@ -150,7 +154,7 @@ function shouldBackfillReceiptBackedUnknownTransactions( } const transactionsMissingActor = transactions.filter((transaction, index) => transactionActorAccountIDs.at(index) === undefined); - return transactionsMissingActor.length > 0 && transactionsMissingActor.every(hasReceiptBackedUnknownDirection); + return transactionsMissingActor.length > 0 && transactionsMissingActor.every(hasNonPendingReceiptBackedUnknownDirection); } function canInferFromTransactionsDuringPartialHydration( @@ -170,7 +174,7 @@ function canInferFromTransactionsDuringPartialHydration( return false; } - if (transactions.some((transaction, index) => transactionActorAccountIDs.at(index) === undefined && !hasReceiptBackedUnknownDirection(transaction))) { + if (transactions.some((transaction, index) => transactionActorAccountIDs.at(index) === undefined && !hasNonPendingReceiptBackedUnknownDirection(transaction))) { return false; } @@ -187,6 +191,10 @@ function shouldPreferFallbackActorForReceiptBackedUnknownTransactions( return false; } + if (transactions.some(hasPendingScanStateAndUnknownDirection)) { + return false; + } + const knownDirectionActorAccountIDs = transactions .map((transaction, index) => ({transaction, actorAccountID: directionBasedActorAccountIDs.at(index)})) .filter(({transaction, actorAccountID}) => !hasReceiptBackedUnknownDirection(transaction) && actorAccountID !== undefined) @@ -268,6 +276,7 @@ function getReportPreviewSenderID({ ) .filter((identifier): identifier is number | string => !!identifier), ) ?? []; + const transactionSigns = transactions?.map((transaction) => getTransactionDirectionSign(transaction)) ?? []; const hasAttendeeIdentifierForEachTransaction = transactions === undefined || (attendeeIdentifierGroups.length === transactions.length && attendeeIdentifierGroups.every((identifiers) => identifiers.length > 0)); const receiptBackedUnknownTransactionCount = transactions?.filter(hasReceiptBackedUnknownDirection).length ?? 0; @@ -301,11 +310,16 @@ function getReportPreviewSenderID({ action?.childRecentReceiptTransactionIDs, missingTransactionCount, ); + const hasPendingScanWithUnknownDirection = (transactions ?? []).some(hasPendingScanStateAndUnknownDirection); if (!hasFinishedInitialReportActionsLoad && missingTransactionCount > 0 && !canInferFromIOUActionsDuringPartialHydration && !canInferFromTransactionDataDuringPartialHydration) { return undefined; } + if (hasPendingScanWithUnknownDirection && !hasActorAccountIDForEachTransaction) { + return undefined; + } + // 1. Use the transaction creator when it can be inferred for every transaction. if (hasActorAccountIDForEachTransaction) { const areAllTransactionsCreatedByOneActor = new Set(transactionActorAccountIDs).size < 2; @@ -317,7 +331,6 @@ function getReportPreviewSenderID({ // 1. If actor data is unavailable, fall back to transaction direction. // We use amount sign here because there can be cases where actions are not available. // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 - const transactionSigns = transactions?.map((transaction) => getTransactionDirectionSign(transaction)) ?? []; const transactionsWithUnknownDirection = (transactions ?? []).filter((transaction, index) => transactionSigns.at(index) === undefined); const hasUnknownDirection = transactionSigns.some((sign) => sign === undefined); const unknownDirectionComesOnlyFromPendingScans = transactionsWithUnknownDirection.length > 0 && transactionsWithUnknownDirection.every(hasPendingScanStateAndUnknownDirection); From 3a9837cc52ee0b7ebb60588c14f28fe9a09db85d Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 14 May 2026 19:42:17 +0430 Subject: [PATCH 15/22] fix: improve preview sender fallback during scan --- .../useReportPreviewSenderID.ts | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index eac696b5fd05..4d2711dfd341 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -129,9 +129,26 @@ function getAccountIDFromTransactionDirection(transaction: Transaction, action: function getLastActorAccountIDForReceiptBackedUnknown( transaction: Transaction, action: OnyxEntry, + chatReport: OnyxEntry, iouReport: OnyxEntry, receiptBackedUnknownTransactionCount: number, ): number | undefined { + if (hasPendingScanStateAndUnknownDirection(transaction)) { + const previewLastActorAccountID = normalizeAccountID(action?.childLastActorAccountID); + + if (previewLastActorAccountID !== undefined) { + return previewLastActorAccountID; + } + + const chatLastActorAccountID = normalizeAccountID(chatReport?.lastActorAccountID); + + if (chatLastActorAccountID !== undefined && chatLastActorAccountID !== CONST.ACCOUNT_ID.CONCIERGE) { + return chatLastActorAccountID; + } + + return normalizeAccountID(iouReport?.lastActorAccountID); + } + if (!hasNonPendingReceiptBackedUnknownDirection(transaction) || receiptBackedUnknownTransactionCount !== 1) { return undefined; } @@ -206,17 +223,17 @@ function shouldPreferFallbackActorForReceiptBackedUnknownTransactions( function isExplicitlyDeletedIOUAction(iouAction: ReportAction): boolean { const originalMessage = getOriginalMessage(iouAction) as OriginalMessageIOU | undefined; - if (originalMessage?.deleted || isDeletedParentAction(iouAction)) { + if (iouAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || originalMessage?.deleted || isDeletedParentAction(iouAction)) { return true; } const message = iouAction.message; if (Array.isArray(message)) { - return message.length === 0 || message.some((fragment) => !!fragment?.deleted || fragment?.html === ''); + return message.length === 0 || message.some((fragment) => !!fragment?.deleted || (fragment?.html === '' && !iouAction.isOptimisticAction)); } - return !!message?.deleted || message?.html === ''; + return !!message?.deleted || (message?.html === '' && !iouAction.isOptimisticAction); } type GetReportPreviewSenderIDParams = { @@ -296,7 +313,7 @@ function getReportPreviewSenderID({ ? receiptBackedUnknownFallbackActorAccountID : getIOUActionForTransactionID(activeIOUActions, transaction.transactionID)?.actorAccountID) ?? directionBasedActorAccountIDs.at(index) ?? - getLastActorAccountIDForReceiptBackedUnknown(transaction, action, iouReport, receiptBackedUnknownTransactionCount), + getLastActorAccountIDForReceiptBackedUnknown(transaction, action, chatReport, iouReport, receiptBackedUnknownTransactionCount), ) ?? []; const transactionActorAccountIDs = shouldBackfillReceiptBackedUnknownTransactions(transactions, initialTransactionActorAccountIDs, receiptBackedUnknownFallbackActorAccountID) ? initialTransactionActorAccountIDs.map((accountID) => accountID ?? receiptBackedUnknownFallbackActorAccountID) @@ -310,16 +327,10 @@ function getReportPreviewSenderID({ action?.childRecentReceiptTransactionIDs, missingTransactionCount, ); - const hasPendingScanWithUnknownDirection = (transactions ?? []).some(hasPendingScanStateAndUnknownDirection); - if (!hasFinishedInitialReportActionsLoad && missingTransactionCount > 0 && !canInferFromIOUActionsDuringPartialHydration && !canInferFromTransactionDataDuringPartialHydration) { return undefined; } - if (hasPendingScanWithUnknownDirection && !hasActorAccountIDForEachTransaction) { - return undefined; - } - // 1. Use the transaction creator when it can be inferred for every transaction. if (hasActorAccountIDForEachTransaction) { const areAllTransactionsCreatedByOneActor = new Set(transactionActorAccountIDs).size < 2; @@ -393,13 +404,12 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(shouldFetchData ? iouReport?.reportID : undefined)}`, { selector: getIOUActionsSelector, }); - const [hasOnceLoadedReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(shouldFetchData ? action?.childReportID : undefined)}`, { - selector: hasOnceLoadedReportActionsSelector, - }); - const [isLoadingInitialReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(shouldFetchData ? action?.childReportID : undefined)}`, { - selector: isLoadingInitialReportActionsSelector, - }); - const hasFinishedInitialReportActionsLoad = hasOnceLoadedReportActions === true || isLoadingInitialReportActions === false; + const [hasFinishedInitialReportActionsLoad] = useOnyx( + `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(shouldFetchData ? action?.childReportID : undefined)}`, + { + selector: (state) => hasOnceLoadedReportActionsSelector(state) === true || isLoadingInitialReportActionsSelector(state) === false, + }, + ); const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(shouldFetchData ? action?.childReportID : undefined); const transactions = useMemo(() => { @@ -408,6 +418,8 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx } const activeMoneyRequestCount = iouReport?.transactionCount ?? action?.childMoneyRequestCount ?? 0; const allReportTransactions = Object.values(reportTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction); + // Start with orphan-inclusive filtering so refreshed receipt-backed expenses are not dropped too early, + // then fall back to the stricter path only when it does not undercount the active requests. const nonDeletedTransactionsIncludingOrphans = getAllNonDeletedTransactions(reportTransactions, iouActions ?? [], false, true); const filteredTransactions = nonDeletedTransactionsIncludingOrphans.length < allReportTransactions.length From 9a3d7624f2561e71e44fa3ac7e62c647367d12df Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 14 May 2026 19:46:17 +0430 Subject: [PATCH 16/22] fix: polish preview sender --- src/components/ReportActionAvatars/useReportPreviewSenderID.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 4d2711dfd341..3e28d4705d5a 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -419,7 +419,7 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx const activeMoneyRequestCount = iouReport?.transactionCount ?? action?.childMoneyRequestCount ?? 0; const allReportTransactions = Object.values(reportTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction); // Start with orphan-inclusive filtering so refreshed receipt-backed expenses are not dropped too early, - // then fall back to the stricter path only when it does not undercount the active requests. + // then fall back to the stricter path only when it does not reduce the active request count too far. const nonDeletedTransactionsIncludingOrphans = getAllNonDeletedTransactions(reportTransactions, iouActions ?? [], false, true); const filteredTransactions = nonDeletedTransactionsIncludingOrphans.length < allReportTransactions.length From 05cca009d362de2b04b9fb511aaf7fc8a80a8e94 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 19 May 2026 15:05:53 +0430 Subject: [PATCH 17/22] fix: improve report preview sender inference --- .../useReportPreviewSenderID.ts | 266 +++------- tests/ui/ReportActionAvatarsTest.tsx | 7 +- tests/unit/ReportUtilsTest.ts | 21 +- tests/unit/getReportPreviewSenderIDTest.ts | 472 ++++-------------- 4 files changed, 186 insertions(+), 580 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 3e28d4705d5a..7b286c6edd2b 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -70,50 +70,30 @@ function hasPendingScanStateAndUnknownDirection(transaction: Transaction): boole return true; } -function hasReceiptBackedUnknownDirection(transaction: Transaction): boolean { - return transaction.receipt?.source !== undefined && getTransactionDirectionSign(transaction) === undefined; -} - -function hasNonPendingReceiptBackedUnknownDirection(transaction: Transaction): boolean { - return hasReceiptBackedUnknownDirection(transaction) && !hasPendingScanStateAndUnknownDirection(transaction); -} - -function normalizeAccountID(accountID: number | string | undefined): number | undefined { - const parsedAccountID = Number(accountID); - - if (!Number.isFinite(parsedAccountID) || parsedAccountID <= 0) { +function getPendingScanActorAccountID(transaction: Transaction, action: OnyxEntry, iouReport: OnyxEntry, chatReport: OnyxEntry): number | undefined { + if (!hasPendingScanStateAndUnknownDirection(transaction)) { return undefined; } - return parsedAccountID; -} - -function getAttendeeIdentifier(attendee: {accountID?: number; email?: string; login?: string; reportID?: string; displayName?: string; text?: string}): number | string | undefined { - if (attendee.accountID !== undefined) { - return attendee.accountID; - } + const chatLastActorAccountID = Number(chatReport?.lastActorAccountID); + const validPendingScanActorAccountIDs = new Set([action?.childOwnerAccountID, action?.childManagerAccountID, iouReport?.ownerAccountID, iouReport?.managerID].filter(Boolean)); - if (attendee.email) { - return attendee.email.toLowerCase(); + if ( + Number.isFinite(chatLastActorAccountID) && + chatLastActorAccountID > 0 && + chatLastActorAccountID !== CONST.ACCOUNT_ID.CONCIERGE && + validPendingScanActorAccountIDs.has(chatLastActorAccountID) + ) { + return chatLastActorAccountID; } - if (attendee.login) { - return attendee.login.toLowerCase(); - } + const previewLastActorAccountID = Number(action?.childLastActorAccountID); - if (attendee.reportID) { - return attendee.reportID; - } - - if (attendee.displayName) { - return attendee.displayName.toLowerCase(); - } - - if (attendee.text) { - return attendee.text.toLowerCase(); + if (!Number.isFinite(previewLastActorAccountID) || previewLastActorAccountID <= 0) { + return undefined; } - return undefined; + return previewLastActorAccountID; } function getAccountIDFromTransactionDirection(transaction: Transaction, action: OnyxEntry, iouReport: OnyxEntry): number | undefined { @@ -123,117 +103,29 @@ function getAccountIDFromTransactionDirection(transaction: Transaction, action: return undefined; } - return directionSign > 0 ? normalizeAccountID(action?.childOwnerAccountID ?? iouReport?.ownerAccountID) : normalizeAccountID(action?.childManagerAccountID ?? iouReport?.managerID); -} + const accountID = Number(directionSign > 0 ? (action?.childOwnerAccountID ?? iouReport?.ownerAccountID) : (action?.childManagerAccountID ?? iouReport?.managerID)); -function getLastActorAccountIDForReceiptBackedUnknown( - transaction: Transaction, - action: OnyxEntry, - chatReport: OnyxEntry, - iouReport: OnyxEntry, - receiptBackedUnknownTransactionCount: number, -): number | undefined { - if (hasPendingScanStateAndUnknownDirection(transaction)) { - const previewLastActorAccountID = normalizeAccountID(action?.childLastActorAccountID); - - if (previewLastActorAccountID !== undefined) { - return previewLastActorAccountID; - } - - const chatLastActorAccountID = normalizeAccountID(chatReport?.lastActorAccountID); - - if (chatLastActorAccountID !== undefined && chatLastActorAccountID !== CONST.ACCOUNT_ID.CONCIERGE) { - return chatLastActorAccountID; - } - - return normalizeAccountID(iouReport?.lastActorAccountID); - } - - if (!hasNonPendingReceiptBackedUnknownDirection(transaction) || receiptBackedUnknownTransactionCount !== 1) { + if (!Number.isFinite(accountID) || accountID <= 0) { return undefined; } - return normalizeAccountID(action?.childLastActorAccountID ?? iouReport?.lastActorAccountID); -} - -function shouldBackfillReceiptBackedUnknownTransactions( - transactions: Transaction[] | undefined, - transactionActorAccountIDs: Array | undefined, - fallbackActorAccountID: number | undefined, -): fallbackActorAccountID is number { - if (!transactions || !transactionActorAccountIDs || fallbackActorAccountID === undefined) { - return false; - } - - const knownActorAccountIDs = transactionActorAccountIDs.filter((accountID): accountID is number => accountID !== undefined); - if (knownActorAccountIDs.length === 0 || knownActorAccountIDs.some((accountID) => accountID !== fallbackActorAccountID)) { - return false; - } - - const transactionsMissingActor = transactions.filter((transaction, index) => transactionActorAccountIDs.at(index) === undefined); - return transactionsMissingActor.length > 0 && transactionsMissingActor.every(hasNonPendingReceiptBackedUnknownDirection); -} - -function canInferFromTransactionsDuringPartialHydration( - transactions: Transaction[] | undefined, - transactionActorAccountIDs: Array | undefined, - fallbackActorAccountID: number | undefined, - childRecentReceiptTransactionIDs: Record | undefined, - missingTransactionCount: number, -): boolean { - if (!transactions || !transactionActorAccountIDs || fallbackActorAccountID === undefined || missingTransactionCount < 1) { - return false; - } - - const uniqueKnownActorAccountIDs = new Set(transactionActorAccountIDs.filter((accountID): accountID is number => accountID !== undefined)); - - if (uniqueKnownActorAccountIDs.size !== 1 || uniqueKnownActorAccountIDs.has(fallbackActorAccountID) === false) { - return false; - } - - if (transactions.some((transaction, index) => transactionActorAccountIDs.at(index) === undefined && !hasNonPendingReceiptBackedUnknownDirection(transaction))) { - return false; - } - - return Object.keys(childRecentReceiptTransactionIDs ?? {}).length >= missingTransactionCount; -} - -function shouldPreferFallbackActorForReceiptBackedUnknownTransactions( - transactions: Transaction[] | undefined, - directionBasedActorAccountIDs: Array | undefined, - fallbackActorAccountID: number | undefined, - hasCompleteActionCoverage: boolean, -): fallbackActorAccountID is number { - if (!transactions || !directionBasedActorAccountIDs || fallbackActorAccountID === undefined || hasCompleteActionCoverage) { - return false; - } - - if (transactions.some(hasPendingScanStateAndUnknownDirection)) { - return false; - } - - const knownDirectionActorAccountIDs = transactions - .map((transaction, index) => ({transaction, actorAccountID: directionBasedActorAccountIDs.at(index)})) - .filter(({transaction, actorAccountID}) => !hasReceiptBackedUnknownDirection(transaction) && actorAccountID !== undefined) - .map(({actorAccountID}) => actorAccountID); - - return knownDirectionActorAccountIDs.length > 0 && knownDirectionActorAccountIDs.every((accountID) => accountID === fallbackActorAccountID); + return accountID; } function isExplicitlyDeletedIOUAction(iouAction: ReportAction): boolean { const originalMessage = getOriginalMessage(iouAction) as OriginalMessageIOU | undefined; - if (iouAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || originalMessage?.deleted || isDeletedParentAction(iouAction)) { + if (originalMessage?.deleted || isDeletedParentAction(iouAction)) { return true; } const message = iouAction.message; if (Array.isArray(message)) { - return message.length === 0 || message.some((fragment) => !!fragment?.deleted || (fragment?.html === '' && !iouAction.isOptimisticAction)); + return message.length === 0 || message.some((fragment) => !!fragment?.deleted || fragment?.html === ''); } - return !!message?.deleted || (message?.html === '' && !iouAction.isOptimisticAction); + return !!message?.deleted || message?.html === ''; } type GetReportPreviewSenderIDParams = { @@ -283,55 +175,23 @@ function getReportPreviewSenderID({ const hasCompleteActionCoverage = activeMoneyRequestCount > 0 && uniqueIOUActionActorMap.size >= activeMoneyRequestCount; const areAllActiveChildRequestsCreatedByOneActor = uniqueIOUActionActorMap.size > 0 && new Set(uniqueIOUActionActorMap.values()).size < 2; const canInferFromIOUActionsDuringPartialHydration = loadedTransactionCount > 0 && hasCompleteActionCoverage && activeIOUActions.length > 0 && areAllActiveChildRequestsCreatedByOneActor; - const attendeeIdentifierGroups = - transactions?.map((transaction) => - convertAttendeesToArray(transaction.comment?.attendees) - .map((attendee) => - transaction.comment?.source === CONST.IOU.TYPE.SPLIT - ? getSplitAuthor(transaction, splits) - : (getPersonalDetailByEmail(attendee.email)?.accountID ?? getAttendeeIdentifier(attendee)), - ) - .filter((identifier): identifier is number | string => !!identifier), - ) ?? []; - const transactionSigns = transactions?.map((transaction) => getTransactionDirectionSign(transaction)) ?? []; - const hasAttendeeIdentifierForEachTransaction = - transactions === undefined || (attendeeIdentifierGroups.length === transactions.length && attendeeIdentifierGroups.every((identifiers) => identifiers.length > 0)); - const receiptBackedUnknownTransactionCount = transactions?.filter(hasReceiptBackedUnknownDirection).length ?? 0; - const missingTransactionCount = Math.max(activeMoneyRequestCount - loadedTransactionCount, 0); - const receiptBackedUnknownFallbackActorAccountID = normalizeAccountID(action?.childLastActorAccountID ?? iouReport?.lastActorAccountID); - const directionBasedActorAccountIDs = transactions?.map((transaction) => getAccountIDFromTransactionDirection(transaction, action, iouReport)) ?? []; - const shouldPreferFallbackActorForReceiptBackedUnknown = shouldPreferFallbackActorForReceiptBackedUnknownTransactions( - transactions, - directionBasedActorAccountIDs, - receiptBackedUnknownFallbackActorAccountID, - hasCompleteActionCoverage, - ); - const initialTransactionActorAccountIDs = - transactions?.map( - (transaction, index) => - (shouldPreferFallbackActorForReceiptBackedUnknown && hasReceiptBackedUnknownDirection(transaction) - ? receiptBackedUnknownFallbackActorAccountID - : getIOUActionForTransactionID(activeIOUActions, transaction.transactionID)?.actorAccountID) ?? - directionBasedActorAccountIDs.at(index) ?? - getLastActorAccountIDForReceiptBackedUnknown(transaction, action, chatReport, iouReport, receiptBackedUnknownTransactionCount), - ) ?? []; - const transactionActorAccountIDs = shouldBackfillReceiptBackedUnknownTransactions(transactions, initialTransactionActorAccountIDs, receiptBackedUnknownFallbackActorAccountID) - ? initialTransactionActorAccountIDs.map((accountID) => accountID ?? receiptBackedUnknownFallbackActorAccountID) - : initialTransactionActorAccountIDs; - const hasActorAccountIDForEachTransaction = - !!transactionActorAccountIDs && transactionActorAccountIDs.length > 0 && transactionActorAccountIDs.every((accountID) => accountID !== undefined); - const canInferFromTransactionDataDuringPartialHydration = canInferFromTransactionsDuringPartialHydration( - transactions, - transactionActorAccountIDs, - receiptBackedUnknownFallbackActorAccountID, - action?.childRecentReceiptTransactionIDs, - missingTransactionCount, - ); - if (!hasFinishedInitialReportActionsLoad && missingTransactionCount > 0 && !canInferFromIOUActionsDuringPartialHydration && !canInferFromTransactionDataDuringPartialHydration) { + + // After refresh, the preview action can hydrate before all active child transactions. + // Avoid collapsing to one avatar unless the available IOU actions already prove the remaining + // active requests all belong to the same sender. + if (!hasFinishedInitialReportActionsLoad && activeMoneyRequestCount > loadedTransactionCount && !canInferFromIOUActionsDuringPartialHydration) { return undefined; } - // 1. Use the transaction creator when it can be inferred for every transaction. + const transactionActorAccountIDs = transactions?.map((transaction) => { + return getIOUActionForTransactionID(activeIOUActions, transaction.transactionID)?.actorAccountID ?? getPendingScanActorAccountID(transaction, action, iouReport, chatReport); + }); + const transactionSigns = transactions?.map((transaction) => getTransactionDirectionSign(transaction)) ?? []; + + const hasActorAccountIDForEachTransaction = + activeIOUActions.length > 0 && !!transactionActorAccountIDs && transactionActorAccountIDs.length > 0 && transactionActorAccountIDs.every((accountID) => accountID !== undefined); + + // 1. Use actorAccountID when it is available for every transaction. Otherwise, fall back to known transaction direction only. if (hasActorAccountIDForEachTransaction) { const areAllTransactionsCreatedByOneActor = new Set(transactionActorAccountIDs).size < 2; @@ -339,21 +199,29 @@ function getReportPreviewSenderID({ return undefined; } } else { - // 1. If actor data is unavailable, fall back to transaction direction. - // We use amount sign here because there can be cases where actions are not available. + // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative. + // We have to do it this way because there can be a case when actions are not available // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 const transactionsWithUnknownDirection = (transactions ?? []).filter((transaction, index) => transactionSigns.at(index) === undefined); const hasUnknownDirection = transactionSigns.some((sign) => sign === undefined); const unknownDirectionComesOnlyFromPendingScans = transactionsWithUnknownDirection.length > 0 && transactionsWithUnknownDirection.every(hasPendingScanStateAndUnknownDirection); - const unknownDirectionComesOnlyFromReceiptBackedTransactions = - hasAttendeeIdentifierForEachTransaction && transactionsWithUnknownDirection.length > 0 && transactionsWithUnknownDirection.every(hasReceiptBackedUnknownDirection); const hasOnlyUnknownDirections = transactionSigns.length > 0 && transactionSigns.every((sign) => sign === undefined); const hasOnlyUnknownNonPendingScanDirections = - hasOnlyUnknownDirections && - transactionsWithUnknownDirection.every((transaction) => !hasPendingScanStateAndUnknownDirection(transaction)) && - hasAttendeeIdentifierForEachTransaction; + hasOnlyUnknownDirections && transactionsWithUnknownDirection.every((transaction) => !hasPendingScanStateAndUnknownDirection(transaction)); + + if (unknownDirectionComesOnlyFromPendingScans) { + const inferredActorAccountIDs = (transactions ?? []) + .map((transaction, index) => { + return transactionActorAccountIDs?.at(index) ?? getAccountIDFromTransactionDirection(transaction, action, iouReport); + }) + .filter((accountID): accountID is number => accountID !== undefined); + + if (new Set(inferredActorAccountIDs).size > 1) { + return undefined; + } + } - if (hasUnknownDirection && !unknownDirectionComesOnlyFromPendingScans && !unknownDirectionComesOnlyFromReceiptBackedTransactions && !hasOnlyUnknownNonPendingScanDirections) { + if (hasUnknownDirection && !unknownDirectionComesOnlyFromPendingScans && !hasOnlyUnknownNonPendingScanDirections) { return undefined; } @@ -373,8 +241,16 @@ function getReportPreviewSenderID({ // 2. If there is only one attendee - we check that by counting unique emails converted to account IDs in the attendees list. // This is a fallback added because: https://github.com/Expensify/App/pull/64802#issuecomment-3007906310 - const attendeeIdentifiers = attendeeIdentifierGroups.flatMap((identifiers) => identifiers); - const isThereOnlyOneAttendee = new Set(attendeeIdentifiers).size <= 1; + const attendeesIDs = transactions + // If the transaction is a split, then attendees are not present as a property so we need to use a helper function. + ?.flatMap((tr) => + convertAttendeesToArray(tr.comment?.attendees).map((att) => + tr.comment?.source === CONST.IOU.TYPE.SPLIT ? getSplitAuthor(tr, splits) : getPersonalDetailByEmail(att.email)?.accountID, + ), + ) + .filter((accountID) => !!accountID); + + const isThereOnlyOneAttendee = new Set(attendeesIDs).size <= 1; if (!isThereOnlyOneAttendee) { return undefined; @@ -389,9 +265,8 @@ function getReportPreviewSenderID({ const isSendMoneyFlow = activeIOUActions.length > 0 ? isSendMoneyFlowBasedOnActions : isSendMoneyFlowBasedOnTransactions; - const singleAvatarAccountID = isSendMoneyFlow - ? normalizeAccountID(action?.childManagerAccountID ?? iouReport?.managerID) - : normalizeAccountID(action?.childOwnerAccountID ?? iouReport?.ownerAccountID); + const singleAvatarAccountID = isSendMoneyFlow ? action?.childManagerAccountID : action?.childOwnerAccountID; + return singleAvatarAccountID; } @@ -404,12 +279,13 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(shouldFetchData ? iouReport?.reportID : undefined)}`, { selector: getIOUActionsSelector, }); - const [hasFinishedInitialReportActionsLoad] = useOnyx( - `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(shouldFetchData ? action?.childReportID : undefined)}`, - { - selector: (state) => hasOnceLoadedReportActionsSelector(state) === true || isLoadingInitialReportActionsSelector(state) === false, - }, - ); + const [hasOnceLoadedReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(shouldFetchData ? action?.childReportID : undefined)}`, { + selector: hasOnceLoadedReportActionsSelector, + }); + const [isLoadingInitialReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${getNonEmptyStringOnyxID(shouldFetchData ? action?.childReportID : undefined)}`, { + selector: isLoadingInitialReportActionsSelector, + }); + const hasFinishedInitialReportActionsLoad = hasOnceLoadedReportActions === true || isLoadingInitialReportActions === false; const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(shouldFetchData ? action?.childReportID : undefined); const transactions = useMemo(() => { @@ -418,8 +294,6 @@ function useReportPreviewSenderID({iouReport, action, chatReport}: {action: Onyx } const activeMoneyRequestCount = iouReport?.transactionCount ?? action?.childMoneyRequestCount ?? 0; const allReportTransactions = Object.values(reportTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction); - // Start with orphan-inclusive filtering so refreshed receipt-backed expenses are not dropped too early, - // then fall back to the stricter path only when it does not reduce the active request count too far. const nonDeletedTransactionsIncludingOrphans = getAllNonDeletedTransactions(reportTransactions, iouActions ?? [], false, true); const filteredTransactions = nonDeletedTransactionsIncludingOrphans.length < allReportTransactions.length diff --git a/tests/ui/ReportActionAvatarsTest.tsx b/tests/ui/ReportActionAvatarsTest.tsx index dd86d1080d89..cf7f1c9bef50 100644 --- a/tests/ui/ReportActionAvatarsTest.tsx +++ b/tests/ui/ReportActionAvatarsTest.tsx @@ -187,7 +187,6 @@ const iouTripReport = { const iouActionOne = { ...actionR14932, - actorAccountID: LOGGED_USER_ID, originalMessage: { ...getOriginalMessage(actionR14932), IOUTransactionID: 'TRANSACTION_NUMBER_ONE', @@ -197,7 +196,6 @@ const iouActionOne = { const iouActionTwo = { ...actionR14932, - actorAccountID: SECOND_USER_ID, originalMessage: { ...getOriginalMessage(actionR14932), IOUTransactionID: 'TRANSACTION_NUMBER_TWO', @@ -207,7 +205,6 @@ const iouActionTwo = { const iouActionThree = { ...actionR14932, - actorAccountID: LOGGED_USER_ID, originalMessage: { ...getOriginalMessage(actionR14932), IOUTransactionID: 'TRANSACTION_NUMBER_THREE', @@ -253,11 +250,11 @@ const reportActionCollectionDataSet = { [reportPreviewDMAction.reportActionID]: reportPreviewDMAction, [reportPreviewSingleTransactionDMAction.reportActionID]: reportPreviewSingleTransactionDMAction, }, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouDMReport.reportID}`]: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportPreviewDMAction.reportID}`]: { [iouActionOne.reportActionID]: iouActionOne, [iouActionTwo.reportActionID]: iouActionTwo, }, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouDMSingleExpenseReport.reportID}`]: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportPreviewSingleTransactionDMAction.reportID}`]: { [iouActionThree.reportActionID]: iouActionThree, }, }; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index a5576a84fc1b..33347568b888 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10689,8 +10689,10 @@ describe('ReportUtils', () => { expect(reportPreviewAction.childOwnerAccountID).toBe(iouReport.ownerAccountID); expect(reportPreviewAction.childManagerAccountID).toBe(iouReport.managerID); }); + }); - it('should refresh childLastActorAccountID when adding a new expense to an existing preview', () => { + describe('updateReportPreview', () => { + it('refreshes childLastActorAccountID when a new non-pay expense is added', () => { const chatReport: Report = { ...createRandomReport(100, undefined), type: CONST.REPORT.TYPE.CHAT, @@ -10704,18 +10706,15 @@ describe('ReportUtils', () => { managerID: 2, }; - const initialPreview = { - ...buildOptimisticReportPreview(chatReport, iouReport), - childLastActorAccountID: undefined, - }; - - const updatedPreview = updateReportPreview(iouReport, initialPreview, false, '', { - transactionID: '123', - created: '2026-05-11 10:30:00', - receipt: {source: 'receipt.png'}, + const reportPreviewAction = buildOptimisticReportPreview(chatReport, iouReport); + const updatedPreviewAction = updateReportPreview(iouReport, reportPreviewAction, false, '', { + transactionID: 'transaction-1', + amount: 0, + created: '2026-05-19 10:00:00', + receipt: {source: 'receipt.jpg'}, } as Transaction); - expect(updatedPreview.childLastActorAccountID).toBe(currentUserAccountID); + expect(updatedPreviewAction.childLastActorAccountID).toBe(currentUserAccountID); }); }); diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index 5518bb8c7469..e738b7886d37 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -84,8 +84,8 @@ describe('getReportPreviewSenderID', () => { }); it('returns childOwnerAccountID when all transactions map to IOU actions from the same actor', () => { - const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: '123'}); - const transaction2 = makeTransaction(200, 'user1@test.com', {transactionID: '321'}); + const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: 'tr-1'}); + const transaction2 = makeTransaction(200, 'user2@test.com', {transactionID: 'tr-2'}); const result = getReportPreviewSenderID({ ...baseParams, @@ -96,7 +96,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: '123', + IOUTransactionID: 'tr-1', amount: 100, currency: 'USD', }, @@ -105,7 +105,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: '321', + IOUTransactionID: 'tr-2', amount: 200, currency: 'USD', }, @@ -118,8 +118,8 @@ describe('getReportPreviewSenderID', () => { }); it('returns undefined when transactions map to IOU actions from different actors', () => { - const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: '123'}); - const transaction2 = makeTransaction(200, 'user1@test.com', {transactionID: '321'}); + const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: 'tr-1'}); + const transaction2 = makeTransaction(200, 'user2@test.com', {transactionID: 'tr-2'}); const result = getReportPreviewSenderID({ ...baseParams, @@ -130,7 +130,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: '123', + IOUTransactionID: 'tr-1', amount: 100, currency: 'USD', }, @@ -139,7 +139,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 20, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: '321', + IOUTransactionID: 'tr-2', amount: 200, currency: 'USD', }, @@ -156,7 +156,7 @@ describe('getReportPreviewSenderID', () => { originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUDetails: {amount: 100, comment: '', currency: 'USD'}, - IOUTransactionID: '123', + IOUTransactionID: 'tr-1', amount: 100, currency: 'USD', }, @@ -232,9 +232,9 @@ describe('getReportPreviewSenderID', () => { action: makeAction({childMoneyRequestCount: 2}), iouActions: [], transactions: [ - makeTransaction(1200, 'user@test.com', {transactionID: '123'}), + makeTransaction(1200, 'user@test.com', {transactionID: 'tr-1'}), makeTransaction(0, 'user@test.com', { - transactionID: '321', + transactionID: 'tr-2', iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, modifiedAmount: 50000, modifiedCreated: '2021-03-18 00:00:00', @@ -248,215 +248,75 @@ describe('getReportPreviewSenderID', () => { expect(result).toBe(OWNER_ACCOUNT_ID); }); - it('returns childOwnerAccountID after cache clear when one sender has a manual expense and one failed receipt without attendees', () => { + it('returns childOwnerAccountID when a pending scan belongs to the same sender as the hydrated transactions', () => { const result = getReportPreviewSenderID({ ...baseParams, - iouReport: makeIOUReport({ - transactionCount: 2, - ownerAccountID: OWNER_ACCOUNT_ID, - managerID: MANAGER_ACCOUNT_ID, - lastActorAccountID: OWNER_ACCOUNT_ID, - }), - action: makeAction({ - childMoneyRequestCount: 2, - childLastActorAccountID: undefined, - }), - iouActions: [], - transactions: [ - makeTransaction(1200, 'user@test.com', { - transactionID: '123', - comment: undefined, - }), - makeTransaction(0, 'user@test.com', { - transactionID: '321', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + iouReport: makeIOUReport({transactionCount: 3}), + action: makeAction({childMoneyRequestCount: 3, childLastActorAccountID: 10}), + iouActions: [ + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-1', + amount: 100, + currency: 'USD', }, }), - ], - }); - - expect(result).toBe(OWNER_ACCOUNT_ID); - }); - - it('returns childOwnerAccountID when the preview action is unavailable and iouReport.lastActorAccountID is rehydrated as a string', () => { - const result = getReportPreviewSenderID({ - ...baseParams, - iouReport: makeIOUReport({ - transactionCount: 2, - ownerAccountID: OWNER_ACCOUNT_ID, - managerID: MANAGER_ACCOUNT_ID, - lastActorAccountID: `${OWNER_ACCOUNT_ID}` as unknown as number, - }), - action: undefined, - iouActions: [], - transactions: [ - makeTransaction(1200, 'user@test.com', { - transactionID: '123', - comment: undefined, - }), - makeTransaction(0, 'user@test.com', { - transactionID: '321', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-2', + amount: 100, + currency: 'USD', }, }), ], - }); - - expect(result).toBe(OWNER_ACCOUNT_ID); - }); - - it('returns undefined after cache clear when the failed receipt was created by the other participant', () => { - const result = getReportPreviewSenderID({ - ...baseParams, - iouReport: makeIOUReport({ - transactionCount: 2, - ownerAccountID: OWNER_ACCOUNT_ID, - managerID: MANAGER_ACCOUNT_ID, - lastActorAccountID: MANAGER_ACCOUNT_ID, - }), - action: makeAction({ - childMoneyRequestCount: 2, - childLastActorAccountID: undefined, - }), - iouActions: [], transactions: [ - makeTransaction(1200, 'user@test.com', { - transactionID: '123', - comment: undefined, - }), + makeTransaction(1200, 'user@test.com', {transactionID: 'tr-1'}), + makeTransaction(0, 'user@test.com', {transactionID: 'tr-2', modifiedAmount: 50000, receipt: {source: 'receipt.jpg'}}), makeTransaction(0, 'user@test.com', { - transactionID: '321', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, - }, + transactionID: 'tr-3', + receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, }), ], }); - expect(result).toBeUndefined(); + expect(result).toBe(OWNER_ACCOUNT_ID); }); - it('returns undefined when all transactions have failed receipt states and no sender-identifying data', () => { + it('returns undefined when a pending scan belongs to a different sender than the hydrated transactions', () => { const result = getReportPreviewSenderID({ ...baseParams, - iouReport: makeIOUReport({transactionCount: 2}), - action: makeAction({childMoneyRequestCount: 2}), - iouActions: [], - transactions: [ - makeTransaction(0, 'user@test.com', { - transactionID: '123', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt-1.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + iouReport: makeIOUReport({transactionCount: 3}), + action: makeAction({childMoneyRequestCount: 3, childLastActorAccountID: 20}), + iouActions: [ + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-1', + amount: 100, + currency: 'USD', }, }), - makeTransaction(0, 'user@test.com', { - transactionID: '321', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt-2.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-2', + amount: 100, + currency: 'USD', }, }), ], - }); - - expect(result).toBeUndefined(); - }); - - it('returns childOwnerAccountID when multiple failed receipts belong to the same sender and known transactions already anchor that sender', () => { - const result = getReportPreviewSenderID({ - ...baseParams, - iouReport: makeIOUReport({ - transactionCount: 3, - ownerAccountID: OWNER_ACCOUNT_ID, - managerID: MANAGER_ACCOUNT_ID, - lastActorAccountID: OWNER_ACCOUNT_ID, - }), - action: makeAction({ - childMoneyRequestCount: 3, - childLastActorAccountID: undefined, - }), - iouActions: [], transactions: [ - makeTransaction(1200, 'user@test.com', { - transactionID: '123', - comment: undefined, - }), - makeTransaction(0, 'user@test.com', { - transactionID: '321', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt-1.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, - }, - }), + makeTransaction(1200, 'user@test.com', {transactionID: 'tr-1'}), + makeTransaction(0, 'user@test.com', {transactionID: 'tr-2', modifiedAmount: 50000, receipt: {source: 'receipt.jpg'}}), makeTransaction(0, 'user@test.com', { - transactionID: '456', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt-2.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, - }, - }), - ], - }); - - expect(result).toBe(OWNER_ACCOUNT_ID); - }); - - it('returns undefined when multiple failed receipts exist but the latest actor does not match the known sender anchor', () => { - const result = getReportPreviewSenderID({ - ...baseParams, - iouReport: makeIOUReport({ - transactionCount: 3, - ownerAccountID: OWNER_ACCOUNT_ID, - managerID: MANAGER_ACCOUNT_ID, - lastActorAccountID: MANAGER_ACCOUNT_ID, - }), - action: makeAction({ - childMoneyRequestCount: 3, - childLastActorAccountID: undefined, - }), - iouActions: [], - transactions: [ - makeTransaction(1200, 'user@test.com', { - transactionID: '123', - comment: undefined, - }), - makeTransaction(0, 'user@test.com', { - transactionID: '321', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt-1.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, - }, - }), - makeTransaction(0, 'user@test.com', { - transactionID: '456', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt-2.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, - }, + transactionID: 'tr-3', + receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, }), ], }); @@ -464,210 +324,86 @@ describe('getReportPreviewSenderID', () => { expect(result).toBeUndefined(); }); - it('returns undefined when the report preview has not loaded all child transactions yet', () => { - const result = getReportPreviewSenderID({ - ...baseParams, - iouReport: makeIOUReport(), - action: makeAction({childMoneyRequestCount: 2}), - iouActions: [], - transactions: [makeTransaction(100)], - }); - - expect(result).toBeUndefined(); - }); + it('returns undefined when chatReport last actor shows the pending scan belongs to the other participant', () => { + const dmChat: Report = { + reportID: 'dm-1', + type: CONST.REPORT.TYPE.CHAT, + lastActorAccountID: MANAGER_ACCOUNT_ID, + participants: { + [OWNER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [MANAGER_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + } as Report; - it('returns childOwnerAccountID during partial hydration when one recent receipt-backed transaction is still missing and the loaded transactions already anchor the same sender', () => { const result = getReportPreviewSenderID({ ...baseParams, - iouReport: makeIOUReport({ - transactionCount: 4, - ownerAccountID: OWNER_ACCOUNT_ID, - managerID: MANAGER_ACCOUNT_ID, - lastActorAccountID: OWNER_ACCOUNT_ID, - }), - action: makeAction({ - childMoneyRequestCount: 4, - childLastActorAccountID: OWNER_ACCOUNT_ID, - childRecentReceiptTransactionIDs: Object.fromEntries([ - ['321', '2026-05-11 10:30:00'], - ['456', '2026-05-11 10:31:00'], - ]), - }), - hasFinishedInitialReportActionsLoad: false, - iouActions: [], - transactions: [ - makeTransaction(1200, 'user@test.com', { - transactionID: '123', - comment: undefined, - }), - makeTransaction(0, 'user@test.com', { - transactionID: '321', - comment: undefined, - modifiedAmount: 50000, - receipt: { - source: 'receipt-complete.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE, - }, - }), - makeTransaction(0, 'user@test.com', { - transactionID: '456', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt-failed.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, + chatReport: dmChat, + iouReport: makeIOUReport({transactionCount: 2, ownerAccountID: OWNER_ACCOUNT_ID, managerID: MANAGER_ACCOUNT_ID}), + action: makeAction({childMoneyRequestCount: 2, childLastActorAccountID: OWNER_ACCOUNT_ID}), + iouActions: [ + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: OWNER_ACCOUNT_ID, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'tr-1', + amount: 100, + currency: 'USD', }, }), ], - }); - - expect(result).toBe(OWNER_ACCOUNT_ID); - }); - - it('returns childOwnerAccountID during partial hydration when multiple recent receipt-backed transactions are still missing and the loaded transactions already anchor the same sender', () => { - const result = getReportPreviewSenderID({ - ...baseParams, - iouReport: makeIOUReport({ - transactionCount: 4, - ownerAccountID: OWNER_ACCOUNT_ID, - managerID: MANAGER_ACCOUNT_ID, - lastActorAccountID: OWNER_ACCOUNT_ID, - }), - action: makeAction({ - childMoneyRequestCount: 4, - childLastActorAccountID: OWNER_ACCOUNT_ID, - childRecentReceiptTransactionIDs: Object.fromEntries([ - ['321', '2026-05-11 10:30:00'], - ['456', '2026-05-11 10:31:00'], - ]), - }), - hasFinishedInitialReportActionsLoad: false, - iouActions: [], transactions: [ - makeTransaction(1200, 'user@test.com', { - transactionID: '123', - comment: undefined, - }), + makeTransaction(1200, 'user@test.com', {transactionID: 'tr-1'}), makeTransaction(0, 'user@test.com', { - transactionID: '789', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'loaded-receipt.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, - }, + transactionID: 'tr-2', + receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, }), ], }); - expect(result).toBe(OWNER_ACCOUNT_ID); + expect(result).toBeUndefined(); }); - it('returns childOwnerAccountID when incomplete IOU action coverage conflicts with anchored direction-based actors for a failed receipt transaction', () => { + it('returns undefined when a pending scan actor conflicts with the manual transaction direction actor', () => { const result = getReportPreviewSenderID({ ...baseParams, - iouReport: makeIOUReport({ - transactionCount: 6, - ownerAccountID: OWNER_ACCOUNT_ID, - managerID: MANAGER_ACCOUNT_ID, - }), + iouReport: makeIOUReport({transactionCount: 2, ownerAccountID: OWNER_ACCOUNT_ID, managerID: MANAGER_ACCOUNT_ID}), action: makeAction({ - childMoneyRequestCount: 6, - childLastActorAccountID: OWNER_ACCOUNT_ID, - childRecentReceiptTransactionIDs: Object.fromEntries([['789', '2026-05-11 10:24:41']]), + childMoneyRequestCount: 2, + childOwnerAccountID: OWNER_ACCOUNT_ID, + childManagerAccountID: MANAGER_ACCOUNT_ID, }), iouActions: [ makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { actorAccountID: MANAGER_ACCOUNT_ID, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: '789', + IOUTransactionID: 'tr-2', amount: 0, currency: 'USD', }, }), ], transactions: [ - makeTransaction(0, 'user@test.com', { - transactionID: '111', - comment: undefined, - modifiedAmount: 50000, - receipt: { - source: 'receipt-1.jpg', - state: CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE, - }, - }), - makeTransaction(2300, 'user@test.com', { - transactionID: '222', - comment: undefined, - }), - makeTransaction(0, 'user@test.com', { - transactionID: '333', - comment: undefined, - modifiedAmount: 50000, - receipt: { - source: 'receipt-2.jpg', - state: CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE, - }, - }), - makeTransaction(4300, 'user@test.com', { - transactionID: '444', - comment: undefined, - }), - makeTransaction(0, 'user@test.com', { - transactionID: '555', - comment: undefined, - modifiedAmount: 3848, - receipt: { - source: 'receipt-3.jpg', - state: CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE, - }, + makeTransaction(1200, 'user@test.com', { + transactionID: 'tr-1', }), makeTransaction(0, 'user@test.com', { - transactionID: '789', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt-failed.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, - }, + transactionID: 'tr-2', + receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}, }), ], }); - expect(result).toBe(OWNER_ACCOUNT_ID); + expect(result).toBeUndefined(); }); - it('returns undefined during partial hydration when the missing transaction case does not have recent receipt metadata to support sender inference', () => { + it('returns undefined when the report preview has not loaded all child transactions yet', () => { const result = getReportPreviewSenderID({ ...baseParams, - iouReport: makeIOUReport({ - transactionCount: 3, - ownerAccountID: OWNER_ACCOUNT_ID, - managerID: MANAGER_ACCOUNT_ID, - lastActorAccountID: OWNER_ACCOUNT_ID, - }), - action: makeAction({ - childMoneyRequestCount: 3, - childLastActorAccountID: OWNER_ACCOUNT_ID, - }), - hasFinishedInitialReportActionsLoad: false, + iouReport: makeIOUReport(), + action: makeAction({childMoneyRequestCount: 2}), iouActions: [], - transactions: [ - makeTransaction(1200, 'user@test.com', { - transactionID: '123', - comment: undefined, - }), - makeTransaction(0, 'user@test.com', { - transactionID: '321', - comment: undefined, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - receipt: { - source: 'receipt-failed.png', - state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED, - }, - }), - ], + transactions: [makeTransaction(100)], }); expect(result).toBeUndefined(); @@ -683,7 +419,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: '123', + IOUTransactionID: 'tr-active', amount: 100, currency: 'USD', }, @@ -692,7 +428,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 20, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: '321', + IOUTransactionID: 'tr-deleted', amount: 100, currency: 'USD', deleted: '2026-04-11 07:12:23.697', @@ -700,7 +436,7 @@ describe('getReportPreviewSenderID', () => { message: [{type: 'COMMENT', text: 'Deleted expense', deleted: '2026-04-11 07:12:23.697'}], }), ], - transactions: [makeTransaction(100, 'user@test.com', {transactionID: '123'})], + transactions: [makeTransaction(100, 'user@test.com', {transactionID: 'tr-active'})], }); expect(result).toBe(OWNER_ACCOUNT_ID); @@ -716,7 +452,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: '123', + IOUTransactionID: 'tr-active', amount: 100, currency: 'USD', }, @@ -732,7 +468,7 @@ describe('getReportPreviewSenderID', () => { message: [{type: 'COMMENT', text: '', deleted: '2026-04-12 04:48:48.212'}], }), ], - transactions: [makeTransaction(100, 'user@test.com', {transactionID: '123'})], + transactions: [makeTransaction(100, 'user@test.com', {transactionID: 'tr-active'})], }); expect(result).toBe(OWNER_ACCOUNT_ID); @@ -743,7 +479,7 @@ describe('getReportPreviewSenderID', () => { // Since getPersonalDetailByEmail returns undefined in test (no Onyx), attendeesIDs will be filtered out // and the set size will be 0, which is <= 1, so we need to use splits to create multiple attendees const splitTr1: Transaction = { - transactionID: '123', + transactionID: 'tr-1', amount: 100, comment: { source: CONST.IOU.TYPE.SPLIT, @@ -753,7 +489,7 @@ describe('getReportPreviewSenderID', () => { } as Transaction; const splitTr2: Transaction = { - transactionID: '321', + transactionID: 'tr-2', amount: 100, comment: { source: CONST.IOU.TYPE.SPLIT, @@ -801,7 +537,7 @@ describe('getReportPreviewSenderID', () => { it('returns childOwnerAccountID for split transaction with single author', () => { const splitTr: Transaction = { - transactionID: '123', + transactionID: 'tr-1', amount: 100, comment: { source: CONST.IOU.TYPE.SPLIT, From c52150d3fdc4a7dfb7922cb61e48efa094ebc54b Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 19 May 2026 15:40:29 +0430 Subject: [PATCH 18/22] fix: polish review feedback --- .../useReportPreviewSenderID.ts | 6 ++--- tests/unit/getReportPreviewSenderIDTest.ts | 22 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 7b286c6edd2b..5679123d6d75 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -199,9 +199,6 @@ function getReportPreviewSenderID({ return undefined; } } else { - // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative. - // We have to do it this way because there can be a case when actions are not available - // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 const transactionsWithUnknownDirection = (transactions ?? []).filter((transaction, index) => transactionSigns.at(index) === undefined); const hasUnknownDirection = transactionSigns.some((sign) => sign === undefined); const unknownDirectionComesOnlyFromPendingScans = transactionsWithUnknownDirection.length > 0 && transactionsWithUnknownDirection.every(hasPendingScanStateAndUnknownDirection); @@ -231,6 +228,9 @@ function getReportPreviewSenderID({ return undefined; } + // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative. + // We have to do it this way because there can be a case when actions are not available. + // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 const areAmountsSignsTheSame = hasOnlyUnknownNonPendingScanDirections || new Set(knownTransactionSigns).size < 2; if (!areAmountsSignsTheSame) { diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index e738b7886d37..f445f2016fe2 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -84,8 +84,8 @@ describe('getReportPreviewSenderID', () => { }); it('returns childOwnerAccountID when all transactions map to IOU actions from the same actor', () => { - const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: 'tr-1'}); - const transaction2 = makeTransaction(200, 'user2@test.com', {transactionID: 'tr-2'}); + const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: '123'}); + const transaction2 = makeTransaction(200, 'user1@test.com', {transactionID: '321'}); const result = getReportPreviewSenderID({ ...baseParams, @@ -96,7 +96,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-1', + IOUTransactionID: '123', amount: 100, currency: 'USD', }, @@ -105,7 +105,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-2', + IOUTransactionID: '321', amount: 200, currency: 'USD', }, @@ -118,8 +118,8 @@ describe('getReportPreviewSenderID', () => { }); it('returns undefined when transactions map to IOU actions from different actors', () => { - const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: 'tr-1'}); - const transaction2 = makeTransaction(200, 'user2@test.com', {transactionID: 'tr-2'}); + const transaction1 = makeTransaction(100, 'user1@test.com', {transactionID: '123'}); + const transaction2 = makeTransaction(200, 'user1@test.com', {transactionID: '321'}); const result = getReportPreviewSenderID({ ...baseParams, @@ -130,7 +130,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-1', + IOUTransactionID: '123', amount: 100, currency: 'USD', }, @@ -139,7 +139,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 20, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-2', + IOUTransactionID: '321', amount: 200, currency: 'USD', }, @@ -419,7 +419,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-active', + IOUTransactionID: '987654321012345678', amount: 100, currency: 'USD', }, @@ -428,7 +428,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 20, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-deleted', + IOUTransactionID: '987654321012345679', amount: 100, currency: 'USD', deleted: '2026-04-11 07:12:23.697', @@ -436,7 +436,7 @@ describe('getReportPreviewSenderID', () => { message: [{type: 'COMMENT', text: 'Deleted expense', deleted: '2026-04-11 07:12:23.697'}], }), ], - transactions: [makeTransaction(100, 'user@test.com', {transactionID: 'tr-active'})], + transactions: [makeTransaction(100, 'user@test.com', {transactionID: '987654321012345678'})], }); expect(result).toBe(OWNER_ACCOUNT_ID); From b239a7acb7ef1b2aa7b940ed6db8ffddbf7170e3 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 20 May 2026 11:38:09 +0430 Subject: [PATCH 19/22] fix: improve deleted IOU action filtering --- .../ReportActionAvatars/useReportPreviewSenderID.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 5679123d6d75..d55bbdad5ced 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -115,17 +115,17 @@ function getAccountIDFromTransactionDirection(transaction: Transaction, action: function isExplicitlyDeletedIOUAction(iouAction: ReportAction): boolean { const originalMessage = getOriginalMessage(iouAction) as OriginalMessageIOU | undefined; - if (originalMessage?.deleted || isDeletedParentAction(iouAction)) { + if (originalMessage?.deleted || isDeletedParentAction(iouAction) || iouAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return true; } const message = iouAction.message; if (Array.isArray(message)) { - return message.length === 0 || message.some((fragment) => !!fragment?.deleted || fragment?.html === ''); + return message.some((fragment) => !!fragment?.deleted); } - return !!message?.deleted || message?.html === ''; + return !!message?.deleted; } type GetReportPreviewSenderIDParams = { From 66131c2a83dd3cfc232398210fbfb913eda6a164 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 21 May 2026 09:48:18 +0430 Subject: [PATCH 20/22] fix: prefer modifiedAmount for preview direction --- .../ReportActionAvatars/useReportPreviewSenderID.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index d55bbdad5ced..33450b7cfec2 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -41,16 +41,16 @@ const getSplitsSelector = (actions: OnyxEntry): Array Date: Thu, 21 May 2026 10:45:15 +0430 Subject: [PATCH 21/22] test: clean up review feedback --- tests/unit/ReportUtilsTest.ts | 2 +- tests/unit/getReportPreviewSenderIDTest.ts | 48 +++++++++++----------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 3ac2d2cbd555..133bde6b8e24 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10768,7 +10768,7 @@ describe('ReportUtils', () => { }); describe('updateReportPreview', () => { - it('refreshes childLastActorAccountID when a new non-pay expense is added', () => { + it('refreshes childLastActorAccountID when a new expense request is added', () => { const chatReport: Report = { ...createRandomReport(100, undefined), type: CONST.REPORT.TYPE.CHAT, diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index f445f2016fe2..59d573497f6f 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -156,7 +156,7 @@ describe('getReportPreviewSenderID', () => { originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUDetails: {amount: 100, comment: '', currency: 'USD'}, - IOUTransactionID: 'tr-1', + IOUTransactionID: '111', amount: 100, currency: 'USD', }, @@ -232,9 +232,9 @@ describe('getReportPreviewSenderID', () => { action: makeAction({childMoneyRequestCount: 2}), iouActions: [], transactions: [ - makeTransaction(1200, 'user@test.com', {transactionID: 'tr-1'}), + makeTransaction(1200, 'user@test.com', {transactionID: '567'}), makeTransaction(0, 'user@test.com', { - transactionID: 'tr-2', + transactionID: '890', iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, modifiedAmount: 50000, modifiedCreated: '2021-03-18 00:00:00', @@ -258,7 +258,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-1', + IOUTransactionID: '111', amount: 100, currency: 'USD', }, @@ -267,17 +267,17 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-2', + IOUTransactionID: '222', amount: 100, currency: 'USD', }, }), ], transactions: [ - makeTransaction(1200, 'user@test.com', {transactionID: 'tr-1'}), - makeTransaction(0, 'user@test.com', {transactionID: 'tr-2', modifiedAmount: 50000, receipt: {source: 'receipt.jpg'}}), + makeTransaction(1200, 'user@test.com', {transactionID: '111'}), + makeTransaction(0, 'user@test.com', {transactionID: '222', modifiedAmount: 50000, receipt: {source: 'receipt.jpg'}}), makeTransaction(0, 'user@test.com', { - transactionID: 'tr-3', + transactionID: '333', receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, }), ], @@ -296,7 +296,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-1', + IOUTransactionID: '111', amount: 100, currency: 'USD', }, @@ -305,17 +305,17 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-2', + IOUTransactionID: '222', amount: 100, currency: 'USD', }, }), ], transactions: [ - makeTransaction(1200, 'user@test.com', {transactionID: 'tr-1'}), - makeTransaction(0, 'user@test.com', {transactionID: 'tr-2', modifiedAmount: 50000, receipt: {source: 'receipt.jpg'}}), + makeTransaction(1200, 'user@test.com', {transactionID: '111'}), + makeTransaction(0, 'user@test.com', {transactionID: '222', modifiedAmount: 50000, receipt: {source: 'receipt.jpg'}}), makeTransaction(0, 'user@test.com', { - transactionID: 'tr-3', + transactionID: '333', receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, }), ], @@ -345,16 +345,16 @@ describe('getReportPreviewSenderID', () => { actorAccountID: OWNER_ACCOUNT_ID, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-1', + IOUTransactionID: '111', amount: 100, currency: 'USD', }, }), ], transactions: [ - makeTransaction(1200, 'user@test.com', {transactionID: 'tr-1'}), + makeTransaction(1200, 'user@test.com', {transactionID: '111'}), makeTransaction(0, 'user@test.com', { - transactionID: 'tr-2', + transactionID: '222', receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, }), ], @@ -377,7 +377,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: MANAGER_ACCOUNT_ID, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-2', + IOUTransactionID: '222', amount: 0, currency: 'USD', }, @@ -385,10 +385,10 @@ describe('getReportPreviewSenderID', () => { ], transactions: [ makeTransaction(1200, 'user@test.com', { - transactionID: 'tr-1', + transactionID: '111', }), makeTransaction(0, 'user@test.com', { - transactionID: 'tr-2', + transactionID: '222', receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}, }), ], @@ -452,7 +452,7 @@ describe('getReportPreviewSenderID', () => { actorAccountID: 10, originalMessage: { type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - IOUTransactionID: 'tr-active', + IOUTransactionID: '444', amount: 100, currency: 'USD', }, @@ -468,7 +468,7 @@ describe('getReportPreviewSenderID', () => { message: [{type: 'COMMENT', text: '', deleted: '2026-04-12 04:48:48.212'}], }), ], - transactions: [makeTransaction(100, 'user@test.com', {transactionID: 'tr-active'})], + transactions: [makeTransaction(100, 'user@test.com', {transactionID: '444'})], }); expect(result).toBe(OWNER_ACCOUNT_ID); @@ -479,7 +479,7 @@ describe('getReportPreviewSenderID', () => { // Since getPersonalDetailByEmail returns undefined in test (no Onyx), attendeesIDs will be filtered out // and the set size will be 0, which is <= 1, so we need to use splits to create multiple attendees const splitTr1: Transaction = { - transactionID: 'tr-1', + transactionID: '111', amount: 100, comment: { source: CONST.IOU.TYPE.SPLIT, @@ -489,7 +489,7 @@ describe('getReportPreviewSenderID', () => { } as Transaction; const splitTr2: Transaction = { - transactionID: 'tr-2', + transactionID: '222', amount: 100, comment: { source: CONST.IOU.TYPE.SPLIT, @@ -537,7 +537,7 @@ describe('getReportPreviewSenderID', () => { it('returns childOwnerAccountID for split transaction with single author', () => { const splitTr: Transaction = { - transactionID: 'tr-1', + transactionID: '111', amount: 100, comment: { source: CONST.IOU.TYPE.SPLIT, From c75f04887d7a51c1e545d23511056c05d9a8be79 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 21 May 2026 11:09:42 +0430 Subject: [PATCH 22/22] fix: make derived report preview avatars reactive --- .../useReportActionAvatars.ts | 17 +++-- tests/unit/useReportActionAvatarsTest.tsx | 69 +++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/components/ReportActionAvatars/useReportActionAvatars.ts b/src/components/ReportActionAvatars/useReportActionAvatars.ts index b73dddd61867..c55416d0ad35 100644 --- a/src/components/ReportActionAvatars/useReportActionAvatars.ts +++ b/src/components/ReportActionAvatars/useReportActionAvatars.ts @@ -6,13 +6,13 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import { getDelegateAccountIDFromReportAction, getHumanAgentAccountIDFromReportAction, getHumanAgentFirstName, getOriginalMessage, - getReportAction, getReportActionActorAccountID, isMoneyRequestAction, } from '@libs/ReportActionsUtils'; @@ -31,6 +31,7 @@ import { import {getDefaultAvatar} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {getReportActionByIDSelector} from '@src/selectors/ReportAction'; import type {InvitedEmailsToAccountIDs, OnyxInputOrEntry, Policy, Report, ReportAction} from '@src/types/onyx'; import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; import useReportPreviewSenderID from './useReportPreviewSenderID'; @@ -77,15 +78,13 @@ function useReportActionAvatars({ const chatReport = isReportAChatReport ? report : reportChatReport; const iouReport = isReportAChatReport ? undefined : report; - let action; + const derivedActionReportID = iouReport?.parentReportActionID ? (chatReport?.reportID ?? iouReport?.chatReportID) : reportChatReport?.reportID; + const derivedActionID = iouReport?.parentReportActionID ?? (!iouReport ? chatReport?.parentReportActionID : undefined); + const [derivedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(!passedAction ? derivedActionReportID : undefined)}`, { + selector: (actions) => getReportActionByIDSelector(actions, derivedActionID), + }); - if (passedAction) { - action = passedAction; - } else if (iouReport?.parentReportActionID) { - action = getReportAction(chatReport?.reportID ?? iouReport?.chatReportID, iouReport?.parentReportActionID); - } else if (!!reportChatReport && !!chatReport?.parentReportActionID && !iouReport) { - action = getReportAction(reportChatReport?.reportID, chatReport.parentReportActionID); - } + const action = passedAction ?? derivedAction; const [actionChildReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${action?.childReportID}`); diff --git a/tests/unit/useReportActionAvatarsTest.tsx b/tests/unit/useReportActionAvatarsTest.tsx index 388d557acc85..d4dcf238c66f 100644 --- a/tests/unit/useReportActionAvatarsTest.tsx +++ b/tests/unit/useReportActionAvatarsTest.tsx @@ -148,4 +148,73 @@ describe('useReportActionAvatars', () => { expect(data.avatars.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); }); }); + + describe('derived parent preview action', () => { + const chatReportID = 9100; + const iouReportID = 9101; + const previewActionID = '9102'; + + beforeEach(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, { + ...createRegularChat(chatReportID, [1, 2]), + reportID: String(chatReportID), + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + ...createInvoiceReport(iouReportID), + reportID: String(iouReportID), + type: CONST.REPORT.TYPE.IOU, + chatReportID: String(chatReportID), + parentReportActionID: previewActionID, + ownerAccountID: 1, + managerID: 2, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, { + [previewActionID]: { + ...createRandomReportAction(Number(previewActionID)), + reportActionID: previewActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + childReportID: String(iouReportID), + childLastActorAccountID: 1, + childOwnerAccountID: 1, + childManagerAccountID: 2, + }, + }); + await waitForBatchedUpdates(); + }); + + afterEach(() => { + Onyx.clear(); + }); + + test('updates derived parent preview action without a refresh', async () => { + const iouReport = { + ...createInvoiceReport(iouReportID), + reportID: String(iouReportID), + type: CONST.REPORT.TYPE.IOU, + chatReportID: String(chatReportID), + parentReportActionID: previewActionID, + ownerAccountID: 1, + managerID: 2, + }; + + const {result} = renderHook(() => useReportActionAvatars({report: iouReport, action: undefined}), {wrapper}); + + expect(result.current.source.action?.childLastActorAccountID).toBe(1); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, { + [previewActionID]: { + ...createRandomReportAction(Number(previewActionID)), + reportActionID: previewActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + childReportID: String(iouReportID), + childLastActorAccountID: 2, + childOwnerAccountID: 1, + childManagerAccountID: 2, + }, + }); + await waitForBatchedUpdates(); + + expect(result.current.source.action?.childLastActorAccountID).toBe(2); + }); + }); });