Skip to content

fix: sort and total Search amounts by the sign the row displays - #99747

Merged
mountiny merged 12 commits into
Expensify:mainfrom
callstack-internal:investigate-issue-671897
Sep 10, 2026
Merged

fix: sort and total Search amounts by the sign the row displays#99747
mountiny merged 12 commits into
Expensify:mainfrom
callstack-internal:investigate-issue-671897

Conversation

@BartekObudzinski

@BartekObudzinski BartekObudzinski commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

Two Search bugs where a negative amount is handled as its magnitude.

The Amount column's sort key (formattedTotal) treated a row as an expense only when report.type === 'expense', while the cell that renders the amount (TotalCell) also counts group-policy rows and unreported/tracked expenses. getAmount() drops the sign for anything outside that classification, so those rows displayed a negative sign but sorted by magnitude, which is how a -$4,000 credit ends up among the +$4,000 expenses. Both paths now call one helper, getTransactionDisplayAmount() in ReportUtils, using the same classification getTransactionDetails() already applies everywhere else. formattedTotal is only ever read as a sort key, so no displayed number changes.

The selection footer summed -Math.abs(transaction.amount) per row, so selecting a -$100 credit and a +$100 expense reported $200 instead of $0 while a whole-group selection already netted correctly from the signed groupAmount. Rows now carry a displayAmount and the footer sums that; the four copies of the sum expression are collapsed into getEntrySource().

Note for reviewers: the Math.abs() in compareValues() that looks like the cause is dead code. The Amount column passes the sorting property formattedTotal, never 'amount', and the Original Amount column has its own branch that passes shouldCompareOriginalValue. I left it alone because removing it means dropping a parameter from five call sites, which is unrelated churn.

Deliberately out of scope: getTotalFormattedAmount() in actions/Search.ts also uses Math.abs, but it feeds the bulk Pay button label, where a positive sum is intended.

One thing I could not check from the App repo: whether the backend Search sort is signed. If it is not, pages can still arrive in an order that differs from the one the client renders, which shows up as rows appearing mid-list while scrolling. That needs someone with Web-Expensify/Auth access.

Draft because automated checks pass but I have not run the manual or on-device steps below.

Fixed Issues

$ #99918
PROPOSAL:

Tests

Needs an account with at least one negative expense (a credit or refund) that is either unreported or on a workspace report whose type is not expense.

  1. Open Search and go to the Expenses view.
  2. Sort by the Amount column, largest to smallest, and scroll until every page has loaded.
  3. Verify every row showing a negative amount sits below every row showing a positive amount, and that the negatives are ordered -$1, -$2, -$3 going down.
  4. Scroll back up and verify no negative row has been placed among the positives.
  5. Flip the sort to smallest first and verify the order reverses with the same grouping.
  6. Select one expense of +$100 and one credit of -$100 with the row checkboxes.
  7. Verify the footer total reads $0.00, not $200.00.
  8. Select a whole group instead and verify its total is unchanged from before.
  9. Verify the amount shown in each row is unchanged from before this PR.
  • Verify that no errors appear in the JS console

Offline tests

  1. Load Search with the Expenses view and let the results settle.
  2. Turn on Force offline in Settings > Troubleshoot.
  3. Sort by Amount and verify the already-loaded rows sort by signed value, with negatives below positives.
  4. Select a positive and a negative expense and verify the footer total nets to $0.00.
  5. Turn Force offline off and verify the order and the total are unchanged once fresh results arrive.

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
Screen.Recording.2026-08-28.at.14.11.46.mov

@codecov

codecov Bot commented Aug 28, 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/components/Search/selectionBuilders.ts 80.00% <100.00%> (+0.54%) ⬆️
...ponents/TransactionItemRow/DataCells/TotalCell.tsx 78.33% <100.00%> (-0.70%) ⬇️
src/libs/ReportUtils.ts 87.29% <100.00%> (+0.03%) ⬆️
src/libs/SearchUIUtils.ts 71.27% <100.00%> (+0.08%) ⬆️
src/components/Search/SearchSelectionFooter.tsx 73.80% <80.00%> (ø)
... and 10 files with indirect coverage changes

@Julesssss Julesssss added Melvin-Test-Web Triggers Melvin to run the testing steps of the PR on web Melvin-Test-Android labels Aug 28, 2026
@MelvinBot

MelvinBot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Android test run: blocked on test data

Result: partial. I reached Search → Expenses on the Android build of this branch (investigate-issue-671897), but the test account has zero expenses — not just no negative one. Every assertion in your Tests section depends on rows existing, so steps 2–9 could not be exercised.

Your own precondition calls this out: "Needs an account with at least one negative expense (a credit or refund) that is either unreported or on a workspace report whose type is not expense." Melvin's Android sessions run on a freshly-onboarded throwaway account, which never has that data. A credit/refund also can't be created through the app UI — negative amounts come from company-card imports, not manual entry.

Step Status Observation
1. Open Search and go to the Expenses view pass Home → Search → recent search chip type:expense from:me opened Search with the Expenses tab selected
2. Sort by Amount, largest to smallest, load all pages fail Expenses view shows "No expenses yet" with the From filter cleared. The Display panel does expose a Sort by → Amount option alongside Date and Status, so the control under test is reachable — there is just nothing to sort
3. Negatives sit below positives, ordered -$1, -$2, -$3 fail Blocked — no rows
4. Scroll back up, no negative among the positives fail Blocked — no rows
5. Flip sort to smallest first, order reverses fail Blocked — no rows
6. Select +$100 expense and -$100 credit fail Blocked — account has no expenses at any amount
7. Footer total reads $0.00, not $200.00 fail Blocked — cannot select rows that don't exist
8. Select a whole group, total unchanged fail Blocked — no groups
9. Row amounts unchanged from before fail Blocked — no rows rendered

To get this verified on Android, the run needs a session seeded with expense data before Melvin starts: at least one +$100 expense and one -$100 credit/refund, with the credit either unreported or on a non-expense report. Your macOS recording already covers the flow, so the gap is specifically the mobile evidence.

Separately, the open question in your description — whether the backend Search sort is signed — is still unanswered and would need someone with Web-Expensify/Auth access. If pagination sorts by magnitude server-side, the client fix alone won't stop negative rows appearing mid-list as pages arrive.

Evidence (1)

Search > Expenses view showing 'No expenses yet' empty state after clearing the From filter

Search > Expenses view showing 'No expenses yet' empty state after clearing the From filter

view run

Evidence (1)

Recordings

- [▶ Session recording of the verification run](https://melvinbot-assets.exops.io/session-33195170914-1-cf5bfe81-95aa-49de-ae82-b85823612f26.mp4)

@MelvinBot

MelvinBot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Web test run: blocked on test data (same as Android)

Result: fail. I reached Search → Expenses on web, but the test account has zero expenses — not just no negative one. Every assertion in your Tests section depends on rows existing, so steps 2–9 could not be exercised. This is the identical blocker the Android run hit.

Your own precondition names it: "Needs an account with at least one negative expense (a credit or refund) that is either unreported or on a workspace report whose type is not expense." Melvin's sessions run on freshly-onboarded throwaway accounts, which never have that data.

Step Status Observation
1. Open Search and go to the Expenses view pass Home → global search → "Go to Expenses" landed on Search → Spend → Expenses with the Expenses/Reports tabs visible
2. Sort by Amount, largest to smallest, load all pages fail "No expenses yet" empty state — there is no Amount column and no row to sort
3. Negatives sit below positives, ordered -$1, -$2, -$3 fail Blocked — no rows
4. Scroll back up, no negative among the positives fail Blocked — no rows
5. Flip sort to smallest first, order reverses fail Blocked — no rows
6. Select +$100 expense and -$100 credit fail Blocked — account has no expenses at any amount, so neither row exists
7. Footer total reads $0.00, not $200.00 fail Blocked — cannot select rows that don't exist
8. Select a whole group, total unchanged fail Blocked — no groups
9. Row amounts unchanged from before fail Blocked — no rows rendered

JS console errors: not captured. The web driver in this session has no console-inspection command, so the "no errors appear in the JS console" checkbox in your PR is still unverified by me.

Run note: after reaching the empty state, a stale element reference clicked "Try it out" instead of "Dismiss" on the "Pre-built agents" onboarding tooltip, which opened the full-window Test Drive overlay. The tester stopped there per its own policy rather than trying to recover. That happened after the blocker, so it changed nothing about the outcome.

To get this verified, the run needs a session seeded before Melvin starts: at least one +$100 expense and one -$100 credit/refund, with the credit either unreported or on a non-expense report. A credit can't be created through the app UI — negative amounts come from card imports. Your macOS recording already covers the flow, so the gap is specifically automated evidence.

Also still open from your description: whether the backend Search sort is signed. If pagination sorts by magnitude server-side, the client fix alone won't stop negative rows appearing mid-list as pages arrive. That needs someone with Web-Expensify/Auth access.

Evidence (4)

Global quick-search dialog opened from Home

Global quick-search dialog opened from Home

Quick-search results showing 'Go to Expenses' option

Quick-search results showing 'Go to Expenses' option

Search > Expenses view showing 'No expenses yet' empty state

Search > Expenses view showing 'No expenses yet' empty state

Test Drive overlay that ended the run early

Test Drive overlay that ended the run early

view run

Evidence (1)

Recordings

- [▶ Session recording of the verification run](https://melvinbot-assets.exops.io/session-33195174951-1-d2b0f35a-6dfc-4db8-b5e9-b831078115d8.webm)

@melvin-bot

melvin-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

@mkhutornyi 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 removed request for a team September 1, 2026 06:19
Comment thread src/libs/ReportUtils.ts Outdated

/**
* Returns a transaction's amount with the sign it is displayed with. A transaction on an expense report, on a group
* policy, unreported, or deleted keeps its sign; any other transaction returns its magnitude.

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-16 (docs)

This newly added JSDoc joins two independent clauses with a semicolon. Per the plain-comment-style guidance, prefer two separate sentences over a semicolon so the comment reads like natural prose.

Split the clause into two sentences:

/**
 * Returns a transaction's amount with the sign it is displayed with. A transaction on an expense report, on a group
 * policy, unreported, or deleted keeps its sign. Any other transaction returns its magnitude.
 */

Reviewed at: faed17f | 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

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: faed17f445

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

garrettmknight
garrettmknight previously approved these changes Sep 1, 2026

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

Looks good from a product perspective.

@mkhutornyi

mkhutornyi commented Sep 1, 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 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 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 and each prop has a /** comment above it */
    • The file is named correctly
    • 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
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • 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 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 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
Android: mWeb Chrome
iOS: HybridApp
ios.mov
iOS: mWeb Safari
MacOS: Chrome / Safari
web.mov

Comment thread src/libs/ReportUtils.ts Outdated
* policy, unreported, or deleted keeps its sign. Any other transaction returns its magnitude.
*/
function getTransactionDisplayAmount(transaction: OnyxInputOrEntry<Transaction>, report: OnyxInputOrEntry<Report>, policy: OnyxInputOrEntry<Policy>): number {
const isFromExpenseReport = (!isEmptyObject(report) && isExpenseReport(report)) || isGroupPolicyPolicyUtils(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.

This is duplicated with

const isFromExpenseReport = (!isEmptyObject(report) && isExpenseReport(report)) || isGroupPolicyPolicyUtils(policy);

Is it worth extracting?

function isTransactionFromExpenseReport(report: OnyxInputOrEntry<Report>, policy: OnyxInputOrEntry<Policy>): boolean {
    return isExpenseReport(report) || isGroupPolicyPolicyUtils(policy);
}

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.

Extracted isTransactionFromExpenseReport and used it in both places, without the !isEmptyObject(report) guard since isExpenseReport already returns false for undefined, null and {}.

Comment thread src/libs/ReportUtils.ts
// Sources are expense-signed (the negation of the displayed amount), so callers sum them with `total - source`.
function getEntrySource(entry: SelectedTransactionInfo): number {
return entry.groupAmount ?? -Math.abs(entry.amount);
return entry.groupAmount ?? -(entry.displayAmount ?? Math.abs(entry.amount));

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.

displayAmount? plus a ?? fallback hides the unhandled case.

formattedTotal is required on TransactionListItemType, so that inner fallback is unreachable for every entry mapTransactionItemToSelectedEntry builds.
The only entries that reach it are the ones from mapEmptyReportToSelectedEntry, which is exactly the case that still totals by magnitude and makes mixed selections wrong.
The optional field plus ?? converts an unhandled case into a silently plausible number.

Either populate it in both builders and make the field required, or branch on the report-row case explicitly so it's visible.

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.

Made displayAmount required and set it in both branches of mapEmptyReportToSelectedEntry, reducing getEntrySource to entry.groupAmount ?? -entry.displayAmount and fixing the Reports view total too, which I can split out if you'd prefer.

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

@mkhutornyi

@mkhutornyi

Copy link
Copy Markdown
Contributor

@codex review

@mkhutornyi

Copy link
Copy Markdown
Contributor

@MelvinBot review

@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: 4969f1f6a3

ℹ️ 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/SearchUIUtils.ts Outdated
return 0;
}
const totalAmount = getAmount(t, t.report?.type === CONST.REPORT.TYPE.EXPENSE);
const totalAmount = getTransactionDisplayAmount(t, t.report, t.policy);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep attendee-total sorting aligned with its displayed value

For an unreported credit (or a transaction without a report under a group policy), this helper now returns a negative total, but the visible Total per attendee cell is still calculated with getAmount(transactionItem, isExpenseReport(report)) in TransactionItemRow/index.tsx, which returns the positive magnitude in those cases. Consequently, sorting this column orders rows by values with signs opposite to those shown—for example, a displayed $25 tracked credit sorts as -$25. Either update the rendered calculation to use the same display helper or retain the existing calculation here.

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. The cell in TransactionItemRow/index.tsx:250 still uses the narrow calculation, so I reverted the sort change instead. Cell and sort match again like they did before.

The display side is a separate pre-existing issue for tracked credits, and I'd rather not change a rendered value in a shared component here.

@MelvinBot

MelvinBot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Re-review of 4969f1f — both blocking findings are fixed correctly, and the new test settles the question I couldn't answer last time. Nothing new to block on.

mkhutornyi — the short version: this is good to approve on the code. The one thing still genuinely unverified is runtime behavior, and that's an environment problem on my end, not the PR's.

What the new commit fixed

Group conversion stampSearchSelectionFooter.tsx:219 is now -group.total, matching getEntrySource. Group total is the same field the row renders raw, so the stamp and the live source now agree at every sign. Correct.

Total per attendeeSearchUIUtils.ts:4418 now routes through getTransactionDisplayAmount(t, t.report, t.policy). Both fields are populated on TransactionListItemType by the same section builder that feeds formattedTotal, so this column now sorts on exactly the same classification as the Amount column. The getAmount import is gone, so there is no magnitude-based amount sort left in the file.

I re-swept for stragglers. The three remaining Math.abs calls under src/components/Search/ are all correct: the IOU branch of ReportListItemHeader.tsx:159 (IOU totals are stored positive), the Pay-button label in PayActionCell.tsx:192, and the deliberately magnitude-only amount field in selectionBuilders.ts:95, which is gated by allowNegativeAmount.

The excluded-credit case is now resolved, and the author picked the right side

I previously flagged metadataTotal - getTransactionTotal(excluded) as possibly wrong by 2× an excluded credit, because I couldn't tell from the App repo which sign the server's grand total uses. The new test asserts 36000 → 46000 when a -$100 credit is excluded, on the premise that the server already counts it as -$100.

That premise is verifiable from the client alone, and it holds. The footer has two paths that must produce the same number for the same set of rows: the partial-selection path sums displayAmount per row (L476-L487), and the everything-selected path uses metadataTotal (L143-L148). Those only agree if the server total is display-signed. So this PR doesn't just avoid a regression here — it removes a pre-existing disagreement, since the old -Math.abs() made the two paths differ for credits. Good call, and the test is the right one to have added.

The mapEmptyReportToSelectedEntry coverage gap is closed too, in selectionBuildersTest.ts, covering both branches with negative inputs.

One cosmetic leftover

selectionBuilders.ts:109 still reads amount: item.totalDisplaySpend ?? item.total ?? 0. getMoneyRequestSpendBreakdown always returns a number, so ?? item.total is dead, and next to displayAmount: item.totalDisplaySpend ?? 0 it reads like one of the two lines is missing a case. Worth deleting, not worth another round.

On the mixed-currency ordering you raised

BartekObudzinski's conclusion is right, but the reasoning undersells what's already wired up. The client does have a route to converted per-row amounts — getFooterConvertedAmounts asks the backend for amounts converted to a target currency and caches them per transaction, per report and per group under SEARCH_FOOTER_CONVERSION. So "no exchange rates on the client" is true, but converted figures are obtainable.

What actually blocks it is pagination. The backend picks which rows land on each page using the amount as stored, unconverted — the comment at SearchUIUtils.ts:384-385 states this for group amounts. A client that re-ranked by converted value would be sorting a set the server chose by a different key, so €100.00 would still be missing from the page it belongs on. That makes it a backend change, and it's the same root gap as the open question in the PR description. Worth its own issue rather than expanding this one — this PR's scope (sign only) is the right cut.

Runtime verification failed again, same way

Nobody has runtime evidence on this PR yet, so I retried — deliberately on the positive-amount path, which needs no credit expense. The run died in the same place as last time: during expense creation a referral panel appeared, the UI re-rendered, and a click aimed at that panel's Back button landed on a link to the public marketing site, which navigates the whole window off the app and drops the session unrecoverably. No expenses were created, so none of the four checks ran.

Step Status
Both expense rows render positive amounts in Search → ExpensesNot reached — no expenses were created before the session was lost.
Sorting by Amount largest-first puts $150 above $25Not reached.
Flipping Amount sort to smallest-first reverses the orderNot reached.
Selecting both rows makes the footer total read $175.00Not reached.
Session integrity while dismissing promo panels during expense creationA click intended to dismiss the referral panel hit a link to expensify.com, navigating off-app and killing the session.
Referral promo panel shown during Create Expense flow
Browser navigated off-app to expensify.com/partners/accountants after stale-ref click

That's twice with the identical cause, so I'd treat automated verification of this PR as unavailable rather than pending. The BartekObudzinski macOS recording plus the unit tests are the evidence base here.

Static checks: everything passes on 4969f1f except Check independent approval, which is the expected human-approval gate.


view run · view recording

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

@mkhutornyi

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

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Web test steps verification results: ⚠️ partial

No regressions found. Amount display, Amount-column sorting both directions, row-level footer totals, report-group footer totals, and amount-edit persistence all behaved correctly on dev NewDot web. Two gaps: the account had no negative-amount expense, so the signed sorting and net-to-zero footer behavior this PR actually fixes was not exercised — steps 3–7 of your Tests section still need a human with a credit or refund. And the literal in-table inline-edit gesture on the Amount cell could not be driven headlessly, so edit-and-persist was verified through the expense detail page reached from a Search row instead.

Expense detail page for Test Merchant D showing 150.00 matching Search row

Step Status
Open Search > Expenses; every row renders a correctly valued/signed amount, no JS console errors4 created expenses rendered as $150.00, $4,000.00, $25.00, $10.00 — all positive, matching entered values. No error banners or error boundaries. Note: this web session exposes no console capture command, so "no console errors" reflects the visual absence of error UI rather than a captured log.
Search Expenses list showing all 4 amounts
Sort Amount column largest-to-smallestClicking the Amount header sorted rows to $4,000.00 > $150.00 > $25.00 > $10.00, monotonically descending, with a down-arrow indicator on the header.
Amount sorted descending
Flip Amount sort to smallest-to-largestClicking the Amount header again reversed the order to $10.00 < $25.00 < $150.00 < $4,000.00, monotonically ascending.
Amount sorted ascending
Select two rows via checkboxes; footer total equals sum of the twoSelected Test Merchant A ($10.00) and Test Merchant B ($25.00); footer showed "Expenses: 2, Total spend: $35.00".
Two rows selected, footer total 35.00
Deselect one row; footer total updates to the remaining row's amountDeselecting Test Merchant A left only Test Merchant B selected; footer updated to "Expenses: 1, Total spend: $25.00".
One row selected, footer total 25.00
Group Search by Reports; select the report group row; footer total equals the group row's displayed total, same signReports tab showed one draft report row with Total $4,185.00 (10+25+4000+150). Selecting that row's checkbox produced footer "Expenses: 4, Total spend: $4,185.00" — matching value and sign.
Reports view showing group row total 4185.00
Report group row selected, footer total 4185.00
Inline-edit one expense's amount from the Search Amount column; row updates and persists on reopening SearchTooling limitation, not a product defect. The Amount column's hover-revealed edit affordance never became interactable in this headless session — the cell's Edit pressables reported zero bounds at two viewport sizes and multiple x-offsets, while general row-hover events were confirmed to fire. Clicking the amount text falls through to the row Pressable and opens the expense detail page. Verified the edit-and-persist mechanism there instead: changed Test Merchant B from $25.00 to $30.00, the audit trail recorded "New amount is '$30.00' (previously '$25.00')", and Search showed $30.00 immediately and again after navigating to Home and back.
Search list showing Test Merchant B updated to 30.00 after edit and reopen
Open an expense from a Search row; detail-page amount matches the Search row's displayed amountOpened Test Merchant D from the row showing $150.00; the detail page header read "$150.00 for Test Merchant D" with "Amount • Cash $150.00". Also confirmed for Test Merchant B both before ($25.00) and after ($30.00) the edit.
Expense detail page for Test Merchant D showing 150.00 matching Search row

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Code quality review of 31ada8c5 — the refactor is sound. Collapsing four copies of groupAmount ?? -Math.abs(amount) into one getEntrySource() is the right move, and it's what turns the sign fix into a one-line change instead of four that could drift. Five things worth addressing, none blocking.

1. The report view's Amount sort key was left on the old classification

src/libs/ReportUtils.ts:14338-14339

case CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT:
    return getTransactionAmount(transaction, isExpenseReport(report), transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID);

This is the sort key for the report view's Amount column (MoneyRequestReportTransactionList.tsx:502-503), and the cell it sorts is TransactionItemRowWide.tsx:481DataCells/TotalCell, which this PR just switched to getTransactionDisplayAmount. So you closed the sort/render split in Search and left the identical one in the report view, 9,000 lines below the new helper in the same file. policy is already a parameter there, so it's one line:

return getTransactionDisplayAmount(transaction, report, policy);

The divergence is narrow — only deleted transactions and report-less group-policy rows — which is why this isn't blocking. But it's exactly the class of bug this PR exists to fix.

2. The amount doc comment is wrong on the common path

src/components/Search/types.ts:72 now says /** The transaction amount, in the raw stored sign convention */. It isn't. prepareTransactionsList hardcodes allowNegativeAmount: false, so every checkbox toggle stores Math.abs(amount) — a magnitude. Only the reconcile path in SearchWriteActionsProvider passes true. Now that two similarly-named amount fields sit side by side, a reader who trusts this comment and writes -entry.amount to get a display value will be wrong most of the time. Describe what it's for (bulk pay magnitude) rather than its sign.

3. The -group.total change is untested

SearchSelectionFooter.tsx:219 changed from -Math.abs(group.total) to -group.total. It can't be reached from the test file: SearchSelectionFooterTest.tsx:38 hardcodes useSearchResultsContext: () => ({currentSearchResults: undefined}), so loadedGroupSourceByKey always early-returns EMPTY_SOURCES. Making that mock a mutable ref like the neighbouring ones and adding a grouped case with a negative group.total would pin the stamp.

4. Dead fallback, and an asymmetry that reads as intentional

selectionBuilders.ts:125-126

amount: item.totalDisplaySpend ?? item.total ?? 0,
displayAmount: item.totalDisplaySpend ?? 0,

?? item.total is unreachable — the only producer of this item type sets totalDisplaySpend unconditionally at SearchUIUtils.ts:3314, and getMoneyRequestSpendBreakdown returns a number on every path. Your new line has the right shape; the old one is the stale one. Dropping the dead middle step makes the two read identically and saves the next reader from deriving why they differ. Worth noting the direction: item.total is the raw stored total, so if that fallback ever became reachable it would feed a stored-signed value into a display-signed field.

5. One new test doesn't test what it says

SearchSelectionFooterTest.tsx:182-193'nets a selected credit report against a selected expense report', with a comment about report rows carrying no transaction of their own. The fixtures never set reportID, so getTransactionCount's key === item.reportID check is 'report1' === undefined and these are counted as ordinary rows — your own count: 2 assertion proves it. A real report row is skipped by the count but still summed into the total, and that asymmetry is the thing worth pinning. Either set reportID on the fixtures and assert count: 0, total: 6000, or rename the test to what it actually covers.

Nits — take or leave

The JSDoc misstates the deleted case. ReportUtils.ts:5096-5100 says a deleted transaction "is stored with the opposite sign". It isn't — isDeletedTransaction feeds getAmount's fourth parameter, which is named allowNegative. Deletion doesn't change storage, the code just stops forcing the value positive. The outcome you describe is right, the reason isn't. Same sentence, "on a group policy with no report of its own" is really "the report is missing and the policy is a group policy".

isTransactionFromExpenseReport(report, policy) takes no transaction. The prefix promises an argument that isn't in the signature. usesExpenseReportSign(report, policy) is honest. It also has no JSDoc, and the non-obvious half — that a report-less transaction is classified by its policy instead — is exactly what a reader needs told.

Placement. ReportUtils.ts is 14,889 lines; TransactionUtils/index.ts is 3,881 and already holds every structural sibling of this helper (getAmount, getConvertedAmount, getOriginalAmountForDisplay, getTaxAmount — all take the same classification boolean). The cycle objection doesn't apply: TransactionUtils already imports a dozen names from ReportUtils and already imports PolicyUtils, so the move adds two names to two existing import lists and no new module edge. It would also un-split TotalCell.tsx, which currently pulls the amount from ReportUtils and the currency from TransactionUtils.

isExpenseReport(report) is evaluated four times inside getTransactionSortValue (lines 14339, 14351, 14357, 14371). If you take finding 1, hoist one const at the top of the switch.

One degenerate test. SearchSelectionFooterTest.tsx:170-180 uses 10000 / -100000, which still passes if getEntrySource flips sign. The asymmetric test right below it does catch that, so the suite is fine — but the values cost nothing to make discriminating.

getTransactionDisplayAmount has no direct unit test. The two branches it newly introduces to these call sites — empty report plus group policy, and deleted transaction — aren't exercised by any of the added tests; all the new SearchUIUtils cases go through UNREPORTED_REPORT_ID.

Pre-existing, not yours: SearchUIUtils.ts:1275-1279 uses @private, which CONSISTENCY-10 bans. It's on a function you're already editing, so it's cheap to drop while you're in there.

What's good
  • The duplication is genuinely gone. Zero remaining copies of the (!isEmptyObject(report) && isExpenseReport(report)) || (isEmptyObject(report) && isGroupPolicy(policy)) pattern in src/ or tests/. The ternary rewrite is equivalent and much easier to read.
  • You fixed a dead parameter. policy in getTransactionItemCommonFormattedProperties was accepted and never used before this PR.
  • No import leftovers. Every import dropped from TotalCell.tsx is genuinely unused afterwards, and isDeletedTransaction is correctly kept in SearchUIUtils.ts where it's still used elsewhere. No new module cycles.
  • displayAmount is required, not optional. That forced every construction site to make a decision instead of rotting behind a silent ?? amount, which is why ~20 fixtures changed. Storing it is also the right call — it can't be derived, since report and group rows have no transaction of their own.
  • The tests discriminate. They fail against pre-PR code rather than just satisfying the type checker. The sort test in particular required knowing that compareValues receives 'formattedTotal' and not TOTAL_AMOUNT, so the Math.abs branch you flagged as dead really is bypassed.
  • getTransactionTotal moved below getEntrySource, so the helper is defined before use and the shared comment sits above what it documents.
  • Comments meet the standards. No CONSISTENCY-15/16/17 violations in anything added. // formattedTotal is the Amount column's sort key… is genuinely useful, because that field name lies about its type.
  • The -group.total change was necessary, not incidental. Leaving it on -Math.abs() would have desynced loadedGroupSourceByKey from getEntrySource and broken conversion-freshness detection silently. Easy to miss.

Findings 1–5 are from reading the code at 31ada8c5; I did not run the test suite. Reply with @MelvinBot implement 1 and 4 (or any subset) and I'll push them.

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

@mkhutornyi The review is completed on your side?

@mkhutornyi

Copy link
Copy Markdown
Contributor

@mkhutornyi The review is completed on your side?

yes

@melvin-bot
melvin-bot Bot requested a review from mountiny September 10, 2026 12:18

@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: 0e90b49e92

ℹ️ 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/SearchUIUtils.ts

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

Looks good to me, did not find anything odd, lets give it a go in staging

@MelvinBot

MelvinBot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

🤖 Web test steps verification results: ❌ fail

I could not run the test steps. The browser session died during setup — a stale element ref resolved to the "refer your team / Learn more" link inside the Create Expense panel and navigated the tab off-app to the public marketing site, which is unrecoverable for this session type. No expenses were created, so none of the sorting or footer-total steps were exercised. Treat everything below as static analysis, not verified behavior.

Reading the diff instead, I found no regression for ordinary positive amounts, but three things worth your attention.

1. The group-policy half of the fix never runs in Search

getTransactionDisplayAmount only consults the policy when the report is empty:

src/libs/ReportUtils.ts:5079-5091

But at both getTransactionItemCommonFormattedProperties call sites, policy is looked up through the report:

Both are data[policies_${report?.policyID}]. When report is undefined, the key becomes policies_undefined and policy is undefined too, so isGroupPolicy(undefined) returns false. The branch can never fire.

TotalCell does not have that constraint — it takes report and policy as independent props (TotalCell.tsx:50-52), so it can still render a signed amount for a group-policy row while formattedTotal sorts it by magnitude. That is exactly the display/sort mismatch the PR description says it removes. The unreported/tracked-expense half of the fix does work.

2. amount and displayAmount disagree on their fallback

src/components/Search/selectionBuilders.ts:124-125:

amount: item.totalDisplaySpend ?? item.total ?? 0,
displayAmount: item.totalDisplaySpend ?? 0,

totalDisplaySpend is optional on the type (types.ts:318). If it is ever missing, the old footer used -Math.abs(amount) and counted item.total; the new footer reads displayAmount and counts $0. Today SearchUIUtils.ts:3300 always populates it, so this is latent rather than live — but either drop the now-dead ?? item.total from amount or give displayAmount the same fallback, so the two can't drift.

3. formattedTotal is no longer sort-key-only

The description says "formattedTotal is only ever read as a sort key, so no displayed number changes." This PR makes that false: selectionBuilders.ts:95 sets displayAmount: item.formattedTotal, and getEntrySource sums that straight into the footer's on-screen total (SearchSelectionFooter.tsx:67-72). Not a bug, but a future change to the sort key now silently moves a dollar figure users read. Worth correcting in the description.

What I checked and found clean
  • Ordinary positive amounts are unchanged everywhere. For a normal expense on an expense-type report, the old and new argument sets to getAmount are identical, so formattedTotal, TotalCell, and the footer all produce the same numbers.
  • sources[key] = -group.total (dropping Math.abs) does not break conversion caching. loadedGroupSourceByKey and groupSourceByKey are merged at SearchSelectionFooter.tsx:361 and compared as freshness stamps. Both read the same raw data[key].total field and now apply the same -x transform, so they still match and conversions do not go permanently stale.
  • Report rows never reach that code path. isGroupEntry matches group_* keys only; report groups are keyed report_*. The Report.total negative-storage convention is therefore not in play at line 219, and the SearchXGroup aggregation totals it does read are already display-signed positive.
  • isTransactionFromExpenseReport is behaviour-preserving — the ternary is equivalent to the two isEmptyObject expressions it replaced in both TotalCell and getTransactionDetails.
  • Invoice reports and non-expense IOU reports still drop the sign, in both old and new code. Pre-existing, not introduced here, but their footer totals will still net wrong for a credit.
  • All 34 CI checks pass, including typecheck and all 8 Jest shards.

What still needs a human

The sorting and footer behaviour is unverified end to end. Steps 2-8 of your test plan need an account with a negative unreported expense, which I never got to create. Someone should run those manually before this leaves draft.


view run · no recording available

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

All of MelvinBot's code findings are already addressed

@mountiny
mountiny merged commit 9e15c3b into Expensify:main Sep 10, 2026
37 of 40 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

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

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

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/mountiny in version: 9.4.76-0 🚀

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

@MelvinBot

Copy link
Copy Markdown
Contributor

Help site review for this PR

Yes, one article needed updating. Draft PR: #100900

No existing sentence in docs/articles was factually wrong before this change, so nothing needed a correction. But this PR changed two behaviors a member can see, and the article that covers them documented neither — so I filled the gap rather than left it.

What changed and why

Behavior this PR fixed Docs state before What I added
Amount column now sorts by signed value, so a -$4,000 credit no longer lands among the +$4,000 expenses The Spend page article had no sorting content at all Two sections: how to sort the Amount column, and where negative amounts land in the order
Selection footer now nets signed amounts, so +$100 and -$100 reads $0.00 instead of $200.00 "What the total spend footer shows" described what the total covers, but not how credits behave in it One sentence plus a worked example in that existing section

Everything lands in docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md — 22 lines added, 1 changed. I checked the rest of docs/articles and nothing else references Amount-column sorting or the footer's handling of negative amounts.

UI labels I verified, and how

I did not take label capitalization on trust:

Written against HELP_AUTHORING_GUIDELINES.md, HELPSITE_NAMING_CONVENTIONS.md, and TEMPLATE.md: task-based ## headings opening with a question word, UI labels bolded in Sentence case, no new ### or # headings, no screenshot placeholders (a screenshot here would only mirror the text). npm run spell-changed passes.

Two things worth flagging:

  1. The article already deviates from the guidelines in ways that predate this PR — multiple # headings, ### subheadings, and no internalScope in the frontmatter. I left those alone to keep the diff scoped to the behavior change. Restructuring the article is separate work.
  2. Browser verification came back partial. The test account had no expenses, so I confirmed the nav labels live but had to verify the table header, footer, and sort interaction from the translation files and components instead. Worth a glance when you review.

@BartekObudzinski, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR Ready for review

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