diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 06dc39974fb2..d3a1a0a47829 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4481,11 +4481,21 @@ function getReasonAndReportActionThatRequiresAttention( const transactions = getReportTransactions(iouReportID); const hasOnlyPendingTransactions = transactions.length > 0 && transactions.every((t) => isPending(t)); + const iouReportActions = getAllReportActions(iouReportID); + const currentUserPlacedHold = didCurrentUserPlaceHoldOnReportExpense(iouReportActions, transactions, currentUserAccountID); + // An all-held report can't move to its next state, so it isn't a to-do. Keep it only for an Approve or Pay report + // where the current user placed a hold, since only they can unhold it. Submit stays excluded even then, because on + // an open report only the owner can hold, so the exception would apply to every all-held report and defeat it. + const isExcludedForHeldExpenses = hasOnlyHeldExpenses(transactions) && (actionBadge === CONST.REPORT.ACTION_BADGE.SUBMIT ? true : !currentUserPlacedHold); + // Has a child report that is awaiting action (e.g. approve, pay, add bank account) from current user. // A report whose only expenses are pending Expensify Card transactions can't be actioned until they post, so it // shouldn't demand attention even when the chat still carries an outstanding-child flag. const hasStaleChildRequest = isTripRoom(optionOrReport) && (optionOrReport.transactionCount ?? 0) === 0; - const hasValidIOUAction = ((optionOrReport.hasOutstandingChildRequest === true && !hasStaleChildRequest) || iouReportActionToApproveOrPay?.reportActionID) && !hasOnlyPendingTransactions; + const hasValidIOUAction = + ((optionOrReport.hasOutstandingChildRequest === true && !hasStaleChildRequest) || iouReportActionToApproveOrPay?.reportActionID) && + !hasOnlyPendingTransactions && + !isExcludedForHeldExpenses; if (actionTypeForAssigneeToComplete) { const isAssigneeExpenseAction = actionTypeForAssigneeToComplete === CONST.REPORT.ACTION_TYPES_FOR_ASSIGNEE_TO_COMPLETE.EXPENSE; @@ -4710,9 +4720,24 @@ function isReportFieldOfTypeTitle(reportField: OnyxEntry): bo /** * Check if Report has any held expenses */ -function isHoldCreator(transaction: OnyxEntry, reportID: string | undefined): boolean { +function isHoldCreator(transaction: OnyxEntry, reportID: string | undefined, currentUserAccountID?: number): boolean { const holdReportAction = getReportAction(reportID, `${transaction?.comment?.hold ?? ''}`); - return isActionCreator(holdReportAction); + return isActionCreator(holdReportAction, currentUserAccountID); +} + +/** + * Whether the current user placed the hold on at least one of the report's held expenses. Used to keep an + * all-held report in that user's to-do queue, since only the person who placed a hold can remove it. + */ +function didCurrentUserPlaceHoldOnReportExpense(reportActions: OnyxEntry, reportTransactions: Transaction[], currentUserAccountID?: number): boolean { + return Object.values(reportActions ?? {}).some((action) => { + if (!isMoneyRequestAction(action)) { + return false; + } + const transactionID = getOriginalMessage(action)?.IOUTransactionID; + const transaction = reportTransactions.find((reportTransaction) => reportTransaction.transactionID === transactionID); + return !!transaction && isOnHoldTransactionUtils(transaction) && isHoldCreator(transaction, action.childReportID, currentUserAccountID); + }); } /** @@ -13717,6 +13742,7 @@ export { isGroupChatAdmin, isHarvestCreatedExpenseReport, isHoldCreator, + didCurrentUserPlaceHoldOnReportExpense, isIOUOwnedByCurrentUser, isIOUReport, isIOUReportUsingReport, diff --git a/src/libs/TodosUtils.ts b/src/libs/TodosUtils.ts index d4d62c812e74..c6158ed930a2 100644 --- a/src/libs/TodosUtils.ts +++ b/src/libs/TodosUtils.ts @@ -8,7 +8,7 @@ import type {SearchKey} from './SearchUIUtils'; import {getLoginByAccountID} from './PersonalDetailsUtils'; import {isApproveAction, isExportAction, isPrimaryPayAction, isSubmitAction} from './ReportPrimaryActionUtils'; -import {hasOnlyHeldExpenses, hasOnlyNonReimbursableTransactions} from './ReportUtils'; +import {didCurrentUserPlaceHoldOnReportExpense, hasOnlyHeldExpenses, hasOnlyNonReimbursableTransactions} from './ReportUtils'; type CreateTodosReportsAndTransactionsParams = { /** Every report, keyed by report Onyx key - iterated to find the expense reports that belong in a to-do bucket */ @@ -78,6 +78,9 @@ type TodoBucketContext = { /** Whether every transaction on the report is on hold - precomputed once so held reports are excluded from submit/approve/pay */ allExpensesHeld: boolean; + /** Whether the current user placed the hold on one of the report's expenses - keeps an all-held report in their approve/pay to-do */ + currentUserPlacedHold: boolean; + /** The report owner's login, resolved from `ownerAccountID` - the submit predicate matches it against the submitter */ ownerLogin: string | undefined; @@ -99,13 +102,25 @@ type TodoBucketContext = { function reportMatchesTodoBucket( searchKey: SearchKey, report: Report, - {policy, reportNameValuePair, reportTransactions, reportMetadata, allReportActions, allExpensesHeld, ownerLogin, bankAccountList, currentUserAccountID, login}: TodoBucketContext, + { + policy, + reportNameValuePair, + reportTransactions, + reportMetadata, + allReportActions, + allExpensesHeld, + currentUserPlacedHold, + ownerLogin, + bankAccountList, + currentUserAccountID, + login, + }: TodoBucketContext, ): boolean { switch (searchKey) { case CONST.SEARCH.SEARCH_KEYS.SUBMIT: return isSubmitAction(report, reportTransactions, reportMetadata, ownerLogin, policy, reportNameValuePair, undefined, login, currentUserAccountID) && !allExpensesHeld; case CONST.SEARCH.SEARCH_KEYS.APPROVE: - return isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && !allExpensesHeld; + return isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && (!allExpensesHeld || currentUserPlacedHold); case CONST.SEARCH.SEARCH_KEYS.PAY: return ( isPrimaryPayAction({ @@ -118,7 +133,7 @@ function reportMatchesTodoBucket( reportNameValuePairs: reportNameValuePair, }) && !hasOnlyNonReimbursableTransactions(report.reportID, reportTransactions) && - !allExpensesHeld + (!allExpensesHeld || currentUserPlacedHold) ); case CONST.SEARCH.SEARCH_KEYS.EXPORT: { const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? []); @@ -170,6 +185,11 @@ function createTodosReportsAndTransactions({ reportMetadata: allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`], allReportActions, allExpensesHeld: hasOnlyHeldExpenses(reportTransactions), + currentUserPlacedHold: didCurrentUserPlaceHoldOnReportExpense( + allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`], + reportTransactions, + currentUserAccountID, + ), ownerLogin: getLoginByAccountID(report.ownerAccountID, personalDetailsList), bankAccountList, currentUserAccountID, @@ -226,6 +246,11 @@ function getTodoReportsForSearchKey( reportMetadata: allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`], allReportActions, allExpensesHeld: hasOnlyHeldExpenses(reportTransactions), + currentUserPlacedHold: didCurrentUserPlaceHoldOnReportExpense( + allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`], + reportTransactions, + currentUserAccountID, + ), ownerLogin: getLoginByAccountID(report.ownerAccountID, personalDetailsList), bankAccountList, currentUserAccountID, diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 22adb95d21ce..4027aab37de0 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -3661,6 +3661,90 @@ describe('ReportUtils', () => { expect(requiresAttentionFromCurrentUser(policyExpenseChat, currentUserEmail, currentUserAccountID)).toBe(false); }); + describe('when the outstanding child expense is all on hold', () => { + const expenseReportID = '7201'; + const transactionThreadReportID = '7202'; + const transactionID = '7201'; + const moneyRequestActionID = 'mr_7201'; + const HOLD_ACTION_ID = 'hold_7201'; + const otherUserAccountID = 99; + + // Seeds an all-held expense report awaiting the current user, plus the money-request action and the thread's + // HOLD action (whose actor is the holder), so the derivation can resolve who placed the hold. + const seedHeldChildExpense = async (holderAccountID: number) => { + const expenseReport = { + ...LHNTestUtils.getFakeReport(), + reportID: expenseReportID, + policyID: '1', + ownerAccountID: otherUserAccountID, + managerID: currentUserAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + + const policyExpenseChat = { + ...createPolicyExpenseChat(201, true), + policyID: '1', + ownerAccountID: currentUserAccountID, + hasOutstandingChildRequest: true, + iouReportID: expenseReportID, + }; + + const heldTransaction = { + ...createRandomTransaction(7201), + transactionID, + reportID: expenseReportID, + status: CONST.TRANSACTION.STATUS.POSTED, + comment: {hold: HOLD_ACTION_ID}, + }; + + const moneyRequestAction: ReportAction = { + reportActionID: moneyRequestActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-01-01 00:00:00.000', + actorAccountID: otherUserAccountID, + childReportID: transactionThreadReportID, + originalMessage: { + IOUTransactionID: transactionID, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 100, + currency: 'USD', + }, + }; + + const holdAction: ReportAction = { + reportActionID: HOLD_ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.HOLD, + created: '2024-01-01 00:00:00.000', + actorAccountID: holderAccountID, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, {reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, heldTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, {[moneyRequestActionID]: moneyRequestAction}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, {[HOLD_ACTION_ID]: holdAction}); + await waitForBatchedUpdates(); + + return policyExpenseChat; + }; + + it('does not require attention when another user placed the hold', async () => { + const policyExpenseChat = await seedHeldChildExpense(otherUserAccountID); + + // An all-held report can't move to its next state, so it isn't a to-do when someone else placed the hold. + expect(requiresAttentionFromCurrentUser(policyExpenseChat, currentUserEmail, currentUserAccountID)).toBe(false); + }); + + it('still requires attention when the current user placed the hold', async () => { + const policyExpenseChat = await seedHeldChildExpense(currentUserAccountID); + + // Only the person who placed the hold can remove it, so the report stays in their to-do queue. + expect(requiresAttentionFromCurrentUser(policyExpenseChat, currentUserEmail, currentUserAccountID)).toBe(true); + }); + }); + it('returns true for expense report awaiting user payment/reimbursement', async () => { const report = { ...LHNTestUtils.getFakeReport(), @@ -14731,7 +14815,7 @@ describe('ReportUtils', () => { }); }); - it('should surface a GBR for admin with held expenses requiring approval or payment and avoid showing an RBR', async () => { + it('should surface a GBR for the admin who placed the hold on an all-held report requiring approval, and avoid showing an RBR', async () => { await Onyx.clear(); const adminAccountID = currentUserAccountID; @@ -14740,6 +14824,8 @@ describe('ReportUtils', () => { const expenseReportID = 'expense-hold'; const transactionID = 'transaction-hold'; const holdReportActionID = 'hold-action'; + const transactionThreadReportID = 'transaction-thread-hold'; + const moneyRequestActionID = 'money-request-action-hold'; const policy1: Policy = { id: policyID, @@ -14804,6 +14890,30 @@ describe('ReportUtils', () => { childReportID: expenseReportID, }; + // The current user (admin) placed the hold, so the all-held report stays in their to-do queue (GBR) - only the + // person who placed a hold can unhold it. The money-request action links the held transaction to the thread + // that carries the HOLD action, so the derivation can resolve who placed the hold. + const moneyRequestAction: ReportAction = { + reportActionID: moneyRequestActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-01-01 00:00:00.000', + actorAccountID: employeeAccountID, + childReportID: transactionThreadReportID, + originalMessage: { + IOUTransactionID: transactionID, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 12345, + currency: CONST.CURRENCY.USD, + }, + }; + + const holdAction: ReportAction = { + reportActionID: holdReportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.HOLD, + created: '2024-01-01 00:00:00.000', + actorAccountID: adminAccountID, + }; + const transactionViolationsKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}` as OnyxKey; const transactionViolationsCollection: OnyxCollection = { [transactionViolationsKey]: [ @@ -14822,6 +14932,12 @@ describe('ReportUtils', () => { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, { [reportPreviewAction.reportActionID]: reportPreviewAction, }), + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, { + [moneyRequestAction.reportActionID]: moneyRequestAction, + }), + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, { + [holdAction.reportActionID]: holdAction, + }), Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction), Onyx.merge(transactionViolationsKey, transactionViolationsCollection[transactionViolationsKey]), ]); @@ -14841,6 +14957,7 @@ describe('ReportUtils', () => { draftComment: '', isReportArchived: undefined, conciergeReportID: undefined, + currentUserAccountID: adminAccountID, }); expect(reason).toBe(CONST.REPORT_IN_LHN_REASONS.HAS_GBR); diff --git a/tests/unit/TodosUtilsTest.ts b/tests/unit/TodosUtilsTest.ts index 9cf35d29f7a9..0ca041cf6af0 100644 --- a/tests/unit/TodosUtilsTest.ts +++ b/tests/unit/TodosUtilsTest.ts @@ -2,7 +2,7 @@ import createTodosReportsAndTransactions, {buildTransactionsByReportID, getTodoR import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, Transaction} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; import Onyx from 'react-native-onyx'; @@ -87,6 +87,31 @@ const createMockTransaction = (transactionID: string, reportID: string, override ...overrides, }) as Transaction; +const HOLD_ACTION_ID = 'HOLD_ACTION_ID'; + +// A money-request (IOU) action whose child report is the transaction thread that carries the HOLD action. +const createMoneyRequestAction = (reportActionID: string, transactionID: string, transactionThreadReportID: string): ReportAction => ({ + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-01-01 00:00:00.000', + actorAccountID: OTHER_USER_ACCOUNT_ID, + childReportID: transactionThreadReportID, + originalMessage: { + IOUTransactionID: transactionID, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 100, + currency: 'USD', + }, +}); + +// The HOLD action lives on the transaction thread; its actor is whoever placed the hold. +const createHoldAction = (holderAccountID: number): ReportAction => ({ + reportActionID: HOLD_ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.HOLD, + created: '2024-01-01 00:00:00.000', + actorAccountID: holderAccountID, +}); + // The utils take Onyx collections (keyed by full Onyx key) directly as arguments, so the tests build those keyed // maps in-memory and call the utils without going through a hook or the live Onyx store. const toReportsCollection = (reports: Report[]) => Object.fromEntries(reports.map((report) => [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report])); @@ -250,6 +275,108 @@ describe('TodosUtils', () => { expect(result.reportsToSubmit).toEqual([]); }); + describe('an all-held report where a specific user placed the hold', () => { + // Builds params for a single all-held report, wiring its money-request action to a transaction thread so + // didCurrentUserPlaceHoldOnReportExpense can resolve who placed the hold. + const buildParams = (report: Report) => { + const transactionID = `trans_${report.reportID}`; + const transactionThreadReportID = `thread_${report.reportID}`; + const moneyRequestActionID = `mr_${report.reportID}`; + const policy = createMockPolicy(POLICY_ID, { + role: CONST.POLICY.ROLE.ADMIN, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + }); + return { + transactionThreadReportID, + params: { + ...baseParams, + allReports: toReportsCollection([report]), + allTransactions: toTransactionsCollection([createMockTransaction(transactionID, report.reportID, {comment: {hold: HOLD_ACTION_ID}})]), + allPolicies: toPoliciesCollection([policy]), + allReportActions: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`]: { + [moneyRequestActionID]: createMoneyRequestAction(moneyRequestActionID, transactionID, transactionThreadReportID), + }, + }, + }, + }; + }; + + // getReportAction/isHoldCreator read the module cache, so the thread's HOLD action must live in Onyx. + const seedHoldAction = async (transactionThreadReportID: string, holderAccountID: number) => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, {[HOLD_ACTION_ID]: createHoldAction(holderAccountID)}); + await waitForBatchedUpdates(); + }; + + const approveReport = () => + createMockReport('held_approve', { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + }); + + const payReport = () => + createMockReport('held_pay', { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + total: -100, + }); + + it('keeps the approve report when the current user placed the hold', async () => { + const {params, transactionThreadReportID} = buildParams(approveReport()); + await seedHoldAction(transactionThreadReportID, CURRENT_USER_ACCOUNT_ID); + + const result = createTodosReportsAndTransactions(params); + + expect(result.reportsToApprove.map((report) => report.reportID)).toEqual(['held_approve']); + }); + + it('excludes the approve report when another user placed the hold', async () => { + const {params, transactionThreadReportID} = buildParams(approveReport()); + await seedHoldAction(transactionThreadReportID, OTHER_USER_ACCOUNT_ID); + + const result = createTodosReportsAndTransactions(params); + + expect(result.reportsToApprove).toEqual([]); + }); + + it('keeps the pay report when the current user placed the hold', async () => { + const {params, transactionThreadReportID} = buildParams(payReport()); + await seedHoldAction(transactionThreadReportID, CURRENT_USER_ACCOUNT_ID); + + const result = createTodosReportsAndTransactions(params); + + expect(result.reportsToPay.map((report) => report.reportID)).toEqual(['held_pay']); + }); + + it('excludes the pay report when another user placed the hold', async () => { + const {params, transactionThreadReportID} = buildParams(payReport()); + await seedHoldAction(transactionThreadReportID, OTHER_USER_ACCOUNT_ID); + + const result = createTodosReportsAndTransactions(params); + + expect(result.reportsToPay).toEqual([]); + }); + + it('still excludes an all-held submit report even when the current user placed the hold', async () => { + const submitReport = createMockReport('held_submit_holder', { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + }); + const {params, transactionThreadReportID} = buildParams(submitReport); + await seedHoldAction(transactionThreadReportID, CURRENT_USER_ACCOUNT_ID); + + const result = createTodosReportsAndTransactions(params); + + expect(result.reportsToSubmit).toEqual([]); + }); + }); + it('excludes a report whose expenses are all pending card transactions', () => { const pendingOverride: Partial = {status: CONST.TRANSACTION.STATUS.PENDING, bank: CONST.EXPENSIFY_CARD.BANK}; const submitReport = createMockReport('pending_submit', { diff --git a/tests/unit/useTodoCountsTest.tsx b/tests/unit/useTodoCountsTest.tsx index 98d648964d76..ea07a0cede7f 100644 --- a/tests/unit/useTodoCountsTest.tsx +++ b/tests/unit/useTodoCountsTest.tsx @@ -4,7 +4,7 @@ import useTodoCounts from '@hooks/useTodoCounts'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, Transaction} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; import type {ACHAccount} from '@src/types/onyx/Policy'; import Onyx from 'react-native-onyx'; @@ -96,6 +96,31 @@ const setTransactions = (transactions: Transaction[]) => Promise.all(transactions.map((transaction) => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction))); const setPolicies = (policies: Policy[]) => Promise.all(policies.map((policy) => Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy))); +const HOLD_ACTION_ID = 'HOLD_ACTION_ID'; + +// A money-request (IOU) action whose child report is the transaction thread that carries the HOLD action. +const createMoneyRequestAction = (reportActionID: string, transactionID: string, transactionThreadReportID: string): ReportAction => ({ + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-01-01 00:00:00.000', + actorAccountID: OTHER_USER_ACCOUNT_ID, + childReportID: transactionThreadReportID, + originalMessage: { + IOUTransactionID: transactionID, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 100, + currency: 'USD', + }, +}); + +// The HOLD action lives on the transaction thread; its actor is whoever placed the hold. +const createHoldAction = (holderAccountID: number): ReportAction => ({ + reportActionID: HOLD_ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.HOLD, + created: '2024-01-01 00:00:00.000', + actorAccountID: holderAccountID, +}); + const renderTodoCounts = async (enabled = true) => { const hook = renderHook(({isEnabled}: {isEnabled: boolean}) => useTodoCounts(isEnabled), {initialProps: {isEnabled: enabled}}); await act(async () => { @@ -185,6 +210,84 @@ describe('useTodoCounts', () => { }); }); + describe('keeps an all-held approve/pay report only for the user who placed the hold', () => { + const HELD_APPROVE_REPORT_ID = 'held_hold_approve'; + const HELD_PAY_REPORT_ID = 'held_hold_pay'; + + // Seeds a single all-held report plus the money-request action and the thread's HOLD action (whose actor is the + // holder), so the derivation can resolve whether the current user placed the hold. + const seedScenario = async (report: Report, holderAccountID: number) => { + const transactionID = `trans_${report.reportID}`; + const transactionThreadReportID = `thread_${report.reportID}`; + const moneyRequestActionID = `mr_${report.reportID}`; + const policy = createMockPolicy(POLICY_ID, { + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + role: CONST.POLICY.ROLE.ADMIN, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + }); + + await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); + await setPolicies([policy]); + await setReports([report]); + await setTransactions([createMockTransaction(transactionID, report.reportID, {comment: {hold: HOLD_ACTION_ID}})]); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [moneyRequestActionID]: createMoneyRequestAction(moneyRequestActionID, transactionID, transactionThreadReportID), + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, {[HOLD_ACTION_ID]: createHoldAction(holderAccountID)}); + await waitForBatchedUpdates(); + }; + + const approveReport = () => + createMockReport(HELD_APPROVE_REPORT_ID, { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + }); + + const payReport = () => + createMockReport(HELD_PAY_REPORT_ID, { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: OTHER_USER_ACCOUNT_ID, + managerID: CURRENT_USER_ACCOUNT_ID, + total: -100, + }); + + it('counts an all-held approve report when the current user placed the hold', async () => { + await seedScenario(approveReport(), CURRENT_USER_ACCOUNT_ID); + + const {result} = await renderTodoCounts(); + + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.APPROVE]).toBe(1); + }); + + it('does not count an all-held approve report when another user placed the hold', async () => { + await seedScenario(approveReport(), OTHER_USER_ACCOUNT_ID); + + const {result} = await renderTodoCounts(); + + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.APPROVE]).toBe(0); + }); + + it('counts an all-held pay report when the current user placed the hold', async () => { + await seedScenario(payReport(), CURRENT_USER_ACCOUNT_ID); + + const {result} = await renderTodoCounts(); + + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.PAY]).toBe(1); + }); + + it('does not count an all-held pay report when another user placed the hold', async () => { + await seedScenario(payReport(), OTHER_USER_ACCOUNT_ID); + + const {result} = await renderTodoCounts(); + + expect(result.current.counts[CONST.SEARCH.SEARCH_KEYS.PAY]).toBe(0); + }); + }); + describe('categorizes reports correctly', () => { const SUBMIT_REPORT_IDS = ['submit_1', 'submit_2', 'submit_3', 'submit_4']; const APPROVE_REPORT_IDS = ['approve_1', 'approve_2', 'approve_3'];