Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Mobile-Expensify
102 changes: 92 additions & 10 deletions src/components/ReportActionAvatars/useReportPreviewSenderID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.IOU>>) {
const {originalTransactionID, source} = transaction.comment ?? {};
Expand Down Expand Up @@ -38,6 +39,42 @@ const getSplitsSelector = (actions: OnyxEntry<ReportActions>): Array<ReportActio
.filter((act) => getOriginalMessage(act)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT);
};

function getTransactionDirectionSign(transaction: Transaction): number | undefined {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add unit test

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<Report>;
action: OnyxEntry<ReportAction>;
Expand All @@ -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
Comment on lines -59 to -61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restore this comment as this logic is still used

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<string, number>();

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;
Comment on lines +131 to +134

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid treating delegate actors as distinct report senders

This new actor-based gate can hide the single-avatar state for valid single-sender previews when requests are submitted by different delegates (or by a delegate plus the owner). In those flows, actorAccountID varies per action even though the preview sender should still resolve to the same childOwnerAccountID/childManagerAccountID, so the early return undefined incorrectly forces multi-avatar rendering. This regression is introduced by using raw actor IDs as the primary sameness check.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this case in the codebase and I don't think this concern applies here.

For IOU actions, delegate/copilot identity is stored separately in delegateAccountID, while the IOU action itself still uses actorAccountID for the main account:

actorAccountID: deprecatedCurrentUserAccountID,

/** The accountID of the copilot who took this action on behalf of the user */
delegateAccountID?: number;

The preview logic in ReportActionItemSingle also already treats delegate separately from the main sender account:

const delegateAccountID = getDelegateAccountIDFromReportAction(action);

So I don’t see evidence that delegate-created IOU actions would cause different actorAccountIDs for the same visible sender in this flow.

}
} 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

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

Expand Down
166 changes: 164 additions & 2 deletions tests/unit/getReportPreviewSenderIDTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -247,6 +409,6 @@ describe('getReportPreviewSenderID', () => {
transactions: [],
});

expect(result).toBe(OWNER_ACCOUNT_ID);
expect(result).toBeUndefined();
});
});
Loading