Skip to content

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

Merged
youssef-lr merged 11 commits into
mainfrom
claude-submitToEmployerRestrictedWorkspace
Sep 21, 2026
Merged

youssef-lr merged 11 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.

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

AI Tests

Run locally by MelvinBot after merging main (9b0054c) into the branch, on merge commit aef8ef0:

Check Result
npx tsc --build tsconfig.json (TS 7 native compiler, all 6 referenced projects) ✅ pass — 0 errors
npm test on tests/actions/IOU/CreateDraftTransactionTest.ts tests/unit/PolicySelectorTest.ts ✅ pass (85 tests)
npm test on tests/actions/IOUTest/TrackExpenseTest.ts tests/unit/ReportUtilsTest.ts tests/unit/PolicyUtilsTest.ts tests/unit/SubscriptionUtilsTest.ts ✅ pass (1995 tests)
npm test on tests/ui/ChatActionableButtonsTest.tsx tests/ui/DynamicReportDetailsPageTest.tsx tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx tests/ui/components/AddExpenseActionButtonTest.tsx ✅ pass (10 tests)
npm run spell-changed ✅ pass (11 files, 0 issues)
npx eslint on every changed file ✅ pass — no new errors; every reported error sits on a line this PR does not touch, and DynamicReportDetailsPage.tsx returns the identical 11 errors when reverted to origin/main
Web manual run of the Tests steps ❌ not run — the CI-provided browser session went blank (0 accessible nodes) right after prestart onboarding diverged, so no step past login could be reached

The merge brought in two file relocations from main, which git followed automatically: src/components/MoneyRequestHeader/MoneyRequestHeaderSecondaryActions.tsxsrc/components/MoneyRequestHeaderSecondaryActions.tsx, and src/pages/home/report/comment/actionContents/ChatActionableButtons.tsxsrc/pages/inbox/report/actionContents/ChatActionableButtons.tsx.

npm run lint (seatbelt ratchet) and npm run react-compiler-compliance-check could not run locally because bun is unavailable in this environment; both remain covered by CI.

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.84% <100.00%> (+0.01%) ⬆️
src/libs/SubscriptionUtils.ts 87.67% <ø> (ø)
src/libs/actions/IOU/StartExpenseFlows.ts 95.53% <100.00%> (+5.72%) ⬆️
src/pages/DynamicReportDetailsPage.tsx 70.00% <100.00%> (+0.14%) ⬆️
...ox/report/actionContents/ChatActionableButtons.tsx 88.63% <100.00%> (+0.26%) ⬆️
src/selectors/Policy.ts 88.00% <100.00%> (+4.09%) ⬆️
.../components/MoneyRequestHeaderSecondaryActions.tsx 0.44% <0.00%> (-0.01%) ⬇️
... and 9 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.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 PR description replaced with the content you provided.

I read "replace the PR author checklist" as replacing the whole description, since what you pasted was a full PR body (Explanation of Change through Screenshots/Videos), not just the checklist section. I unwrapped the outer <details><summary>PR Author Checklist</summary> you used to collapse it in your comment — leaving it in would have made the entire description one collapsed block. Everything inside is verbatim, including the curly quotes in “Add a payment card to unlock!” (byte-verified after posting).

I kept the ### AI Tests section at the end, since your content didn't include it and dropping it would have silently deleted the local test and merge records. Say the word if you want it gone.

The checklist check is now skipping rather than failing. The only red check is Check independent approval, which is a process gate, not a code failure.

Two things to look at:

  1. $ #99325 will probably break the issue-linking automation. The template says explicitly: "Do NOT only link the issue number like this: $ #<issueID>" — it wants the full URL. The previous body had $ https://github.com/Expensify/App/issues/99325. I applied your version as written rather than silently overriding it, but this is the one line I'd change back. Reply and I'll fix it.

  2. The checklist now asserts more than the PR's own evidence supports. All five platform boxes are checked and "I included screenshots or videos for tests on all platforms" is checked, but the five Screenshots/Videos sections are still empty <!-- add screenshots or videos here --> placeholders. The AI Tests section also records the Android run as ⚠️ partial — the Restricted screen was unreachable because the expired-payment precondition can't be produced on a fresh test account. That's your call as the human co-author, but a reviewer will likely notice the gap.

Comment thread src/selectors/Policy.ts Outdated
}
if (filteredPoliciesCount === 0) {
firstPolicyID = policy.id;
firstPolicy = {id: policy.id, ownerAccountID: policy.ownerAccountID};

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.

This inline projection is identical to the new billingRestrictionPolicySelector added just below (line 389) — both build a BillingRestrictionPolicy as {id: policy.id, ownerAccountID: policy.ownerAccountID}, and both carry the same "projected to only the fields the billing gate needs" rationale. Two copies means the projection has to be kept in sync by hand if the gate ever reads another field.

policy is already non-null here (guarded by the if (!policy …) continue above) and the target is BillingRestrictionPolicy | undefined, so we can just reuse the selector:

Suggested change
firstPolicy = {id: policy.id, ownerAccountID: policy.ownerAccountID};
firstPolicy = billingRestrictionPolicySelector(policy);

Both are module-scope consts invoked at runtime, so referencing billingRestrictionPolicySelector before its declaration is fine; move its definition above this selector if you'd rather keep read-order clarity.

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 6585a59firstPolicy = billingRestrictionPolicySelector(policy);, so the projection exists in one place.

I moved the selector's definition above createFilteredPoliciesInfoSelector rather than relying on hoisting, per your read-order note.

Its doc comment also needed a small change: it used to say "the preferred workspace", which stopped being true once it served both call sites. It now reads "Projects a policy down to just the fields the billing gate reads — see BillingRestrictionPolicy."

currentUserEmail: RORY_EMAIL,
currentUserLocalCurrency: '',
filteredPoliciesCount: 1,
firstPolicy: restrictedPolicy,

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.

NAB: this test uses the default submitDestination (FRIEND), so the EMPLOYER branch is skipped and firstPolicy is never read on this path — only restrictedPreferredPolicy drives the assertion.

firstPolicy: restrictedPolicy is harmless but redundant here; dropping it would make the test isolate the preferred-policy gate more clearly.

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 6585a59 — you're right that it was never read on this path.

One adjustment: firstPolicy is a required key (that was the fix for your earlier fail-open comment), so it's now firstPolicy: undefined rather than dropped, with a comment recording why:

// Left unset so only the preferred-policy gate can drive the assertions below: the default
// `submitDestination` is FRIEND, so the EMPLOYER branch that reads `firstPolicy` never runs.
firstPolicy: undefined,

The test still passes, which confirms your reading.

Comment on lines +210 to +217
/**
* The preferred workspace, set only when the user is restricted to submitting there. One non-nullable value
* instead of an `isRestrictedToPreferredPolicy`/`preferredPolicyID`/`preferredPolicy` trio: the fast path it
* unlocks skips the participant picker, which is where the billing restriction is otherwise enforced, so
* "submit straight to the preferred workspace" and "here is the policy to gate on" have to be the same fact.
* As three parallel optionals a caller could supply the flag and the ID but not the policy — the flag and the
* ID come from the security group, the policy from Onyx — and silently disable the gate.
*/

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.

This doc block is one dense argument with a mid-sentence dashed aside nested inside a longer clause, so the actual rule — these facts must travel together or the gate silently turns off — only surfaces after a couple of reads. Lead with the rule, drop the trio enumeration:

Suggested change
/**
* The preferred workspace, set only when the user is restricted to submitting there. One non-nullable value
* instead of an `isRestrictedToPreferredPolicy`/`preferredPolicyID`/`preferredPolicy` trio: the fast path it
* unlocks skips the participant picker, which is where the billing restriction is otherwise enforced, so
* "submit straight to the preferred workspace" and "here is the policy to gate on" have to be the same fact.
* As three parallel optionals a caller could supply the flag and the ID but not the policy the flag and the
* ID come from the security group, the policy from Onyx and silently disable the gate.
*/
/**
* The preferred workspace, set only when the user is restricted to submitting there. Kept as one value
* (not a separate flag + ID) so the fast path that skips the participant picker where the billing gate
* normally runs can't be taken with the gate accidentally left off.
*/

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 6585a59 — applied your wording verbatim. Leading with the rule does read better than making the reader assemble it from the trio enumeration.

Comment thread src/selectors/Policy.ts Outdated
Comment on lines +359 to +363
/**
* The first policy that should be shown to the user, so callers can gate on it without re-reading a
* separately-timed policy cache. Projected to only the fields the billing gate needs, so this output stays
* fixed-size (see `policyMapper` above) and no `employeeList`/`customUnits` is deep-compared on a POLICY write.
*/

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.

The "projected so employeeList/customUnits isn't deep-compared on a POLICY write" rationale is stated nearly in full in four places:

  • src/libs/SubscriptionUtils.ts:507–511 (BillingRestrictionPolicy type — the natural canonical home)
  • here, src/selectors/Policy.ts:359–363
  • src/selectors/Policy.ts:367 (// Fixed-size output … inline)
  • src/selectors/Policy.ts:388 (billingRestrictionPolicySelector)

Keep the full explanation on the BillingRestrictionPolicy type and shorten the rest to a pointer (the billingRestrictionPolicySelector comment at :388 already does this well). Suggested trim for this block:

Suggested change
/**
* The first policy that should be shown to the user, so callers can gate on it without re-reading a
* separately-timed policy cache. Projected to only the fields the billing gate needs, so this output stays
* fixed-size (see `policyMapper` above) and no `employeeList`/`customUnits` is deep-compared on a POLICY write.
*/
/** The first policy to show the user, projected to the billing-gate fields — see `BillingRestrictionPolicy`. */

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 6585a59 — trimmed to your suggested one-liner:

/** The first policy to show the user, projected to the billing-gate fields — see `BillingRestrictionPolicy`. */

The full rationale stays on BillingRestrictionPolicy in SubscriptionUtils.ts, and the // Fixed-size output … line above the selector body stays as-is since it explains the short-circuit rather than the projection.

return;
}

const policyExpenseReport = getPolicyExpenseChat(currentUserAccountID, restrictedPreferredPolicy.id);

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.

Test coverage — missing the not-restricted (happy) path of this branch.

The new gate above (navigateToRestrictedActionIfNeeded(restrictedPreferredPolicy, …)) has its restricted arm covered by the new "preferred workspace has an expired required payment" test, but the pass-throughrestrictedPreferredPolicy set yet not billing-restricted → submit straight to the preferred workspace here → draft rebound → confirmation — has no test. (Confirmed: no test sets restrictedPreferredPolicy on a non-restricted policy.)

Add to tests/actions/IOU/CreateDraftTransactionTest.ts (submitting a tracked expense to an employer describe):
it('should submit straight to the preferred workspace when it is not billing-restricted') — build a preferred policy the user owns with no amountOwed/grace period (so shouldRestrictUserBillableActions is false), create its policy expense chat, call createDraftTransactionAndNavigateToParticipantSelector with restrictedPreferredPolicy set, then assert Navigation.navigate was not called with RESTRICTED_ACTION and the draft's reportID equals the preferred chat's reportID (mirror the existing single-workspace bind test at :512).

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.

Added in 6585a59it('should submit straight to the preferred workspace when it is not billing-restricted'), built as you described: a preferred policy the user owns with amountOwed: 0 and no grace period, so shouldRestrictUserBillableActions is false.

It asserts all three things the pass-through is responsible for, not just the absence of the redirect:

  • Navigation.navigate was not called with RESTRICTED_ACTION
  • the draft's reportID and first participant's reportID are the preferred chat's (the rebinding)
  • the confirmation route for that chat was navigated to

That last pair matters — asserting only "no restricted-action navigation" would also pass if the flow silently fell through to the participant picker, which is the other way this branch can fail.

Paired with the existing restricted test, the two now differ only in amountOwed/grace period, so they isolate the gate itself.

Comment thread src/selectors/Policy.ts Outdated
}
if (filteredPoliciesCount === 0) {
firstPolicyID = policy.id;
firstPolicy = {id: policy.id, ownerAccountID: policy.ownerAccountID};

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.

Test coverage — createFilteredPoliciesInfoSelector is not tested directly.

The action tests bypass this selector by passing firstPolicy / filteredPoliciesCount as params, so the selector's own logic — shouldShowPolicy / isTeachersUnitePolicyID filtering, the short-circuit at 2, and the new {id, ownerAccountID} projection on this line — has zero coverage. tests/unit/PolicySelectorTest.ts already tests sibling selectors, so it belongs there.

Add describe('createFilteredPoliciesInfoSelector') with:

  • no showable policies → {filteredPoliciesCount: 0, firstPolicy: undefined}
  • one showable policy → firstPolicy deep-equals {id, ownerAccountID} of that policy (assert it's the projection, not the full Policy)
  • a Teachers-Unite / non-showable policy is skipped
  • 2+ showable policies → short-circuits at filteredPoliciesCount: 2 with firstPolicy = the first match

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.

Added in 6585a59describe('createFilteredPoliciesInfoSelector') in tests/unit/PolicySelectorTest.ts, with the four cases you listed:

  • no policies → {filteredPoliciesCount: 0, firstPolicy: undefined}
  • nothing passes shouldShowPolicy (a personal policy) → same
  • one showable policy → expect(result.firstPolicy).toStrictEqual({id: 'p1', ownerAccountID: 42})
  • Teachers Unite policy → skipped
  • three showable policies → short-circuits at filteredPoliciesCount: 2, firstPolicy is the first match

On asserting the projection rather than the full Policy: toStrictEqual against the two-key literal is what does that work — the policy in that case is built with an employeeList, so the assertion fails if the selector ever starts passing the whole object through.

Comment thread src/selectors/Policy.ts Outdated
};

/** The preferred workspace, projected to only what the billing gate reads, for the same reason as `firstPolicy` above. */
const billingRestrictionPolicySelector = (policy: OnyxEntry<Policy>): BillingRestrictionPolicy | undefined => (policy ? {id: policy.id, ownerAccountID: policy.ownerAccountID} : undefined);

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.

Test coverage — new billingRestrictionPolicySelector has no test.

Trivial projection, but new and both arms are unexercised. Quick to cover in tests/unit/PolicySelectorTest.ts:

Add describe('billingRestrictionPolicySelector'):

  • a defined policy → returns exactly {id, ownerAccountID} (and nothing else)
  • undefined input → returns undefined

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.

Added in 6585a59describe('billingRestrictionPolicySelector') with both arms:

  • a policy carrying name, type and an employeeList → returns exactly {id, ownerAccountID} (toStrictEqual, so an extra field fails it)
  • undefinedundefined

Worth a little more now than when you flagged it: per your comment on Policy.ts:378, createFilteredPoliciesInfoSelector now calls this function instead of inlining the projection, so these two tests cover both projection sites.

ownerBillingGracePeriodEnd,
isRestrictedToPreferredPolicy,
preferredPolicyID,
restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined,

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.

Test coverage — the isRestrictedToPreferredPolicy ? preferredPolicy : undefined branch is unasserted.

Neither arm of this ternary (true → preferredPolicy, false → undefined) is verified. It's thin wiring over already-tested createDraftTransactionAndNavigateToParticipantSelector logic, so low severity — closing the StartExpenseFlows.ts:451 gap covers the downstream behavior. If you want the wiring itself asserted, drive this component with isRestrictedToPreferredPolicy = true and a present preferredPolicy, and assert the restricted-action navigation.

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.

Taking your first option: the StartExpenseFlows.ts:451 gap is now closed (6585a59), so both outcomes this ternary selects between are asserted at the action level — restricted → restricted-action screen, not restricted → straight to the preferred workspace.

I left the component wiring untested here deliberately. A test that renders this page to assert isRestrictedToPreferredPolicy ? preferredPolicy : undefined would be asserting the ternary against itself; the risk it would actually catch — passing the flag without the policy — is the one the restrictedPreferredPolicy single-field contract already made unrepresentable.

Happy to add it if you'd rather have the wiring pinned explicitly.

ownerBillingGracePeriodEnd,
isRestrictedToPreferredPolicy,
preferredPolicyID,
restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined,

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.

Test coverage — same untested ternary as the other two entry points.

isRestrictedToPreferredPolicy ? preferredPolicy : undefined — neither arm asserted here. Low severity (wiring over tested action logic); covered in practice once StartExpenseFlows.ts:451 gets its not-restricted test. Optionally assert restricted-action navigation by rendering this page with isRestrictedToPreferredPolicy = true and a present preferredPolicy.

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.

Same as the MoneyRequestHeaderSecondaryActions.tsx:391 thread: the StartExpenseFlows.ts:451 not-restricted test landed in 6585a59, so both arms' downstream behavior is asserted at the action level.

Skipping the render-level assertion here for the same reason — it would pin the ternary to itself rather than to a behavior, and the fail-open shape it used to guard against is gone now that the flag and the policy are one field. Say the word if you want it anyway.

filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0,
firstPolicyID: filteredPoliciesInfo?.firstPolicyID,
firstPolicy: filteredPoliciesInfo?.firstPolicy,
restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined,

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.

Test coverage — untested ternary.

isRestrictedToPreferredPolicy ? preferredPolicy : undefined — both arms unasserted. tests/actions/IOUTest/TrackExpenseTest.ts exercises this flow but never sets up a restricted preferred policy to hit the true arm. Low severity; extend that test to drive TrackExpenseButtons with isRestrictedToPreferredPolicy = true + a present preferredPolicy and assert the restricted-action navigation — or rely on the StartExpenseFlows.ts:451 unit gap being closed.

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.

Going with the fallback you offered: StartExpenseFlows.ts:451 now has its not-restricted test (6585a59), so both arms of this ternary have their behavior covered at the action level.

I didn't extend TrackExpenseTest.ts to drive TrackExpenseButtons with a restricted preferred policy. It would exercise the same action-level branch through a heavier render path, and the collapse to a single restrictedPreferredPolicy field removed the disagreement this was guarding against. Let me know if you'd still like it there.

@marufsharifi

Copy link
Copy Markdown
Contributor

@MelvinBot address the new suggested changes.

…lector and pass-through tests

- Reuse billingRestrictionPolicySelector in createFilteredPoliciesInfoSelector instead of
  repeating the {id, ownerAccountID} projection inline.
- Keep the full projection rationale on BillingRestrictionPolicy; shorten the copies to pointers.
- Lead the restrictedPreferredPolicy doc with the rule instead of the trio enumeration.
- Add the not-restricted pass-through test for the preferred-workspace fast path.
- Cover createFilteredPoliciesInfoSelector and billingRestrictionPolicySelector directly.
- Drop the redundant firstPolicy from the preferred-policy restriction test.
@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Addressed all 10 review comments in 6585a59 (plus 57d45a5, a spellcheck follow-up). Replied in each thread.

Seven changes applied, three declined with reasoning.

Applied:

Comment Change
selectors/Policy.ts:378 — duplicated projection firstPolicy = billingRestrictionPolicySelector(policy); moved the selector above its use rather than relying on hoisting
selectors/Policy.ts:363 — rationale stated four times Trimmed to your one-line pointer; full explanation stays on BillingRestrictionPolicy
StartExpenseFlows.ts:217 — dense doc block Applied your wording verbatim
CreateDraftTransactionTest.ts:626 — redundant firstPolicy Now firstPolicy: undefined with a comment (the key is required, so it can't be dropped outright)
StartExpenseFlows.ts:451 — missing pass-through test Added should submit straight to the preferred workspace when it is not billing-restricted
selectors/Policy.ts:378 — selector untested Added describe('createFilteredPoliciesInfoSelector'), all four cases
selectors/Policy.ts:389 — selector untested Added describe('billingRestrictionPolicySelector'), both arms

Declined — the three untested-ternary comments (MoneyRequestHeaderSecondaryActions.tsx:391, DynamicReportDetailsPage.tsx:567, ChatActionableButtons.tsx:222). Each offered "or rely on the StartExpenseFlows.ts:451 unit gap being closed," and that gap is now closed, so both arms' behavior is asserted at the action level. A render-level test would pin the ternary to itself, and the fail-open shape it guarded against is unrepresentable now that the flag and the policy are one field. Happy to add them if you'd rather have the wiring pinned explicitly.

On the new pass-through test

It asserts three things, not just the absence of the redirect:

  • Navigation.navigate was not called with RESTRICTED_ACTION
  • the draft's reportID and first participant's reportID are the preferred chat's — the rebinding
  • the confirmation route for that chat was navigated to

Asserting only "no restricted-action navigation" would also pass if the flow silently fell through to the participant picker, which is the other way this branch can fail. Paired with the existing restricted test, the two now differ only in amountOwed and the grace period, so they isolate the gate.

One extra change you didn't ask for

billingRestrictionPolicySelector's doc comment said "The preferred workspace, projected to…". That stopped being accurate once createFilteredPoliciesInfoSelector started calling it too, so it now reads "Projects a policy down to just the fields the billing gate reads — see BillingRestrictionPolicy."

Verification

CI on 57d45a5: typecheck, ESLint check, spellcheck, React Compiler Compliance, and all 8 test jobs pass. The only failing check is Check independent approval, a process gate.

Locally: 2062 tests across the 9 affected suites pass, plus npm run typecheck and npx eslint on every changed file (no new errors).

npm run spell-changed could not run locally — it resolves a merge base, which this shallow clone doesn't have. That's how showable reached CI in 6585a59; 57d45a5 renames the two test titles that used it, and CI spellcheck is green.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Nothing new to address — this request was already handled by my reply a few minutes ago, in 6585a59 and 57d45a5.

I re-checked rather than assuming, and the branch tip (57d45a5) is current:

  • All 16 inline review threads have a reply. No unanswered comment exists on this PR — the newest one is from 08:13 UTC, and no review or conversation comment has been posted since 08:20.
  • The one GitHub suggestion block (the restrictedPreferredPolicy doc comment) is applied byte-for-byte at src/libs/actions/IOU/StartExpenseFlows.ts:210-213, verified with a literal grep -F, not by eye.
  • The dedupe landed: createFilteredPoliciesInfoSelector now calls billingRestrictionPolicySelector instead of rebuilding the projection inline, and the selector was moved above its caller — src/selectors/Policy.ts:363-377.
  • The requested tests exist: describe('createFilteredPoliciesInfoSelector') (5 cases) and describe('billingRestrictionPolicySelector') (2 cases) in tests/unit/PolicySelectorTest.ts, plus the pass-through case should submit straight to the preferred workspace when it is not billing-restricted in tests/actions/IOU/CreateDraftTransactionTest.ts.
  • CI is green on 57d45a5 — ESLint, Oxfmt, spellcheck, React Compiler Compliance, Storybook, and all 8 test jobs pass.

The only red check is Check independent approval, which isn't a code failure — it's the process gate requiring a human approval on a PR I authored. That one needs you, not me.

If you meant a different set of suggestions — something posted somewhere I'm not seeing, or a review you started but haven't submitted yet — a pending review stays invisible to the API until it's submitted. Submit it and tag me again, or paste the feedback here directly and I'll work from that.

@marufsharifi

marufsharifi commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible 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 checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (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 verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • 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 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.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this 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.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
Screen_Recording_20260916_160309_Expensify.Dev.mp4
Android: mWeb Chrome
Screen_Recording_20260916_160625_Chrome.mp4
iOS: HybridApp
Screen.Recording.2026-09-16.at.5.30.25.PM.mov
iOS: mWeb Safari
Screen.Recording.2026-09-16.at.5.04.31.PM.mov
MacOS: Chrome / Safari
Screen.Recording.2026-09-16.at.3.42.59.PM.mov

@JmillsExpensify JmillsExpensify left a comment

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.

LGTM

@marufsharifi

Copy link
Copy Markdown
Contributor

@MelvinBot Merge the main.

…erRestrictedWorkspace

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

MelvinBot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Merged main into claude-submitToEmployerRestrictedWorkspace and pushed. No conflicts — the branch was 1223 commits behind; merge commit is aef8ef0.

Two files this PR touches were relocated on main, and git followed both renames automatically:

The fix itself is unchanged: the diff against main is still the same 11 files.

Tests re-run locally on the merge commit — all green:

Check Result
npx tsc --build tsconfig.json (TS 7 native compiler, all 6 referenced projects) ✅ pass — 0 errors
npm testCreateDraftTransactionTest, PolicySelectorTest ✅ pass (85 tests)
npm testTrackExpenseTest, ReportUtilsTest, PolicyUtilsTest, SubscriptionUtilsTest ✅ pass (1995 tests)
npm testChatActionableButtonsTest, both DynamicReportDetailsPageTests, AddExpenseActionButtonTest ✅ pass (10 tests)
npm run spell-changed ✅ pass (11 files, 0 issues)
npx eslint on every changed file ✅ pass — no new errors
On the ESLint errors

Running raw npx eslint bypasses the seatbelt ratchet, so it reports grandfathered errors. Every one of them sits on a line this PR does not touch. For src/pages/DynamicReportDetailsPage.tsx I checked it directly: reverting just that file to origin/main and re-linting returns the identical 11 errors at the identical lines.

npm run lint (the ratcheted entry point) and npm run react-compiler-compliance-check need bun, which isn't available in this environment. Both still run in CI.

I could not re-run the manual Tests steps on web. The browser session CI handed me went blank — 0 accessible nodes, blank screenshots — immediately after prestart onboarding diverged, and stayed that way through three minutes of retries. Only the login step was confirmed, from the prestart screenshot. Worth noting separately that the Tests precondition (exactly one workspace, with an expired required payment) can't be produced on a fresh test account anyway, which is the same limitation the earlier Android run hit.


Next Steps: Reply with @MelvinBot <your feedback> if you want anything else on this branch.


view run · no recording available

@marufsharifi marufsharifi left a comment

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.

LGTM

@marufsharifi

Copy link
Copy Markdown
Contributor

LGTM, all yours @youssef-lr thanks.

@Nakagawa-master Nakagawa-master 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.

There is still a time-of-check gap after the new entry-point guards that seems distinct from the earlier stale-allPolicies finding.

The PR correctly prevents an already-restricted workspace from reaching confirmation through the single/preferred-workspace fast paths. But once the user has reached the confirmation page, the restriction is no longer checked before the write:

  • StartExpenseFlows checks shouldRestrictUserBillableActions(...) before navigation.
  • useExpenseSubmission.requestMoney() performs the final optimistic/write path without that check.
  • the PR description itself notes that confirmation / validation / requestMoney do not re-check the billing restriction.

So this sequence can still cross the boundary:

  1. workspace is healthy; user chooses “Submit to my employer” and reaches confirmation;
  2. while the confirmation page remains open (or while the app is offline/backgrounded), the required-payment state becomes restricted and the billing NVPs update;
  3. user presses the final submit/create action;
  4. the write path does not consult the now-current restriction state.

The earlier P1 fixed which snapshot is used at the entry check; this is a separate when the authorization-like business gate is checked issue.

A discriminating regression would start the flow unrestricted, reach confirmation, then update the billing state to restricted before invoking the submit handler and assert that no RequestMoney/expense write is issued and the user is sent to RESTRICTED_ACTION.

The strongest place to enforce this is server-side at the write command, because offline/queued clients cannot guarantee freshness. If the backend already rejects this condition, then a confirmation-time client check would still avoid an optimistic expense that later rolls back; if the backend does not, the entry-only guard remains bypassable by this state transition.

@marufsharifi

Copy link
Copy Markdown
Contributor

@youssef-lr, could you please take a look at this when you get a chance. thanks.

Comment on lines +40 to +41
* Takes the resolved policy rather than an ID on purpose: callers pass the snapshot they already hold, instead of a
* separately-timed `allPolicies` cache, which can lag a caller's own snapshot and let the gate fail open.

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.

Suggested change
* Takes the resolved policy rather than an ID on purpose: callers pass the snapshot they already hold, instead of a
* separately-timed `allPolicies` cache, which can lag a caller's own snapshot and let the gate fail open.

These couple of lines are unnecessary

@youssef-lr youssef-lr left a comment

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.

Minor unnecessary comment that can be removed - but won't block on it.

@youssef-lr
youssef-lr merged commit 5a44978 into main Sep 21, 2026
46 checks passed
@youssef-lr
youssef-lr deleted the claude-submitToEmployerRestrictedWorkspace branch September 21, 2026 14:37
@OSBotify

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@github-actions

Copy link
Copy Markdown
Contributor

🚧 youssef-lr has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/youssef-lr in version: 9.4.91-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 No help site changes are required for this PR, so I did not create a draft docs PR.

This is a pure bug fix that restores already-intended behavior. It adds no feature, setting, tab, button, or user-facing copy.

Why no docs change is needed

What changed: the billing-restriction gate was only applied in the destination picker, so the single-workspace fast path for Submit to my employer skipped it. The fix extracts the existing check into navigateToRestrictedActionIfNeeded and applies it to the two ungated fast paths (L406 and L444). The remaining files are a type narrowing in shouldRestrictUserBillableActions, selector plumbing, and tests.

Docs I checked against it:

Article Verdict
Track Expenses — the only article that documents Submit to my employer Still accurate. It describes submitting a tracked expense to a workspace; it never promised the flow works on a workspace with an unresolved payment.
Fix a Billing Issue Already covers the outcome: "If the billing issue isn't resolved within 7 days, the workspace loses access to paid features until the outstanding payment is successfully processed."

The Restricted screen the fix now routes to is existing, unchanged UI — no new strings were added to src/languages/en.ts. So before this PR the docs were correct and the app was wrong; after it, both agree.

One optional gap, pre-existing and not caused by this PR: Fix a Billing Issue describes the consequence only as losing "access to paid features." In the app, a workspace past its grace period actually blocks new activity outright — both the owner and members are sent to a Restricted screen when they try to create or submit an expense. If you'd like, reply and I'll open a draft PR adding an FAQ to that article spelling that out (and verify the exact on-screen labels against the live UI first).


@marufsharifi, no help site PR was created because this PR changes no documented behavior — please confirm you agree with that call. If you'd rather I document the Restricted screen behavior described above, reply with @MelvinBot create the docs PR and I'll open it as a draft, label it HelpDot, and assign it to you.


view run

@unzig21

unzig21 commented Sep 22, 2026

Copy link
Copy Markdown

Deploy Blocker #101835 was identified to be related to this PR.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/lakchote in version: 9.4.91-3 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Bundle Size Analysis (Sentry):

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.

7 participants