Skip to content

Gate "Submit to my employer" on the workspace billing restriction - #100584

Open
MelvinBot wants to merge 7 commits into
mainfrom
claude-submitToEmployerRestrictedWorkspace
Open

Gate "Submit to my employer" on the workspace billing restriction#100584
MelvinBot wants to merge 7 commits into
mainfrom
claude-submitToEmployerRestrictedWorkspace

Conversation

@MelvinBot

@MelvinBot MelvinBot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

"Submit to my employer" from a self DM could create an expense on a workspace with an expired required payment, instead of showing the "Restricted" screen.

The billing-restriction gate for this flow lived only in the destination picker, in the row handler at src/pages/iou/request/ParticipantSearchResults.tsx. But createDraftTransactionAndNavigateToParticipantSelector has a fast path that skips that picker when the user belongs to exactly one workspace — it binds the draft straight to that workspace's expense chat and navigates to the confirmation page. Skip the picker, skip the gate. Nothing downstream re-checks: the confirmation page, confirmAction, the validation hook, and requestMoney contain no billing-restriction checks, so the expense is created against the expired workspace.

That is why the bug needs the exact preconditions in the report — one workspace, and it's expired. With two or more workspaces you hit the picker and correctly land on the Restricted screen.

This change applies the same gate the sibling CATEGORIZE branch already applies, before the fast paths navigate:

  1. The single-workspace EMPLOYER fast path now resolves the policy for the one accessible workspace and, when shouldRestrictUserBillableActions(...) is true, navigates to ROUTES.RESTRICTED_ACTION and returns.
  2. The preferred-policy fast path just below it skips the picker the same way and was also ungated, so it gets the same guard.

All four inputs (ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) were already threaded into this function for the CATEGORIZE check, so no new plumbing was needed. Fixing it inside this helper covers all three "Submit to my employer" entry points at once, since they all call it: the self-DM whisper buttons, the report-details menu, and the expense header menu.

Structure of the gate (after review)

The check is a shared helper, navigateToRestrictedActionIfNeeded, used by all six billable entry points in ReportUtils — the four that already had the pattern inline plus the two this PR adds.

It takes a resolved policy rather than an ID, so it never reads this file's independently-timed allPolicies cache, which can lag a caller's own snapshot and let the gate fail open.

Each fast path has exactly one handle on its workspace, and that handle is the policy:

  • firstPolicy (no parallel firstPolicyID) for the single-workspace path.
  • restrictedPreferredPolicy, one non-nullable field replacing the isRestrictedToPreferredPolicy / preferredPolicyID / preferredPolicy trio, for the preferred-workspace path.

So "I have a workspace to submit to" and "I have the policy to gate on" are the same fact, and a caller cannot enter a fast path with the gate silently disabled.

Both are typed as BillingRestrictionPolicyPick<Policy, 'id' | 'ownerAccountID'>, the only fields the restriction depends on. That keeps the useOnyx selectors that produce them fixed-size, so no employeeList / customUnits deep-compare lands in the report-details or track-expense-whisper render paths.

Alternative considered

Filtering restricted workspaces out of the filteredPoliciesCount selector. Rejected: it would push the single-expired-workspace case into the count === 0 branch, which silently spins up a brand-new Submit workspace instead of showing the Restricted screen.

Automated tests added

Two regression tests in tests/actions/IOU/CreateDraftTransactionTest.ts, both confirmed to fail on main and pass with this change:

  • should show the restricted action screen when the only accessible workspace has an expired required payment — also asserts the draft is left unbound, so nothing can be submitted to the restricted workspace.
  • should show the restricted action screen when the preferred workspace has an expired required payment

The existing happy-path test (should bind the draft transaction to the destination chat when exactly one workspace is accessible) still passes, so the non-restricted flow is unchanged.

Fixed Issues

$ #99325
PROPOSAL: #99325 (comment)

Tests

// TODO: The human co-author must fill out the tests they ran before marking this PR as "ready for review".
// Please describe what tests you performed that validate the change works.
//
// Reaching the buggy state needs a workspace with an expired required payment, which depends on billing NVPs and is not
// reproducible from a fresh test account. Suggested coverage for whoever has such an account:
// 1. As the owner of a single workspace with an expired required payment, open the self DM.
// 2. Create a new expense -> "Submit to my employer" -> enter a merchant -> Create expense.
// 3. Verify you land on the "Restricted" screen with the "Add a payment card to unlock!" badge.
// 4. Regression: repeat with a single healthy workspace and verify you still land on the expense confirmation page,
// bound to that workspace (the workspace field must not read "None").

  • Verify that no errors appear in the JS console

Offline tests

// TODO: The human co-author must fill out the expected offline behavior before marking this PR as "ready for review".
// This change only affects client-side navigation before the expense is written, so behavior is expected to be unchanged
// offline, but please confirm.

QA Steps

// TODO: These must be filled out, or the issue title must include "[No QA]."
// The human co-author must fill out the QA steps before marking this PR as "ready for review".

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

AI Tests

Run locally by MelvinBot on this branch, on the merge commit 78c1706 (main merged in at 0858963):

Check Result
npm run typecheck ✅ pass
npm test -- tests/actions/IOU/CreateDraftTransactionTest.ts tests/unit/ReportUtilsTest.ts tests/actions/IOUTest/TrackExpenseTest.ts ✅ pass (1385 tests)
npm test -- tests/ui/ChatActionableButtonsTest.tsx tests/ui/DynamicReportDetailsPageTest.tsx tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx tests/unit/PolicySelectorTest.ts tests/unit/PolicyUtilsTest.ts tests/unit/SubscriptionUtilsTest.ts ✅ pass (663 tests)
npm run lint-changed ✅ pass
npx eslint on every changed file ✅ pass — no new errors. The errors that remain on these files are pre-existing and covered by the seatbelt ratchet; verified against a pre-merge baseline for DynamicReportDetailsPage.tsx, the one file with a merge conflict.
npm run spell-changed ⚠️ 16 issues in 7 files, none of them touched by this PR — they come in with the main merge (Childr, shoul, Fsearch, Aexpense in DecisionModal.tsx, FeatureTrainingModal.tsx, PopoverMenu/index.tsx, FilterPopupButton.tsx, useReportSubmitToPopover.tsx, UpdateMoneyRequest.ts, enableGlobalReimbursementsDynamicRouteTest.ts). Reporting rather than fixing, since they are unrelated.
npm run react-compiler-compliance-check check-changed ⏭️ could not run — GITHUB_BASE_REF is empty in this environment, so the script resolves the base ref to origin/ and aborts. The ESLint runs above apply the React Compiler rules per file and show no new Compilation Skipped errors, so no memoization regression.
npm run prettier ⏭️ n/a — no such script in this repo; formatting is enforced through ESLint
Browser verification of the happy path ❌ blocked — the test session rendered a blank page for the whole run (0 accessibility nodes, no captured network traffic) despite the dev server returning 200. An environment failure, not a defect in this change. Covered by the unit tests instead.

The single-workspace and preferred-workspace fast paths skip the
destination picker, which is the only place the SUBMIT flow checked
shouldRestrictUserBillableActions. Apply the same gate before those
fast paths navigate, matching the sibling CATEGORIZE branch.

Co-authored-by: Maruf Sharifi <marufsharifi@users.noreply.github.com>
@MelvinBot
MelvinBot requested a review from a team September 8, 2026 11:07
@melvin-bot melvin-bot Bot added Melvin-Test-Android Melvin-Test-Web Triggers Melvin to run the testing steps of the PR on web labels Sep 8, 2026
@MelvinBot

Copy link
Copy Markdown
Contributor Author

I created this PR from an upstream branch because I don't have push access to your fork.

To take ownership of this branch and be able to push updates, run:

git remote add upstream https://github.com/Expensify/App.git
git fetch upstream claude-submitToEmployerRestrictedWorkspace
git checkout -b claude-submitToEmployerRestrictedWorkspace upstream/claude-submitToEmployerRestrictedWorkspace
git push -u origin claude-submitToEmployerRestrictedWorkspace

Then you can close this PR and open a new one from your fork.

@MelvinBot

MelvinBot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Android test steps verification results: ⚠️ partial

Regression path verified: with a single healthy workspace, "Submit to my employer" from the self DM correctly binds to that workspace's confirmation page (not "None") and submits cleanly. The restricted precondition (single workspace with an expired required payment) cannot be created from a fresh test account and is reported as blocked rather than fabricated.

Draft expense report created in the workspace's expense chat, not restricted

Step Status
Open the self DM (chat with own name / "Expense" self chat)Navigated via global Search > Recent chats > the signed-in user "(you)", landing on the "Your space" self-DM screen.
Self DM ('Your space') opened
Create a new expense from the self DM and choose "Submit to my employer"Tapped + > Create expense > Manual, entered $25 and merchant "Test Merchant", tapped "Create expense". The expense posted to the self DM and Concierge whispered "What would you like to do with this expense?" with the buttons "Submit to a friend", "Submit to my employer", "Nothing for now".
Manual expense entry form with Amount $25 and Merchant 'Test Merchant'
Self-DM whisper with 'Submit to my employer' button after expense creation
Enter a merchant and tap "Create expense"Merchant "Test Merchant" was entered on the manual-entry form and "Create expense" was tapped, creating the tracked expense in the self DM.
[Restricted precondition] Single workspace with an expired required payment lands on the "Restricted" screen with the "Add a payment card to unlock!" badgeBLOCKED — this needs billing NVPs (ownerBillingGracePeriodEnd / userBillingGracePeriodEnds / amountOwed) showing an expired required payment on the account's only workspace. That is server-side billing state and cannot be produced by driving the UI on a fresh test account, so this path was not exercised.
[Regression] With a single healthy workspace, "Submit to my employer" still lands on the confirmation page bound to that workspace — the workspace field must NOT read "None"After tapping "Submit to my employer", the app navigated to "Confirm details" headed by the account's workspace and "Submits to " — bound to the real workspace, not "None", and not the Restricted screen. Completing "Create expense" there produced a draft Expense Report in the workspace's expense chat (Draft, 1 expense, Test Merchant $25.00).
Confirm details page bound to the healthy workspace after 'Submit to my employer'
Draft expense report created in the workspace's expense chat, not restricted

No console errors were observed. The billing-restricted path still needs a human with an account whose only workspace has an expired required payment; the unit tests in tests/actions/IOU/CreateDraftTransactionTest.ts are the only coverage of it today.


view run · view recording

@MelvinBot

MelvinBot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Web test steps verification results: ⚠️ partial

Regression path passes on dev NewDot web: "Submit to my employer" from the self DM lands on the confirmation page correctly bound to the single healthy workspace, not "None". The Restricted-screen precondition (single workspace with an expired required payment) could not be created because this session's account had zero workspaces, which is outside what UI driving can simulate.

Expense report created and bound to the actual workspace, not None

Step Status
Open the self DM (chat with own name / "Your space")Navigated Inbox > Start chat > selected own account from Recents; landed on the report titled with the signed-in address "(you)" / "Your space".
Self DM ('Your space') opened
Create a new expense from the self DM and choose "Submit to my employer"Used the + composer menu > Create expense > Manual, entered $25.00, clicked "Create expense". That created a tracked expense in the self DM and produced the Concierge whisper "What would you like to do with this expense?" with the buttons "Submit to a friend", "Submit to my employer", "Nothing for now". Clicked "Submit to my employer".
Whisper with 'Submit to my employer' button after tracking $25 expense
Enter a merchant and click "Create expense"On the resulting "Confirm details" page, filled the Merchant field; snapshot read-back confirmed the value was exactly "Test Merchant" before submitting, then clicked "Create expense".
Confirm details page with merchant 'Test Merchant' filled
[Restricted precondition] Single workspace with an expired required payment shows the "Restricted" screen with the "Add a payment card to unlock!" badgeBLOCKED — the precondition could not be created. This session's test account started with zero workspaces (Workspaces tab read "No workspaces yet"). The buggy state needs a workspace whose required payment is already expired, which depends on backend billing NVPs that cannot be set by driving the UI. Only a fresh, healthy workspace could be created, so the Restricted screen was never reachable. Reported as blocked rather than fabricated.
Account had no workspaces before one was created for the regression test
[Regression] With a single healthy workspace, "Submit to my employer" lands on the confirmation page bound to that workspace — the workspace field must NOT read "None"Created one healthy workspace, repeated the self-DM flow, and after clicking "Submit to my employer" the Confirm details destination read back as the workspace name plus "Submits to <owner email>" — never "None". After "Create expense" the app navigated to the workspace expense report showing the $25.00 "Test Merchant" expense in a Draft report, confirming correct binding.
Expense report created and bound to the actual workspace, not None

Console errors could not be read directly — agent-device logs is unsupported on the web platform in this environment. As a proxy, a network dump across the whole flow showed only 200 responses (OpenReport, AddTrackedExpenseToPolicy, Log, ReadNewestAction, AuthenticatePusher, OpenDraftWorkspaceRequest, OpenPolicyProfilePage, Ping) with no 4xx or 5xx calls.

The billing-restricted path still needs a human with an account whose only workspace has an expired required payment. It matches the Android run, where the same step was blocked for the same reason; today the only coverage of it is the unit tests in tests/actions/IOU/CreateDraftTransactionTest.ts.


view run · no recording available

@marufsharifi

Copy link
Copy Markdown
Contributor

@MelvinBot Mark the pr as ready for review

@MelvinBot
MelvinBot marked this pull request as ready for review September 10, 2026 08:18
@MelvinBot
MelvinBot requested a review from a team as a code owner September 10, 2026 08:18
@melvin-bot
melvin-bot Bot requested review from marufsharifi and removed request for a team September 10, 2026 08:19
@melvin-bot

melvin-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

@marufsharifi Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot
melvin-bot Bot requested review from JmillsExpensify and removed request for a team September 10, 2026 08:19
@MelvinBot

MelvinBot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Marked this PR as ready for review. PullerBear and Expensify/product-pr are now requested as reviewers.

One thing to fix before a reviewer picks it up: the Tests, Offline tests, and QA Steps sections still hold my // TODO: placeholders. Those are yours to fill in — I can't claim tests a human ran. The Tests section already carries suggested coverage; reaching the buggy state needs an account owning a single workspace with an expired required payment.


view run · view recording

Comment thread src/libs/ReportUtils.ts Outdated
// Exactly one accessible workspace: skip the destination picker and submit straight to that workspace.
if (filteredPoliciesCount === 1 && firstPolicyID) {
// The destination picker we skip here is where the billing restriction is normally enforced, so gate it here too.
const firstPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${firstPolicyID}`];

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.

❌ CONSISTENCY-3 (docs)

This billing-restriction gate is added twice in this PR (here and again in the isRestrictedToPreferredPolicy branch below), and it repeats a pattern that already appears four other times in this file (lines ~3295, ~3312, ~3329, ~12288): look up the policy from allPolicies, call shouldRestrictUserBillableActions(...), and Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(...)). Duplicating the same guard raises the risk of the copies drifting apart as the restriction logic evolves.

Extract a small helper and call it from each site, e.g.:

function navigateToRestrictedActionIfNeeded(policyID: string | undefined): boolean {
    const policy = policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined;
    if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) {
        Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policyID));
        return true;
    }
    return false;
}

// then at each call site:
if (navigateToRestrictedActionIfNeeded(firstPolicyID)) {
    return;
}

Reviewed at: 3e68c46 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

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.

Done in 1905451. Extracted navigateToRestrictedActionIfNeeded and routed all six sites through it — the four you listed plus the two this PR added.

One change from your sketch: the helper takes the resolved policy instead of a policyID, so it can't reach for allPolicies itself. That also settles the sibling comment about the module cache being stale, and it matches the four pre-existing sites, which already had a Policy on hand.

The CREATE_NEW_EXPENSE site keeps its policy.type !== CONST.POLICY.TYPE.PERSONAL check at the call site, since that condition is specific to that entry point.


view run

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3e68c46e45

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/libs/ReportUtils.ts Outdated
Comment on lines +12377 to +12378
const firstPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${firstPolicyID}`];
if (firstPolicy && shouldRestrictUserBillableActions(firstPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Pass the selected policy snapshot into the billing gate

When the caller's policy selector has received a newly loaded or updated workspace before this module-level Onyx.connect cache has caught up, firstPolicy is undefined and the new check fails open, so the function binds the draft to the restricted workspace and navigates to confirmation. This file already documents that allPolicies is independently timed and can be stale or missing policies available in a caller's snapshot; pass the selected policy from the callers alongside firstPolicyID (and do the same for the preferred-policy branch) instead of reading this cache.

AGENTS.md reference: AGENTS.md:L1-L3

Useful? React with 👍 / 👎.

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.

Good catch — fixed in 1905451. The gate now takes a resolved policy from the caller, and allPolicies is gone from both branches.

Single-workspace branch: createFilteredPoliciesInfoSelector now also returns firstPolicy, and the callers pass it alongside firstPolicyID. Because both come out of the same pass over the same snapshot, firstPolicy is set whenever firstPolicyID is — so this path can no longer fail open.

Preferred-workspace branch: the three callers (ChatActionableButtons, DynamicReportDetailsPage, MoneyRequestHeaderSecondaryActions) subscribe to the preferred policy key and pass preferredPolicy. usePreferredPolicy only carries the ID from the security group, and it has ~39 consumers, so I added the subscription in the three callers rather than widening the hook.

Both new params are optional, matching preferredPolicyID. Say the word if you'd rather firstPolicy were required to pair with firstPolicyID — it's the safer contract, but it means adding firstPolicy: undefined to roughly 29 existing test call sites.


view run

@marufsharifi

Copy link
Copy Markdown
Contributor

@MelvinBot address the comments.

Two review comments on the previous commit:

CONSISTENCY-3 (github-actions): the gate was written out six times in
ReportUtils.ts. Extract navigateToRestrictedActionIfNeeded and call it from
all six sites so the restriction logic can only evolve in one place.

P1 (codex): the two new gates resolved the policy from the module-level
allPolicies cache, which this file already documents as independently timed
and able to lag a caller's own snapshot. When it lags, the policy is
undefined and the gate fails open, binding the draft to the restricted
workspace. The helper now takes a resolved policy, and the callers pass the
snapshot they already hold:

- createFilteredPoliciesInfoSelector also returns firstPolicy, so the gate
  reads the same snapshot that produced filteredPoliciesCount/firstPolicyID.
  If firstPolicyID is set, firstPolicy is set by construction.
- The three callers subscribe to the preferred policy key and pass
  preferredPolicy for the preferred-workspace branch.

Co-authored-by: Maruf Sharifi <marufsharifi@users.noreply.github.com>
@MelvinBot
MelvinBot requested a review from a team as a code owner September 10, 2026 08:52
@melvin-bot
melvin-bot Bot requested a review from youssef-lr September 10, 2026 08:52
@MelvinBot

MelvinBot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Addressed both review comments in 1905451 and replied in each thread.

CONSISTENCY-3 (github-actions) — extracted navigateToRestrictedActionIfNeeded in ReportUtils.ts and routed all six gate sites through it: the four that already existed plus the two this PR added.

P1 (chatgpt-codex-connector) — the gate no longer reads the module-level allPolicies cache. It takes a resolved policy, and the callers pass the snapshot they already hold:

  • createFilteredPoliciesInfoSelector now also returns firstPolicy, so the single-workspace gate reads the same snapshot that produced filteredPoliciesCount / firstPolicyID. firstPolicy is set whenever firstPolicyID is, so that path can no longer fail open.
  • The three callers subscribe to the preferred policy key and pass preferredPolicy for the preferred-workspace branch. usePreferredPolicy only carries the ID and has ~39 consumers, so I added the subscription in the three callers instead of widening the hook.
Checks run locally on this branch
Check Result
npm run typecheck ✅ pass
npm test -- tests/actions/IOU/CreateDraftTransactionTest.ts tests/unit/ReportUtilsTest.ts tests/actions/IOUTest/TrackExpenseTest.ts ✅ pass (1371 tests)
eslint on the six changed files ✅ no error on any changed line (the 4 reported errors sit on untouched pre-existing lines)
npm run react-compiler-compliance-check check on the 3 changed components ✅ no regression — ChatActionableButtons and MoneyRequestHeaderSecondaryActions compile clean; DynamicReportDetailsPage reports the same 4 pre-existing errors with and without this change
npm run spell-changed ⏭️ not run — the script needs a git merge-base this environment can't resolve, same as on the first commit

Two things still open, both for a human:

  1. The Tests, Offline tests, and QA Steps sections of the PR body still hold my // TODO: placeholders.
  2. One open question for the reviewer, left in the P1 thread: firstPolicy and preferredPolicy are optional params, matching preferredPolicyID. Making firstPolicy required would pair it with firstPolicyID and make the gate impossible to skip, but it means adding firstPolicy: undefined to roughly 29 existing test call sites. I left it optional; happy to tighten it if you'd rather.

view run · view recording

@melvin-bot
melvin-bot Bot removed the request for review from a team September 10, 2026 08:52
@melvin-bot

melvin-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

@youssef-lr Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@MelvinBot

MelvinBot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

No code fix is needed — nothing is actually broken. Both failing checks are human gates, not automated failures:

Failing check Why it fails Who unblocks it
PR Reviewer Checklist / checklist Log ends with No PR Reviewer Checklist was found — the checklist comment hasn't been posted yet A reviewer posts the PR Reviewer Checklist
Check independent approval No independent approval on the PR yet An independent reviewer approves

Every automated check passes: ESLint, Oxfmt, typecheck, spellcheck, Bun tests, all 8 Jest shards, Storybook tests, knip, unused styles, React Compiler Compliance, and all four builds. The two perf-test jobs are still pending, not failing.

There is nothing I can push to make these two go green — they clear when a reviewer posts the checklist and approves.


view run · view recording

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
src/libs/PolicyUtils.ts 81.28% <100.00%> (+0.01%) ⬆️
src/libs/SubscriptionUtils.ts 87.67% <ø> (ø)
src/pages/DynamicReportDetailsPage.tsx 70.00% <100.00%> (+0.14%) ⬆️
...ox/report/actionContents/ChatActionableButtons.tsx 88.63% <100.00%> (+0.26%) ⬆️
src/libs/ReportUtils.ts 87.29% <94.44%> (-0.02%) ⬇️
src/selectors/Policy.ts 83.90% <66.66%> (+0.09%) ⬆️
.../components/MoneyRequestHeaderSecondaryActions.tsx 0.44% <0.00%> (-0.01%) ⬇️
... and 6 files with indirect coverage changes

Comment thread src/libs/ReportUtils.ts Outdated
Comment on lines +12226 to +12227
/** The workspace `firstPolicyID` refers to, from the same caller snapshot that produced the count above. */
firstPolicy?: OnyxEntry<Policy>;

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.

firstPolicy? being optional makes the security gate fail open silently — and the second new test already demonstrates it.

navigateToRestrictedActionIfNeeded opens with if (!policy || ...) return false. So a caller that passes firstPolicyID and forgets firstPolicy silently disables the gate — no type error, no lint error, no runtime signal. The expense goes straight to the restricted workspace.

This is already happening in this PR's own test at CreateDraftTransactionTest.ts:624-625, which passes filteredPoliciesCount: 1 + firstPolicyID with no firstPolicy. It only passes because submitDestination is unset. Add submitDestination: EMPLOYER to that test later and it flips from "asserts the gate works" to "asserts nothing", with no failure.

firstPolicyID is now fully derivable as firstPolicy?.id, so drop the parallel ID and make the policy the single source of truth:

Suggested change
/** The workspace `firstPolicyID` refers to, from the same caller snapshot that produced the count above. */
firstPolicy?: OnyxEntry<Policy>;
filteredPoliciesCount: number;
/** The single accessible workspace, from the same caller snapshot that produced the count above. */
firstPolicy: OnyxEntry<Policy>;

then at L12398:

if (filteredPoliciesCount === 1 && firstPolicy) {
    if (navigateToRestrictedActionIfNeeded(firstPolicy, ...)) {
        return;
    }
    const policyExpenseReport = getPolicyExpenseChat(deprecatedCurrentUserAccountID, firstPolicy.id);

Now "I have a workspace to submit to" and "I have the policy to gate on" are the same fact — it becomes impossible to have one without the other.

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.

Done in 121218afirstPolicyID is gone, firstPolicy is the only handle on that workspace, and the branch is now filteredPoliciesCount === 1 && firstPolicy.

Two follow-ons from making the policy the source of truth:

  • The CATEGORIZE branch also read firstPolicyID; it now reads firstPolicy?.id, so there is no remaining way to name that workspace without holding the policy.
  • firstPolicy is a required key (firstPolicy: BillingRestrictionPolicy | undefined, not firstPolicy?:), so omitting it is a type error rather than a silently ungated call. That is the 29 test call sites you flagged earlier — updated in this commit.

The test you called out is now firstPolicy: restrictedPolicy with filteredPoliciesCount: 1, so adding submitDestination: EMPLOYER to it would exercise the single-workspace gate rather than asserting nothing.

Comment thread src/libs/ReportUtils.ts Outdated
Comment on lines +12210 to +12211
/** The preferred workspace itself, so the billing gate reads the caller's snapshot rather than this file's `allPolicies` cache. */
preferredPolicy?: OnyxEntry<Policy>;

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.

Same fail-open shape for preferredPolicy — but here the ID and the policy genuinely come from different sources, so they need to be bound at the type level.

preferredPolicyID comes from the security group (usePreferredPolicy), preferredPolicy from an Onyx read. A caller can supply the first without the second and the gate at L12438 no-ops.

Make the trio inseparable with a discriminated union rather than three independent optionals:

Suggested change
/** The preferred workspace itself, so the billing gate reads the caller's snapshot rather than this file's `allPolicies` cache. */
preferredPolicy?: OnyxEntry<Policy>;
} & (
| {
isRestrictedToPreferredPolicy: true;
preferredPolicyID: string;
/** Required alongside the ID: the gate at the preferred-policy fast path no-ops without it. */
preferredPolicy: OnyxEntry<Policy>;
}
| {isRestrictedToPreferredPolicy?: false; preferredPolicyID?: undefined; preferredPolicy?: undefined}
);

At minimum, make preferredPolicy non-optional. As written, the compiler cannot tell the difference between "not restricted" and "restricted but I forgot the policy" — and those have opposite security outcomes.

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.

Agreed on the problem; fixed in 121218a, but with one non-nullable field instead of the discriminated union.

The union as written does not compile at the three call sites. All of them do:

const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy();

isRestrictedToPreferredPolicy is a boolean, not a literal, so TypeScript cannot narrow the object literal to either arm — boolean is assignable to neither true nor false | undefined. Making it work would mean changing usePreferredPolicy to return a discriminated result, and it has ~39 consumers.

The union also would not have closed the hole on its own: its true arm types the policy as OnyxEntry<Policy>, which includes undefined, so {isRestrictedToPreferredPolicy: true, preferredPolicyID: 'x', preferredPolicy: undefined} still type-checks and still no-ops the gate.

So the trio collapsed into one field:

/** The preferred workspace, set only when the user is restricted to submitting there. ... */
restrictedPreferredPolicy?: BillingRestrictionPolicy;

Non-nullable, and the branch is just if (restrictedPreferredPolicy). The ID comes off .id, so there is nothing left to forget. Callers pass restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined — one place where the flag and the policy meet, instead of three parallel params that can disagree.

One behavior change worth naming: when the user is restricted but the policy has not loaded from Onyx yet, the fast path is now skipped and the flow falls through to the participant picker, which enforces the restriction itself. Previously it took the fast path ungated. Practically this is a load-order edge — getPolicyExpenseChat would not have found the destination chat in that window either — and falling back to the picker is the safe direction.

Comment thread src/selectors/Policy.ts Outdated
Comment on lines +352 to +353
/** The first policy itself, so callers can gate on it without re-reading a separately-timed policy cache */
firstPolicy: OnyxEntry<Policy>;

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.

Putting the whole Policy in this selector's output puts an employeeList/customUnits deep-compare in a hot path — which is exactly what the sibling selector 40 lines above warns against.

useOnyx wraps every selector in createMemoizedSelector, which falls back to fast-equals deepEqual on the output (react-native-onyx/dist/createMemoizedSelector.js:29). The output went from {number, string} to a full Policy, so every write anywhere in the POLICY collection now deep-walks employeeList, customUnits, rules, taxRates, connections, mccGroup.

This file already documents the hazard, at L310:

// Fixed-size output: same shape on 5 workspaces or 5000, so no employeeList/customUnits deepEqual and no growing ID list

and the convention for "I need policy fields in a selector" is a Pick projection — policyMapper / type PolicySelector = Pick<Policy, 'type' | 'role' | ...> at L282, and again in usePersonalPolicy.ts:8.

The gate needs exactly two fields. shouldRestrictUserBillableActions only calls isPolicyOwner(policy, id), which reads policy.ownerAccountID (PolicyUtils.ts#L855), plus policy.id for the route:

Suggested change
/** The first policy itself, so callers can gate on it without re-reading a separately-timed policy cache */
firstPolicy: OnyxEntry<Policy>;
/** Only the fields the billing gate needs, so the output stays fixed-size (see `policyMapper` above) */
firstPolicy: Pick<Policy, 'id' | 'ownerAccountID'> | undefined;

and at L368:

firstPolicy = {id: policy.id, ownerAccountID: policy.ownerAccountID};

Then widen shouldRestrictUserBillableActions's parameter to Pick<Policy, 'id' | 'ownerAccountID'> — no cast needed, and it documents what the check actually depends on.

Symptom if unaddressed: dropped frames scrolling a chat with several track-expense whispers on a high-traffic account with a large employeeList, and a stutter in the report-details RHP on any workspace write.

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.

Good catch — fixed in 121218a. Confirmed createMemoizedSelector falls back to deepEqual on the output, so the full Policy was putting employeeList/customUnits in the comparison on every POLICY write.

The projection is a named type next to the check it serves, in SubscriptionUtils.ts:

type BillingRestrictionPolicy = Pick<Policy, 'id' | 'ownerAccountID'>;

and shouldRestrictUserBillableActions now takes OnyxEntry<Pick<Policy, 'ownerAccountID'>>isPolicyOwner widened to match, so no cast anywhere. As you said, ownerAccountID is all the check reads; id is only there for the route.

Applied in two places, not one. firstPolicy in createFilteredPoliciesInfoSelector is the one you flagged. The preferredPolicy read in all three callers had the same problem — a bare useOnyx(POLICY + id) pulling the whole policy — so it now goes through a module-level billingRestrictionPolicySelector.

isSmallScreenWidth,
isRestrictedToPreferredPolicy,
preferredPolicyID,
preferredPolicy,

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.

Adding the whole preferredPolicy object to this dependency array rebuilds the entire report-details menu on any field change to that policy.

This memo produces the full menu-item list. With the object itself as a dep, it now invalidates when a member is added, a category is edited, or pendingFields flips during any workspace write — none of which affect the menu.

Once the selector returns a Pick projection (see my comment on selectors/Policy.ts:352), depend on the scalar the gate actually reads instead:

Suggested change
preferredPolicy,
preferredPolicyID,
preferredPolicy?.ownerAccountID,

Same applies to filteredPoliciesInfo?.firstPolicy at L819.

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.

Fixed in 121218a, at the Onyx read rather than in the dependency array.

Swapping the dep to preferredPolicy?.ownerAccountID while the memo body still closes over preferredPolicy trips react-hooks/exhaustive-deps, and it leaves the underlying read pulling the whole policy. So instead both reads are projected:

  • preferredPolicy now uses billingRestrictionPolicySelector, a module-level Pick<Policy, 'id' | 'ownerAccountID'> projection.
  • filteredPoliciesInfo.firstPolicy is the same projection, per your comment on selectors/Policy.ts:352.

Because useOnyx wraps selectors in createMemoizedSelector, a fixed-size output means deepEqual returns the previous reference when nothing relevant changed. So depending on the object is now equivalent to depending on the scalars: a member add, a category edit, or a pendingFields flip no longer invalidates the menu.

filteredPoliciesInfo?.firstPolicyID is out of the dep array (the param is gone), and so is preferredPolicyID — it was only there to be passed through, and ESLint flagged it as unnecessary once restrictedPreferredPolicy replaced it.

@marufsharifi

Copy link
Copy Markdown
Contributor

@MelvinBot check the new comments.

…ted fast path

- Drop firstPolicyID; firstPolicy is now the only handle on the single
  accessible workspace, so the count and the policy cannot come apart.
- Collapse isRestrictedToPreferredPolicy/preferredPolicyID/preferredPolicy
  into one non-nullable restrictedPreferredPolicy, so the preferred-workspace
  fast path cannot be entered without the policy its gate reads.
- Project both to Pick<Policy, 'id' | 'ownerAccountID'> (BillingRestrictionPolicy)
  so the useOnyx selectors keep a fixed-size output and no employeeList /
  customUnits deep-compare lands in a hot path.

Co-authored-by: Maruf Sharifi <marufsharifi@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Addressed all four review comments in 121218a. Replied in each thread; the short version:

Comment What changed
firstPolicy? fails open silently Dropped firstPolicyID. firstPolicy is the only handle on that workspace, and it is a required key, so omitting it is a type error.
Same shape for preferredPolicy Collapsed the trio into one non-nullable restrictedPreferredPolicy. Took a different route than the discriminated union — see below.
Whole Policy in a hot-path selector Both policies are now Pick<Policy, 'id' | 'ownerAccountID'>; shouldRestrictUserBillableActions widened to match, no cast.
Whole object in the memo deps Fixed at the Onyx read instead of the dep array, so the projected value is reference-stable.

One deviation to flag. The discriminated union does not compile. All three callers destructure isRestrictedToPreferredPolicy from usePreferredPolicy() as a boolean, which narrows to neither arm; and its true arm types the policy as OnyxEntry<Policy>, which still admits undefined, so it would not have closed the hole anyway. One non-nullable field gets the property you were after — the fast path and the policy it gates on are literally the same value:

restrictedPreferredPolicy?: BillingRestrictionPolicy;

One behavior change worth naming. If the user is restricted to a preferred workspace but that policy has not loaded from Onyx yet, the fast path is now skipped and the flow falls through to the participant picker, which enforces the restriction itself. Previously it took the fast path ungated. This is a load-order edge — getPolicyExpenseChat would not have found the destination chat in that window either — and falling back to the picker is the safe direction. Say the word if you'd rather preserve the old shortcut.

Verification
  • npm run typecheck — pass
  • 1371 tests in CreateDraftTransactionTest / ReportUtilsTest / TrackExpenseTest — pass
  • 582 tests in PolicySelectorTest / PolicyUtilsTest / SubscriptionUtilsTest — pass
  • 9 tests in ChatActionableButtonsTest / both DynamicReportDetailsPageTest files — pass
  • npx eslint on every changed file — no new errors. lint-changed, spell-changed, and the React Compiler compliance check could not run here: the checkout is shallow, so git merge-base against origin/main fails. The direct ESLint run covers the React Compiler rules per file and matches a stashed baseline exactly.

Full table is in the PR description.

@marufsharifi

Copy link
Copy Markdown
Contributor

@MelvinBot Merge the main

…erRestrictedWorkspace

# Conflicts:
#	src/pages/DynamicReportDetailsPage.tsx
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Merged main in — 78c1706. The PR was CONFLICTING; it now reports MERGEABLE.

One conflict, in src/pages/DynamicReportDetailsPage.tsx:247. Both sides added a different useOnyx line at the same spot — this branch added the preferredPolicy read, main added const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE). Kept both. No logic overlapped, and rules from main is still consumed in the six places that use it.

Everything else auto-merged.

Verification on the merge commit
  • npm run typecheck — pass
  • 1385 tests in CreateDraftTransactionTest / ReportUtilsTest / TrackExpenseTest — pass
  • 663 tests in ChatActionableButtonsTest / both DynamicReportDetailsPageTest files / PolicySelectorTest / PolicyUtilsTest / SubscriptionUtilsTest — pass
  • npm run lint-changed — pass. It works now that the merge gave the shallow checkout a merge base, which is also why the last round could only run ESLint directly.
  • npm run spell-changed — 16 issues in 7 files, none of them touched by this PR. They arrive with the main merge (Childr in DecisionModal.tsx, FeatureTrainingModal.tsx, PopoverMenu/index.tsx, FilterPopupButton.tsx, useReportSubmitToPopover.tsx; shoul in UpdateMoneyRequest.ts; Fsearch / Aexpense in enableGlobalReimbursementsDynamicRouteTest.ts). Reporting rather than fixing, since they are unrelated to this change.
  • npm run react-compiler-compliance-check check-changed — could not run: GITHUB_BASE_REF is empty here, so the script resolves the base ref to origin/ and aborts. The ESLint runs cover the React Compiler rules per file and show no new Compilation Skipped errors.

Full table is in the PR description.

MelvinBot and others added 2 commits September 10, 2026 19:15
…erRestrictedWorkspace

Co-authored-by: Maruf Sharifi <marufsharifi@users.noreply.github.com>
Co-authored-by: Maruf Sharifi <marufsharifi@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

⚠️ MelvinBot CI Fix Loop Detected

MelvinBot has made 5 consecutive commits trying to fix CI failures. Automatic CI fix dispatches are now paused for this PR.

Next steps: A human should review the recent commits and resolve the conflicting CI errors manually.

@MelvinBot

MelvinBot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Fixed the failing check and pushed to this branch.

What was failing: Oxfmt checkoxfmt wanted the BillingRestrictionPolicy type import grouped with the other import type statements at the top of the file instead of sitting next to the shouldRestrictUserBillableActions value import.

Fix: ran npm run fmt, which moved that one line. See src/libs/ReportUtils.ts:106. No logic change — one line moved, nothing else in the repo was reformatted.

Oxfmt check now passes on aa560993e2e.

The only other red check is Check independent approval, which just needs a human reviewer and isn't something I can resolve.

Local verification on the fix commit
Check Result
npm run fmt (re-run, clean tree) ✅ no diff
npm run typecheck ✅ pass
npm test -- tests/actions/IOU/CreateDraftTransactionTest.ts tests/unit/ReportUtilsTest.ts ✅ pass (1338 tests)
npx eslint src/libs/ReportUtils.ts ✅ no import-order errors; the 103 remaining errors are pre-existing on this file and covered by the seatbelt ratchet (the CI ESLint check passes)
npm run lint-changed ⏭️ could not run — the script's git merge-base step fails in this environment. Covered by the direct eslint run above.

view run · no recording available

…dWorkspace' into claude-submitToEmployerRestrictedWorkspace

Co-authored-by: Maruf Sharifi <marufsharifi@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Merged main into the branch — it was 10 commits behind, now behind_by: 0. Pushed as f69f527.

One conflict-free auto-merge in src/libs/ReportUtils.ts: main reworked canEditFieldOfMoneyRequest (#100460), which sits well away from the billing-gate code this PR touches. Nothing else overlapped.

Two merge commits rather than one — a formatting fix (aa56099) landed on the branch while the main merge was building, so that got merged in too rather than force-pushed over.

Verified after the merge
Check Result
npm run typecheck ✅ pass
npm run lint-changed ✅ pass (now resolves — the checkout had to be unshallowed for git merge-base to work)
npm run spell-changed ✅ pass — 10 files, 0 issues
npm run fmt + git diff --exit-code (the Oxfmt gate) ✅ pass — no diff
npm testCreateDraftTransactionTest, ReportUtilsTest, TrackExpenseTest, canEditFieldOfMoneyRequestTest ✅ pass (1413 tests)
npm testPolicySelectorTest, PolicyUtilsTest, SubscriptionUtilsTest, ChatActionableButtonsTest, both DynamicReportDetailsPageTest files ✅ pass (663 tests)
react-compiler-compliance-check ✅ no regression — check-changed can't run here (GITHUB_BASE_REF is empty, so it resolves the base as origin/), so I ran check on the three changed components instead. ChatActionableButtons and MoneyRequestHeaderSecondaryActions pass; DynamicReportDetailsPage reports 4 pre-existing errors, identical to the same file checked out from origin/main.

I included canEditFieldOfMoneyRequestTest because that is the suite main grew alongside the ReportUtils.ts change that auto-merged.

@marufsharifi

Copy link
Copy Markdown
Contributor

@MelvinBot Merge the main.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

I didn't merge — main conflicts with this PR architecturally, and my safety rules stop me from resolving that class of conflict. Nothing was pushed; the branch is untouched.

Why: #100164 ("Move expense-flow starters out of ReportUtils into actions/IOU to break import cycles") moved both functions this PR rewrites out of ReportUtils.ts into the new src/libs/actions/IOU/StartExpenseFlows.ts. Git can't reconcile "this PR edits these functions" with "main deleted them from this file," so the diff has to be re-applied by hand at the new location.

Only 2 files conflict (src/libs/ReportUtils.ts, tests/actions/IOU/CreateDraftTransactionTest.ts); the other 107 commits of main merge cleanly, and every call site plus tests/unit/ReportUtilsTest.ts and tests/actions/IOUTest/TrackExpenseTest.ts already auto-merged onto the new import path.

What the resolution needs
  1. src/libs/ReportUtils.ts — take main's side for both hunks (the functions are gone from this file). Drop the PR's now-unused BillingRestrictionPolicy type import.
  2. src/libs/actions/IOU/StartExpenseFlows.ts — re-apply the PR's diff here:
    • add navigateToRestrictedActionIfNeeded (all 6 call sites live in this file now, so it belongs here rather than in ReportUtils);
    • swap the three inline shouldRestrictUserBillableActions + Navigation.navigate pairs in getAddExpenseDropdownOptions and the CATEGORIZE gate at L275 for the helper;
    • rename the params: firstPolicyIDfirstPolicy, and isRestrictedToPreferredPolicy/preferredPolicyIDrestrictedPreferredPolicy;
    • add the two new gates in the single-workspace branch (L378) and the preferred-workspace branch (L411).
    • Watch one silent rename in that move: deprecatedCurrentUserAccountID became the currentUserAccountID param, so use main's version on the lines the PR touches.
  3. tests/actions/IOU/CreateDraftTransactionTest.ts — import createDraftTransactionAndNavigateToParticipantSelector from @userActions/IOU/StartExpenseFlows (main's side), keeping the PR's BillingRestrictionPolicy type import from @libs/SubscriptionUtils.

No changes to src/selectors/Policy.ts, src/libs/SubscriptionUtils.ts, or src/libs/PolicyUtils.ts are needed — those merged clean.

The guardrail is that I don't resolve conflicts where main restructured the code my diff sits on — the odds of silently disabling one of these billing gates are too high for an unreviewed bot merge. Can you take the merge, or would you rather I open a follow-up PR that does the transplant as reviewable commits?

@marufsharifi

Copy link
Copy Markdown
Contributor

@MelvinBot Merge the main

@MelvinBot

Copy link
Copy Markdown
Contributor Author

⏱️ Ran out of time before I could finish. This usually means the task was too complex or I got stuck exploring. Please try again with a more specific request or post in #melvinbot-ai.

Run details

@marufsharifi

Copy link
Copy Markdown
Contributor

@MelvinBot Merge the main

@MelvinBot

Copy link
Copy Markdown
Contributor Author

⏱️ Ran out of time before I could finish. This usually means the task was too complex or I got stuck exploring. Please try again with a more specific request or post in #melvinbot-ai.

Run details

@marufsharifi

Copy link
Copy Markdown
Contributor

@MelvinBot refill the pr author checklist with verified one

Details

Explanation of Change

"Submit to my employer" from a self DM could create an expense on a workspace with an expired required payment, instead of showing the "Restricted" screen.

The billing-restriction gate for this flow lived only in the destination picker, in the row handler at src/pages/iou/request/ParticipantSearchResults.tsx. But createDraftTransactionAndNavigateToParticipantSelector has a fast path that skips that picker when the user belongs to exactly one workspace — it binds the draft straight to that workspace's expense chat and navigates to the confirmation page. Skip the picker, skip the gate. Nothing downstream re-checks: the confirmation page, confirmAction, the validation hook, and requestMoney contain no billing-restriction checks, so the expense is created against the expired workspace.

That is why the bug needs the exact preconditions in the report — one workspace, and it's expired. With two or more workspaces you hit the picker and correctly land on the Restricted screen.

This change applies the same gate the sibling CATEGORIZE branch already applies, before the fast paths navigate:

  1. The single-workspace EMPLOYER fast path now resolves the policy for the one accessible workspace and, when shouldRestrictUserBillableActions(...) is true, navigates to ROUTES.RESTRICTED_ACTION and returns.
  2. The preferred-policy fast path just below it skips the picker the same way and was also ungated, so it gets the same guard.

All four inputs (ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) were already threaded into this function for the CATEGORIZE check, so no new plumbing was needed. Fixing it inside this helper covers all three "Submit to my employer" entry points at once, since they all call it: the self-DM whisper buttons, the report-details menu, and the expense header menu.

Fixed Issues

$ #99325
PROPOSAL: #99325 (comment)

Tests

Precondition: The user has only an expired workspace payment requirement.

  1. Log in to the app.
  2. Navigate to your Self-DM.
  3. Create a new expense.
  4. Select Submit to my employer.
  5. Verify that you are redirected to the Restricted screen.
  6. Verify that the “Add a payment card to unlock!” badge is displayed.
  • Verify that no errors appear in the JS console

Offline tests

Same as Tests.

QA Steps

Same as Tests.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Melvin-Test-Android Melvin-Test-Web Triggers Melvin to run the testing steps of the PR on web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants