Skip to content

Show PAY badge for p2p IOUs before IOU report loads in Onyx#91449

Draft
MelvinBot wants to merge 5 commits into
mainfrom
claude-payBadgeFallbackFromReportAction
Draft

Show PAY badge for p2p IOUs before IOU report loads in Onyx#91449
MelvinBot wants to merge 5 commits into
mainfrom
claude-payBadgeFallbackFromReportAction

Conversation

@MelvinBot
Copy link
Copy Markdown
Contributor

@MelvinBot MelvinBot commented May 22, 2026

Explanation of Change

When a user logs in, the IOU report may not yet be loaded into Onyx. The existing getIOUReportActionWithBadge() relies on fetching the full IOU report via getReportOrDraftReport(action.childReportID) to determine the PAY badge. When the report isn't loaded, canIOUBePaid() bails early and no badge is returned — resulting in a green dot instead of a PAY badge in the LHN.

This PR adds canPayIOUFromReportAction(), a lightweight fallback that determines PAY badge eligibility for p2p IOUs only using the REPORTPREVIEW action's child* fields (childType, childManagerAccountID, childReportID, childStatusNum) — without requiring the full IOU report to be in Onyx.

The fallback activates only when:

  • The IOU report is not loaded in Onyx (!iouReport)
  • The chat report has hasOutstandingChildRequest === true
  • The action's childType is IOU (p2p, not expense)
  • The action's childReportID matches the chat report's iouReportID
  • The action's childManagerAccountID matches the current user (they are the payer)
  • The action's childStatusNum is not REIMBURSED (not settled)

Fixed Issues

$ #86897

Tests

  1. Log in to an account that has at least one p2p IOU where you owe someone (you are the payer).
  2. Observe the LHN immediately after login — the PAY badge should appear on the relevant chat, even before the IOU report fully loads.
  3. Verify that settled (reimbursed) IOUs do NOT show a PAY badge.
  4. Verify that IOUs where you are NOT the payer do NOT show a PAY badge.
  5. Verify that archived chats with outstanding IOUs do NOT show a PAY badge.
  6. Once the IOU report loads, verify the PAY badge persists (the real canIOUBePaid path takes over seamlessly).
  • Verify that no errors appear in the JS console

Offline tests

  1. Log in while online so reports load.
  2. Go offline.
  3. Force close and reopen the app.
  4. Verify the PAY badge appears correctly from cached Onyx data — the fallback uses child* fields and hasOutstandingChildRequest which are cached locally.

QA Steps

  1. Log in to a staging account that has an outstanding p2p IOU (you are the payer).
  2. Immediately after login, check the LHN for the PAY badge on the relevant chat.
  3. Verify the PAY badge appears promptly without waiting for the full IOU report to load.
  4. Verify settled IOUs and archived chats do not show a PAY badge.
  • 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 any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • 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 shown in the product is localized by adding it to src/languages/* files and using the translation method
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • 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)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • 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)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • 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.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • 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

… Onyx

When logging in, the IOU report may not be loaded in Onyx yet, causing
getIOUReportActionWithBadge to fail to determine the PAY badge (since
canIOUBePaid bails when iouReport is empty). This results in a green
dot instead of a PAY badge in the LHN until the user clicks into the chat.

Add canPayIOUFromReportAction() that uses REPORTPREVIEW action's child*
fields (childType, childManagerAccountID, childReportID, childStatusNum)
to determine PAY badge eligibility for p2p IOUs without requiring the
full report. Used as a fallback when the IOU report isn't in Onyx.

Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
action.childType === CONST.REPORT.TYPE.IOU &&
action.childReportID === chatReport?.iouReportID &&
action.childManagerAccountID === currentUserAccountID &&
action.childStatusNum !== CONST.REPORT.STATUS_NUM.REIMBURSED

This comment was marked as resolved.

Comment thread src/libs/actions/IOU/ReportWorkflow.ts Outdated

// Fallback for p2p IOUs when the IOU report isn't loaded in Onyx yet (e.g. right after login).
// Use the REPORTPREVIEW action's child* fields to determine PAY badge without the full report.
if (!iouReport && chatReport?.hasOutstandingChildRequest && canPayIOUFromReportAction(action, chatReport, currentUserAccountID)) {
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.

@MelvinBot I prefer early compute iouReport existence so if not exist, don't reachgetBadgeFromIOUReport

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 — restructured to check iouReport existence first. If !iouReport, we go straight to the fallback (or continue), skipping getBadgeFromIOUReport entirely.

- Pass `undefined` as second arg to `createRandomReport()` (requires chatType param)
- Use `undefined` instead of `null` for policy param in `getIOUReportActionWithBadge()` calls

Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
@MelvinBot
Copy link
Copy Markdown
Contributor Author

Fixed the failing typecheck: the three new PAY badge fallback tests had two issues — createRandomReport() was called with 1 arg instead of the required 2, and null was passed where OnyxEntry<Policy> (undefined) was expected. Both are now corrected.

@MelvinBot
Copy link
Copy Markdown
Contributor Author

Yes, childStatusNum compared against REIMBURSED appears in one other place:

That's the only other production usage. There's also a test assertion in ReportWorkflowTest.ts:3713 that sets childStatusNum: CONST.REPORT.STATUS_NUM.REIMBURSED in test data.

…IOUReport

Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
@aimane-chnaif
Copy link
Copy Markdown
Contributor

@codex review

@MelvinBot
Copy link
Copy Markdown
Contributor Author

Code Review

The approach is sound — using child* fields as a lightweight fallback for p2p IOUs when the report isn't loaded is a clean solution. The test coverage for the happy path and two key negative cases is good. A few observations:

1. Minor gap: reimbursableSpend > 0 not checked in fallback

The real canIOUBePaid at ReportWorkflow.ts:214 requires reimbursableSpend > 0 for IOU reports. The fallback has no equivalent. However, hasOutstandingChildRequest is computed from reimbursableSpend !== 0 so this is mostly guarded. Edge case risk is low.

2. Archived chat check missing

The real path checks isChatReportArchived before showing PAY (ReportWorkflow.ts:172-187). The fallback doesn't. If a chat is archived but the IOU report hasn't loaded, the fallback could briefly show a PAY badge. Consider adding an isArchivedReport check to canPayIOUFromReportAction if the report name value pairs are available at this point.

3. hasOutstandingChildRequest staleness at login

This flag comes from Onyx cache and reflects the last-known state before the app was closed. If the IOU was settled while offline, there's a brief window where the fallback shows PAY until ReconnectApp syncs. This is inherently transient (the real path takes over once the report loads), so it's acceptable — just worth being aware of.

4. childStatusNum !== REIMBURSED is correct for p2p IOUs

I confirmed that for IOU-type reports, the real canIOUBePaid only excludes REIMBURSED (via isSettled). It does NOT exclude CLOSED or other statuses for IOUs. So the fallback's check is consistent with the real path.

Overall this looks good. Items 1 and 3 are acceptable trade-offs for a transient fallback. Item 2 (archived chat) is the only one I'd suggest considering as an improvement.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

ℹ️ 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".

@aimane-chnaif
Copy link
Copy Markdown
Contributor

2. Archived chat check missing

The real path checks isChatReportArchived before showing PAY (ReportWorkflow.ts:172-187). The fallback doesn't. If a chat is archived but the IOU report hasn't loaded, the fallback could briefly show a PAY badge. Consider adding an isArchivedReport check to canPayIOUFromReportAction if the report name value pairs are available at this point.

@MelvinBot address this. Also add unit test

Look up reportNameValuePairs for the chat report and check
isArchivedReport before showing the PAY badge in the fallback path,
matching the real path in canIOUBePaid. Add unit test.

Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
@MelvinBot
Copy link
Copy Markdown
Contributor Author

Added isArchivedReport check to canPayIOUFromReportAction — it now looks up the chat report's reportNameValuePairs via getAllReportNameValuePairs() (same pattern as canIOUBePaid) and returns false when the chat is archived.

Also added a unit test: "should NOT return PAY badge fallback when chat report is archived".

@MelvinBot
Copy link
Copy Markdown
Contributor Author

Regression Analysis

Refactor equivalence: The restructured loaded-report path (if (badge) { ... } vs if (!badge) { continue; }) is logically identical — no regression.

Potential regressions in the fallback path:

Risk Severity Detail
False PAY on zero-balance IOU Medium canIOUBePaid checks reimbursableSpend > 0 — the fallback doesn't. If expenses are deleted/voided leaving $0, fallback could show PAY.
UI flicker Low Fallback shows PAY → IOU report loads → canIOUBePaid returns false → badge disappears. Only affects edge cases (zero balance, etc.) since for valid p2p IOUs both paths agree. Window is brief (sub-second to a few seconds post-login).
Stale hasOutstandingChildRequest Very Low Could be true after settlement, but childStatusNum !== REIMBURSED provides a secondary guard. Both fields come from the same server sync, so simultaneous staleness is unlikely.

Not a risk (confirmed safe):

  • Expense reports / invoices: excluded by childType === IOU at ReportWorkflow.ts:317
  • childStatusNum !== REIMBURSED sufficiency: isSettled() essentially checks the same condition (statusNum === REIMBURSED). For p2p IOUs the lifecycle is OPEN → REIMBURSED, so intermediate states (CLOSED, APPROVED) aren't applicable.
  • Payer check: childManagerAccountID === currentUserAccountID is functionally equivalent to the isPayer check in canIOUBePaid for p2p IOUs.

Summary: The main gap is the missing reimbursableSpend > 0 check, which could produce a false PAY badge on a zero-balance p2p IOU that hasn't been formally settled. This is a narrow edge case. All other checks the fallback skips (auto-reimbursement, policy payment settings, invoice logic, pay-at-end, etc.) are expense-report-only and safely excluded by the childType === IOU guard.

@aimane-chnaif
Copy link
Copy Markdown
Contributor

@MelvinBot add tests steps and check off all items. They're verified.

@MelvinBot
Copy link
Copy Markdown
Contributor Author

Done — added test steps (Tests, Offline tests, QA Steps) and checked off all PR Author Checklist items.

Comment thread src/libs/actions/IOU/ReportWorkflow.ts Outdated
action.childReportID === chatReport?.iouReportID &&
action.childManagerAccountID === currentUserAccountID &&
action.childStatusNum !== CONST.REPORT.STATUS_NUM.REIMBURSED &&
!isArchivedReport(reportNameValuePairs)

This comment was marked as resolved.

@MelvinBot
Copy link
Copy Markdown
Contributor Author

No — DM (1:1) chats cannot be archived. The private_isArchived field is only set during workspace-level operations (workspace deletion, member removal, leaving a workspace, policy changes), all of which filter by policyID. DMs have no real policyID, so they're never included in archival flows.

The isArchivedReport check in canPayIOUFromReportAction is still reasonable defensive code (guarding against unexpected data states), but in practice it won't trigger for DM chats.

@aimane-chnaif
Copy link
Copy Markdown
Contributor

No — DM (1:1) chats cannot be archived. The private_isArchived field is only set during workspace-level operations (workspace deletion, member removal, leaving a workspace, policy changes), all of which filter by policyID. DMs have no real policyID, so they're never included in archival flows.

The isArchivedReport check in canPayIOUFromReportAction is still reasonable defensive code (guarding against unexpected data states), but in practice it won't trigger for DM chats.

@MelvinBot ok so we can remove !isArchivedReport condition as action.childType === CONST.REPORT.TYPE.IOU && already guards this is DM chat, NOT workspace chat, right?

childType === IOU already ensures this is a p2p IOU in a DM chat,
and DM chats cannot be archived. The archived check was defensive
but unnecessary.

Co-authored-by: Aimane Chnaif <aimane-chnaif@users.noreply.github.com>
@MelvinBot
Copy link
Copy Markdown
Contributor Author

Correct — childType === CONST.REPORT.TYPE.IOU guarantees it's a p2p IOU in a DM chat, and DM chats can't be archived (archival is workspace-only via policyID). Removed the isArchivedReport check and its unit test.

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

@aimane-chnaif
Copy link
Copy Markdown
Contributor

You completed 35 out of 49 checklist items with 0 unfinished items
Make sure you are using the most up to date checklist found here: https://raw.githubusercontent.com/Expensify/App/main/.github/PULL_REQUEST_TEMPLATE.md
Error: PR Author Checklist is not completely filled out. Please check every box to verify you've thought about the item.

@MelvinBot fix this

@codecov
Copy link
Copy Markdown

codecov Bot commented May 23, 2026

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/libs/actions/IOU/ReportWorkflow.ts 79.30% <100.00%> (+0.58%) ⬆️
... and 109 files with indirect coverage changes

@MelvinBot
Copy link
Copy Markdown
Contributor Author

Updated the PR body to use the latest checklist template and checked off all items. The previous version was missing several checklist items (CSS styles, assets, markdown, generic components, Storybook, deeplinks, UI modifications, ScrollView).

@MelvinBot
Copy link
Copy Markdown
Contributor Author

Regression Analysis: Group Chat & Split Bill

No regressions found for split bills or group chats. The refactored loaded-report path is logically equivalent to the old code. The fallback is strictly additive (shows PAY where previously nothing was shown). Two items to be aware of:

Split Bills — Safe

Split bills create IOU SPLIT actions on the group chat, not REPORT_PREVIEW actions (Split.ts:495-503). Since getIOUReportActionWithBadge only processes REPORT_PREVIEW actions (line 337), the fallback path is never reached for split group chats. The 1:1 DM REPORT_PREVIEW actions created per split participant are standard IOUs and handled correctly.

Group Chats — Safe (with known limitation)

Group chats can have multiple REPORT_PREVIEW actions (one per IOU/expense report). The fallback only matches the one whose childReportID === chatReport.iouReportID, and iouReportID tracks the latest IOU (MoneyRequestBuilder.ts:464, PayMoneyRequest.ts:549-550). This means older outstanding IOUs won't get a fallback badge — but this is a temporary state (once reports load from Onyx, the full getBadgeFromIOUReport path handles all IOUs correctly).

Missing: Archived Chat Check

⚠️ The isArchivedReport check was reportedly added but is not present in the current branch. canPayIOUFromReportAction at line 314 does not check for archived chats, and the corresponding unit test is also missing. This means an archived chat with an outstanding p2p IOU could briefly show a PAY badge via the fallback until the IOU report loads. This should be addressed before merge.

@aimane-chnaif
Copy link
Copy Markdown
Contributor

@MelvinBot mark as ready for review

@MelvinBot
Copy link
Copy Markdown
Contributor Author

Marked the PR as ready for review.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants