From 9334d49f30975167ba02ad99513ff3ef5260a736 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Fri, 5 Jun 2026 17:33:17 +0100 Subject: [PATCH 01/32] account for manual reimbursement flow in isPayer --- src/libs/ReportUtils.ts | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index fdadf7ef4a28..48de6de76932 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2700,26 +2700,35 @@ function isPayer( const reimbursementChoice = policy?.reimbursementChoice; if (isPaidGroupPolicy(iouReport)) { - if (reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES) { - if (!policy?.achAccount?.reimburser) { - return isAdmin; - } + const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL || onlyShowPayElsewhere; + + // Reimbursement is disabled for this workspace. + if (!isAutoReimbursement && !isManualReimbursement) { + return false; + } + + // No designated reimburser means any workspace admin can pay. + if (!policy?.achAccount?.reimburser) { + return isAdmin; + } - // If user is the reimburser, or a policy admin with access to the business bank account via sharees, they can pay. - const isReimburser = currentUserEmailParam === policy?.achAccount?.reimburser; + const isReimburser = currentUserEmailParam === policy.achAccount.reimburser; - // Check if the current user has access to the bank account via sharees - const bankAccountID = policy?.achAccount?.bankAccountID; + // If using auto reimbursement, then the reimburser can pay, or an admin with access to the business bank account. + if (isAutoReimbursement) { + const bankAccountID = policy.achAccount.bankAccountID; const bankAccount = bankAccountID ? bankAccountList?.[bankAccountID] : null; const hasAccessToBankAccount = currentUserEmailParam && bankAccount?.accountData?.sharees ? bankAccount.accountData.sharees.includes(currentUserEmailParam) : false; return isReimburser || (isAdmin && hasAccessToBankAccount); } - if (reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL || onlyShowPayElsewhere) { - return isAdmin; - } - return false; + + // If using manual reimbursement, then only the designated reimburser can pay. + return isReimburser; } + + // Personal workspaces and IOU reports fall back to admin or report manager. return isAdmin || (isMoneyRequestReport(iouReport) && isManager); } From 566eff9a140bb05ae7b34eaccdfe9f6d7f360de5 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Fri, 5 Jun 2026 17:36:55 +0100 Subject: [PATCH 02/32] handle reimburse manual flow for workspace workflows --- .../workspace/workflows/WorkspaceWorkflowsPage.tsx | 3 ++- .../workflows/WorkspaceWorkflowsPayerPage.tsx | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index aa80a5cdf125..30e94e08deda 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -346,6 +346,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const isBusinessBankAccountLocked = state === CONST.BANK_ACCOUNT.STATE.LOCKED; const shouldShowBankAccount = (!!isBankAccountFullySetup || !!bankAccountConnectedToWorkspace) && policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + const shouldShowPayer = shouldShowBankAccount || policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; const bankAccountPendingAction = bankAccountConnectedToWorkspace?.pendingAction; const isBankAccountPendingDelete = bankAccountPendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; @@ -696,7 +697,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { /> ) )} - {shouldShowBankAccount && ( + {shouldShowPayer && ( setSelectedPayer(personalDetails?.[member.accountID]?.login); + const isManualReimbursement = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; const shouldShowBlockingPage = - (isEmptyObject(policy) && !isLoadingReportData) || isPendingDeletePolicy(policy) || policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + (isEmptyObject(policy) && !isLoadingReportData) || + isPendingDeletePolicy(policy) || + (policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && !isManualReimbursement); const totalNumberOfEmployeesEitherOwnerOrAdmin = Object.entries(policy?.employeeList ?? {}).filter(([email, policyEmployee]) => { const isOwner = policy?.owner === email; From 6bac69bd50faa2318fa35c0532f97f10ac2f5882 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Fri, 5 Jun 2026 17:37:42 +0100 Subject: [PATCH 03/32] add tests --- tests/actions/IOUTest/ReportWorkflowTest.ts | 56 ++++++++++++++++++ tests/actions/PolicyTest.ts | 31 ++++++++++ tests/unit/ReportUtilsTest.ts | 64 +++++++++++++++++++++ 3 files changed, 151 insertions(+) diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index 3f180737bd73..ffd0e51e55fb 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -4061,5 +4061,61 @@ describe('actions/IOU/ReportWorkflow', () => { const result = getBadgeFromIOUReport(fakeIouReport, fakeChatReport, fakePolicy, {}, undefined, RORY_EMAIL, RORY_ACCOUNT_ID); expect(result).toBeUndefined(); }); + + it('should not return PAY badge for non-reimburser admin in manual mode with designated payer', async () => { + const iouReportID = '1700'; + const chatReportID = '1701'; + const policyID = '1702'; + const reimburserEmail = 'designated-payer@test.com'; + + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + achAccount: { + reimburser: reimburserEmail, + }, + }; + + const fakeChatReport: Report = { + ...createRandomReport(Number(chatReportID), CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + reportID: chatReportID, + policyID, + }; + + const fakeIouReport: Report = { + ...createRandomReport(Number(iouReportID), CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + reportID: iouReportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + total: -10000, + nonReimbursableTotal: 0, + isWaitingOnBankAccount: false, + }; + + const fakeTransaction: Transaction = { + ...createRandomTransaction(0), + reportID: iouReportID, + amount: 10000, + status: CONST.TRANSACTION.STATUS.POSTED, + bank: '', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, fakeChatReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, fakeIouReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`, fakeTransaction); + await waitForBatchedUpdates(); + + // RORY_EMAIL is not the designated payer, so they should not get the PAY badge + const result = getBadgeFromIOUReport(fakeIouReport, fakeChatReport, fakePolicy, {}, undefined, RORY_EMAIL, RORY_ACCOUNT_ID); + expect(result).toBeUndefined(); + }); }); }); diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index bad221118bef..690e75eed174 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -5532,6 +5532,37 @@ describe('actions/Policy', () => { expect(updatedPolicy?.pendingFields?.reimburser).toBeUndefined(); expect(updatedPolicy?.errorFields?.reimburser).toBeTruthy(); }); + + it('should update workspace payer in manual reimbursement mode', async () => { + // Given a workspace in manual reimbursement mode with no designated payer + const policy = { + ...createRandomPolicy(0), + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + reimburser: '', + achAccount: {reimburser: ''}, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + mockFetch.pause(); + + // When setting a designated payer in manual mode + Policy.setWorkspacePayer(policy.id, 'payer@manual.com', policy.reimburser); + + // Then optimistic data should set the reimburser + await waitForBatchedUpdates(); + let updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.reimburser).toBe('payer@manual.com'); + expect(updatedPolicy?.achAccount?.reimburser).toBe('payer@manual.com'); + expect(updatedPolicy?.reimbursementChoice).toBe(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL); + expect(updatedPolicy?.pendingFields?.reimburser).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + + // When the fetch resumes and succeeds + await mockFetch.resume(); + + // Then pendingFields should be cleared and reimburser preserved + updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.achAccount?.reimburser).toBe('payer@manual.com'); + expect(updatedPolicy?.pendingFields?.reimburser).toBeUndefined(); + }); }); describe('setPolicyPreventSelfApproval', () => { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 655c9908aeb6..f64636721843 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -8443,6 +8443,70 @@ describe('ReportUtils', () => { // IOU receiver (payer/manager) should still be able to pay even without access to the policy expect(isPayer(currentUserAccountID, currentUserEmail, iouReportWithPolicyID, undefined, undefined, false)).toBe(true); }); + + it('should return true for designated reimburser in manual reimbursement mode', () => { + const reimburserEmail = 'reimburser@manual-test.com'; + const reimburserAccountID = 700; + + const manualPolicyWithReimburser: Policy = { + ...policyTest, + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + employeeList: { + [reimburserEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + }, + }, + achAccount: { + reimburser: reimburserEmail, + }, + }; + + expect(isPayer(reimburserAccountID, reimburserEmail, approvedReport, undefined, manualPolicyWithReimburser, false)).toBe(true); + }); + + it('should return false for non-reimburser admin in manual reimbursement mode with designated payer', () => { + const otherAdminEmail = 'other-admin@manual-test.com'; + const otherAdminAccountID = 701; + const reimburserEmail = 'reimburser@manual-test.com'; + + const manualPolicyWithReimburser: Policy = { + ...policyTest, + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + employeeList: { + [otherAdminEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + }, + [reimburserEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + }, + }, + achAccount: { + reimburser: reimburserEmail, + }, + }; + + expect(isPayer(otherAdminAccountID, otherAdminEmail, approvedReport, undefined, manualPolicyWithReimburser, false)).toBe(false); + }); + + it('should return true for any admin in manual reimbursement mode without designated payer', () => { + const adminEmail = 'admin@manual-fallback.com'; + const adminAccountID = 702; + + const manualPolicyNoReimburser: Policy = { + ...policyTest, + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + employeeList: { + [adminEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + }, + }, + }; + + expect(isPayer(adminAccountID, adminEmail, approvedReport, undefined, manualPolicyNoReimburser, false)).toBe(true); + }); }); describe('buildReportNameFromParticipantNames', () => { beforeAll(async () => { From fac689b36fa71fa752ef972537a7116174025825 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Fri, 5 Jun 2026 18:15:32 +0100 Subject: [PATCH 04/32] define isManualReimbursement earlier --- src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx index 9d0bc2e47e3d..fec99afcb856 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx @@ -89,6 +89,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR const ownerDetails = policy?.owner ? getPersonalDetailByEmail(policy?.owner) : undefined; const accountID = selectedPayer ? policyMemberEmailsToAccountIDs?.[selectedPayer] : ''; const authorizedPayerEmail = personalDetails?.[accountID]?.login ?? ''; + const isManualReimbursement = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; const isDeletedPolicyEmployee = (policyEmployee: PolicyEmployee) => !isOffline && policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyEmployee.errors); @@ -254,7 +255,6 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR const setPolicyAuthorizedPayer = (member: MemberOption) => setSelectedPayer(personalDetails?.[member.accountID]?.login); - const isManualReimbursement = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; const shouldShowBlockingPage = (isEmptyObject(policy) && !isLoadingReportData) || isPendingDeletePolicy(policy) || From fffb187c30d33149572b86e5036006a917565fc3 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Fri, 5 Jun 2026 18:30:41 +0100 Subject: [PATCH 05/32] align on policy.achAccount.reimburser --- src/libs/PolicyUtils.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 76059d15e5a3..3f85d51db129 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -572,17 +572,22 @@ function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | und } const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; - const isReimburser = policy.reimburser === currentUserLogin; + const isAutoReimbursement = policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + const isManualReimbursement = policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; - if (policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES) { - return policy.reimburser ? isReimburser : isAdmin; + // Reimbursement is disabled for this workspace. + if (!isAutoReimbursement && !isManualReimbursement) { + return false; } - if (policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL) { + const reimburserEmail = policy.achAccount?.reimburser; + + // No designated reimburser means any workspace admin can pay. + if (!reimburserEmail) { return isAdmin; } - return false; + return currentUserLogin === reimburserEmail; } /** Check if the passed employee is an approver in the policy's employeeList */ From 6b41645899f389df94ad42d64b7e482b2731b7aa Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Fri, 5 Jun 2026 18:30:48 +0100 Subject: [PATCH 06/32] add isPayer test --- tests/unit/PolicyUtilsTest.ts | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index a3c34524dc13..ead5ce9130e4 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -41,6 +41,7 @@ import { hasPolicyWithXeroConnection, hasVendorFeature, isPolicyMemberWithoutPendingDelete, + isPolicyPayer, shouldShowPolicy, sortPoliciesByName, sortWorkspacesBySelected, @@ -245,6 +246,51 @@ describe('PolicyUtils', () => { }); }); + describe('isPolicyPayer', () => { + const reimburserEmail = 'payer@test.com'; + const otherAdminEmail = 'other-admin@test.com'; + + const buildPolicy = (reimbursementChoice: Policy['reimbursementChoice'], reimburser?: string): Policy => + ({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice, + achAccount: reimburser ? {reimburser} : undefined, + }) as Policy; + + it('should return true for designated reimburser in manual reimbursement mode', () => { + expect(isPolicyPayer(buildPolicy(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, reimburserEmail), reimburserEmail)).toBe(true); + }); + + it('should return false for non-reimburser admin in manual reimbursement mode with designated payer', () => { + expect(isPolicyPayer(buildPolicy(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, reimburserEmail), otherAdminEmail)).toBe(false); + }); + + it('should return true for any admin in manual reimbursement mode without designated payer', () => { + expect(isPolicyPayer(buildPolicy(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL), otherAdminEmail)).toBe(true); + }); + + it('should return true for designated reimburser in auto reimbursement mode', () => { + expect(isPolicyPayer(buildPolicy(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, reimburserEmail), reimburserEmail)).toBe(true); + }); + + it('should return false for non-reimburser admin in auto reimbursement mode with designated payer', () => { + expect(isPolicyPayer(buildPolicy(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, reimburserEmail), otherAdminEmail)).toBe(false); + }); + + it('should ignore top-level policy reimburser when achAccount reimburser is not set', () => { + const policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: reimburserEmail, + } as Policy; + + expect(isPolicyPayer(policy, otherAdminEmail)).toBe(true); + expect(isPolicyPayer(policy, reimburserEmail)).toBe(true); + }); + }); + describe('canMemberRead and canMemberWrite', () => { const memberLogin = 'member@test.com'; const buildPolicy = (role: Policy['role']): Policy => From 3768f72d97dc4c67d08131f6d97784cd27495fbc Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Fri, 5 Jun 2026 19:46:19 +0100 Subject: [PATCH 07/32] type checks --- tests/actions/IOUTest/ReportWorkflowTest.ts | 5 +++++ tests/unit/ReportUtilsTest.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index ffd0e51e55fb..ec4e229ce418 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -4077,6 +4077,11 @@ describe('actions/IOU/ReportWorkflow', () => { reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, achAccount: { reimburser: reimburserEmail, + bankAccountID: 1, + accountNumber: '1234567890', + routingNumber: '987654321', + addressName: 'Test Address', + bankName: 'Test Bank', }, }; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index f64636721843..bf1cbb1a78a4 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -8459,6 +8459,11 @@ describe('ReportUtils', () => { }, achAccount: { reimburser: reimburserEmail, + bankAccountID: 1, + accountNumber: '1234567890', + routingNumber: '987654321', + addressName: 'Test Address', + bankName: 'Test Bank', }, }; @@ -8484,6 +8489,11 @@ describe('ReportUtils', () => { }, achAccount: { reimburser: reimburserEmail, + bankAccountID: 1, + accountNumber: '1234567890', + routingNumber: '987654321', + addressName: 'Test Address', + bankName: 'Test Bank', }, }; From 0ccd17dc66c3771f3ed8e203fc746f1a75b2c7c8 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Fri, 5 Jun 2026 20:47:20 +0100 Subject: [PATCH 08/32] prettier --- .../workspace/workflows/WorkspaceWorkflowsPayerPage.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx index 69c5cde94261..63f5e95ce2b9 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx @@ -256,10 +256,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR const setPolicyAuthorizedPayer = (member: MemberOption) => setSelectedPayer(personalDetails?.[member.accountID]?.login); - const shouldShowBlockingPage = - (isEmptyObject(policy) && !isLoadingReportData) || - isPendingDeletePolicy(policy) || - (isAutoReimbursement && !isManualReimbursement); + const shouldShowBlockingPage = (isEmptyObject(policy) && !isLoadingReportData) || isPendingDeletePolicy(policy) || (isAutoReimbursement && !isManualReimbursement); const totalNumberOfEmployeesEitherOwnerOrAdmin = Object.entries(policy?.employeeList ?? {}).filter(([email, policyEmployee]) => { const isOwner = policy?.owner === email; From 873a043a53bec5c0c6eb462982aa796006b034e2 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Wed, 17 Jun 2026 16:17:26 +0100 Subject: [PATCH 09/32] fall back on policy owner --- .../workspace/workflows/WorkspaceWorkflowsPage.tsx | 8 +++----- .../workflows/WorkspaceWorkflowsPayerPage.tsx | 10 +++++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 95368905b824..d91e181abc98 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -138,7 +138,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const delegateAccountID = useDelegateAccountID(); const {accountID: currentUserAccountID, email: currentUserEmail = '', login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); - const isUserReimburser = policy?.achAccount?.reimburser !== undefined && account?.primaryLogin !== undefined && policy?.achAccount?.reimburser === account?.primaryLogin; + const isUserReimburser = account?.primaryLogin !== undefined && (policy?.achAccount?.reimburser ?? policy?.owner) === account?.primaryLogin; const {approvalWorkflows, availableMembers, usedApproverEmails} = useMemo( () => convertPolicyEmployeesToApprovalWorkflows({ @@ -155,10 +155,8 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const isAdvanceApproval = (approvalWorkflows.length > 1 || (approvalWorkflows?.at(0)?.approvers ?? []).length > 1) && isControlPolicy(policy); const updateApprovalMode = isAdvanceApproval ? CONST.POLICY.APPROVAL_MODE.ADVANCED : CONST.POLICY.APPROVAL_MODE.BASIC; - const displayNameForAuthorizedPayer = useMemo( - () => getDisplayNameOrDefault(getPersonalDetailByEmail(policy?.achAccount?.reimburser ?? ''), policy?.achAccount?.reimburser), - [policy?.achAccount?.reimburser], - ); + const policyReimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner; + const displayNameForAuthorizedPayer = useMemo(() => getDisplayNameOrDefault(getPersonalDetailByEmail(policyReimburserEmail ?? ''), policyReimburserEmail), [policyReimburserEmail]); const isNonUSDWorkspace = policy?.outputCurrency !== CONST.CURRENCY.USD; const achData = reimbursementAccount?.achData; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx index 63f5e95ce2b9..db9de63b5803 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx @@ -76,7 +76,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const [searchTerm, setSearchTerm] = useState(''); const [sharedBankAccountData] = useOnyx(ONYXKEYS.SHARE_BANK_ACCOUNT); - const [selectedPayer, setSelectedPayer] = useState(policy?.achAccount?.reimburser); + const [selectedPayer, setSelectedPayer] = useState(policy?.achAccount?.reimburser ?? policy?.owner); const shouldShowSuccess = sharedBankAccountData?.shouldShowSuccess ?? false; const styles = useThemeStyles(); const {showConfirmModal} = useConfirmModal(); @@ -179,7 +179,11 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR setIsAlertVisible(true); return; } - if (policy?.achAccount?.reimburser === authorizedPayerEmail || !isAutoReimbursement) { + // When no payer is stored on the policy, the owner is the fallback payer + const reimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner; + + // Skip bank-account sharing when the selection matches the payer, or when manual reimbursement has no bank account to share. + if (reimburserEmail === authorizedPayerEmail || !isAutoReimbursement) { Navigation.goBack(); return; } @@ -208,7 +212,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR } const isSelectedPayerOwner = policy?.owner === selectedPayer; - const isSelectedAlreadyAPayer = policy?.achAccount?.reimburser === selectedPayer; + const isSelectedAlreadyAPayer = (policy?.achAccount?.reimburser ?? policy?.owner) === selectedPayer; const isAccountAlreadyShared = bankAccountInfo?.accountData?.sharees ? bankAccountInfo?.accountData.sharees.includes(selectedPayer) : false; const isAccountAlreadySharedOnMainBankAccount = policy?.achAccount?.sharees ? policy?.achAccount.sharees.includes(selectedPayer) : false; From 33f2078c2be8bac35dc3b27d07b135b0993abb44 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Wed, 17 Jun 2026 16:47:47 +0100 Subject: [PATCH 10/32] update tests --- tests/ui/WorkspaceWorkflowsPayerRowTest.tsx | 13 +----- tests/unit/PolicyUtilsTest.ts | 46 --------------------- 2 files changed, 2 insertions(+), 57 deletions(-) diff --git a/tests/ui/WorkspaceWorkflowsPayerRowTest.tsx b/tests/ui/WorkspaceWorkflowsPayerRowTest.tsx index d4e3dccdb0b7..fe4a8d087452 100644 --- a/tests/ui/WorkspaceWorkflowsPayerRowTest.tsx +++ b/tests/ui/WorkspaceWorkflowsPayerRowTest.tsx @@ -137,22 +137,13 @@ describe('WorkspaceWorkflowsPage - Payer row visibility', () => { expect(screen.getByText(TestHelper.translateLocal('workflowsPayerPage.payer'))).toBeOnTheScreen(); }); - it('hides the Payer row when reimbursementChoice is REIMBURSEMENT_MANUAL', async () => { + it('shows the Payer row when reimbursementChoice is REIMBURSEMENT_MANUAL', async () => { await TestHelper.signInWithTestUser(); await act(async () => { await Onyx.merge( `${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({ reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, - achAccount: { - reimburser: 'test@user.com', - bankAccountID: 123456, - accountNumber: '1234567890', - routingNumber: '011000015', - bankName: 'Test Bank', - addressName: 'Test Address', - state: CONST.BANK_ACCOUNT.STATE.OPEN, - }, }), ); }); @@ -160,7 +151,7 @@ describe('WorkspaceWorkflowsPage - Payer row visibility', () => { renderPage(); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(TestHelper.translateLocal('workflowsPayerPage.payer'))).not.toBeOnTheScreen(); + expect(screen.getByText(TestHelper.translateLocal('workflowsPayerPage.payer'))).toBeOnTheScreen(); }); it('shows the Payer row when reimbursementChoice is undefined (legacy workspaces)', async () => { diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 04d860cf6b40..9886c0e22e0a 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -43,7 +43,6 @@ import { hasPolicyWithXeroConnection, hasVendorFeature, isPolicyMemberWithoutPendingDelete, - isPolicyPayer, shouldShowPolicy, sortPoliciesByName, sortWorkspacesBySelected, @@ -254,51 +253,6 @@ describe('PolicyUtils', () => { }); }); - describe('isPolicyPayer', () => { - const reimburserEmail = 'payer@test.com'; - const otherAdminEmail = 'other-admin@test.com'; - - const buildPolicy = (reimbursementChoice: Policy['reimbursementChoice'], reimburser?: string): Policy => - ({ - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: CONST.POLICY.ROLE.ADMIN, - reimbursementChoice, - achAccount: reimburser ? {reimburser} : undefined, - }) as Policy; - - it('should return true for designated reimburser in manual reimbursement mode', () => { - expect(isPolicyPayer(buildPolicy(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, reimburserEmail), reimburserEmail)).toBe(true); - }); - - it('should return false for non-reimburser admin in manual reimbursement mode with designated payer', () => { - expect(isPolicyPayer(buildPolicy(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, reimburserEmail), otherAdminEmail)).toBe(false); - }); - - it('should return true for any admin in manual reimbursement mode without designated payer', () => { - expect(isPolicyPayer(buildPolicy(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL), otherAdminEmail)).toBe(true); - }); - - it('should return true for designated reimburser in auto reimbursement mode', () => { - expect(isPolicyPayer(buildPolicy(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, reimburserEmail), reimburserEmail)).toBe(true); - }); - - it('should return false for non-reimburser admin in auto reimbursement mode with designated payer', () => { - expect(isPolicyPayer(buildPolicy(CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, reimburserEmail), otherAdminEmail)).toBe(false); - }); - - it('should ignore top-level policy reimburser when achAccount reimburser is not set', () => { - const policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), - role: CONST.POLICY.ROLE.ADMIN, - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - reimburser: reimburserEmail, - } as Policy; - - expect(isPolicyPayer(policy, otherAdminEmail)).toBe(true); - expect(isPolicyPayer(policy, reimburserEmail)).toBe(true); - }); - }); - describe('canMemberRead and canMemberWrite', () => { const memberLogin = 'member@test.com'; const buildPolicy = (role: Policy['role']): Policy => From 4b1360939b8453e98afab019dc237dfdfdbf0090 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Wed, 17 Jun 2026 17:57:38 +0100 Subject: [PATCH 11/32] update to reimburse manual when no bank account is selected --- src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx | 4 +++- src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx | 2 +- .../workspace/workflows/WorkspaceWorkflowsPayerPage.tsx | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index fdbf973f9d9c..008b828bf843 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -257,7 +257,9 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.payments.id: { let newReimbursementChoice; - if (!!policy?.achAccount && !isCurrencySupportedForDirectReimbursement(policy?.outputCurrency ?? '')) { + const hasConnectedBank = + policy?.achAccount?.bankAccountID && (policy.achAccount.state === CONST.BANK_ACCOUNT.STATE.OPEN || policy.achAccount.state === CONST.BANK_ACCOUNT.STATE.LOCKED); + if (!hasConnectedBank || !isCurrencySupportedForDirectReimbursement(policy?.outputCurrency ?? '')) { newReimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; } else { newReimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index d91e181abc98..e4774b835302 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -563,7 +563,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { let newReimbursementChoice; if (!isEnabled) { newReimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; - } else if (!!policy?.achAccount && !isCurrencySupportedForDirectReimbursement((policy?.outputCurrency ?? '') as CurrencyType)) { + } else if ((!isBankAccountFullySetup && !bankAccountConnectedToWorkspace) || !isCurrencySupportedForDirectReimbursement((policy?.outputCurrency ?? '') as CurrencyType)) { newReimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; } else { newReimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx index db9de63b5803..bc5c525fddff 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx @@ -260,7 +260,10 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR const setPolicyAuthorizedPayer = (member: MemberOption) => setSelectedPayer(personalDetails?.[member.accountID]?.login); - const shouldShowBlockingPage = (isEmptyObject(policy) && !isLoadingReportData) || isPendingDeletePolicy(policy) || (isAutoReimbursement && !isManualReimbursement); + const shouldShowBlockingPage = + (isEmptyObject(policy) && !isLoadingReportData) || + isPendingDeletePolicy(policy) || + policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; const totalNumberOfEmployeesEitherOwnerOrAdmin = Object.entries(policy?.employeeList ?? {}).filter(([email, policyEmployee]) => { const isOwner = policy?.owner === email; From 5a13f44f235f4c646a57e3c2722862f5d2eab459 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Wed, 17 Jun 2026 22:51:45 +0100 Subject: [PATCH 12/32] style --- src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx index bc5c525fddff..31fa12f6a242 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx @@ -261,9 +261,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR const setPolicyAuthorizedPayer = (member: MemberOption) => setSelectedPayer(personalDetails?.[member.accountID]?.login); const shouldShowBlockingPage = - (isEmptyObject(policy) && !isLoadingReportData) || - isPendingDeletePolicy(policy) || - policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + (isEmptyObject(policy) && !isLoadingReportData) || isPendingDeletePolicy(policy) || policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; const totalNumberOfEmployeesEitherOwnerOrAdmin = Object.entries(policy?.employeeList ?? {}).filter(([email, policyEmployee]) => { const isOwner = policy?.owner === email; From 501bafb511f02112333caa08a5b10d6e32b30c82 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Thu, 18 Jun 2026 14:18:06 +0100 Subject: [PATCH 13/32] allow any admin when only paying elsewhere --- src/libs/ReportUtils.ts | 6 +++++- tests/unit/ReportUtilsTest.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 45669695d989..46003651b571 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2842,8 +2842,12 @@ function isPayer( const reimbursementChoice = policy?.reimbursementChoice; if (isPaidGroupPolicy(iouReport)) { + if (onlyShowPayElsewhere) { + return isAdmin; + } + const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; - const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL || onlyShowPayElsewhere; + const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; // Reimbursement is disabled for this workspace. if (!isAutoReimbursement && !isManualReimbursement) { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 19fba303e2b5..46c993e0047d 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -8532,6 +8532,36 @@ describe('ReportUtils', () => { expect(isPayer(adminAccountID, adminEmail, approvedReport, undefined, manualPolicyNoReimburser, false)).toBe(true); }); + + it('should return true for any admin when onlyShowPayElsewhere is true even if reimbursement is disabled and a reimburser is set', () => { + const adminEmail = 'admin@pay-elsewhere.com'; + const adminAccountID = 703; + const reimburserEmail = 'reimburser@pay-elsewhere.com'; + + const policyWithReimbursementDisabled: Policy = { + ...policyTest, + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, + employeeList: { + [adminEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + }, + [reimburserEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + }, + }, + achAccount: { + reimburser: reimburserEmail, + bankAccountID: 1, + accountNumber: '1234567890', + routingNumber: '987654321', + addressName: 'Test Address', + bankName: 'Test Bank', + }, + }; + + expect(isPayer(adminAccountID, adminEmail, approvedReport, undefined, policyWithReimbursementDisabled, true)).toBe(true); + }); }); describe('buildReportNameFromParticipantNames', () => { beforeAll(async () => { From 8276218b02f1c6df3ea4b2856ac3d54bfa65ed12 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Thu, 18 Jun 2026 15:16:41 +0100 Subject: [PATCH 14/32] restrict to when reimbursement choice is no --- src/libs/ReportUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 46003651b571..d8879625e381 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2842,7 +2842,8 @@ function isPayer( const reimbursementChoice = policy?.reimbursementChoice; if (isPaidGroupPolicy(iouReport)) { - if (onlyShowPayElsewhere) { + // Pay-elsewhere / mark-paid flows for disabled reimbursement still allow any admin to act. + if (onlyShowPayElsewhere && reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO) { return isAdmin; } From 174d67df08f20cd69358d9e4001be65830eb1df4 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Thu, 18 Jun 2026 15:16:49 +0100 Subject: [PATCH 15/32] add test --- tests/unit/ReportUtilsTest.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 46c993e0047d..a080d0c60f5a 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -8562,6 +8562,38 @@ describe('ReportUtils', () => { expect(isPayer(adminAccountID, adminEmail, approvedReport, undefined, policyWithReimbursementDisabled, true)).toBe(true); }); + + it('should return false for non-reimburser admin in manual mode with designated payer when onlyShowPayElsewhere is true', () => { + const otherAdminEmail = 'other-admin@manual-pay-elsewhere.com'; + const otherAdminAccountID = 704; + const reimburserEmail = 'reimburser@manual-pay-elsewhere.com'; + const reimburserAccountID = 705; + + const manualPolicyWithReimburser: Policy = { + ...policyTest, + role: CONST.POLICY.ROLE.ADMIN, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + employeeList: { + [otherAdminEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + }, + [reimburserEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + }, + }, + achAccount: { + reimburser: reimburserEmail, + bankAccountID: 1, + accountNumber: '1234567890', + routingNumber: '987654321', + addressName: 'Test Address', + bankName: 'Test Bank', + }, + }; + + expect(isPayer(otherAdminAccountID, otherAdminEmail, approvedReport, undefined, manualPolicyWithReimburser, true)).toBe(false); + expect(isPayer(reimburserAccountID, reimburserEmail, approvedReport, undefined, manualPolicyWithReimburser, true)).toBe(true); + }); }); describe('buildReportNameFromParticipantNames', () => { beforeAll(async () => { From df3e3bcde0f69b98ad8855f265ed6512f04df5a7 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Mon, 22 Jun 2026 23:42:34 +0100 Subject: [PATCH 16/32] allow non-payer admins to pay report, but don't show other other payment ui elements --- src/libs/ReportPrimaryActionUtils.ts | 18 ++++++------------ src/libs/ReportUtils.ts | 13 ++++++++----- src/libs/actions/IOU/ReportWorkflow.ts | 16 ++++++++++------ .../workflows/WorkspaceWorkflowsPayerPage.tsx | 3 +-- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 3eaa2b7dcdbf..7ba5c74ccad3 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -3,6 +3,7 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {BankAccountList, Policy, Report, ReportAction, ReportMetadata, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; +import {canIOUBePaid as canIOUBePaidAction} from './actions/IOU/ReportWorkflow'; import { arePaymentsEnabled as arePaymentsEnabledUtils, getSubmitToAccountID, @@ -481,6 +482,9 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf 0) { + if (isIOU && canPay && !iouSettled && reimbursableSpend > 0) { return true; } @@ -238,7 +239,7 @@ function canIOUBePaid( } return ( - isPayer && + canPay && isReportFinished && !iouSettled && (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount || isOnlyNonReimbursablePayElsewhere) && @@ -293,15 +294,18 @@ function getBadgeFromIOUReport( currentUserLogin: string, currentUserAccountID: number, ): ValueOf | undefined { - // Show to the actual payer, or to policy admins via the pay-elsewhere path for negative expenses - const canBePaidNow = canIOUBePaid(iouReport, chatReport, policy, undefined, currentUserLogin, currentUserAccountID, undefined, undefined, undefined, invoiceReceiverPolicy); + const isReportPayer = isPayerReportUtils(currentUserAccountID, currentUserLogin, iouReport, undefined, policy, false); + const canBePaidNow = + isReportPayer && canIOUBePaid(iouReport, chatReport, policy, undefined, currentUserLogin, currentUserAccountID, undefined, undefined, undefined, invoiceReceiverPolicy); if (canBePaidNow) { return CONST.REPORT.ACTION_BADGE.PAY; } // Pay-elsewhere path: covers negative reimbursable spend (mark-as-paid flow for credits). // Skip the PAY badge when every expense is non-reimbursable — paying is optional and // should not pin the report in the LHN. - const canBePaidElsewhere = canIOUBePaid(iouReport, chatReport, policy, undefined, currentUserLogin, currentUserAccountID, undefined, true, undefined, invoiceReceiverPolicy); + const canPayElsewhereActor = isPayerReportUtils(currentUserAccountID, currentUserLogin, iouReport, undefined, policy, true); + const canBePaidElsewhere = + canPayElsewhereActor && canIOUBePaid(iouReport, chatReport, policy, undefined, currentUserLogin, currentUserAccountID, undefined, true, undefined, invoiceReceiverPolicy); if (canBePaidElsewhere) { return hasOnlyNonReimbursableTransactions(iouReport?.reportID) ? undefined : CONST.REPORT.ACTION_BADGE.PAY; } diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx index 31fa12f6a242..f20ab07c4c0a 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx @@ -205,8 +205,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR return; } - // In manual reimbursement mode there's no bank account to share - if (isManualReimbursement) { + if (isManualReimbursement || !bankAccountID) { onButtonPress(); return; } From 5ad0416ad872743492e54de33ddb64027d517826 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Mon, 22 Jun 2026 23:42:57 +0100 Subject: [PATCH 17/32] default to owner for bank account logic --- .../ReimbursementAccount/resetNonUSDBankAccount.ts | 12 +++++++++--- .../ReimbursementAccount/resetUSDBankAccount.ts | 3 ++- .../workspace/WorkspaceResetBankAccountModal.tsx | 4 ++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts b/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts index 2921f6229155..3469f49f9c38 100644 --- a/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts +++ b/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts @@ -8,7 +8,13 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {ACHAccount} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; -function resetNonUSDBankAccount(policyID: string | undefined, achAccount: OnyxEntry, bankAccountID?: number, lastUsedPaymentMethod?: OnyxTypes.LastPaymentMethodType) { +function resetNonUSDBankAccount( + policyID: string | undefined, + achAccount: OnyxEntry, + bankAccountID?: number, + lastUsedPaymentMethod?: OnyxTypes.LastPaymentMethodType, + policyOwner?: string, +) { // If there's no bankAccountID, we reset locally without making an API call if (!bankAccountID) { const updateData: Array> = [ @@ -21,7 +27,7 @@ function resetNonUSDBankAccount(policyID: string | undefined, achAccount: OnyxEn onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - achAccount: null, + achAccount: policyOwner ? {reimburser: policyOwner} : null, }, }, { @@ -97,7 +103,7 @@ function resetNonUSDBankAccount(policyID: string | undefined, achAccount: OnyxEn onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - achAccount: null, + achAccount: policyOwner ? {reimburser: policyOwner} : null, }, }); diff --git a/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts b/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts index b1b3a6df7190..3926fd799a45 100644 --- a/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts +++ b/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts @@ -18,6 +18,7 @@ function resetUSDBankAccount( policyID: string | undefined, achAccount: ACHAccount | undefined, lastUsedPaymentMethod?: OnyxTypes.LastPaymentMethodType, + policyOwner?: string, ) { if (!bankAccountID) { throw new Error('Missing bankAccountID when attempting to reset free plan bank account'); @@ -55,7 +56,7 @@ function resetUSDBankAccount( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - achAccount: null, + achAccount: policyOwner ? {reimburser: policyOwner} : null, }, }, { diff --git a/src/pages/workspace/WorkspaceResetBankAccountModal.tsx b/src/pages/workspace/WorkspaceResetBankAccountModal.tsx index 2a8e760fc9f8..d34fbbf7d046 100644 --- a/src/pages/workspace/WorkspaceResetBankAccountModal.tsx +++ b/src/pages/workspace/WorkspaceResetBankAccountModal.tsx @@ -70,7 +70,7 @@ function WorkspaceResetBankAccountModal({ const handleConfirm = () => { if (isNonUSDWorkspace) { - resetNonUSDBankAccount(policyID, policy?.achAccount, achData?.bankAccountID, lastPaymentMethod); + resetNonUSDBankAccount(policyID, policy?.achAccount, achData?.bankAccountID, lastPaymentMethod, policy?.owner); if (setShouldShowConnectedVerifiedBankAccount) { setShouldShowConnectedVerifiedBankAccount(false); @@ -84,7 +84,7 @@ function WorkspaceResetBankAccountModal({ ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID: policyID ?? CONST.POLICY.ID_FAKE, page: CONST.NON_USD_BANK_ACCOUNT.PAGE_NAME.CURRENCY_AND_COUNTRY, backTo}), ); } else { - resetUSDBankAccount(bankAccountID, session, policyID, policy?.achAccount, lastPaymentMethod); + resetUSDBankAccount(bankAccountID, session, policyID, policy?.achAccount, lastPaymentMethod, policy?.owner); if (setShouldShowContinueSetupButton) { setShouldShowContinueSetupButton(false); From e3939acfd7383f8585ad0f615a59c2280f347ee1 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Mon, 22 Jun 2026 23:53:21 +0100 Subject: [PATCH 18/32] add tests --- tests/actions/IOUTest/ReportWorkflowTest.ts | 37 +++++++++++++++++++++ tests/unit/ReportUtilsTest.ts | 18 +++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index ed69a8cb7d88..4628cd121c05 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -2614,6 +2614,43 @@ describe('actions/IOU/ReportWorkflow', () => { expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, RORY_EMAIL, RORY_ACCOUNT_ID, zeroAmountNonReimbursableTransactions, false)).toBeFalsy(); expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, RORY_EMAIL, RORY_ACCOUNT_ID, zeroAmountNonReimbursableTransactions, true)).toBeFalsy(); }); + + it('allows non-reimburser admin to pay in manual reimbursement mode', async () => { + const policyChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + + const fakePolicy: Policy = { + ...createRandomPolicy(1), + id: 'AA', + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + role: CONST.POLICY.ROLE.ADMIN, + achAccount: { + reimburser: CARLOS_EMAIL, + bankAccountID: 1, + accountNumber: '1234567890', + routingNumber: '987654321', + addressName: 'Test Address', + bankName: 'Test Bank', + }, + }; + + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'AA', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: CARLOS_ACCOUNT_ID, + managerID: RORY_ACCOUNT_ID, + isWaitingOnBankAccount: false, + total: -10000, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + + expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, RORY_EMAIL, RORY_ACCOUNT_ID, [], false)).toBeTruthy(); + }); }); describe('retractReport', () => { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 9278006f9a9a..97f060ab3572 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -8515,22 +8515,29 @@ describe('ReportUtils', () => { expect(isPayer(otherAdminAccountID, otherAdminEmail, approvedReport, undefined, manualPolicyWithReimburser, false)).toBe(false); }); - it('should return true for any admin in manual reimbursement mode without designated payer', () => { - const adminEmail = 'admin@manual-fallback.com'; - const adminAccountID = 702; + it('should return true for policy owner in manual reimbursement mode without explicit reimburser', () => { + const ownerEmail = 'owner@manual-fallback.com'; + const ownerAccountID = 702; + const otherAdminEmail = 'other-admin@manual-fallback.com'; + const otherAdminAccountID = 703; const manualPolicyNoReimburser: Policy = { ...policyTest, + owner: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, employeeList: { - [adminEmail]: { + [ownerEmail]: { + role: CONST.POLICY.ROLE.ADMIN, + }, + [otherAdminEmail]: { role: CONST.POLICY.ROLE.ADMIN, }, }, }; - expect(isPayer(adminAccountID, adminEmail, approvedReport, undefined, manualPolicyNoReimburser, false)).toBe(true); + expect(isPayer(ownerAccountID, ownerEmail, approvedReport, undefined, manualPolicyNoReimburser, false)).toBe(true); + expect(isPayer(otherAdminAccountID, otherAdminEmail, approvedReport, undefined, manualPolicyNoReimburser, false)).toBe(false); }); it('should return true for any admin when onlyShowPayElsewhere is true even if reimbursement is disabled and a reimburser is set', () => { @@ -8595,6 +8602,7 @@ describe('ReportUtils', () => { expect(isPayer(reimburserAccountID, reimburserEmail, approvedReport, undefined, manualPolicyWithReimburser, true)).toBe(true); }); }); + describe('buildReportNameFromParticipantNames', () => { beforeAll(async () => { await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID}); From 10b5f535a212a32fc90dd640fdb0b8200dbf1711 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Tue, 23 Jun 2026 12:09:02 +0100 Subject: [PATCH 19/32] add email to tests --- tests/actions/IOUTest/ReportWorkflowTest.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index 4628cd121c05..84d6e11532a3 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -3627,6 +3627,7 @@ describe('actions/IOU/ReportWorkflow', () => { type: CONST.POLICY.TYPE.TEAM, approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, role: CONST.POLICY.ROLE.ADMIN, + owner: RORY_EMAIL, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, }; @@ -3756,6 +3757,7 @@ describe('actions/IOU/ReportWorkflow', () => { type: CONST.POLICY.TYPE.TEAM, approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, role: CONST.POLICY.ROLE.ADMIN, + owner: RORY_EMAIL, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, }; @@ -4196,6 +4198,7 @@ describe('actions/IOU/ReportWorkflow', () => { type: CONST.POLICY.TYPE.TEAM, approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, role: CONST.POLICY.ROLE.ADMIN, + owner: RORY_EMAIL, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, }; @@ -4303,6 +4306,7 @@ describe('actions/IOU/ReportWorkflow', () => { type: CONST.POLICY.TYPE.TEAM, approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, role: CONST.POLICY.ROLE.ADMIN, + owner: RORY_EMAIL, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, }; @@ -4367,6 +4371,7 @@ describe('actions/IOU/ReportWorkflow', () => { type: CONST.POLICY.TYPE.TEAM, approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, role: CONST.POLICY.ROLE.ADMIN, + owner: RORY_EMAIL, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, }; From e3962b87cc9be88db73ff050ec6e7f0ae25826d3 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Tue, 23 Jun 2026 13:44:29 +0100 Subject: [PATCH 20/32] account for invoices --- src/libs/ReportPrimaryActionUtils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 7ba5c74ccad3..8dfdb29b998c 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -526,6 +526,8 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf 0) && !didExportFail && !allExpensesHeld ) { From 1c5028f872ed2c775235843ba8a294d941f4eac8 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Fri, 26 Jun 2026 13:55:55 +0100 Subject: [PATCH 21/32] show payer option on report preview --- src/libs/ReportPreviewActionUtils.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index c11a5cbe65ac..747d39679ef4 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -8,6 +8,7 @@ import { getValidConnectedIntegration, hasDynamicExternalWorkflow, hasIntegrationAutoSync, + isPolicyAdmin, isPreferredExporter, isSubmitterApproveBlockedOnSubmitWorkspace, } from './PolicyUtils'; @@ -119,6 +120,7 @@ function canPay( } const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false); + const canPayReport = isReportPayer || (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && isPolicyAdmin(policy)); const isExpense = isExpenseReport(report); const isPaymentsEnabled = arePaymentsEnabled(policy); const isProcessing = isProcessingReport(report); @@ -136,7 +138,7 @@ function canPay( if ( isExpense && - isReportPayer && + canPayReport && isPaymentsEnabled && isReportFinished && (reimbursableSpend !== 0 || (nonReimbursableSpend !== 0 && hasOnlyNonReimbursableTransactions(report?.reportID, transactions))) @@ -150,7 +152,7 @@ function canPay( const isIOU = isIOUReport(report); - if (isIOU && isReportPayer && !isReimbursed && reimbursableSpend > 0) { + if (isIOU && canPayReport && !isReimbursed && reimbursableSpend > 0) { return true; } From 704dcda7ac79f469665cdbdfd5e941af75f4c2fd Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Fri, 26 Jun 2026 13:56:01 +0100 Subject: [PATCH 22/32] update test --- tests/actions/ReportPreviewActionUtilsTest.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index ab9c857af91b..1a22647583e7 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -688,6 +688,52 @@ describe('getReportPreviewAction', () => { ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); }); + it('canPay should return PAY for non-reimburser admin in manual reimbursement mode', async () => { + const DESIGNATED_PAYER_EMAIL = 'designated-payer@mail.com'; + const report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID + 1, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + total: -100, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0); + policy.role = CONST.POLICY.ROLE.ADMIN; + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.reimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; + policy.achAccount = { + reimburser: DESIGNATED_PAYER_EMAIL, + bankAccountID: 1, + accountNumber: '1234567890', + routingNumber: '987654321', + addressName: 'Test Address', + bankName: 'Test Bank', + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = { + reportID: `${REPORT_ID}`, + } as unknown as Transaction; + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + expect( + getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: undefined, + }), + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); + }); + it('canPay should return false for Expense report with zero total amount', async () => { const report = { ...createRandomReport(REPORT_ID, undefined), From 7066ee9fb40a7b0157216e0c046adce5a946d3bd Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Fri, 26 Jun 2026 14:58:36 +0100 Subject: [PATCH 23/32] eslint changes --- tests/actions/ReportPreviewActionUtilsTest.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 1a22647583e7..ab91e4884bd6 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -14,6 +14,7 @@ import * as InvoiceData from '../data/Invoice'; import type {InvoiceTestData} from '../data/Invoice'; import createRandomPolicy from '../utils/collections/policies'; import {createRandomReport} from '../utils/collections/reports'; +import createRandomTransaction from '../utils/collections/transaction'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const CURRENT_USER_ACCOUNT_ID = 1; @@ -715,8 +716,9 @@ describe('getReportPreviewAction', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); const transaction = { + ...createRandomTransaction(REPORT_ID), reportID: `${REPORT_ID}`, - } as unknown as Transaction; + }; const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); await waitForBatchedUpdatesWithAct(); From 72a1d999cb27ec5db95feb54ef79a090a9a0a0fe Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Mon, 29 Jun 2026 12:14:46 +0100 Subject: [PATCH 24/32] fix bank account reset --- .../resetNonUSDBankAccount.ts | 22 +++++++++++++++++-- .../resetUSDBankAccount.ts | 11 +++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts b/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts index 3469f49f9c38..60b8d5c234df 100644 --- a/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts +++ b/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts @@ -27,7 +27,16 @@ function resetNonUSDBankAccount( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - achAccount: policyOwner ? {reimburser: policyOwner} : null, + achAccount: policyOwner + ? { + reimburser: policyOwner, + bankAccountID: null, + accountNumber: null, + addressName: null, + bankName: null, + state: null, + } + : null, }, }, { @@ -103,7 +112,16 @@ function resetNonUSDBankAccount( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - achAccount: policyOwner ? {reimburser: policyOwner} : null, + achAccount: policyOwner + ? { + reimburser: policyOwner, + bankAccountID: null, + accountNumber: null, + addressName: null, + bankName: null, + state: null, + } + : null, }, }); diff --git a/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts b/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts index 3926fd799a45..8b52ca694124 100644 --- a/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts +++ b/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts @@ -56,7 +56,16 @@ function resetUSDBankAccount( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - achAccount: policyOwner ? {reimburser: policyOwner} : null, + achAccount: policyOwner + ? { + reimburser: policyOwner, + bankAccountID: null, + accountNumber: null, + addressName: null, + bankName: null, + state: null, + } + : null, }, }, { From 42c1c09beff58dd745e1ff141a07cd3133c985de Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Mon, 29 Jun 2026 12:16:12 +0100 Subject: [PATCH 25/32] reuse isPrimaryPay action --- src/libs/PolicyUtils.ts | 2 +- src/libs/ReportPrimaryActionUtils.ts | 29 ++++++++++++++++++---------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index a428f98d2c71..7775401c821c 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -603,7 +603,7 @@ function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | und return false; } - const reimburserEmail = policy.achAccount?.reimburser; + const reimburserEmail = policy.achAccount?.reimburser ?? (isManualReimbursement ? policy.owner : undefined); // No designated reimburser means any workspace admin can pay. if (!reimburserEmail) { diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 3b730f32fd1a..eb48233afa4b 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -3,7 +3,6 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {BankAccountList, Policy, Report, ReportAction, ReportMetadata, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; -import {canIOUBePaid as canIOUBePaidAction} from './actions/IOU/ReportWorkflow'; import { arePaymentsEnabled as arePaymentsEnabledUtils, getSubmitToAccountID, @@ -92,6 +91,7 @@ type IsPrimaryPayActionParams = { invoiceReceiverPolicy?: Policy; reportActions?: ReportAction[]; isSecondaryAction?: boolean; + canNonPayerAdminPay?: boolean; }; function isAddExpenseAction(report: Report, reportTransactions: Transaction[], isChatReportArchived: boolean) { @@ -206,6 +206,7 @@ function isPrimaryPayAction({ invoiceReceiverPolicy, reportActions, isSecondaryAction, + canNonPayerAdminPay, }: IsPrimaryPayActionParams) { if (isArchivedReport(reportNameValuePairs) || isChatReportArchived) { return false; @@ -215,6 +216,8 @@ function isPrimaryPayAction({ return false; } const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false); + const canPayReport = + isReportPayer || (canNonPayerAdminPay && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && isPolicyAdminPolicyUtils(policy)); const arePaymentsEnabled = arePaymentsEnabledUtils(policy); const isReportApproved = isReportApprovedUtils({report}); const isReportClosed = isClosedReportUtils(report); @@ -230,7 +233,7 @@ function isPrimaryPayAction({ const {reimbursableSpend, nonReimbursableSpend} = getMoneyRequestSpendBreakdown(report); if ( - isReportPayer && + canPayReport && isExpenseReport && arePaymentsEnabled && isReportFinished && @@ -245,7 +248,7 @@ function isPrimaryPayAction({ const isIOUReport = isIOUReportUtils(report); - if (isIOUReport && isReportPayer && reimbursableSpend > 0) { + if (isIOUReport && canPayReport && reimbursableSpend > 0) { return true; } @@ -480,9 +483,6 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf 0) && - !didExportFail && + isPrimaryPayAction({ + report, + reportTransactions, + currentUserAccountID, + currentUserLogin, + bankAccountList, + policy, + reportNameValuePairs, + isChatReportArchived, + invoiceReceiverPolicy, + reportActions, + canNonPayerAdminPay: true, + }) && !allExpensesHeld ) { return CONST.REPORT.PRIMARY_ACTIONS.PAY; From e8e82af49bb762c6cbba8fe658534d23a319d97f Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Mon, 29 Jun 2026 12:16:17 +0100 Subject: [PATCH 26/32] update tests --- tests/actions/PolicyTest.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 9856133aae7b..a733e9f3ecf0 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -4,6 +4,7 @@ import Onyx from 'react-native-onyx'; import {WRITE_COMMANDS} from '@libs/API/types'; import GoogleTagManager from '@libs/GoogleTagManager'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import {isPolicyPayer} from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -5577,6 +5578,20 @@ describe('actions/Policy', () => { }); }); + describe('isPolicyPayer', () => { + it('returns true only for workspace owner when manual reimbursement has no achAccount reimburser', () => { + const policy = { + ...createRandomPolicy(0), + role: CONST.POLICY.ROLE.ADMIN, + owner: 'owner@test.com', + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + }; + + expect(isPolicyPayer(policy, 'owner@test.com')).toBe(true); + expect(isPolicyPayer(policy, 'other-admin@test.com')).toBe(false); + }); + }); + describe('setPolicyPreventSelfApproval', () => { it('should update prevent self approval optimistically and succeed', async () => { // Given a workspace with prevent self approval disabled From b2b692c9ccdf79bb94b00d37dc1a1b4a0706a650 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Mon, 29 Jun 2026 14:05:32 +0100 Subject: [PATCH 27/32] fallback to owner --- .../ReimbursementAccount/resetNonUSDBankAccount.ts | 10 ++++++---- .../ReimbursementAccount/resetUSDBankAccount.ts | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts b/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts index 60b8d5c234df..2476cb59c5d2 100644 --- a/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts +++ b/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts @@ -15,6 +15,8 @@ function resetNonUSDBankAccount( lastUsedPaymentMethod?: OnyxTypes.LastPaymentMethodType, policyOwner?: string, ) { + const reimburserEmail = achAccount?.reimburser ?? policyOwner; + // If there's no bankAccountID, we reset locally without making an API call if (!bankAccountID) { const updateData: Array> = [ @@ -27,9 +29,9 @@ function resetNonUSDBankAccount( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - achAccount: policyOwner + achAccount: reimburserEmail ? { - reimburser: policyOwner, + reimburser: reimburserEmail, bankAccountID: null, accountNumber: null, addressName: null, @@ -112,9 +114,9 @@ function resetNonUSDBankAccount( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - achAccount: policyOwner + achAccount: reimburserEmail ? { - reimburser: policyOwner, + reimburser: reimburserEmail, bankAccountID: null, accountNumber: null, addressName: null, diff --git a/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts b/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts index 8b52ca694124..249cd31d243c 100644 --- a/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts +++ b/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts @@ -29,6 +29,7 @@ function resetUSDBankAccount( const isLastUsedPaymentMethodBBA = lastUsedPaymentMethod?.expense?.name === CONST.IOU.PAYMENT_TYPE.VBBA; const isPreviousLastUsedPaymentMethodBBA = lastUsedPaymentMethod?.lastUsed?.name === CONST.IOU.PAYMENT_TYPE.VBBA; + const reimburserEmail = achAccount?.reimburser ?? policyOwner; const onyxData: OnyxData< | typeof ONYXKEYS.REIMBURSEMENT_ACCOUNT @@ -56,9 +57,9 @@ function resetUSDBankAccount( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - achAccount: policyOwner + achAccount: reimburserEmail ? { - reimburser: policyOwner, + reimburser: reimburserEmail, bankAccountID: null, accountNumber: null, addressName: null, From 75a56c798c48f3d5745254d81243ff0563d9fceb Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Mon, 29 Jun 2026 15:41:44 +0100 Subject: [PATCH 28/32] updatet tests for bank account clearing --- tests/actions/ReimbursementAccountTest.ts | 78 +++++++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/tests/actions/ReimbursementAccountTest.ts b/tests/actions/ReimbursementAccountTest.ts index eb9eb0fb2e7d..5e7a4e0ba7e8 100644 --- a/tests/actions/ReimbursementAccountTest.ts +++ b/tests/actions/ReimbursementAccountTest.ts @@ -15,6 +15,15 @@ const bankAccountID = 1; const policyID = '1234567890'; const session = {email: TEST_EMAIL, accountID: TEST_ACCOUNT_ID}; +function expectDisconnectedAchAccount(achAccount: ACHAccount | null | undefined, reimburser: string) { + expect(achAccount?.reimburser).toBe(reimburser); + expect(achAccount?.bankAccountID).toBeFalsy(); + expect(achAccount?.accountNumber).toBeFalsy(); + expect(achAccount?.addressName).toBeFalsy(); + expect(achAccount?.bankName).toBeFalsy(); + expect(achAccount?.state).toBeFalsy(); +} + describe('ReimbursementAccount', () => { beforeAll(() => { Onyx.init({ @@ -54,7 +63,7 @@ describe('ReimbursementAccount', () => { key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, callback: (policy) => { Onyx.disconnect(connection); - expect(policy?.achAccount).toBeUndefined(); + expectDisconnectedAchAccount(policy?.achAccount, TEST_EMAIL); resolve(); }, }); @@ -180,7 +189,7 @@ describe('ReimbursementAccount', () => { }); }); - it('should clear policy achAccount optimistically', async () => { + it('should preserve designated payer and clear bank fields optimistically', async () => { (fetch as MockFetch)?.pause?.(); const achAccount: ACHAccount = { bankAccountID, @@ -199,7 +208,7 @@ describe('ReimbursementAccount', () => { key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, callback: (policy) => { Onyx.disconnect(connection); - expect(policy?.achAccount).toBeUndefined(); + expectDisconnectedAchAccount(policy?.achAccount, TEST_EMAIL); resolve(); }, }); @@ -220,11 +229,70 @@ describe('ReimbursementAccount', () => { await waitForBatchedUpdates(); return new Promise((resolve) => { - const connection = Onyx.connect({ + const reimbursementConnection = Onyx.connect({ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, callback: (reimbursementAccount) => { - Onyx.disconnect(connection); + Onyx.disconnect(reimbursementConnection); expect(reimbursementAccount).toEqual(CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA); + }, + }); + const policyConnection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + callback: (policy) => { + Onyx.disconnect(policyConnection); + expectDisconnectedAchAccount(policy?.achAccount, TEST_EMAIL); + resolve(); + }, + }); + }); + }); + + it('should preserve designated payer when it differs from owner', async () => { + const designatedPayer = 'payer@test.com'; + const policyOwner = 'owner@test.com'; + const achAccount: ACHAccount = { + bankAccountID, + addressName: 'Test Address', + bankName: 'Test Bank', + reimburser: designatedPayer, + accountNumber: '1234567890', + routingNumber: '123456789', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {achAccount}); + resetNonUSDBankAccount(policyID, achAccount, bankAccountID, undefined, policyOwner); + + await waitForBatchedUpdates(); + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + callback: (policy) => { + Onyx.disconnect(connection); + expectDisconnectedAchAccount(policy?.achAccount, designatedPayer); + resolve(); + }, + }); + }); + }); + + it('should fall back to owner when achAccount has no reimburser', async () => { + const policyOwner = 'owner@test.com'; + const achAccount: ACHAccount = { + bankAccountID, + addressName: 'Test Address', + bankName: 'Test Bank', + accountNumber: '1234567890', + routingNumber: '123456789', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {achAccount}); + resetNonUSDBankAccount(policyID, achAccount, bankAccountID, undefined, policyOwner); + + await waitForBatchedUpdates(); + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + callback: (policy) => { + Onyx.disconnect(connection); + expectDisconnectedAchAccount(policy?.achAccount, policyOwner); resolve(); }, }); From d4b6fdab872849115974a361f5f17899cd3b45bd Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Mon, 29 Jun 2026 16:15:15 +0100 Subject: [PATCH 29/32] update tests again, undo bad merge --- src/components/Modal/internalPopstateGuard.ts | 19 +++++ tests/actions/ReimbursementAccountTest.ts | 4 +- tests/actions/ReportPreviewActionUtilsTest.ts | 1 + tests/unit/internalPopstateGuardTest.ts | 74 +++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 src/components/Modal/internalPopstateGuard.ts create mode 100644 tests/unit/internalPopstateGuardTest.ts diff --git a/src/components/Modal/internalPopstateGuard.ts b/src/components/Modal/internalPopstateGuard.ts new file mode 100644 index 000000000000..96cdd3d52533 --- /dev/null +++ b/src/components/Modal/internalPopstateGuard.ts @@ -0,0 +1,19 @@ +let isInternal = false; + +/** Returns true when the next `popstate` was triggered by our own `history.back()`, not a real user back navigation. */ +function isInternalPopstateInProgress(): boolean { + return isInternal; +} + +/** Runs `action` (e.g. `history.back()`) while flagging the resulting `popstate` as internal, so listeners can ignore it. */ +function withInternalPopstate(action: () => void) { + isInternal = true; + const clear = () => { + isInternal = false; + window.removeEventListener('popstate', clear); + }; + window.addEventListener('popstate', clear); + action(); +} + +export {isInternalPopstateInProgress, withInternalPopstate}; diff --git a/tests/actions/ReimbursementAccountTest.ts b/tests/actions/ReimbursementAccountTest.ts index 5e7a4e0ba7e8..1be4a53e5a82 100644 --- a/tests/actions/ReimbursementAccountTest.ts +++ b/tests/actions/ReimbursementAccountTest.ts @@ -276,13 +276,13 @@ describe('ReimbursementAccount', () => { it('should fall back to owner when achAccount has no reimburser', async () => { const policyOwner = 'owner@test.com'; - const achAccount: ACHAccount = { + const achAccount = { bankAccountID, addressName: 'Test Address', bankName: 'Test Bank', accountNumber: '1234567890', routingNumber: '123456789', - }; + } as ACHAccount; await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {achAccount}); resetNonUSDBankAccount(policyID, achAccount, bankAccountID, undefined, policyOwner); diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 0726d01fdd2e..fc53f7b82507 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -747,6 +747,7 @@ describe('getReportPreviewAction', () => { transactions: [transaction], bankAccountList: {}, reportMetadata: undefined, + ownerLogin: CURRENT_USER_EMAIL, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); }); diff --git a/tests/unit/internalPopstateGuardTest.ts b/tests/unit/internalPopstateGuardTest.ts new file mode 100644 index 000000000000..875088da3e1b --- /dev/null +++ b/tests/unit/internalPopstateGuardTest.ts @@ -0,0 +1,74 @@ +import {isInternalPopstateInProgress, withInternalPopstate} from '@components/Modal/internalPopstateGuard'; + +describe('internalPopstateGuard', () => { + afterEach(() => { + // Drain any lingering popstate listener so the module-scoped flag is back to false before the next case. + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + it('reports false by default', () => { + expect(isInternalPopstateInProgress()).toBe(false); + }); + + it('reports true synchronously after withInternalPopstate is called, before any popstate fires', () => { + withInternalPopstate(() => {}); + expect(isInternalPopstateInProgress()).toBe(true); + }); + + it('runs the action synchronously', () => { + const action = jest.fn(); + withInternalPopstate(action); + expect(action).toHaveBeenCalledTimes(1); + }); + + it('clears the flag after the next popstate event fires', () => { + withInternalPopstate(() => {}); + expect(isInternalPopstateInProgress()).toBe(true); + + window.dispatchEvent(new PopStateEvent('popstate')); + + expect(isInternalPopstateInProgress()).toBe(false); + }); + + it('detaches its popstate listener after one event (subsequent popstates do not flip the flag)', () => { + withInternalPopstate(() => {}); + window.dispatchEvent(new PopStateEvent('popstate')); + expect(isInternalPopstateInProgress()).toBe(false); + + window.dispatchEvent(new PopStateEvent('popstate')); + expect(isInternalPopstateInProgress()).toBe(false); + }); + + it('a listener registered before withInternalPopstate sees the flag as true during the same popstate', () => { + let observedDuringEvent: boolean | undefined; + const popoverListener = () => { + observedDuringEvent = isInternalPopstateInProgress(); + }; + window.addEventListener('popstate', popoverListener); + + withInternalPopstate(() => {}); + window.dispatchEvent(new PopStateEvent('popstate')); + + expect(observedDuringEvent).toBe(true); + expect(isInternalPopstateInProgress()).toBe(false); + + window.removeEventListener('popstate', popoverListener); + }); + + it('a listener registered after withInternalPopstate sees the flag already cleared', () => { + withInternalPopstate(() => {}); + + let observedDuringEvent: boolean | undefined; + const lateListener = () => { + observedDuringEvent = isInternalPopstateInProgress(); + }; + window.addEventListener('popstate', lateListener); + + window.dispatchEvent(new PopStateEvent('popstate')); + + // Clear listener was registered before lateListener, so by the time lateListener runs the flag is already released. + expect(observedDuringEvent).toBe(false); + + window.removeEventListener('popstate', lateListener); + }); +}); From 9009b9551d58254c72853cc7da503f7e651aa866 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Mon, 29 Jun 2026 16:25:37 +0100 Subject: [PATCH 30/32] remove files from bad merge --- src/components/Modal/internalPopstateGuard.ts | 19 ----- tests/unit/internalPopstateGuardTest.ts | 74 ------------------- 2 files changed, 93 deletions(-) delete mode 100644 src/components/Modal/internalPopstateGuard.ts delete mode 100644 tests/unit/internalPopstateGuardTest.ts diff --git a/src/components/Modal/internalPopstateGuard.ts b/src/components/Modal/internalPopstateGuard.ts deleted file mode 100644 index 96cdd3d52533..000000000000 --- a/src/components/Modal/internalPopstateGuard.ts +++ /dev/null @@ -1,19 +0,0 @@ -let isInternal = false; - -/** Returns true when the next `popstate` was triggered by our own `history.back()`, not a real user back navigation. */ -function isInternalPopstateInProgress(): boolean { - return isInternal; -} - -/** Runs `action` (e.g. `history.back()`) while flagging the resulting `popstate` as internal, so listeners can ignore it. */ -function withInternalPopstate(action: () => void) { - isInternal = true; - const clear = () => { - isInternal = false; - window.removeEventListener('popstate', clear); - }; - window.addEventListener('popstate', clear); - action(); -} - -export {isInternalPopstateInProgress, withInternalPopstate}; diff --git a/tests/unit/internalPopstateGuardTest.ts b/tests/unit/internalPopstateGuardTest.ts deleted file mode 100644 index 875088da3e1b..000000000000 --- a/tests/unit/internalPopstateGuardTest.ts +++ /dev/null @@ -1,74 +0,0 @@ -import {isInternalPopstateInProgress, withInternalPopstate} from '@components/Modal/internalPopstateGuard'; - -describe('internalPopstateGuard', () => { - afterEach(() => { - // Drain any lingering popstate listener so the module-scoped flag is back to false before the next case. - window.dispatchEvent(new PopStateEvent('popstate')); - }); - - it('reports false by default', () => { - expect(isInternalPopstateInProgress()).toBe(false); - }); - - it('reports true synchronously after withInternalPopstate is called, before any popstate fires', () => { - withInternalPopstate(() => {}); - expect(isInternalPopstateInProgress()).toBe(true); - }); - - it('runs the action synchronously', () => { - const action = jest.fn(); - withInternalPopstate(action); - expect(action).toHaveBeenCalledTimes(1); - }); - - it('clears the flag after the next popstate event fires', () => { - withInternalPopstate(() => {}); - expect(isInternalPopstateInProgress()).toBe(true); - - window.dispatchEvent(new PopStateEvent('popstate')); - - expect(isInternalPopstateInProgress()).toBe(false); - }); - - it('detaches its popstate listener after one event (subsequent popstates do not flip the flag)', () => { - withInternalPopstate(() => {}); - window.dispatchEvent(new PopStateEvent('popstate')); - expect(isInternalPopstateInProgress()).toBe(false); - - window.dispatchEvent(new PopStateEvent('popstate')); - expect(isInternalPopstateInProgress()).toBe(false); - }); - - it('a listener registered before withInternalPopstate sees the flag as true during the same popstate', () => { - let observedDuringEvent: boolean | undefined; - const popoverListener = () => { - observedDuringEvent = isInternalPopstateInProgress(); - }; - window.addEventListener('popstate', popoverListener); - - withInternalPopstate(() => {}); - window.dispatchEvent(new PopStateEvent('popstate')); - - expect(observedDuringEvent).toBe(true); - expect(isInternalPopstateInProgress()).toBe(false); - - window.removeEventListener('popstate', popoverListener); - }); - - it('a listener registered after withInternalPopstate sees the flag already cleared', () => { - withInternalPopstate(() => {}); - - let observedDuringEvent: boolean | undefined; - const lateListener = () => { - observedDuringEvent = isInternalPopstateInProgress(); - }; - window.addEventListener('popstate', lateListener); - - window.dispatchEvent(new PopStateEvent('popstate')); - - // Clear listener was registered before lateListener, so by the time lateListener runs the flag is already released. - expect(observedDuringEvent).toBe(false); - - window.removeEventListener('popstate', lateListener); - }); -}); From 81030ed181a76e8261c00f913d5a0577417e4858 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Mon, 29 Jun 2026 19:34:33 +0100 Subject: [PATCH 31/32] eslint --- tests/actions/ReimbursementAccountTest.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/tests/actions/ReimbursementAccountTest.ts b/tests/actions/ReimbursementAccountTest.ts index 1be4a53e5a82..a7fc41b66b96 100644 --- a/tests/actions/ReimbursementAccountTest.ts +++ b/tests/actions/ReimbursementAccountTest.ts @@ -44,7 +44,7 @@ describe('ReimbursementAccount', () => { }); it('should reset the USDBankAccount', async () => { - (fetch as MockFetch)?.pause?.(); + mockFetch.pause?.(); const achAccount: ACHAccount = { bankAccountID, addressName: 'Test Address', @@ -72,7 +72,7 @@ describe('ReimbursementAccount', () => { }); it('should optimistically mark bank account as pending deletion', async () => { - (fetch as MockFetch)?.pause?.(); + mockFetch.pause?.(); const achAccount: ACHAccount = { bankAccountID, addressName: 'Test Address', @@ -135,7 +135,7 @@ describe('ReimbursementAccount', () => { }); it('should optimistically mark bank account as pending deletion', async () => { - (fetch as MockFetch)?.pause?.(); + mockFetch.pause?.(); const achAccount: ACHAccount = { bankAccountID, addressName: 'Test Address', @@ -190,7 +190,7 @@ describe('ReimbursementAccount', () => { }); it('should preserve designated payer and clear bank fields optimistically', async () => { - (fetch as MockFetch)?.pause?.(); + mockFetch.pause?.(); const achAccount: ACHAccount = { bankAccountID, addressName: 'Test Address', @@ -276,15 +276,8 @@ describe('ReimbursementAccount', () => { it('should fall back to owner when achAccount has no reimburser', async () => { const policyOwner = 'owner@test.com'; - const achAccount = { - bankAccountID, - addressName: 'Test Address', - bankName: 'Test Bank', - accountNumber: '1234567890', - routingNumber: '123456789', - } as ACHAccount; - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {achAccount}); - resetNonUSDBankAccount(policyID, achAccount, bankAccountID, undefined, policyOwner); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {}); + resetNonUSDBankAccount(policyID, undefined, bankAccountID, undefined, policyOwner); await waitForBatchedUpdates(); return new Promise((resolve) => { From 101150de4a299eed8491e07e8fae087828a23c35 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Tue, 30 Jun 2026 12:16:44 +0100 Subject: [PATCH 32/32] clear bank account on reset add tests --- src/hooks/useResetBankAccountModal.tsx | 4 ++-- .../actions/ReimbursementAccount/resetNonUSDBankAccount.ts | 4 ++++ src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts | 2 ++ tests/actions/ReimbursementAccountTest.ts | 2 ++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/hooks/useResetBankAccountModal.tsx b/src/hooks/useResetBankAccountModal.tsx index 002281385502..f591268b753f 100644 --- a/src/hooks/useResetBankAccountModal.tsx +++ b/src/hooks/useResetBankAccountModal.tsx @@ -60,7 +60,7 @@ function useResetBankAccountModal({ const achData = reimbursementAccount?.achData; const shouldShowResetModal = reimbursementAccount?.shouldShowResetModal ?? false; const isInOpenState = achData?.state === CONST.BANK_ACCOUNT.STATE.OPEN; - const bankAccountID = achData?.bankAccountID; + const bankAccountID = achData?.bankAccountID ?? policy?.achAccount?.bankAccountID; const bankShortName = `${achData?.addressName ?? ''} ${(achData?.accountNumber ?? '').slice(-4)}`; const lastPaymentMethodSelector = useCallback( @@ -77,7 +77,7 @@ function useResetBankAccountModal({ const handleConfirm = () => { if (isNonUSDWorkspace) { - resetNonUSDBankAccount(policyID, policy?.achAccount, achData?.bankAccountID, lastPaymentMethod, policy?.owner); + resetNonUSDBankAccount(policyID, policy?.achAccount, bankAccountID, lastPaymentMethod, policy?.owner); if (setShouldShowConnectedVerifiedBankAccount) { setShouldShowConnectedVerifiedBankAccount(false); diff --git a/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts b/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts index 2476cb59c5d2..bd2b770d2c91 100644 --- a/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts +++ b/src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts @@ -34,9 +34,11 @@ function resetNonUSDBankAccount( reimburser: reimburserEmail, bankAccountID: null, accountNumber: null, + routingNumber: null, addressName: null, bankName: null, state: null, + sharees: null, } : null, }, @@ -119,9 +121,11 @@ function resetNonUSDBankAccount( reimburser: reimburserEmail, bankAccountID: null, accountNumber: null, + routingNumber: null, addressName: null, bankName: null, state: null, + sharees: null, } : null, }, diff --git a/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts b/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts index 249cd31d243c..831c1e041a95 100644 --- a/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts +++ b/src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts @@ -62,9 +62,11 @@ function resetUSDBankAccount( reimburser: reimburserEmail, bankAccountID: null, accountNumber: null, + routingNumber: null, addressName: null, bankName: null, state: null, + sharees: null, } : null, }, diff --git a/tests/actions/ReimbursementAccountTest.ts b/tests/actions/ReimbursementAccountTest.ts index a7fc41b66b96..cb2cd5df222f 100644 --- a/tests/actions/ReimbursementAccountTest.ts +++ b/tests/actions/ReimbursementAccountTest.ts @@ -22,6 +22,8 @@ function expectDisconnectedAchAccount(achAccount: ACHAccount | null | undefined, expect(achAccount?.addressName).toBeFalsy(); expect(achAccount?.bankName).toBeFalsy(); expect(achAccount?.state).toBeFalsy(); + expect(achAccount?.routingNumber).toBeFalsy(); + expect(achAccount?.sharees).toBeFalsy(); } describe('ReimbursementAccount', () => {