diff --git a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 1ff56f67abb8a..85d84cfcf7aee 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; @@ -51,21 +88,66 @@ type GetReportPreviewSenderIDParams = { function getReportPreviewSenderID({iouReport, action, chatReport, iouActions, transactions, splits, policy, currentUserAccountID}: GetReportPreviewSenderIDParams): number | undefined { const isOptimisticReportPreview = action?.isOptimisticAction && action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && isIOUReport(iouReport); - if (isOptimisticReportPreview) { return currentUserAccountID; } - // 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 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(); - const areAmountsSignsTheSame = new Set(transactions?.map((tr) => Math.sign(tr.amount))).size < 2; + for (const iouAction of activeIOUActions) { + const iouTransactionID = (getOriginalMessage(iouAction) as OriginalMessageIOU | undefined)?.IOUTransactionID; - if (!areAmountsSignsTheSame) { + if (!iouTransactionID || iouAction.actorAccountID === undefined) { + continue; + } + + uniqueIOUActionActorMap.set(iouTransactionID, iouAction.actorAccountID); + } + + const hasCompleteActionCoverage = activeMoneyRequestCount > 0 && uniqueIOUActionActorMap.size >= activeMoneyRequestCount; + const areAllActiveChildRequestsCreatedByOneActor = 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 (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 295bc5367409f..1eb2a6976c607 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(); }); });