Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
9334d49
account for manual reimbursement flow in isPayer
NikkiWines Jun 5, 2026
566eff9
handle reimburse manual flow for workspace workflows
NikkiWines Jun 5, 2026
6bac69b
add tests
NikkiWines Jun 5, 2026
fac689b
define isManualReimbursement earlier
NikkiWines Jun 5, 2026
fffb187
align on policy.achAccount.reimburser
NikkiWines Jun 5, 2026
6b41645
add isPayer test
NikkiWines Jun 5, 2026
3768f72
type checks
NikkiWines Jun 5, 2026
1d62b2b
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 5, 2026
0ccd17d
prettier
NikkiWines Jun 5, 2026
ce42d33
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 8, 2026
b6e6a59
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 9, 2026
19ef76b
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 15, 2026
873a043
fall back on policy owner
NikkiWines Jun 17, 2026
b7ad25c
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 17, 2026
33f2078
update tests
NikkiWines Jun 17, 2026
4b13609
update to reimburse manual when no bank account is selected
NikkiWines Jun 17, 2026
5a13f44
style
NikkiWines Jun 17, 2026
501bafb
allow any admin when only paying elsewhere
NikkiWines Jun 18, 2026
8276218
restrict to when reimbursement choice is no
NikkiWines Jun 18, 2026
174d67d
add test
NikkiWines Jun 18, 2026
d4f2217
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 22, 2026
f49c2f6
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 22, 2026
df3e3bc
allow non-payer admins to pay report, but don't show other other paym…
NikkiWines Jun 22, 2026
5ad0416
default to owner for bank account logic
NikkiWines Jun 22, 2026
e3939ac
add tests
NikkiWines Jun 22, 2026
974e0ad
Merge branch 'nikki-adjust-payer-gbr' of github.com:Expensify/App int…
NikkiWines Jun 22, 2026
be66c82
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 22, 2026
10b5f53
add email to tests
NikkiWines Jun 23, 2026
e3962b8
account for invoices
NikkiWines Jun 23, 2026
00427e8
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 26, 2026
1c5028f
show payer option on report preview
NikkiWines Jun 26, 2026
704dcda
update test
NikkiWines Jun 26, 2026
7066ee9
eslint changes
NikkiWines Jun 26, 2026
9032164
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 29, 2026
72a1d99
fix bank account reset
NikkiWines Jun 29, 2026
42c1c09
reuse isPrimaryPay action
NikkiWines Jun 29, 2026
e8e82af
update tests
NikkiWines Jun 29, 2026
b2b692c
fallback to owner
NikkiWines Jun 29, 2026
75a56c7
updatet tests for bank account clearing
NikkiWines Jun 29, 2026
f29826f
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 29, 2026
d4b6fda
update tests again, undo bad merge
NikkiWines Jun 29, 2026
08a601f
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 29, 2026
9009b95
remove files from bad merge
NikkiWines Jun 29, 2026
81030ed
eslint
NikkiWines Jun 29, 2026
101150d
clear bank account on reset
NikkiWines Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve admin access for Pay elsewhere

When a workspace is switched to REIMBURSEMENT_NO, the existing achAccount.reimburser is retained, and canIOUBePaid calls this helper with onlyShowPayElsewhere=true for submitted/negative/non-reimbursable reports. Treating that flag as manual reimbursement then falls through to the reimburser-only check below, so every other admin loses the Pay elsewhere/mark-paid action; before this change the onlyShowPayElsewhere path returned isAdmin.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I verified this comment, and I think it makes logical sense. The SetWorkspaceReimbursement API does not delete achAccount.reimburser in optimisticData; it is only removed by the backend response returned from this API. As a result, the value is still retained while offline.

However, this does not currently introduce a regression because canIOUBePaid has a guard: policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NikkiWines do you want to address this before merging?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, looking now!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, updated!


// 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;
Comment thread
NikkiWines marked this conversation as resolved.
}

// 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;
Comment thread
NikkiWines marked this conversation as resolved.
Outdated
}

// Personal workspaces and IOU reports fall back to admin or report manager.
return isAdmin || (isMoneyRequestReport(iouReport) && isManager);
}

Expand Down
3 changes: 2 additions & 1 deletion src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -696,7 +697,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) {
/>
)
)}
{shouldShowBankAccount && (
{shouldShowPayer && (
<OfflineWithFeedback
pendingAction={policy?.pendingFields?.reimburser}
shouldDisableOpacity={isOffline && !!policy?.pendingFields?.reimbursementChoice && !!policy?.pendingFields?.reimburser}
Expand Down
12 changes: 11 additions & 1 deletion src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@
if (!selectedPayer) {
return;
}

// In manual reimbursement mode there's no bank account to share
if (isManualReimbursement) {

Check failure on line 203 in src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

'isManualReimbursement' was used before it was defined

Check failure on line 203 in src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

'isManualReimbursement' was used before it was defined
onButtonPress();
return;
}

const isSelectedPayerOwner = policy?.owner === selectedPayer;
const isSelectedAlreadyAPayer = policy?.achAccount?.reimburser === selectedPayer;
const isAccountAlreadyShared = bankAccountInfo?.accountData?.sharees ? bankAccountInfo?.accountData.sharees.includes(selectedPayer) : false;
Expand Down Expand Up @@ -247,8 +254,11 @@

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) || 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;
Expand Down
56 changes: 56 additions & 0 deletions tests/actions/IOUTest/ReportWorkflowTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
31 changes: 31 additions & 0 deletions tests/actions/PolicyTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/ReportUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8443,6 +8443,70 @@
// 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);
});

Check failure on line 8466 in tests/unit/ReportUtilsTest.ts

View workflow job for this annotation

GitHub Actions / typecheck

Type '{ reimburser: string; }' is missing the following properties from type 'ACHAccount': bankAccountID, accountNumber, routingNumber, addressName, bankName

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);
});

Check failure on line 8491 in tests/unit/ReportUtilsTest.ts

View workflow job for this annotation

GitHub Actions / typecheck

Type '{ reimburser: string; }' is missing the following properties from type 'ACHAccount': bankAccountID, accountNumber, routingNumber, addressName, bankName

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 () => {
Expand Down
Loading