diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 8dbc8047cffe..321e5a77299a 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -11,6 +11,7 @@ import { getValidConnectedIntegration, hasDynamicExternalWorkflow, hasIntegrationAutoSync, + isGroupPolicy, isPreferredExporter, isSubmitterApproveBlockedOnSubmitWorkspace, } from './PolicyUtils'; @@ -127,9 +128,13 @@ function canPay( } const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false); + + // The admin pay path is for workspace expense reports. Personal policies should only offer Pay to the actual payer. const canPayReport = isReportPayer || - (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); + (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); diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index acaf0fbcda33..4fc91267d5e4 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -222,9 +222,12 @@ function isPrimaryPayAction({ return false; } const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false); + + // The admin pay path is for workspace expense reports. Personal policies should only offer Pay to the actual payer. const canPayReport = isReportPayer || (canNonPayerAdminPay && + isGroupPolicy(policy) && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); const arePaymentsEnabled = arePaymentsEnabledUtils(policy); diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 94ddd5339053..d448bb05e86a 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -24,6 +24,7 @@ import { getAccountIDForSubmitManagerEmail, getSubmitReportManagerAccountID, hasDynamicExternalWorkflow, + isGroupPolicy, isPaidGroupPolicy, isSubmitAndClose, isSubmitPolicy, @@ -238,9 +239,13 @@ function canIOUBePaid( } const isReportPayer = isPayerReportUtils(currentUserAccountID, currentUserLogin, iouReport, bankAccountList, policy, onlyShowPayElsewhere); + + // The admin pay path is for workspace expense reports. Personal policies should only offer Pay to the actual payer. const canPay = isReportPayer || - (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); + (isGroupPolicy(policy) && + policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && + canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS)); const {reimbursableSpend, nonReimbursableSpend} = getMoneyRequestSpendBreakdown(iouReport); const isAutoReimbursable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES ? false : canBeAutoReimbursed(iouReport, policy); diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index c43997c59837..d265de539ced 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -3155,6 +3155,40 @@ describe('actions/IOU/ReportWorkflow', () => { expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, paymentsAdminEmail, paymentsAdminAccountID, [], false)).toBeTruthy(); expect(isPayer(paymentsAdminAccountID, paymentsAdminEmail, fakeReport, {}, fakePolicy, false)).toBe(false); }); + + it('should not return PAY for the expense owner in a 1:1 IOU on a personal policy with manual reimbursement', async () => { + const chatReport = createRandomReport(2, undefined); + const fakePolicy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.PERSONAL), + id: 'AA', + type: CONST.POLICY.TYPE.PERSONAL, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [RORY_EMAIL]: { + email: RORY_EMAIL, + role: CONST.POLICY.ROLE.ADMIN, + }, + }, + }; + + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.IOU, + policyID: 'AA', + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: RORY_ACCOUNT_ID, + managerID: CARLOS_ACCOUNT_ID, + isWaitingOnBankAccount: false, + total: -10000, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + + expect(canIOUBePaid(fakeReport, chatReport, fakePolicy, {}, RORY_EMAIL, RORY_ACCOUNT_ID, [], false)).toBeFalsy(); + expect(isPayer(RORY_ACCOUNT_ID, RORY_EMAIL, fakeReport, {}, fakePolicy, false)).toBe(false); + }); }); describe('retractReport', () => { diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 9f03c51f91a6..061f477dd237 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -3,7 +3,7 @@ import {renderHook} from '@testing-library/react-native'; import useReportIsArchived from '@hooks/useReportIsArchived'; import type * as PolicyUtils from '@libs/PolicyUtils'; -import {getValidConnectedIntegration} from '@libs/PolicyUtils'; +import {getValidConnectedIntegration, isGroupPolicy} from '@libs/PolicyUtils'; import getReportPreviewAction from '@libs/ReportPreviewActionUtils'; import type * as ReportUtils from '@libs/ReportUtils'; import {hasOnlyNonReimbursableTransactions} from '@libs/ReportUtils'; @@ -869,6 +869,59 @@ describe('getReportPreviewAction', () => { ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); }); + it('canPay should not return PAY for the expense owner in a 1:1 IOU on a personal policy with manual reimbursement', async () => { + const managerAccountID = CURRENT_USER_ACCOUNT_ID + 1; + const report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + managerID: managerAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + total: -100, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0, CONST.POLICY.TYPE.PERSONAL); + policy.role = CONST.POLICY.ROLE.ADMIN; + policy.reimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + policy.employeeList = { + [CURRENT_USER_EMAIL]: { + email: CURRENT_USER_EMAIL, + role: CONST.POLICY.ROLE.ADMIN, + }, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + amount: 100, + merchant: 'Test Merchant', + created: '2025-01-01', + }); + + const {isGroupPolicy: actualIsGroupPolicy} = jest.requireActual('@libs/PolicyUtils'); + jest.mocked(isGroupPolicy).mockImplementation(actualIsGroupPolicy); + + try { + expect( + getReportPreviewAction({ + isReportArchived: false, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: undefined, + ownerLogin: CURRENT_USER_EMAIL, + }), + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); + } finally { + jest.mocked(isGroupPolicy).mockReturnValue(true); + } + }); + it('canPay should return false for Expense report with zero total amount', async () => { const report = { ...createRandomReport(REPORT_ID, undefined), diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index 72649f7f127c..886b620e6c42 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -2,7 +2,7 @@ import {renderHook} from '@testing-library/react-native'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import {getValidConnectedIntegration, isPreferredExporter} from '@libs/PolicyUtils'; +import {getValidConnectedIntegration, isGroupPolicy, isPreferredExporter} from '@libs/PolicyUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; import { getReportPrimaryAction, @@ -857,6 +857,60 @@ describe('getPrimaryAction', () => { ).toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY); }); + it('should not return PAY for the expense owner in a 1:1 IOU on a personal policy with manual reimbursement', async () => { + const managerAccountID = CURRENT_USER_ACCOUNT_ID + 1; + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.IOU, + policyID: POLICY_ID, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + managerID: managerAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + total: -300, + isWaitingOnBankAccount: false, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = createMock({ + id: POLICY_ID, + type: CONST.POLICY.TYPE.PERSONAL, + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + employeeList: { + [CURRENT_USER_EMAIL]: { + email: CURRENT_USER_EMAIL, + role: CONST.POLICY.ROLE.ADMIN, + }, + }, + }); + const transaction = createMock({ + reportID: REPORT_ID, + amount: 300, + }); + + const {isGroupPolicy: actualIsGroupPolicy} = jest.requireActual('@libs/PolicyUtils'); + jest.mocked(isGroupPolicy).mockImplementation(actualIsGroupPolicy); + + try { + expect( + getReportPrimaryAction({ + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + ownerLogin: CURRENT_USER_EMAIL, + chatReport, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy, + isChatReportArchived: false, + }), + ).not.toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY); + } finally { + jest.mocked(isGroupPolicy).mockReturnValue(true); + } + }); + it('should not return PAY for an expense report when every expense is held', async () => { const report = createMock({ reportID: REPORT_ID,