diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index e8bd76549847..84aaa4962959 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -209,7 +209,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, @@ -253,7 +253,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 2d214637f92b..4b643ba6b42e 100644 --- a/src/components/Search/selectionBuilders.ts +++ b/src/components/Search/selectionBuilders.ts @@ -61,7 +61,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 2b47b360c2db..57fcc37210d2 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -368,7 +368,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/PolicyUtils.ts b/src/libs/PolicyUtils.ts index c5699b833348..538e887b0844 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -103,6 +103,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. @@ -3215,6 +3224,7 @@ export { arePolicyRulesEnabled, isPolicyFeatureEnabled, isPolicyFieldListEmpty, + isArchivedOrPendingDeletePolicy, isArchivedPolicy, getUberConnectionErrorDirectlyFromPolicy, isPolicyOwner, diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 321e5a77299a..8ddea54d16a7 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -11,6 +11,7 @@ import { getValidConnectedIntegration, hasDynamicExternalWorkflow, hasIntegrationAutoSync, + isArchivedOrPendingDeletePolicy, isGroupPolicy, isPreferredExporter, isSubmitterApproveBlockedOnSubmitWorkspace, @@ -29,6 +30,7 @@ import { isInvoiceReport, isIOUReport, isOpenReport, + isPayBlockedByArchivedState, isPayer, isProcessingReport, isReportApproved, @@ -38,7 +40,6 @@ import {hasOnlyPendingCardTransactions, hasSmartScanFailedWithMissingFields, has function canSubmit( report: Report, - isReportArchived: boolean, currentUserAccountID: number, currentUserEmail: string, ownerLogin: string | undefined, @@ -46,7 +47,9 @@ function canSubmit( policy?: Policy, transactions?: Transaction[], ) { - if (isReportArchived) { + // 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 (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -78,6 +81,10 @@ function canSubmit( } function canApprove(report: Report, currentUserAccountID: number, reportMetadata: OnyxEntry, policy?: Policy, transactions?: Transaction[]) { + if (isArchivedOrPendingDeletePolicy(policy)) { + return false; + } + if (isSubmitterApproveBlockedOnSubmitWorkspace(policy, report.ownerAccountID, currentUserAccountID)) { return false; } @@ -123,7 +130,9 @@ function canPay( policy?: Policy, invoiceReceiverPolicy?: Policy, ) { - if (isReportArchived) { + const isExpense = isExpenseReport(report); + + if (isPayBlockedByArchivedState(report, policy, isReportArchived)) { return false; } @@ -135,7 +144,6 @@ function canPay( (isGroupPolicy(policy) && 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; @@ -268,7 +276,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 4fc91267d5e4..64776f21d470 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -13,6 +13,7 @@ import { getValidConnectedIntegration, hasDynamicExternalWorkflow, hasIntegrationAutoSync, + isArchivedOrPendingDeletePolicy, isGroupPolicy, isPaidGroupPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils, @@ -48,6 +49,7 @@ import { isInvoiceReport as isInvoiceReportUtils, isIOUReport as isIOUReportUtils, isOpenReport as isOpenReportUtils, + isPayBlockedByArchivedState, isPayer, isProcessingReport as isProcessingReportUtils, isReportApproved as isReportApprovedUtils, @@ -118,12 +120,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 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 (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -161,6 +164,10 @@ function isSubmitAction( } function isApproveAction(report: Report, reportTransactions: Transaction[], currentUserAccountID: number, reportMetadata: OnyxEntry, policy?: Policy) { + if (isArchivedOrPendingDeletePolicy(policy)) { + return false; + } + if (isSubmitterApproveBlockedOnSubmitWorkspace(policy, report.ownerAccountID, currentUserAccountID)) { return false; } @@ -214,10 +221,11 @@ function isPrimaryPayAction({ isSecondaryAction, canNonPayerAdminPay, }: IsPrimaryPayActionParams) { - if (isArchivedReport(reportNameValuePairs) || isChatReportArchived) { + const isExpenseReport = isExpenseReportUtils(report); + + if (isPayBlockedByArchivedState(report, policy, isArchivedReport(reportNameValuePairs) || !!isChatReportArchived)) { return false; } - const isExpenseReport = isExpenseReportUtils(report); if (isExpenseReport && !isPaidGroupPolicy(policy)) { return false; } @@ -539,7 +547,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 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 (isArchivedOrPendingDeletePolicy(policy)) { return false; } @@ -306,6 +305,10 @@ function isApproveAction( reportMetadata: OnyxEntry, policy?: Policy, ): boolean { + if (isArchivedOrPendingDeletePolicy(policy)) { + return false; + } + if (isSubmitterApproveBlockedOnSubmitWorkspace(policy, report.ownerAccountID, currentUserAccountID)) { return false; } @@ -373,6 +376,10 @@ function isApproveAction( } function isUnapproveAction(currentUserLogin: string, currentUserAccountID: number, report: Report, policy?: Policy): boolean { + if (isArchivedOrPendingDeletePolicy(policy)) { + return false; + } + const isExpenseReport = isExpenseReportUtils(report); const isReportApprover = isPolicyApprover(policy, currentUserLogin); const isReportApproved = isReportApprovedUtils({report}); @@ -429,6 +436,10 @@ function isCancelPaymentAction( return false; } + if (isExpenseReport && isArchivedOrPendingDeletePolicy(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 @@ -724,6 +735,10 @@ function shouldShowEditSplitInDeleteAction( } function isRetractAction(report: Report, policy?: Policy): boolean { + if (isArchivedOrPendingDeletePolicy(policy)) { + return false; + } + const isExpenseReport = isExpenseReportUtils(report); // This should be removed after we change how instant submit works @@ -747,6 +762,10 @@ function isRetractAction(report: Report, policy?: Policy): boolean { } function isReopenAction(report: Report, policy?: Policy): boolean { + if (isArchivedOrPendingDeletePolicy(policy)) { + return false; + } + const isExpenseReport = isExpenseReportUtils(report); if (!isExpenseReport) { return false; @@ -1025,10 +1044,8 @@ function getSecondaryReportActions({ report, reportTransactions, policy, - reportNameValuePairs, reportActions, reportMetadata, - isChatReportArchived, primaryAction, violations, currentUserLogin, @@ -1071,7 +1088,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); } @@ -1207,7 +1224,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 bbb8b4a8d796..b4bdaf66d57b 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, @@ -13634,7 +13635,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); @@ -13644,6 +13645,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 } @@ -13659,6 +13665,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; @@ -14611,6 +14626,7 @@ export { pushTransactionAutoSelectionsOnyxData, navigateOnDeleteExpense, canRejectReportAction, + isPayBlockedByArchivedState, hasReportBeenReopened, hasReportBeenRetracted, getNextApproverAccountID, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 72bbf98004cd..96cf46124c87 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -126,6 +126,7 @@ import { getCommaSeparatedTagNameWithSanitizedColons, getSubmitToAccountID, getTagGLCode, + isArchivedOrPendingDeletePolicy, isGroupPolicy, isPaidGroupPolicy, isPolicyAdmin, @@ -2540,10 +2541,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 @@ -2598,7 +2597,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); 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/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 245bd94c5182..d276efe7d16d 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -24,6 +24,7 @@ import { getAccountIDForSubmitManagerEmail, getSubmitReportManagerAccountID, hasDynamicExternalWorkflow, + isArchivedOrPendingDeletePolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitAndClose, @@ -60,6 +61,7 @@ import { isOpenExpenseReport as isOpenExpenseReportReportUtils, isOpenInvoiceReport as isOpenInvoiceReportReportUtils, isPayAtEndExpenseReport as isPayAtEndExpenseReportReportUtils, + isPayBlockedByArchivedState, isPayer as isPayerReportUtils, isProcessingReport, isReportApproved, @@ -160,6 +162,12 @@ function canApproveIOU( return false; } + // 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 (isArchivedOrPendingDeletePolicy(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. @@ -188,8 +196,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) { @@ -197,9 +203,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( @@ -278,7 +282,7 @@ function canIOUBePaid( isReportFinished && !iouSettled && (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount || isOnlyNonReimbursablePayElsewhere) && - !isChatReportArchived && + !isPayBlockedByArchivedState(iouReport, policy, isChatReportArchived) && !isAutoReimbursable && !isPayAtEndExpenseReport && (!isExpenseReport(iouReport) || arePaymentsEnabled(policy as OnyxEntry)) diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index 3341f6221b2f..741d3b5d2820 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -1131,6 +1131,24 @@ describe('actions/IOU/ReportWorkflow', () => { } return waitForBatchedUpdates(); }) + .then(() => { + // 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( + () => + 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); + resolve(); + }, + }); + }), + ) .then( () => new Promise((resolve) => { diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index aedd83d23833..fb00094d0d57 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -129,6 +129,105 @@ 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 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, @@ -683,6 +782,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, @@ -879,6 +1003,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 6bbac67e1800..f438b3195ca7 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,6 +405,40 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(true); }); + 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, + }); + 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, @@ -1839,6 +1875,122 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); }); + // 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 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, + 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}, + + // Approve action is present in secondary actions only when the report has a duplicate violation. + 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, + ...reportData, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + 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 baseArgs = { + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: hasDuplicateViolation ? [transaction] : [], + originalTransaction: createMock({}), + violations: hasDuplicateViolation ? {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`]: [violation]} : {}, + bankAccountList: {}, + }; + + 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, + ...reportData, + }); + 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: createMock(policyData), + moveExpenseReportNameValuePairs: { + [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: new Date().toString()}, + }, + isChatReportArchived: true, + }); + expect(result.includes(action)).toBe(true); + }); + it('includes CANCEL_PAYMENT option for report before nacha cutoff', async () => { const report = createMock({ reportID: REPORT_ID, diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index f45e3e0f524b..d1772f9b26f0 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -15187,19 +15187,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); }); }); diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index 9a7ed8ede20c..4d2829ca3060 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -32,7 +32,8 @@ export default function createRandomPolicy(index: number, type?: ValueOf action !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)), errors: {}, customUnits: {}, errorFields: {},