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/src/components/ReportActionAvatars/useReportPreviewSenderID.ts b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts index 49f8a633e30a..33450b7cfec2 100644 --- a/src/components/ReportActionAvatars/useReportPreviewSenderID.ts +++ b/src/components/ReportActionAvatars/useReportPreviewSenderID.ts @@ -7,11 +7,12 @@ import {convertAttendeesToArray} from '@libs/AttendeeUtils'; 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 CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; +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>) { const {originalTransactionID, source} = transaction.comment ?? {}; @@ -39,6 +40,94 @@ const getSplitsSelector = (actions: OnyxEntry): Array getOriginalMessage(act)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT); }; +function getTransactionDirectionSign(transaction: Transaction): number | undefined { + const modifiedAmount = Number(transaction.modifiedAmount); + + if (Number.isFinite(modifiedAmount) && modifiedAmount !== 0) { + return Math.sign(modifiedAmount); + } + + if (transaction.amount !== 0) { + return Math.sign(transaction.amount); + } + + return undefined; +} + +function hasPendingScanStateAndUnknownDirection(transaction: Transaction): boolean { + 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 getPendingScanActorAccountID(transaction: Transaction, action: OnyxEntry, iouReport: OnyxEntry, chatReport: OnyxEntry): number | undefined { + if (!hasPendingScanStateAndUnknownDirection(transaction)) { + return undefined; + } + + const chatLastActorAccountID = Number(chatReport?.lastActorAccountID); + const validPendingScanActorAccountIDs = new Set([action?.childOwnerAccountID, action?.childManagerAccountID, iouReport?.ownerAccountID, iouReport?.managerID].filter(Boolean)); + + if ( + Number.isFinite(chatLastActorAccountID) && + chatLastActorAccountID > 0 && + chatLastActorAccountID !== CONST.ACCOUNT_ID.CONCIERGE && + validPendingScanActorAccountIDs.has(chatLastActorAccountID) + ) { + return chatLastActorAccountID; + } + + const previewLastActorAccountID = Number(action?.childLastActorAccountID); + + if (!Number.isFinite(previewLastActorAccountID) || previewLastActorAccountID <= 0) { + return undefined; + } + + return previewLastActorAccountID; +} + +function getAccountIDFromTransactionDirection(transaction: Transaction, action: OnyxEntry, iouReport: OnyxEntry): number | undefined { + const directionSign = getTransactionDirectionSign(transaction); + + if (directionSign === undefined) { + return undefined; + } + + const accountID = Number(directionSign > 0 ? (action?.childOwnerAccountID ?? iouReport?.ownerAccountID) : (action?.childManagerAccountID ?? iouReport?.managerID)); + + if (!Number.isFinite(accountID) || accountID <= 0) { + return undefined; + } + + return accountID; +} + +function isExplicitlyDeletedIOUAction(iouAction: ReportAction): boolean { + const originalMessage = getOriginalMessage(iouAction) as OriginalMessageIOU | undefined; + + 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.some((fragment) => !!fragment?.deleted); + } + + return !!message?.deleted; +} + type GetReportPreviewSenderIDParams = { iouReport: OnyxEntry; action: OnyxEntry; @@ -48,25 +137,107 @@ 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) { return currentUserAccountID; } + const loadedTransactionCount = transactions?.length ?? 0; + const childMoneyRequestCount = action?.childMoneyRequestCount ?? 0; + const activeMoneyRequestCount = iouReport?.transactionCount ?? childMoneyRequestCount; + const activeIOUActions = iouActions?.filter((iouAction) => !isExplicitlyDeletedIOUAction(iouAction)) ?? []; + const uniqueIOUActionActorMap = new Map(); - // 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 + for (const iouAction of activeIOUActions) { + const iouTransactionID = (getOriginalMessage(iouAction) as OriginalMessageIOU | undefined)?.IOUTransactionID; - const areAmountsSignsTheSame = new Set(transactions?.map((tr) => Math.sign(tr.amount))).size < 2; + if (!iouTransactionID || iouAction.actorAccountID === undefined) { + continue; + } + + uniqueIOUActionActorMap.set(iouTransactionID, iouAction.actorAccountID); + } - if (!areAmountsSignsTheSame) { + 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) { return undefined; } + 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; + + if (!areAllTransactionsCreatedByOneActor) { + return undefined; + } + } else { + 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 (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 && !hasOnlyUnknownNonPendingScanDirections) { + return undefined; + } + + const knownTransactionSigns = transactionSigns.filter((sign): sign is number => sign !== undefined); + + if (knownTransactionSigns.length === 0 && !hasOnlyUnknownNonPendingScanDirections) { + 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) { + 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 @@ -86,13 +257,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; @@ -108,14 +279,33 @@ 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(() => { if (!shouldFetchData) { return undefined; } - return getAllNonDeletedTransactions(reportTransactions, iouActions ?? []); - }, [reportTransactions, iouActions, shouldFetchData]); + const activeMoneyRequestCount = iouReport?.transactionCount ?? action?.childMoneyRequestCount ?? 0; + const allReportTransactions = Object.values(reportTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction); + const nonDeletedTransactionsIncludingOrphans = getAllNonDeletedTransactions(reportTransactions, iouActions ?? [], false, true); + const filteredTransactions = + nonDeletedTransactionsIncludingOrphans.length < allReportTransactions.length + ? getAllNonDeletedTransactions(reportTransactions, iouActions ?? []) + : nonDeletedTransactionsIncludingOrphans; + + if (filteredTransactions.length < allReportTransactions.length && filteredTransactions.length < activeMoneyRequestCount) { + return nonDeletedTransactionsIncludingOrphans; + } + + return filteredTransactions; + }, [reportTransactions, iouActions, shouldFetchData, iouReport?.transactionCount, action?.childMoneyRequestCount]); const [splits] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(shouldFetchData ? chatReport?.reportID : undefined)}`, { selector: getSplitsSelector, @@ -124,7 +314,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; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 912746d15b65..da8e3ae8137c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7696,6 +7696,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 964e3713c968..133bde6b8e24 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'; @@ -10766,6 +10767,33 @@ describe('ReportUtils', () => { }); }); + describe('updateReportPreview', () => { + it('refreshes childLastActorAccountID when a new expense request is added', () => { + 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 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(updatedPreviewAction.childLastActorAccountID).toBe(currentUserAccountID); + }); + }); + describe('compute (Formula.ts for optimistic report names)', () => { const mockPolicy: Policy = { id: 'test-policy-id', diff --git a/tests/unit/getReportPreviewSenderIDTest.ts b/tests/unit/getReportPreviewSenderIDTest.ts index 7509010fc794..59d573497f6f 100644 --- a/tests/unit/getReportPreviewSenderIDTest.ts +++ b/tests/unit/getReportPreviewSenderIDTest.ts @@ -83,12 +83,80 @@ 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: '123'}); + const transaction2 = makeTransaction(200, 'user1@test.com', {transactionID: '321'}); + + 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: '123', + amount: 100, + currency: 'USD', + }, + }), + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: '321', + 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: '123'}); + const transaction2 = makeTransaction(200, 'user1@test.com', {transactionID: '321'}); + + 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: '123', + amount: 100, + currency: 'USD', + }, + }), + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 20, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: '321', + 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: { type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUDetails: {amount: 100, comment: '', currency: 'USD'}, - IOUTransactionID: 'tr-1', + IOUTransactionID: '111', amount: 100, currency: 'USD', }, @@ -140,12 +208,278 @@ 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 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: '567'}), + makeTransaction(0, 'user@test.com', { + transactionID: '890', + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + modifiedAmount: 50000, + modifiedCreated: '2021-03-18 00:00:00', + modifiedCurrency: 'INR', + modifiedMerchant: 'merchant', + receipt: {source: 'receipt.jpg'}, + }), + ], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + + it('returns childOwnerAccountID when a pending scan belongs to the same sender as the hydrated transactions', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + 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: '111', + amount: 100, + currency: 'USD', + }, + }), + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: '222', + amount: 100, + currency: 'USD', + }, + }), + ], + transactions: [ + 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: '333', + receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, + }), + ], + }); + + expect(result).toBe(OWNER_ACCOUNT_ID); + }); + + it('returns undefined when a pending scan belongs to a different sender than the hydrated transactions', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + 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: '111', + amount: 100, + currency: 'USD', + }, + }), + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 10, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: '222', + amount: 100, + currency: 'USD', + }, + }), + ], + transactions: [ + 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: '333', + receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, + }), + ], + }); + + 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; + + const result = getReportPreviewSenderID({ + ...baseParams, + 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: '111', + amount: 100, + currency: 'USD', + }, + }), + ], + transactions: [ + makeTransaction(1200, 'user@test.com', {transactionID: '111'}), + makeTransaction(0, 'user@test.com', { + transactionID: '222', + receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, + }), + ], + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when a pending scan actor conflicts with the manual transaction direction actor', () => { + const result = getReportPreviewSenderID({ + ...baseParams, + iouReport: makeIOUReport({transactionCount: 2, ownerAccountID: OWNER_ACCOUNT_ID, managerID: MANAGER_ACCOUNT_ID}), + action: makeAction({ + 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: '222', + amount: 0, + currency: 'USD', + }, + }), + ], + transactions: [ + makeTransaction(1200, 'user@test.com', { + transactionID: '111', + }), + makeTransaction(0, 'user@test.com', { + transactionID: '222', + receipt: {source: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}, + }), + ], + }); + + 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 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: '987654321012345678', + amount: 100, + currency: 'USD', + }, + }), + makeIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE, { + actorAccountID: 20, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: '987654321012345679', + 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: '987654321012345678'})], + }); + + 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: '444', + 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: '444'})], + }); + + 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 // 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, @@ -155,7 +489,7 @@ describe('getReportPreviewSenderID', () => { } as Transaction; const splitTr2: Transaction = { - transactionID: 'tr-2', + transactionID: '222', amount: 100, comment: { source: CONST.IOU.TYPE.SPLIT, @@ -203,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, @@ -238,7 +572,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,7 +581,7 @@ describe('getReportPreviewSenderID', () => { transactions: [], }); - expect(result).toBe(OWNER_ACCOUNT_ID); + expect(result).toBeUndefined(); }); it('ignores attendees with undefined email without crashing', () => { 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); + }); + }); });