From 33c8a0fd98b0b7246e334b2c4c872a2f58bb0780 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Wed, 19 Aug 2026 12:18:11 +0530 Subject: [PATCH 01/13] Restrict report state transitions to archived policies only --- src/libs/ReportPrimaryActionUtils.ts | 20 +- src/libs/ReportSecondaryActionUtils.ts | 31 +- src/libs/TodosUtils.ts | 2 +- tests/unit/ReportPrimaryActionUtilsTest.ts | 157 ++++++++++ tests/unit/ReportSecondaryActionUtilsTest.ts | 286 +++++++++++++++++++ 5 files changed, 483 insertions(+), 13 deletions(-) diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index acaf0fbcda33..67ce2cfc75c5 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -13,6 +13,7 @@ import { getValidConnectedIntegration, hasDynamicExternalWorkflow, hasIntegrationAutoSync, + isArchivedPolicy, isGroupPolicy, isPaidGroupPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils, @@ -118,12 +119,13 @@ function isSubmitAction( reportMetadata: OnyxEntry, ownerLogin: string | undefined, policy?: Policy, - reportNameValuePairs?: ReportNameValuePairs, violations?: OnyxCollection, currentUserEmail?: string, currentUserAccountID?: number, ) { - if (isArchivedReport(reportNameValuePairs)) { + // State transitions are blocked only on archived policies. Reports archived for other reasons + // (e.g. the submitter was unshared from the policy) can still move through the workflow. + if (isArchivedPolicy(policy)) { return false; } @@ -161,6 +163,10 @@ function isSubmitAction( } function isApproveAction(report: Report, reportTransactions: Transaction[], currentUserAccountID: number, reportMetadata: OnyxEntry, policy?: Policy) { + if (isArchivedPolicy(policy)) { + return false; + } + if (isSubmitterApproveBlockedOnSubmitWorkspace(policy, report.ownerAccountID, currentUserAccountID)) { return false; } @@ -214,10 +220,14 @@ function isPrimaryPayAction({ isSecondaryAction, canNonPayerAdminPay, }: IsPrimaryPayActionParams) { - if (isArchivedReport(reportNameValuePairs) || isChatReportArchived) { + const isExpenseReport = isExpenseReportUtils(report); + + // Expense reports on archived policies cannot be paid, but reports archived for other reasons + // (e.g. the submitter was unshared from the policy) can. IOU and invoice reports have no policy + // archived state, so they keep the archived report/chat restriction. + if (isExpenseReport ? isArchivedPolicy(policy) : isArchivedReport(reportNameValuePairs) || isChatReportArchived) { return false; } - const isExpenseReport = isExpenseReportUtils(report); if (isExpenseReport && !isPaidGroupPolicy(policy)) { return false; } @@ -536,7 +546,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf; - isChatReportArchived?: boolean; primaryAction?: ValueOf | ''; violations?: OnyxCollection; currentUserLogin?: string; currentUserAccountID: number; ownerLogin: string | undefined; }): boolean { - if (isArchivedReport(reportNameValuePairs) || isChatReportArchived) { + // State transitions are blocked only on archived policies. Reports archived for other reasons + // (e.g. the submitter was unshared from the policy) can still move through the workflow. + if (isArchivedPolicy(policy)) { return false; } @@ -307,6 +306,10 @@ function isApproveAction( reportMetadata: OnyxEntry, policy?: Policy, ): boolean { + if (isArchivedPolicy(policy)) { + return false; + } + if (isSubmitterApproveBlockedOnSubmitWorkspace(policy, report.ownerAccountID, currentUserAccountID)) { return false; } @@ -374,6 +377,10 @@ function isApproveAction( } function isUnapproveAction(currentUserLogin: string, currentUserAccountID: number, report: Report, policy?: Policy): boolean { + if (isArchivedPolicy(policy)) { + return false; + } + const isExpenseReport = isExpenseReportUtils(report); const isReportApprover = isPolicyApprover(policy, currentUserLogin); const isReportApproved = isReportApprovedUtils({report}); @@ -430,6 +437,10 @@ function isCancelPaymentAction( return false; } + if (isExpenseReport && isArchivedPolicy(policy)) { + return false; + } + const isPayer = isPayerUtils(currentAccountID, currentUserEmail, report, bankAccountList, policy, false); // A P2P "send money" payment made with the Expensify wallet that is waiting for the receiver to set up their @@ -729,6 +740,10 @@ function shouldShowEditSplitInDeleteAction( } function isRetractAction(report: Report, policy?: Policy): boolean { + if (isArchivedPolicy(policy)) { + return false; + } + const isExpenseReport = isExpenseReportUtils(report); // This should be removed after we change how instant submit works @@ -752,6 +767,10 @@ function isRetractAction(report: Report, policy?: Policy): boolean { } function isReopenAction(report: Report, policy?: Policy): boolean { + if (isArchivedPolicy(policy)) { + return false; + } + const isExpenseReport = isExpenseReportUtils(report); if (!isExpenseReport) { return false; @@ -1032,10 +1051,8 @@ function getSecondaryReportActions({ report, reportTransactions, policy, - reportNameValuePairs, reportActions, reportMetadata, - isChatReportArchived, primaryAction, violations, currentUserLogin, diff --git a/src/libs/TodosUtils.ts b/src/libs/TodosUtils.ts index bba0cec20c77..b551a41feca7 100644 --- a/src/libs/TodosUtils.ts +++ b/src/libs/TodosUtils.ts @@ -137,7 +137,7 @@ function reportMatchesTodoBucket( } // isSubmitAction also allows workflow approvers to submit on the owner's behalf; the to-do only nudges the owner. - return isSubmitAction(report, reportTransactions, reportMetadata, ownerLogin, policy, reportNameValuePair, undefined, login, currentUserAccountID) && !allExpensesHeld; + return isSubmitAction(report, reportTransactions, reportMetadata, ownerLogin, policy, undefined, login, currentUserAccountID) && !allExpensesHeld; case CONST.SEARCH.SEARCH_KEYS.APPROVE: return isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy) && (!allExpensesHeld || currentUserPlacedHold); case CONST.SEARCH.SEARCH_KEYS.PAY: diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index 72649f7f127c..2556f96c47df 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -129,6 +129,72 @@ describe('getPrimaryAction', () => { ).toBe(CONST.REPORT.PRIMARY_ACTIONS.SUBMIT); }); + it('should return SUBMIT for expense report when the report is archived but the policy is not', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = createMock({ + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE, + }); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + + expect( + getReportPrimaryAction({ + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + ownerLogin: '', + chatReport, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy, + reportNameValuePairs: {private_isArchived: new Date().toString()}, + isChatReportArchived: true, + }), + ).toBe(CONST.REPORT.PRIMARY_ACTIONS.SUBMIT); + }); + + it('should return empty string for open expense report on an archived policy', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = createMock({ + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE, + archivedDate: '2026-08-01 00:00:00', + }); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + + expect( + getReportPrimaryAction({ + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + ownerLogin: '', + chatReport, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy, + isChatReportArchived: false, + }), + ).toBe(''); + }); + it('should return SUBMIT while a retract update is pending', async () => { const report = createMock({ reportID: REPORT_ID, @@ -612,6 +678,31 @@ describe('getPrimaryAction', () => { expect(isApproveAction(report, [transaction], CURRENT_USER_ACCOUNT_ID, {}, policy)).toBe(true); }); + it('should return false from isApproveAction on an archived policy', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: CURRENT_USER_ACCOUNT_ID, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = createMock({ + approver: CURRENT_USER_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + archivedDate: '2026-08-01 00:00:00', + }); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + amount: 10, + merchant: 'Merchant', + created: '2025-01-01', + }); + + expect(isApproveAction(report, [transaction], CURRENT_USER_ACCOUNT_ID, {}, policy)).toBe(false); + }); + it('should return false from isApproveAction when submitter views their own report on a Submit workspace', async () => { const report = createMock({ reportID: REPORT_ID, @@ -808,6 +899,72 @@ describe('getPrimaryAction', () => { ).toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY); }); + it('should return PAY for expense report when the report is archived but the policy is not', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + total: -300, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = createMock({ + role: CONST.POLICY.ROLE.ADMIN, + }); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + + expect( + getReportPrimaryAction({ + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + ownerLogin: '', + chatReport, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy, + reportNameValuePairs: {private_isArchived: new Date().toString()}, + isChatReportArchived: true, + }), + ).toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY); + }); + + it('should not return PAY for expense report on an archived policy', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + total: -300, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = createMock({ + role: CONST.POLICY.ROLE.ADMIN, + archivedDate: '2026-08-01 00:00:00', + }); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + + expect( + getReportPrimaryAction({ + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + ownerLogin: '', + chatReport, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy, + isChatReportArchived: false, + }), + ).not.toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY); + }); + it('should return PAY for non-reimburser payments admin in manual reimbursement mode when owner is payer', async () => { const ownerEmail = 'owner@manual-test.com'; const report = createMock({ diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 864477d9f7ad..6ce69827e0cc 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -411,6 +411,79 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); + it('includes SUBMIT option when the report is archived but the policy is not', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 10, + }); + const policy = createMock({ + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, + harvesting: { + enabled: true, + }, + type: CONST.POLICY.TYPE.CORPORATE, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + moveExpenseReportNameValuePairs: { + [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: new Date().toString()}, + }, + isChatReportArchived: true, + isProduction: false, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); + }); + + it('excludes SUBMIT option on an archived policy', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 10, + }); + const policy = createMock({ + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, + harvesting: { + enabled: true, + }, + type: CONST.POLICY.TYPE.CORPORATE, + archivedDate: '2026-08-01 00:00:00', + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + isProduction: false, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); + }); + it('includes SUBMIT option for workflow approver on OPEN expense report with stale managerID', async () => { const SUBMITTER_ACCOUNT_ID = 5; const SUBMITTER_EMAIL = 'submitter@mail.com'; @@ -1078,6 +1151,48 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(true); }); + it('does not include APPROVE option on an archived policy', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: EMPLOYEE_ACCOUNT_ID, + }); + const policy = createMock({ + approver: EMPLOYEE_EMAIL, + archivedDate: '2026-08-01 00:00:00', + }); + const TRANSACTION_ID = 'TRANSACTION_ID'; + const transaction = createMock({ + transactionID: TRANSACTION_ID, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + + const violation = createMock({ + name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [transaction], + originalTransaction: createMock({}), + violations: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}, + bankAccountList: {}, + policy, + isProduction: false, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); + }); + it('does not include APPROVE option for approver and report with only pending transactions', async () => { const report = createMock({ reportID: REPORT_ID, @@ -1524,6 +1639,33 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(true); }); + it('excludes UNAPPROVE option on an archived policy', () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: EMPLOYEE_ACCOUNT_ID, + }); + const policy = createMock({approver: EMPLOYEE_EMAIL, archivedDate: '2026-08-01 00:00:00'}); + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + isProduction: false, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(false); + }); + it('includes UNAPPROVE option for admin on finally approved report', () => { const report = createMock({ reportID: REPORT_ID, @@ -1803,6 +1945,150 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); }); + it('excludes CANCEL_PAYMENT option on an archived policy', () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + managerID: EMPLOYEE_ACCOUNT_ID, + }); + const policy = createMock({ + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + archivedDate: '2026-08-01 00:00:00', + }); + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + isProduction: false, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(false); + }); + + it('includes RETRACT option for submitter on processing report when the report is archived but the policy is not', () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }); + const policy = createMock({}); + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + moveExpenseReportNameValuePairs: { + [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: new Date().toString()}, + }, + isChatReportArchived: true, + isProduction: false, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RETRACT)).toBe(true); + }); + + it('excludes RETRACT option on an archived policy', () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }); + const policy = createMock({archivedDate: '2026-08-01 00:00:00'}); + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + isProduction: false, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RETRACT)).toBe(false); + }); + + it('includes REOPEN option for admin on closed report when the report is archived but the policy is not', () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + }); + const policy = createMock({role: CONST.POLICY.ROLE.ADMIN}); + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + moveExpenseReportNameValuePairs: { + [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: new Date().toString()}, + }, + isChatReportArchived: true, + isProduction: false, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.REOPEN)).toBe(true); + }); + + it('excludes REOPEN option on an archived policy', () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + }); + const policy = createMock({role: CONST.POLICY.ROLE.ADMIN, archivedDate: '2026-08-01 00:00:00'}); + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + isProduction: false, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.REOPEN)).toBe(false); + }); + it('includes CANCEL_PAYMENT option for report before nacha cutoff', async () => { const report = createMock({ reportID: REPORT_ID, From 38d3cf81bd6b1c47039acb6d1524106fb9e8414b Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Wed, 19 Aug 2026 14:53:25 +0530 Subject: [PATCH 02/13] Gate preview and pay button actions on archived policies --- src/libs/ReportPreviewActionUtils.ts | 20 +++++++++++++++----- src/libs/ReportPrimaryActionUtils.ts | 6 +++--- src/libs/actions/IOU/ReportWorkflow.ts | 20 ++++++++++++++------ src/libs/actions/Policy/Policy.ts | 4 ++++ tests/actions/IOUTest/ReportWorkflowTest.ts | 15 +++++++++++++++ 5 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 8dbc8047cffe..efd732f132d1 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -11,6 +11,7 @@ import { getValidConnectedIntegration, hasDynamicExternalWorkflow, hasIntegrationAutoSync, + isArchivedPolicy, isPreferredExporter, isSubmitterApproveBlockedOnSubmitWorkspace, } from './PolicyUtils'; @@ -37,7 +38,6 @@ import {hasOnlyPendingCardTransactions, hasSmartScanFailedWithMissingFields, has function canSubmit( report: Report, - isReportArchived: boolean, currentUserAccountID: number, currentUserEmail: string, ownerLogin: string | undefined, @@ -45,7 +45,9 @@ function canSubmit( policy?: Policy, transactions?: Transaction[], ) { - if (isReportArchived) { + // State transitions are blocked only on archived policies. Reports archived for other reasons + // (e.g. the submitter was unshared from the policy) can still move through the workflow. + if (isArchivedPolicy(policy)) { return false; } @@ -77,6 +79,10 @@ function canSubmit( } function canApprove(report: Report, currentUserAccountID: number, reportMetadata: OnyxEntry, policy?: Policy, transactions?: Transaction[]) { + if (isArchivedPolicy(policy)) { + return false; + } + if (isSubmitterApproveBlockedOnSubmitWorkspace(policy, report.ownerAccountID, currentUserAccountID)) { return false; } @@ -122,7 +128,12 @@ function canPay( policy?: Policy, invoiceReceiverPolicy?: Policy, ) { - if (isReportArchived) { + const isExpense = isExpenseReport(report); + + // Expense reports cannot be paid when their policy is archived. Reports archived for other reasons + // (e.g. the submitter was unshared from the policy) can still be paid. IOU and invoice reports have + // no policy archived state, so an archived report blocks payment instead. + if (isExpense ? isArchivedPolicy(policy) : isReportArchived) { return false; } @@ -130,7 +141,6 @@ function canPay( const canPayReport = isReportPayer || (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); - const isExpense = isExpenseReport(report); const isPaymentsEnabled = arePaymentsEnabled(policy); const isProcessing = isProcessingReport(report); const isApprovalEnabled = policy ? policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false; @@ -263,7 +273,7 @@ function getReportPreviewAction({ return CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW; } - if (canSubmit(report, isReportArchived, currentUserAccountID, currentUserLogin, ownerLogin, violationsData, policy, transactions)) { + if (canSubmit(report, currentUserAccountID, currentUserLogin, ownerLogin, violationsData, policy, transactions)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT; } if (canApprove(report, currentUserAccountID, reportMetadata, policy, transactions)) { diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 67ce2cfc75c5..990668536bdc 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -222,9 +222,9 @@ function isPrimaryPayAction({ }: IsPrimaryPayActionParams) { const isExpenseReport = isExpenseReportUtils(report); - // Expense reports on archived policies cannot be paid, but reports archived for other reasons - // (e.g. the submitter was unshared from the policy) can. IOU and invoice reports have no policy - // archived state, so they keep the archived report/chat restriction. + // Expense reports cannot be paid when their policy is archived. Reports archived for other reasons + // (e.g. the submitter was unshared from the policy) can still be paid. IOU and invoice reports have + // no policy archived state, so an archived report or chat blocks payment instead. if (isExpenseReport ? isArchivedPolicy(policy) : isArchivedReport(reportNameValuePairs) || isChatReportArchived) { return false; } diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 94ddd5339053..29912c6b7fff 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -24,6 +24,7 @@ import { getAccountIDForSubmitManagerEmail, getSubmitReportManagerAccountID, hasDynamicExternalWorkflow, + isArchivedPolicy, isPaidGroupPolicy, isSubmitAndClose, isSubmitPolicy, @@ -156,6 +157,12 @@ function canApproveIOU( return false; } + // State transitions are blocked only on archived policies. Reports archived for other reasons + // (e.g. the submitter was unshared from the policy) can still move through the workflow. + if (isArchivedPolicy(policy)) { + return false; + } + // On a Submit workspace the submitter is also the report manager, so hide Approve for reports they submitted. // Mark as paid stays available via the pay flow. This is checked before the paid-group gate so it keeps hiding // Approve for the submitter even though Submit workspaces now show Approve (which routes to the upgrade modal) for other users. @@ -184,8 +191,6 @@ function canApproveIOU( const isOpenExpenseReport = isOpenExpenseReportReportUtils(iouReport); const isApproved = isReportApproved({report: iouReport}); const iouSettled = isSettled(iouReport); - const reportNameValuePairs = getAllReportNameValuePairs()?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${iouReport?.reportID}`]; - const isArchivedExpenseReport = isArchivedReport(reportNameValuePairs); const reportTransactions = iouTransactions ?? getReportTransactions(iouReport?.reportID); const hasOnlyPendingCardOrScanningTransactions = reportTransactions.length > 0 && reportTransactions.every((transaction) => isScanning(transaction) || isPending(transaction)); if (hasOnlyPendingCardOrScanningTransactions) { @@ -193,9 +198,7 @@ function canApproveIOU( } const isPayAtEndExpenseReport = isPayAtEndExpenseReportReportUtils(iouReport ?? undefined, reportTransactions); const isClosedReport = isClosedReportUtil(iouReport); - return ( - reportTransactions.length > 0 && isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedExpenseReport && !isPayAtEndExpenseReport && !isClosedReport - ); + return reportTransactions.length > 0 && isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isPayAtEndExpenseReport && !isClosedReport; } function canIOUBePaid( @@ -265,12 +268,17 @@ function canIOUBePaid( return false; } + // Expense reports cannot be paid when their policy is archived. Reports archived for other reasons + // (e.g. the submitter was unshared from the policy) can still be paid. IOU reports have no policy + // archived state, so an archived chat blocks payment instead. + const isBlockedByArchivedState = isExpenseReport(iouReport) ? isArchivedPolicy(policy) : isChatReportArchived; + return ( canPay && isReportFinished && !iouSettled && (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount || isOnlyNonReimbursablePayElsewhere) && - !isChatReportArchived && + !isBlockedByArchivedState && !isAutoReimbursable && !isPayAtEndExpenseReport && (!isExpenseReport(iouReport) || arePaymentsEnabled(policy as OnyxEntry)) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 1bb653c6f6e4..cb07d04b73da 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -461,6 +461,9 @@ function deleteWorkspace(params: DeleteWorkspaceActionParams) { value: { avatarURL: '', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + // Deleting a workspace archives its policy, and report state transitions (Submit, Approve, + // Pay, ...) are gated on the policy archived state, so mark it archived right away. + archivedDate: DateUtils.getDBTime(), errors: null, }, }, @@ -527,6 +530,7 @@ function deleteWorkspace(params: DeleteWorkspaceActionParams) { value: { avatarURL: policy?.avatarURL, pendingAction: null, + archivedDate: policy?.archivedDate ?? null, }, }, { diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index c43997c59837..001426abd830 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -662,6 +662,21 @@ describe('actions/IOU/ReportWorkflow', () => { } return waitForBatchedUpdates(); }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (allPolicies) => { + Onyx.disconnect(connection); + policy = Object.values(allPolicies ?? {}).find((p): p is OnyxEntry => p?.id === policy?.id); + // Deleting the workspace archives its policy + expect(policy?.archivedDate).toBeTruthy(); + resolve(); + }, + }); + }), + ) .then( () => new Promise((resolve) => { From 5cf066bafe28d7ca5bff8bf5fe88ea63ca826e84 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Wed, 19 Aug 2026 17:19:59 +0530 Subject: [PATCH 03/13] Simulate backend policy archiving in test instead of optimistic data --- src/libs/actions/Policy/Policy.ts | 4 ---- tests/actions/IOUTest/ReportWorkflowTest.ts | 7 +++++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index cb07d04b73da..1bb653c6f6e4 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -461,9 +461,6 @@ function deleteWorkspace(params: DeleteWorkspaceActionParams) { value: { avatarURL: '', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - // Deleting a workspace archives its policy, and report state transitions (Submit, Approve, - // Pay, ...) are gated on the policy archived state, so mark it archived right away. - archivedDate: DateUtils.getDBTime(), errors: null, }, }, @@ -530,7 +527,6 @@ function deleteWorkspace(params: DeleteWorkspaceActionParams) { value: { avatarURL: policy?.avatarURL, pendingAction: null, - archivedDate: policy?.archivedDate ?? null, }, }, { diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index 4fb72f88a546..67d543e28abe 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -1131,6 +1131,11 @@ describe('actions/IOU/ReportWorkflow', () => { } return waitForBatchedUpdates(); }) + .then(() => { + // The backend archives the policy when the workspace is deleted. Simulate its + // response since the optimistic data doesn't set archivedDate. + return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, {archivedDate: DateUtils.getDBTime()}); + }) .then( () => new Promise((resolve) => { @@ -1139,8 +1144,6 @@ describe('actions/IOU/ReportWorkflow', () => { callback: (allPolicies) => { Onyx.disconnect(connection); policy = Object.values(allPolicies ?? {}).find((p): p is OnyxEntry => p?.id === policy?.id); - // Deleting the workspace archives its policy - expect(policy?.archivedDate).toBeTruthy(); resolve(); }, }); From 258f3cc3f5b098459729cae7089299559bb50569 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Wed, 19 Aug 2026 17:51:06 +0530 Subject: [PATCH 04/13] cleanup comments --- tests/actions/IOUTest/ReportWorkflowTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index 67d543e28abe..36c023b28f8d 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -1132,8 +1132,8 @@ describe('actions/IOU/ReportWorkflow', () => { return waitForBatchedUpdates(); }) .then(() => { - // The backend archives the policy when the workspace is deleted. Simulate its - // response since the optimistic data doesn't set archivedDate. + // Delete workspace action will be replaced with archive workspace. + // Simulate archive workspace response with merging archivedDate to the policy. return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy?.id}`, {archivedDate: DateUtils.getDBTime()}); }) .then( From 596646c03ff79755937154f65fae8c35bde90952 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Wed, 19 Aug 2026 21:01:20 +0530 Subject: [PATCH 05/13] Treat policies pending deletion as archived in transition gates --- src/libs/PolicyUtils.ts | 10 ++++++ src/libs/ReportPreviewActionUtils.ts | 12 +++---- src/libs/ReportPrimaryActionUtils.ts | 12 +++---- src/libs/ReportSecondaryActionUtils.ts | 16 ++++----- src/libs/actions/IOU/ReportWorkflow.ts | 10 +++--- tests/unit/ReportPrimaryActionUtilsTest.ts | 33 ++++++++++++++++++ tests/unit/ReportSecondaryActionUtilsTest.ts | 35 ++++++++++++++++++++ tests/utils/collections/policies.ts | 4 ++- 8 files changed, 106 insertions(+), 26 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index a3bd4ec81852..fdc50478a155 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -95,6 +95,15 @@ function isArchivedPolicy(policy: OnyxInputOrEntry): boolean { return !!policy?.archivedDate; } +/** + * Whether the policy is archived or is optimistically pending deletion. Deleting a workspace + * archives it on the backend, but the optimistic data only sets pendingAction, so report state + * transitions must also treat a pending delete as archived while the request is in flight. + */ +function isArchivedOrPendingDeletePolicy(policy: OnyxInputOrEntry): boolean { + return isArchivedPolicy(policy) || policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; +} + /** * Filter out the active policies, which will exclude policies with pending deletion * and policies the current user doesn't belong to. @@ -3109,6 +3118,7 @@ export { arePolicyRulesEnabled, isPolicyFeatureEnabled, isPolicyFieldListEmpty, + isArchivedOrPendingDeletePolicy, isArchivedPolicy, getUberConnectionErrorDirectlyFromPolicy, isPolicyOwner, diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 84390634ecd5..ca19842d6ccb 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -11,7 +11,7 @@ import { getValidConnectedIntegration, hasDynamicExternalWorkflow, hasIntegrationAutoSync, - isArchivedPolicy, + isArchivedOrPendingDeletePolicy, isGroupPolicy, isPreferredExporter, isSubmitterApproveBlockedOnSubmitWorkspace, @@ -46,9 +46,9 @@ function canSubmit( policy?: Policy, transactions?: Transaction[], ) { - // State transitions are blocked only on archived policies. Reports archived for other reasons + // State transitions are blocked only on archived or pending-delete policies. Reports archived for other reasons // (e.g. the submitter was unshared from the policy) can still move through the workflow. - if (isArchivedPolicy(policy)) { + if (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -80,7 +80,7 @@ function canSubmit( } function canApprove(report: Report, currentUserAccountID: number, reportMetadata: OnyxEntry, policy?: Policy, transactions?: Transaction[]) { - if (isArchivedPolicy(policy)) { + if (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -131,10 +131,10 @@ function canPay( ) { const isExpense = isExpenseReport(report); - // Expense reports cannot be paid when their policy is archived. Reports archived for other reasons + // Expense reports cannot be paid when their policy is archived or pending delete. Reports archived for other reasons // (e.g. the submitter was unshared from the policy) can still be paid. IOU and invoice reports have // no policy archived state, so an archived report blocks payment instead. - if (isExpense ? isArchivedPolicy(policy) : isReportArchived) { + if (isExpense ? isArchivedOrPendingDeletePolicy(policy) : isReportArchived) { return false; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 16c247d252e1..828fbcf93f99 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -13,7 +13,7 @@ import { getValidConnectedIntegration, hasDynamicExternalWorkflow, hasIntegrationAutoSync, - isArchivedPolicy, + isArchivedOrPendingDeletePolicy, isGroupPolicy, isPaidGroupPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils, @@ -123,9 +123,9 @@ function isSubmitAction( currentUserEmail?: string, currentUserAccountID?: number, ) { - // State transitions are blocked only on archived policies. Reports archived for other reasons + // State transitions are blocked only on archived or pending-delete policies. Reports archived for other reasons // (e.g. the submitter was unshared from the policy) can still move through the workflow. - if (isArchivedPolicy(policy)) { + if (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -163,7 +163,7 @@ function isSubmitAction( } function isApproveAction(report: Report, reportTransactions: Transaction[], currentUserAccountID: number, reportMetadata: OnyxEntry, policy?: Policy) { - if (isArchivedPolicy(policy)) { + if (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -222,10 +222,10 @@ function isPrimaryPayAction({ }: IsPrimaryPayActionParams) { const isExpenseReport = isExpenseReportUtils(report); - // Expense reports cannot be paid when their policy is archived. Reports archived for other reasons + // Expense reports cannot be paid when their policy is archived or pending delete. Reports archived for other reasons // (e.g. the submitter was unshared from the policy) can still be paid. IOU and invoice reports have // no policy archived state, so an archived report or chat blocks payment instead. - if (isExpenseReport ? isArchivedPolicy(policy) : isArchivedReport(reportNameValuePairs) || isChatReportArchived) { + if (isExpenseReport ? isArchivedOrPendingDeletePolicy(policy) : isArchivedReport(reportNameValuePairs) || isChatReportArchived) { return false; } if (isExpenseReport && !isPaidGroupPolicy(policy)) { diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 6ff7e2bfd478..aea435f027dd 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -25,7 +25,7 @@ import { getSubmitToAccountID, getValidConnectedIntegration, hasDynamicExternalWorkflow, - isArchivedPolicy, + isArchivedOrPendingDeletePolicy, isGroupPolicy, isInstantSubmitEnabled, isPolicyAdmin, @@ -213,9 +213,9 @@ function isSubmitAction({ currentUserAccountID: number; ownerLogin: string | undefined; }): boolean { - // State transitions are blocked only on archived policies. Reports archived for other reasons + // State transitions are blocked only on archived or pending-delete policies. Reports archived for other reasons // (e.g. the submitter was unshared from the policy) can still move through the workflow. - if (isArchivedPolicy(policy)) { + if (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -306,7 +306,7 @@ function isApproveAction( reportMetadata: OnyxEntry, policy?: Policy, ): boolean { - if (isArchivedPolicy(policy)) { + if (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -377,7 +377,7 @@ function isApproveAction( } function isUnapproveAction(currentUserLogin: string, currentUserAccountID: number, report: Report, policy?: Policy): boolean { - if (isArchivedPolicy(policy)) { + if (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -437,7 +437,7 @@ function isCancelPaymentAction( return false; } - if (isExpenseReport && isArchivedPolicy(policy)) { + if (isExpenseReport && isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -740,7 +740,7 @@ function shouldShowEditSplitInDeleteAction( } function isRetractAction(report: Report, policy?: Policy): boolean { - if (isArchivedPolicy(policy)) { + if (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -767,7 +767,7 @@ function isRetractAction(report: Report, policy?: Policy): boolean { } function isReopenAction(report: Report, policy?: Policy): boolean { - if (isArchivedPolicy(policy)) { + if (isArchivedOrPendingDeletePolicy(policy)) { return false; } diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 78d21be52485..ea5f2b45919b 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -24,7 +24,7 @@ import { getAccountIDForSubmitManagerEmail, getSubmitReportManagerAccountID, hasDynamicExternalWorkflow, - isArchivedPolicy, + isArchivedOrPendingDeletePolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitAndClose, @@ -161,9 +161,9 @@ function canApproveIOU( return false; } - // State transitions are blocked only on archived policies. Reports archived for other reasons + // State transitions are blocked only on archived or pending-delete policies. Reports archived for other reasons // (e.g. the submitter was unshared from the policy) can still move through the workflow. - if (isArchivedPolicy(policy)) { + if (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -276,10 +276,10 @@ function canIOUBePaid( return false; } - // Expense reports cannot be paid when their policy is archived. Reports archived for other reasons + // Expense reports cannot be paid when their policy is archived or pending delete. Reports archived for other reasons // (e.g. the submitter was unshared from the policy) can still be paid. IOU reports have no policy // archived state, so an archived chat blocks payment instead. - const isBlockedByArchivedState = isExpenseReport(iouReport) ? isArchivedPolicy(policy) : isChatReportArchived; + const isBlockedByArchivedState = isExpenseReport(iouReport) ? isArchivedOrPendingDeletePolicy(policy) : isChatReportArchived; return ( canPay && diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index 54a61280cbd4..fb00094d0d57 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -195,6 +195,39 @@ describe('getPrimaryAction', () => { ).toBe(''); }); + it('should return empty string for open expense report on a policy pending deletion', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = createMock({ + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + + expect( + getReportPrimaryAction({ + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + ownerLogin: '', + chatReport, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy, + isChatReportArchived: false, + }), + ).toBe(''); + }); + it('should not return SUBMIT when every transaction is on hold', async () => { const report = createMock({ reportID: REPORT_ID, diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 2fcfbe5a9952..a17786c494fb 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -484,6 +484,41 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); + it('excludes SUBMIT option on a policy pending deletion', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 10, + }); + const policy = createMock({ + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, + harvesting: { + enabled: true, + }, + type: CONST.POLICY.TYPE.CORPORATE, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + isProduction: false, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); + }); + it('includes SUBMIT option when every transaction is on hold', async () => { const report = createMock({ reportID: REPORT_ID, diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index 9a7ed8ede20c..9f38695e9fce 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -32,7 +32,9 @@ export default function createRandomPolicy(index: number, type?: ValueOf action !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)), errors: {}, customUnits: {}, errorFields: {}, From 9a7700cc1739555e747edc80e5dc5e69d61ac7b1 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Wed, 19 Aug 2026 21:52:01 +0530 Subject: [PATCH 06/13] cleanup comments --- tests/utils/collections/policies.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index 9f38695e9fce..4d2829ca3060 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -32,8 +32,7 @@ export default function createRandomPolicy(index: number, type?: ValueOf action !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)), errors: {}, customUnits: {}, From 3a2bffba32e7db7cc5ca7765bb6d91760aaa3f41 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Wed, 19 Aug 2026 22:10:42 +0530 Subject: [PATCH 07/13] Key Search submit action on policy archived state --- src/libs/SearchUIUtils.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index fac95bc842c1..dc9ce47959fe 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -126,6 +126,7 @@ import { getCommaSeparatedTagNameWithSanitizedColons, getSubmitToAccountID, getTagGLCode, + isArchivedOrPendingDeletePolicy, isGroupPolicy, isPaidGroupPolicy, isPolicyAdmin, @@ -2539,10 +2540,8 @@ function getActions( } const reportNVP = getReportNameValuePairsFromKey(data, report); - const isIOUReportArchived = isArchivedReport(reportNVP); const chatReportRNVP = data[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.chatReportID}`] ?? undefined; - const isChatReportArchived = isArchivedReport(chatReportRNVP); // Submit/Approve/Pay can only be taken on transactions if the transaction is the only one on the report, otherwise `View` is the only option. // If this condition is not met, return early for performance reasons @@ -2597,7 +2596,7 @@ function getActions( // We check submit eligibility separately from approve: on Submit workspaces the popover picks // the manager, so don't block Submit when the default submit-to route is the owner. if ( - canSubmitReport(report, ownerLogin, policy, allReportTransactions, allViolations, isIOUReportArchived || isChatReportArchived, currentUserLogin, currentUserAccountID) && + canSubmitReport(report, ownerLogin, policy, allReportTransactions, allViolations, isArchivedOrPendingDeletePolicy(policy), currentUserLogin, currentUserAccountID) && isSubmitActionAllowedForSearch(report, policy, submitToAccountID, currentUserAccountID) ) { allActions.push(CONST.SEARCH.ACTION_TYPES.SUBMIT); From e8c1082113c4690b97a35d3b456a46a2a5332981 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Thu, 20 Aug 2026 14:05:54 +0530 Subject: [PATCH 08/13] Hide Reject on archived or pending-delete policies --- .../Search/SearchWriteActionsProvider.tsx | 4 ++-- src/components/Search/selectionBuilders.ts | 2 +- src/hooks/useSelectedTransactionsActions.ts | 2 +- src/libs/ReportSecondaryActionUtils.ts | 4 ++-- src/libs/ReportUtils.ts | 8 +++++++- tests/unit/ReportUtilsTest.ts | 16 ++++++++++++---- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 08a6ce556386..69b4bff6587a 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -215,7 +215,7 @@ function useReconcileSelectionWithData({ const liveSelectionEntry: SelectedTransactionInfo = { ...baseEntry, isSelected: !isExcluded && (areAllMatchingItemsSelected || !!previousSelection?.isSelected || propagateSelectionToAllRows), - canReject: transactionItem.report ? canRejectReportAction(transactionItem.report, currentUserAccountID) : false, + canReject: transactionItem.report ? canRejectReportAction(transactionItem.report, currentUserAccountID, transactionItem.policy) : false, policyID: transactionItem.report?.policyID, groupKey: previousSelection?.groupKey ?? (propagateSelectionToAllRows && !isExpenseReportType ? reportKey : undefined), isSelectedViaGroup: previousSelection?.isSelectedViaGroup, @@ -260,7 +260,7 @@ function useReconcileSelectionWithData({ const liveSelectionEntry: SelectedTransactionInfo = { ...baseEntry, isSelected: areAllMatchingItemsSelected || !!flatPreviousSelection?.isSelected, - canReject: transactionItem.report ? canRejectReportAction(transactionItem.report, currentUserAccountID) : false, + canReject: transactionItem.report ? canRejectReportAction(transactionItem.report, currentUserAccountID, transactionItem.policy) : false, policyID: transactionItem.report?.policyID, }; liveSelectionEntries.set(listKey, liveSelectionEntry); diff --git a/src/components/Search/selectionBuilders.ts b/src/components/Search/selectionBuilders.ts index 0ce3bedc8359..e0100f2f7c04 100644 --- a/src/components/Search/selectionBuilders.ts +++ b/src/components/Search/selectionBuilders.ts @@ -65,7 +65,7 @@ function mapTransactionItemToSelectedEntry({ parentReport, }: MapTransactionItemToSelectedEntryParams): [string, SelectedTransactionInfo] { const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(item.report, item.reportAction, item.holdReportAction, item, item.policy, currentUserAccountID); - const canRejectRequest = item.report ? canRejectReportAction(item.report, currentUserAccountID) : false; + const canRejectRequest = item.report ? canRejectReportAction(item.report, currentUserAccountID, item.policy) : false; const amount = hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : item.amount; const isUnreported = isExpenseUnreported(item); const reportForSplit = item.report ?? (isUnreported ? selfDMReport : undefined); diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index dae9a46e6faf..cf85ebc1dafb 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -370,7 +370,7 @@ function useSelectedTransactionsActions({ const hasNoRejectedTransaction = selectedTransactionIDs.every((id) => !hasTransactionBeenRejected(allTransactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + id] ?? [])); const canRejectTransactions = - selectedTransactionsList.length > 0 && isMoneyRequestReport && !!report && canRejectReportAction(report, session?.accountID) && hasNoRejectedTransaction; + selectedTransactionsList.length > 0 && isMoneyRequestReport && !!report && canRejectReportAction(report, session?.accountID, policy) && hasNoRejectedTransaction; if (canRejectTransactions) { options.push({ text: translate('search.bulkActions.reject'), diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index aea435f027dd..ec9fd46716e0 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -1095,7 +1095,7 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.REMOVE_HOLD); } - if (canRejectReportAction(report, currentUserAccountID)) { + if (canRejectReportAction(report, currentUserAccountID, policy)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.REJECT); } @@ -1230,7 +1230,7 @@ function getSecondaryTransactionThreadActions({ options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REMOVE_HOLD); } - if (canRejectReportAction(parentReport, currentUserAccountID)) { + if (canRejectReportAction(parentReport, currentUserAccountID, policy)) { options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 73715eabf92b..f43b0ed08ba8 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -163,6 +163,7 @@ import { canMemberWrite as canMemberWritePolicyUtils, hasDependentTags as hasDependentTagsPolicyUtils, hasDynamicExternalWorkflow, + isArchivedOrPendingDeletePolicy, isExpensifyTeam, isGroupPolicyByType, isGroupPolicy as isGroupPolicyPolicyUtils, @@ -13521,7 +13522,7 @@ function getReportPersonalDetailsParticipants(report: Report, personalDetailsPar }; } -function canRejectReportAction(report: Report, currentUserAccountID: number | undefined): boolean { +function canRejectReportAction(report: Report, currentUserAccountID: number | undefined, policy: OnyxEntry): boolean { const isReportBeingProcessed = isProcessingReport(report); const isIOU = isIOUReport(report); const isInvoice = isInvoiceReport(report); @@ -13531,6 +13532,11 @@ function canRejectReportAction(report: Report, currentUserAccountID: number | un return false; } + // Rejecting changes the report state, which is blocked on archived or pending-delete policies. + if (isArchivedOrPendingDeletePolicy(policy)) { + return false; + } + if (isIOU) { return false; // Disable IOU } diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index e45057c18c78..44ba0ed2e4fd 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -14748,19 +14748,27 @@ describe('ReportUtils', () => { }); it('should return false if the user is not the report manager', () => { - expect(canRejectReportAction(buildReportToReject(), 2)).toBe(false); + expect(canRejectReportAction(buildReportToReject(), 2, undefined)).toBe(false); }); it('should return false when no account ID is passed', () => { - expect(canRejectReportAction(buildReportToReject(), undefined)).toBe(false); + expect(canRejectReportAction(buildReportToReject(), undefined, undefined)).toBe(false); }); it('should return true if the passed user is the manager of a report being processed', () => { - expect(canRejectReportAction(buildReportToReject(), managerAccountID)).toBe(true); + expect(canRejectReportAction(buildReportToReject(), managerAccountID, undefined)).toBe(true); }); it('should return false for IOU reports even when the passed user is the manager', () => { - expect(canRejectReportAction({...buildReportToReject(), type: CONST.REPORT.TYPE.IOU}, managerAccountID)).toBe(false); + expect(canRejectReportAction({...buildReportToReject(), type: CONST.REPORT.TYPE.IOU}, managerAccountID, undefined)).toBe(false); + }); + + it('should return false when the policy is archived', () => { + expect(canRejectReportAction(buildReportToReject(), managerAccountID, {...createRandomPolicy(0), archivedDate: '2026-08-19 00:00:00'})).toBe(false); + }); + + it('should return false when the policy is pending deletion', () => { + expect(canRejectReportAction(buildReportToReject(), managerAccountID, {...createRandomPolicy(0), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE})).toBe(false); }); }); From 9ea305917937ec9524a43adac58643cb53d78ed1 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Thu, 20 Aug 2026 14:25:53 +0530 Subject: [PATCH 09/13] Extract shared isPayBlockedByArchivedState helper --- src/libs/ReportPreviewActionUtils.ts | 6 ++---- src/libs/ReportPrimaryActionUtils.ts | 6 ++---- src/libs/ReportUtils.ts | 10 ++++++++++ src/libs/actions/IOU/ReportWorkflow.ts | 8 ++------ 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index ca19842d6ccb..8ddea54d16a7 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -30,6 +30,7 @@ import { isInvoiceReport, isIOUReport, isOpenReport, + isPayBlockedByArchivedState, isPayer, isProcessingReport, isReportApproved, @@ -131,10 +132,7 @@ function canPay( ) { const isExpense = isExpenseReport(report); - // Expense reports cannot be paid when their policy is archived or pending delete. Reports archived for other reasons - // (e.g. the submitter was unshared from the policy) can still be paid. IOU and invoice reports have - // no policy archived state, so an archived report blocks payment instead. - if (isExpense ? isArchivedOrPendingDeletePolicy(policy) : isReportArchived) { + if (isPayBlockedByArchivedState(report, policy, isReportArchived)) { return false; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 828fbcf93f99..64776f21d470 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -49,6 +49,7 @@ import { isInvoiceReport as isInvoiceReportUtils, isIOUReport as isIOUReportUtils, isOpenReport as isOpenReportUtils, + isPayBlockedByArchivedState, isPayer, isProcessingReport as isProcessingReportUtils, isReportApproved as isReportApprovedUtils, @@ -222,10 +223,7 @@ function isPrimaryPayAction({ }: IsPrimaryPayActionParams) { const isExpenseReport = isExpenseReportUtils(report); - // Expense reports cannot be paid when their policy is archived or pending delete. Reports archived for other reasons - // (e.g. the submitter was unshared from the policy) can still be paid. IOU and invoice reports have - // no policy archived state, so an archived report or chat blocks payment instead. - if (isExpenseReport ? isArchivedOrPendingDeletePolicy(policy) : isArchivedReport(reportNameValuePairs) || isChatReportArchived) { + if (isPayBlockedByArchivedState(report, policy, isArchivedReport(reportNameValuePairs) || !!isChatReportArchived)) { return false; } if (isExpenseReport && !isPaidGroupPolicy(policy)) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f43b0ed08ba8..0e2bf47f2e16 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13552,6 +13552,15 @@ function canRejectReportAction(report: Report, currentUserAccountID: number | un return false; } +/** + * Whether Pay is blocked by archived state. Expense reports key on the policy archived or pending-delete state, + * so reports archived for other reasons (e.g. the submitter was unshared from the policy) can still be paid. + * IOU and invoice reports have no policy archived state, so the passed report/chat archived flag blocks instead. + */ +function isPayBlockedByArchivedState(report: OnyxInputOrEntry, policy: OnyxInputOrEntry, isReportOrChatArchived: boolean): boolean { + return isExpenseReport(report) ? isArchivedOrPendingDeletePolicy(policy) : isReportOrChatArchived; +} + function hasReportBeenReopened(report: OnyxEntry, reportActions?: OnyxEntry | ReportAction[]): boolean { if (report?.hasReportBeenReopened !== undefined) { return report.hasReportBeenReopened; @@ -14502,6 +14511,7 @@ export { pushTransactionAutoSelectionsOnyxData, navigateOnDeleteExpense, canRejectReportAction, + isPayBlockedByArchivedState, hasReportBeenReopened, hasReportBeenRetracted, getNextApproverAccountID, diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index ea5f2b45919b..d276efe7d16d 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -61,6 +61,7 @@ import { isOpenExpenseReport as isOpenExpenseReportReportUtils, isOpenInvoiceReport as isOpenInvoiceReportReportUtils, isPayAtEndExpenseReport as isPayAtEndExpenseReportReportUtils, + isPayBlockedByArchivedState, isPayer as isPayerReportUtils, isProcessingReport, isReportApproved, @@ -276,17 +277,12 @@ function canIOUBePaid( return false; } - // Expense reports cannot be paid when their policy is archived or pending delete. Reports archived for other reasons - // (e.g. the submitter was unshared from the policy) can still be paid. IOU reports have no policy - // archived state, so an archived chat blocks payment instead. - const isBlockedByArchivedState = isExpenseReport(iouReport) ? isArchivedOrPendingDeletePolicy(policy) : isChatReportArchived; - return ( canPay && isReportFinished && !iouSettled && (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount || isOnlyNonReimbursablePayElsewhere) && - !isBlockedByArchivedState && + !isPayBlockedByArchivedState(iouReport, policy, isChatReportArchived) && !isAutoReimbursable && !isPayAtEndExpenseReport && (!isExpenseReport(iouReport) || arePaymentsEnabled(policy as OnyxEntry)) From c4ad9f30a25720f06b7c21e4ba99171140b20d69 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Thu, 27 Aug 2026 21:29:52 +0530 Subject: [PATCH 10/13] Drop removed isProduction param from tests --- tests/unit/ReportSecondaryActionUtilsTest.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 7fbb3f8b73fd..09f69c52ae6a 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -436,7 +436,6 @@ describe('getSecondaryAction', () => { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: new Date().toString()}, }, isChatReportArchived: true, - isProduction: false, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); @@ -471,7 +470,6 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, - isProduction: false, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); @@ -506,7 +504,6 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, - isProduction: false, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); @@ -1279,7 +1276,6 @@ describe('getSecondaryAction', () => { violations: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}, bankAccountList: {}, policy, - isProduction: false, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); }); @@ -1741,7 +1737,6 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, - isProduction: false, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(false); }); @@ -2043,7 +2038,6 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, - isProduction: false, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(false); }); @@ -2073,7 +2067,6 @@ describe('getSecondaryAction', () => { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: new Date().toString()}, }, isChatReportArchived: true, - isProduction: false, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RETRACT)).toBe(true); }); @@ -2099,7 +2092,6 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, - isProduction: false, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RETRACT)).toBe(false); }); @@ -2129,7 +2121,6 @@ describe('getSecondaryAction', () => { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: new Date().toString()}, }, isChatReportArchived: true, - isProduction: false, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.REOPEN)).toBe(true); }); @@ -2155,7 +2146,6 @@ describe('getSecondaryAction', () => { violations: {}, bankAccountList: {}, policy, - isProduction: false, }); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.REOPEN)).toBe(false); }); From 14378cf724b2f879bd0aa3b1cae034941c318a94 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Fri, 28 Aug 2026 12:22:15 +0530 Subject: [PATCH 11/13] Consolidate archived-policy secondary action tests into parametrized tests --- tests/unit/ReportSecondaryActionUtilsTest.ts | 317 +++++-------------- 1 file changed, 78 insertions(+), 239 deletions(-) diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 09f69c52ae6a..3e7e2906bce5 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -16,6 +16,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, ReportAction, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; import type {Connections} from '@src/types/onyx/Policy'; +import type {ValueOf} from 'type-fest'; + import Onyx from 'react-native-onyx'; import {actionR14932, originalMessageR14932} from '../../__mocks__/reportData/actions'; @@ -403,77 +405,6 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); - it('includes SUBMIT option when the report is archived but the policy is not', async () => { - const report = createMock({ - reportID: REPORT_ID, - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: EMPLOYEE_ACCOUNT_ID, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - total: 10, - }); - const policy = createMock({ - autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, - harvesting: { - enabled: true, - }, - type: CONST.POLICY.TYPE.CORPORATE, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); - - const result = getSecondaryReportActions({ - currentUserLogin: EMPLOYEE_EMAIL, - currentUserAccountID: EMPLOYEE_ACCOUNT_ID, - submitterLogin: '', - report, - chatReport, - reportTransactions: [], - originalTransaction: createMock({}), - violations: {}, - bankAccountList: {}, - policy, - moveExpenseReportNameValuePairs: { - [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: new Date().toString()}, - }, - isChatReportArchived: true, - }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); - }); - - it('excludes SUBMIT option on an archived policy', async () => { - const report = createMock({ - reportID: REPORT_ID, - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: EMPLOYEE_ACCOUNT_ID, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - total: 10, - }); - const policy = createMock({ - autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, - harvesting: { - enabled: true, - }, - type: CONST.POLICY.TYPE.CORPORATE, - archivedDate: '2026-08-01 00:00:00', - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); - - const result = getSecondaryReportActions({ - currentUserLogin: EMPLOYEE_EMAIL, - currentUserAccountID: EMPLOYEE_ACCOUNT_ID, - submitterLogin: '', - report, - chatReport, - reportTransactions: [], - originalTransaction: createMock({}), - violations: {}, - bankAccountList: {}, - policy, - }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); - }); - it('excludes SUBMIT option on a policy pending deletion', async () => { const report = createMock({ reportID: REPORT_ID, @@ -1239,47 +1170,6 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(true); }); - it('does not include APPROVE option on an archived policy', async () => { - const report = createMock({ - reportID: REPORT_ID, - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: EMPLOYEE_ACCOUNT_ID, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - managerID: EMPLOYEE_ACCOUNT_ID, - }); - const policy = createMock({ - approver: EMPLOYEE_EMAIL, - archivedDate: '2026-08-01 00:00:00', - }); - const TRANSACTION_ID = 'TRANSACTION_ID'; - const transaction = createMock({ - transactionID: TRANSACTION_ID, - }); - - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); - - const violation = createMock({ - name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, - }); - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); - - const result = getSecondaryReportActions({ - currentUserLogin: EMPLOYEE_EMAIL, - currentUserAccountID: EMPLOYEE_ACCOUNT_ID, - submitterLogin: '', - report, - chatReport, - reportTransactions: [transaction], - originalTransaction: createMock({}), - violations: {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]}, - bankAccountList: {}, - policy, - }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE)).toBe(false); - }); - it('does not include APPROVE option for approver and report with only pending transactions', async () => { const report = createMock({ reportID: REPORT_ID, @@ -1715,32 +1605,6 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(true); }); - it('excludes UNAPPROVE option on an archived policy', () => { - const report = createMock({ - reportID: REPORT_ID, - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: EMPLOYEE_ACCOUNT_ID, - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: EMPLOYEE_ACCOUNT_ID, - }); - const policy = createMock({approver: EMPLOYEE_EMAIL, archivedDate: '2026-08-01 00:00:00'}); - - const result = getSecondaryReportActions({ - currentUserLogin: EMPLOYEE_EMAIL, - currentUserAccountID: EMPLOYEE_ACCOUNT_ID, - submitterLogin: '', - report, - chatReport, - reportTransactions: [], - originalTransaction: createMock({}), - violations: {}, - bankAccountList: {}, - policy, - }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE)).toBe(false); - }); - it('includes UNAPPROVE option for admin on finally approved report', () => { const report = createMock({ reportID: REPORT_ID, @@ -2011,100 +1875,100 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); }); - it('excludes CANCEL_PAYMENT option on an archived policy', () => { + // Each workflow action requires a different eligible report state, so every case carries its own report and + // policy data. Each case first asserts the action is available without archivedDate to guarantee the exclusion + // assertion is not passing vacuously. + it.each<{action: ValueOf; reportData: Partial; policyData: Partial; hasDuplicateViolation?: boolean}>([ + { + action: CONST.REPORT.SECONDARY_ACTIONS.SUBMIT, + reportData: {stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, total: 10}, + policyData: {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, harvesting: {enabled: true}, type: CONST.POLICY.TYPE.CORPORATE}, + }, + { + action: CONST.REPORT.SECONDARY_ACTIONS.APPROVE, + reportData: {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, managerID: EMPLOYEE_ACCOUNT_ID}, + policyData: {approver: EMPLOYEE_EMAIL}, + hasDuplicateViolation: true, + }, + { + action: CONST.REPORT.SECONDARY_ACTIONS.UNAPPROVE, + reportData: {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED, managerID: EMPLOYEE_ACCOUNT_ID}, + policyData: {approver: EMPLOYEE_EMAIL}, + }, + { + action: CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT, + reportData: {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, managerID: EMPLOYEE_ACCOUNT_ID}, + policyData: {role: CONST.POLICY.ROLE.ADMIN, type: CONST.POLICY.TYPE.TEAM, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL}, + }, + { + action: CONST.REPORT.SECONDARY_ACTIONS.RETRACT, + reportData: {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}, + policyData: {}, + }, + { + action: CONST.REPORT.SECONDARY_ACTIONS.REOPEN, + reportData: {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.CLOSED}, + policyData: {role: CONST.POLICY.ROLE.ADMIN}, + }, + ])('excludes $action option on an archived policy', async ({action, reportData, policyData, hasDuplicateViolation}) => { const report = createMock({ reportID: REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, ownerAccountID: EMPLOYEE_ACCOUNT_ID, - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, - managerID: EMPLOYEE_ACCOUNT_ID, + ...reportData, }); - const policy = createMock({ - role: CONST.POLICY.ROLE.ADMIN, - type: CONST.POLICY.TYPE.TEAM, - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - archivedDate: '2026-08-01 00:00:00', - }); - - const result = getSecondaryReportActions({ - currentUserLogin: EMPLOYEE_EMAIL, - currentUserAccountID: EMPLOYEE_ACCOUNT_ID, - submitterLogin: '', - report, - chatReport, - reportTransactions: [], - originalTransaction: createMock({}), - violations: {}, - bankAccountList: {}, - policy, - }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(false); - }); - - it('includes RETRACT option for submitter on processing report when the report is archived but the policy is not', () => { - const report = createMock({ - reportID: REPORT_ID, - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: EMPLOYEE_ACCOUNT_ID, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - }); - const policy = createMock({}); - - const result = getSecondaryReportActions({ - currentUserLogin: EMPLOYEE_EMAIL, - currentUserAccountID: EMPLOYEE_ACCOUNT_ID, - submitterLogin: '', - report, - chatReport, - reportTransactions: [], - originalTransaction: createMock({}), - violations: {}, - bankAccountList: {}, - policy, - moveExpenseReportNameValuePairs: { - [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: new Date().toString()}, - }, - isChatReportArchived: true, - }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RETRACT)).toBe(true); - }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); - it('excludes RETRACT option on an archived policy', () => { - const report = createMock({ - reportID: REPORT_ID, - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: EMPLOYEE_ACCOUNT_ID, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - }); - const policy = createMock({archivedDate: '2026-08-01 00:00:00'}); + const TRANSACTION_ID = 'TRANSACTION_ID'; + const transaction = createMock({transactionID: TRANSACTION_ID}); + if (hasDuplicateViolation) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + } + const violation = createMock({name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION}); - const result = getSecondaryReportActions({ + const baseArgs = { currentUserLogin: EMPLOYEE_EMAIL, currentUserAccountID: EMPLOYEE_ACCOUNT_ID, submitterLogin: '', report, chatReport, - reportTransactions: [], + reportTransactions: hasDuplicateViolation ? [transaction] : [], originalTransaction: createMock({}), - violations: {}, + violations: hasDuplicateViolation ? {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]} : {}, bankAccountList: {}, - policy, - }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.RETRACT)).toBe(false); - }); + }; - it('includes REOPEN option for admin on closed report when the report is archived but the policy is not', () => { + const resultWithoutArchive = getSecondaryReportActions({...baseArgs, policy: createMock(policyData)}); + expect(resultWithoutArchive.includes(action)).toBe(true); + + const resultWithArchive = getSecondaryReportActions({...baseArgs, policy: createMock({...policyData, archivedDate: '2026-08-01 00:00:00'})}); + expect(resultWithArchive.includes(action)).toBe(false); + }); + + it.each<{action: ValueOf; reportData: Partial; policyData: Partial}>([ + { + action: CONST.REPORT.SECONDARY_ACTIONS.SUBMIT, + reportData: {stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, total: 10}, + policyData: {autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, harvesting: {enabled: true}, type: CONST.POLICY.TYPE.CORPORATE}, + }, + { + action: CONST.REPORT.SECONDARY_ACTIONS.RETRACT, + reportData: {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}, + policyData: {}, + }, + { + action: CONST.REPORT.SECONDARY_ACTIONS.REOPEN, + reportData: {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.CLOSED}, + policyData: {role: CONST.POLICY.ROLE.ADMIN}, + }, + ])('includes $action option when the report is archived but the policy is not', async ({action, reportData, policyData}) => { const report = createMock({ reportID: REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, ownerAccountID: EMPLOYEE_ACCOUNT_ID, - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + ...reportData, }); - const policy = createMock({role: CONST.POLICY.ROLE.ADMIN}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); const result = getSecondaryReportActions({ currentUserLogin: EMPLOYEE_EMAIL, @@ -2116,38 +1980,13 @@ describe('getSecondaryAction', () => { originalTransaction: createMock({}), violations: {}, bankAccountList: {}, - policy, + policy: createMock(policyData), moveExpenseReportNameValuePairs: { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: new Date().toString()}, }, isChatReportArchived: true, }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.REOPEN)).toBe(true); - }); - - it('excludes REOPEN option on an archived policy', () => { - const report = createMock({ - reportID: REPORT_ID, - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: EMPLOYEE_ACCOUNT_ID, - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.CLOSED, - }); - const policy = createMock({role: CONST.POLICY.ROLE.ADMIN, archivedDate: '2026-08-01 00:00:00'}); - - const result = getSecondaryReportActions({ - currentUserLogin: EMPLOYEE_EMAIL, - currentUserAccountID: EMPLOYEE_ACCOUNT_ID, - submitterLogin: '', - report, - chatReport, - reportTransactions: [], - originalTransaction: createMock({}), - violations: {}, - bankAccountList: {}, - policy, - }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.REOPEN)).toBe(false); + expect(result.includes(action)).toBe(true); }); it('includes CANCEL_PAYMENT option for report before nacha cutoff', async () => { From a76475201702e5501f86b6b2ebbc85a6ffd61a22 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Fri, 28 Aug 2026 12:38:57 +0530 Subject: [PATCH 12/13] cleamnup --- tests/unit/ReportSecondaryActionUtilsTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 3e7e2906bce5..5f4522838f8c 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -1876,8 +1876,8 @@ describe('getSecondaryAction', () => { }); // Each workflow action requires a different eligible report state, so every case carries its own report and - // policy data. Each case first asserts the action is available without archivedDate to guarantee the exclusion - // assertion is not passing vacuously. + // policy data. Each case first asserts the action is available without archivedDate and then asserts the action + // is not available with archivedDate. it.each<{action: ValueOf; reportData: Partial; policyData: Partial; hasDuplicateViolation?: boolean}>([ { action: CONST.REPORT.SECONDARY_ACTIONS.SUBMIT, From 4ec6316e22a84074594cfea4a1f45d946223ba5c Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Fri, 28 Aug 2026 12:45:13 +0530 Subject: [PATCH 13/13] add duplicate violation clarifying comment --- tests/unit/ReportSecondaryActionUtilsTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 5f4522838f8c..f438b3195ca7 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -1888,6 +1888,8 @@ describe('getSecondaryAction', () => { action: CONST.REPORT.SECONDARY_ACTIONS.APPROVE, reportData: {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, managerID: EMPLOYEE_ACCOUNT_ID}, policyData: {approver: EMPLOYEE_EMAIL}, + + // Approve action is present in secondary actions only when the report has a duplicate violation. hasDuplicateViolation: true, }, {