From 2de036d35ba8683c1cf5fd21de5b60e3b67378fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Fri, 28 Aug 2026 13:46:36 +0200 Subject: [PATCH 1/9] fix: sort and total Search amounts by the sign the row displays --- .../Search/SearchSelectionFooter.tsx | 15 +++++++------ src/components/Search/selectionBuilders.ts | 1 + src/components/Search/types.ts | 5 ++++- .../DataCells/TotalCell.tsx | 10 +++------ src/libs/ReportUtils.ts | 12 ++++++++++ src/libs/SearchUIUtils.ts | 8 +++---- .../unit/Search/SearchSelectionFooterTest.tsx | 12 ++++++++++ tests/unit/Search/SearchUIUtilsTest.ts | 22 +++++++++++++++---- 8 files changed, 61 insertions(+), 24 deletions(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 0daa952154c3..a7b5f898fe2c 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -60,15 +60,16 @@ function getTransactionCount(transactionKeys: string[], transactions: SelectedTr }, 0); } -function getTransactionTotal(transactions: SelectedTransactionInfo[]): number { - return transactions.reduce((total, transaction) => total - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0); -} - // The live default-currency figure a row contributes to the footer total (also what the footer falls back to before a // conversion arrives). The footer stamps each conversion against this value and compares it on every render, so an // inline edit that moves it is detected and the cached conversion is fetched again. +// 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)); +} + +function getTransactionTotal(transactions: SelectedTransactionInfo[]): number { + return transactions.reduce((total, transaction) => total - getEntrySource(transaction), 0); } // Every selected row needs a fresh cached conversion for the target currency before the selected total can be shown @@ -482,7 +483,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { convertedAmount = convertedTransactions?.[transaction.transaction.transactionID]?.[selectedCurrency]; } } - return acc - (convertedAmount ?? transaction.groupAmount ?? -Math.abs(transaction.amount)); + return acc - (convertedAmount ?? getEntrySource(transaction)); }, 0); } @@ -500,7 +501,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { } else if (transactionID) { convertedAmount = convertedTransactions?.[transactionID]?.[selectedCurrency]; } - return total - (convertedAmount ?? transaction.groupAmount ?? -Math.abs(transaction.amount)); + return total - (convertedAmount ?? getEntrySource(transaction)); }, 0) : 0; return { diff --git a/src/components/Search/selectionBuilders.ts b/src/components/Search/selectionBuilders.ts index 2d214637f92b..947a28bc3e25 100644 --- a/src/components/Search/selectionBuilders.ts +++ b/src/components/Search/selectionBuilders.ts @@ -93,6 +93,7 @@ function mapTransactionItemToSelectedEntry({ reportID: item.reportID, policyID: item.policyID, amount: allowNegativeAmount ? amount : Math.abs(amount), + displayAmount: item.formattedTotal, groupAmount: item.groupAmount, currency: item.currency, isFromOneTransactionReport: isOneTransactionReport(item.report), diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 64ddbaedb3ed..11cb53c1fa14 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -69,9 +69,12 @@ type SelectedTransactionInfo = { /** The policyID tied to the report the transaction is reported on */ policyID: string | undefined; - /** The transaction amount */ + /** The transaction amount, in the raw stored sign convention */ amount: number; + /** The signed amount the row displays. Undefined for report rows that carry no transaction of their own. */ + displayAmount?: number; + /** The transaction currency */ currency: string; diff --git a/src/components/TransactionItemRow/DataCells/TotalCell.tsx b/src/components/TransactionItemRow/DataCells/TotalCell.tsx index f396e9641ad8..e40a5310e8ec 100644 --- a/src/components/TransactionItemRow/DataCells/TotalCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TotalCell.tsx @@ -12,13 +12,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {convertToBackendAmount, convertToFrontendAmountAsString, sanitizeCurrencyCode} from '@libs/CurrencyUtils'; import {formatToParts} from '@libs/NumberFormatUtils'; import {parseFloatAnyLocale, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {isGroupPolicy} from '@libs/PolicyUtils'; -import {isExpenseReport, isInvoiceReport, shouldEnableNegative} from '@libs/ReportUtils'; -import {getAmount as getTransactionAmount, getCurrency as getTransactionCurrency, isDeletedTransaction, isExpenseUnreported, isScanning} from '@libs/TransactionUtils'; +import {getTransactionDisplayAmount, isInvoiceReport, shouldEnableNegative} from '@libs/ReportUtils'; +import {getCurrency as getTransactionCurrency, isExpenseUnreported, isScanning} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {Policy, Report} from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import React, {useRef, useState} from 'react'; @@ -51,9 +49,7 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report, const effectiveReport = report ?? transactionItem.report; const effectivePolicy = policy ?? transactionItem.policy; - const isDeleted = isDeletedTransaction(transactionItem); - const isFromExpenseReport = (!isEmptyObject(effectiveReport) && isExpenseReport(effectiveReport)) || isGroupPolicy(effectivePolicy); - const amount = getTransactionAmount(transactionItem, isFromExpenseReport, transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID, isDeleted); + const amount = getTransactionDisplayAmount(transactionItem, effectiveReport, effectivePolicy); let amountToDisplay = convertToDisplayString(amount, currency); if (isScanning(transactionItem)) { amountToDisplay = translate('iou.receiptStatusTitle'); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 4f2512d2e0aa..c3341756f98d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -280,6 +280,7 @@ import { hasViolation, hasWarningTypeViolation, isManagedCardTransaction as isCardTransactionTransactionUtils, + isDeletedTransaction, isDemoTransaction, isDistanceRequest, isFetchingWaypointsFromServer, @@ -5028,6 +5029,16 @@ function getAvailableReportFields(report: OnyxEntry, policyReportFields: return fields.filter(Boolean) as PolicyReportField[]; } +/** + * getAmount() drops the sign unless the row is classified as an expense-report or tracked expense, so anything that + * renders or sorts an amount must classify it here or the two can disagree on a row's sign. + */ +function getTransactionDisplayAmount(transaction: OnyxInputOrEntry, report: OnyxInputOrEntry, policy: OnyxInputOrEntry): number { + const isFromExpenseReport = (!isEmptyObject(report) && isExpenseReport(report)) || isGroupPolicyPolicyUtils(policy); + const isFromTrackedExpense = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + return getTransactionAmount(transaction, isFromExpenseReport, isFromTrackedExpense, !!transaction && isDeletedTransaction(transaction)); +} + /** * Gets transaction created, amount, currency, comment, and waypoints (for distance expense) * into a flat object. Used for displaying transactions and sending them in API commands @@ -14400,6 +14411,7 @@ export { getAddExpenseDropdownOptions, getTaskAssigneeChatOnyxData, getTransactionDetails, + getTransactionDisplayAmount, getTransactionReportName, getDisplayedReportID, getTransactionsWithReceipts, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index d846183c0326..d23579e8833a 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -162,6 +162,7 @@ import { getReportOrDraftReport, getReportStatusTooltipTranslation, getReportStatusTranslation, + getTransactionDisplayAmount, hasHeldExpenses, hasInvoiceReports, hasOnlyNonReimbursableTransactions, @@ -215,7 +216,6 @@ import { getTag, getTaxAmount, getTaxName, - getAmount as getTransactionAmount, getCreated as getTransactionCreatedDate, getMerchant as getTransactionMerchant, getTransactionViolations, @@ -1233,8 +1233,6 @@ function getTransactionItemCommonFormattedProperties( report: OnyxTypes.Report | undefined, translate: LocalizedTranslate, ): Pick { - const isExpenseReport = report?.type === CONST.REPORT.TYPE.EXPENSE; - const formattedFrom = temporaryGetDisplayNameOrDefault({passedPersonalDetails: from, translate, formatPhoneNumber}); // Sometimes the search data personal detail for the 'to' account might not hold neither the display name nor the login @@ -1244,8 +1242,8 @@ function getTransactionItemCommonFormattedProperties( formattedTo = temporaryGetDisplayNameOrDefault({passedPersonalDetails: getPersonalDetailsForAccountID(to?.accountID), translate, formatPhoneNumber}); } - const isDeleted = isDeletedTransaction(transactionItem); - const formattedTotal = getTransactionAmount(transactionItem, isExpenseReport, false, isDeleted); + // formattedTotal is the Amount column's sort key, so it has to be signed exactly like the amount the row renders. + const formattedTotal = getTransactionDisplayAmount(transactionItem, report, policy); const date = transactionItem?.modifiedCreated ? transactionItem.modifiedCreated : transactionItem?.created; const merchant = getTransactionMerchant(transactionItem); const formattedMerchant = isInvalidMerchantValue(merchant) ? '' : merchant; diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index c356272eb005..d09f5001cb78 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -166,6 +166,18 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 10, total: 36000, currency: CONST.CURRENCY.USD})); }); + it('nets a selected credit against a selected expense instead of summing their magnitudes', async () => { + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: 10000}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -10000}, + }; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 0})); + }); + it("offers the user's live payment currency as the Reset target when there is no active workspace", async () => { // A fresh no-workspace account: the active policy is the personal policy, and the only selected expense // happens to be in a different currency (JPY) from the live payment currency (GBP). diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index ca03c4a6ca32..910e9ed79e70 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -1044,7 +1044,7 @@ const transactionsListItems = createMock([ }, formattedFrom: 'Admin', formattedTo: 'Approver', - formattedTotal: 1200, + formattedTotal: -1200, formattedMerchant: '', date: '2025-03-05', shouldShowMerchant: false, @@ -1114,7 +1114,7 @@ const transactionsListItems = createMock([ }, formattedFrom: 'Admin', formattedTo: 'Approver', - formattedTotal: 3200, + formattedTotal: -3200, formattedMerchant: '', date: '2025-03-05', shouldShowMerchant: false, @@ -1539,7 +1539,7 @@ const transactionReportGroupListItems = createMock { const item = sections.find((s) => s.transactionID === filterTestTxID); expect(item?.submitted).toBe(''); }); + + it('should keep the negative sign on formattedTotal for an unreported (tracked) credit', () => { + const data = makeFilterTestData({}, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: 5000}); + const [sections] = callGetTransactionsSections(data); + const item = sections.find((s) => s.transactionID === filterTestTxID); + expect(item?.formattedTotal).toBe(-5000); + }); + + it('should keep the negative sign on formattedTotal for a credit on a group policy whose report is not an expense report', () => { + const data = makeFilterTestData({type: CONST.REPORT.TYPE.IOU}, {amount: 5000}); + const [sections] = callGetTransactionsSections(data); + const item = sections.find((s) => s.transactionID === filterTestTxID); + expect(item?.formattedTotal).toBe(-5000); + }); }); describe('getReportSections filtering and edge cases', () => { From b6a587427c63425af62749fba6a585865a5c1992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Fri, 28 Aug 2026 13:54:49 +0200 Subject: [PATCH 2/9] test: assert Amount column sort order puts credits below positive expenses --- tests/unit/Search/SearchUIUtilsTest.ts | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 910e9ed79e70..2ab92894e6c5 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -6406,6 +6406,36 @@ describe('SearchUIUtils', () => { const item = sections.find((s) => s.transactionID === filterTestTxID); expect(item?.formattedTotal).toBe(-5000); }); + + // Amounts are stored with the opposite sign, so these three rows render +$80.00, -$40.00 and +$10.00. + // Ordering them by magnitude instead puts the credit between the two expenses. + function makeAmountSortData() { + const baseTransaction = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + return makeFilterTestData( + {type: CONST.REPORT.TYPE.IOU}, + {amount: -8000}, + { + [`${ONYXKEYS.COLLECTION.TRANSACTION}sort-credit`]: {...baseTransaction, transactionID: 'sort-credit', reportID: filterTestReportID, amount: 4000}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}sort-small`]: {...baseTransaction, transactionID: 'sort-small', reportID: filterTestReportID, amount: -1000}, + }, + ); + } + + function getAmountSortedIDs(sortOrder: SortOrder) { + const [sections] = callGetTransactionsSections(makeAmountSortData()); + const rows = sections.filter((section) => [filterTestTxID, 'sort-credit', 'sort-small'].includes(section.transactionID)); + expect(rows).toHaveLength(3); + const sorted = SearchUIUtils.getSortedSections(CONST.SEARCH.DATA_TYPES.EXPENSE, rows, localeCompare, translateLocal, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, sortOrder); + return sorted.map((item) => ('transactionID' in item ? item.transactionID : undefined)); + } + + it('should rank a credit below every positive expense when sorting the Amount column descending', () => { + expect(getAmountSortedIDs(CONST.SEARCH.SORT_ORDER.DESC)).toEqual([filterTestTxID, 'sort-small', 'sort-credit']); + }); + + it('should rank a credit above every positive expense when sorting the Amount column ascending', () => { + expect(getAmountSortedIDs(CONST.SEARCH.SORT_ORDER.ASC)).toEqual(['sort-credit', 'sort-small', filterTestTxID]); + }); }); describe('getReportSections filtering and edge cases', () => { From faed17f445cd89bedfe5bc38bb1e05bfe205d106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Fri, 28 Aug 2026 13:59:14 +0200 Subject: [PATCH 3/9] docs: describe what the amount helpers do rather than the bug they fix --- src/libs/ReportUtils.ts | 4 ++-- src/libs/SearchUIUtils.ts | 2 +- tests/unit/Search/SearchUIUtilsTest.ts | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index c3341756f98d..1cb04617d448 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5030,8 +5030,8 @@ function getAvailableReportFields(report: OnyxEntry, policyReportFields: } /** - * getAmount() drops the sign unless the row is classified as an expense-report or tracked expense, so anything that - * renders or sorts an amount must classify it here or the two can disagree on a row's sign. + * 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. */ function getTransactionDisplayAmount(transaction: OnyxInputOrEntry, report: OnyxInputOrEntry, policy: OnyxInputOrEntry): number { const isFromExpenseReport = (!isEmptyObject(report) && isExpenseReport(report)) || isGroupPolicyPolicyUtils(policy); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index d23579e8833a..1a7879639e70 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1242,7 +1242,7 @@ function getTransactionItemCommonFormattedProperties( formattedTo = temporaryGetDisplayNameOrDefault({passedPersonalDetails: getPersonalDetailsForAccountID(to?.accountID), translate, formatPhoneNumber}); } - // formattedTotal is the Amount column's sort key, so it has to be signed exactly like the amount the row renders. + // formattedTotal is the Amount column's sort key and holds the same signed value the row displays. const formattedTotal = getTransactionDisplayAmount(transactionItem, report, policy); const date = transactionItem?.modifiedCreated ? transactionItem.modifiedCreated : transactionItem?.created; const merchant = getTransactionMerchant(transactionItem); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 2ab92894e6c5..8ba23d199275 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -6408,7 +6408,6 @@ describe('SearchUIUtils', () => { }); // Amounts are stored with the opposite sign, so these three rows render +$80.00, -$40.00 and +$10.00. - // Ordering them by magnitude instead puts the credit between the two expenses. function makeAmountSortData() { const baseTransaction = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; return makeFilterTestData( From b23ed04a02a66ed4a92ef97d9a564bedd0de461f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Tue, 1 Sep 2026 08:56:56 +0200 Subject: [PATCH 4/9] docs: split the amount helper comment into two sentences --- src/libs/ReportUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 1cb04617d448..8d33b84b0d84 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5031,7 +5031,7 @@ function getAvailableReportFields(report: OnyxEntry, policyReportFields: /** * 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. + * policy, unreported, or deleted keeps its sign. Any other transaction returns its magnitude. */ function getTransactionDisplayAmount(transaction: OnyxInputOrEntry, report: OnyxInputOrEntry, policy: OnyxInputOrEntry): number { const isFromExpenseReport = (!isEmptyObject(report) && isExpenseReport(report)) || isGroupPolicyPolicyUtils(policy); From ce86590ea8f71b062ac2a255e6831af66e727a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Tue, 1 Sep 2026 14:29:12 +0200 Subject: [PATCH 5/9] refactor: extract isTransactionFromExpenseReport and require displayAmount --- src/components/Search/SearchSelectionFooter.tsx | 2 +- src/components/Search/selectionBuilders.ts | 2 ++ src/components/Search/types.ts | 4 ++-- src/libs/ReportUtils.ts | 12 ++++++++---- tests/unit/Search/SearchBulkActionsButtonTest.tsx | 1 + tests/unit/Search/SearchSelectionFooterTest.tsx | 14 ++++++++++++++ tests/unit/Search/SearchSelectionProviderTest.tsx | 1 + tests/unit/Search/useRowSelectionTest.tsx | 1 + tests/unit/Search/useSyncSelectedReportsTest.tsx | 1 + tests/unit/hooks/useSearchBulkActionsDeleteTest.ts | 1 + .../hooks/useSearchBulkActionsDownloadPDFTest.ts | 7 +++++++ .../useSearchBulkActionsDownloadReceiptsTest.ts | 1 + .../hooks/useSearchBulkActionsDuplicateTest.ts | 1 + tests/unit/hooks/useSearchBulkActionsExportTest.ts | 1 + tests/unit/hooks/useSearchBulkActionsPayTest.ts | 1 + tests/unit/hooks/useSearchBulkActionsTest.ts | 1 + tests/utils/ExpensifyCardStatementTestUtils.ts | 1 + 17 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index a7b5f898fe2c..0f376e13ada3 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -65,7 +65,7 @@ function getTransactionCount(transactionKeys: string[], transactions: SelectedTr // inline edit that moves it is detected and the cached conversion is fetched again. // 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 ?? -(entry.displayAmount ?? Math.abs(entry.amount)); + return entry.groupAmount ?? -entry.displayAmount; } function getTransactionTotal(transactions: SelectedTransactionInfo[]): number { diff --git a/src/components/Search/selectionBuilders.ts b/src/components/Search/selectionBuilders.ts index 947a28bc3e25..29a1674ca9b2 100644 --- a/src/components/Search/selectionBuilders.ts +++ b/src/components/Search/selectionBuilders.ts @@ -123,6 +123,7 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType reportID: item.reportID, policyID: item.policyID ?? CONST.POLICY.ID_FAKE, amount: item.totalDisplaySpend ?? item.total ?? 0, + displayAmount: item.totalDisplaySpend ?? 0, currency, ...(currency ? {groupCurrency: currency} : {}), }, @@ -147,6 +148,7 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType reportID: item.reportID, policyID: item.policyID ?? CONST.POLICY.ID_FAKE, amount: item.total ?? 0, + displayAmount: item.total ?? 0, currency, ...(currency ? {groupCurrency: currency} : {}), }, diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 11cb53c1fa14..e24019f35d38 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -72,8 +72,8 @@ type SelectedTransactionInfo = { /** The transaction amount, in the raw stored sign convention */ amount: number; - /** The signed amount the row displays. Undefined for report rows that carry no transaction of their own. */ - displayAmount?: number; + /** The signed amount the row displays */ + displayAmount: number; /** The transaction currency */ currency: string; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 8d33b84b0d84..7347d5b7c6cc 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5029,14 +5029,18 @@ function getAvailableReportFields(report: OnyxEntry, policyReportFields: return fields.filter(Boolean) as PolicyReportField[]; } +function isTransactionFromExpenseReport(report: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean { + return isExpenseReport(report) || isGroupPolicyPolicyUtils(policy); +} + /** * 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. + * policy, unreported, or deleted is stored with the opposite sign, so its stored amount is negated. Any other + * transaction returns its magnitude. */ function getTransactionDisplayAmount(transaction: OnyxInputOrEntry, report: OnyxInputOrEntry, policy: OnyxInputOrEntry): number { - const isFromExpenseReport = (!isEmptyObject(report) && isExpenseReport(report)) || isGroupPolicyPolicyUtils(policy); const isFromTrackedExpense = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; - return getTransactionAmount(transaction, isFromExpenseReport, isFromTrackedExpense, !!transaction && isDeletedTransaction(transaction)); + return getTransactionAmount(transaction, isTransactionFromExpenseReport(report, policy), isFromTrackedExpense, !!transaction && isDeletedTransaction(transaction)); } /** @@ -5058,7 +5062,7 @@ function getTransactionDetails( } const report = getReportOrDraftReport(transaction?.reportID, undefined, 'report' in transaction ? transaction.report : undefined); - const isFromExpenseReport = (!isEmptyObject(report) && isExpenseReport(report)) || isGroupPolicyPolicyUtils(policy); + const isFromExpenseReport = isTransactionFromExpenseReport(report, policy); return { created: getFormattedCreated(transaction, createdDateFormat, dateFnsLocale), diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index 5e876676a443..3a4010b0ada5 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -111,6 +111,7 @@ function makeTransaction(): SelectedTransactions[string] { reportID: 'report1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', }; } diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index d09f5001cb78..ed998a127164 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -104,6 +104,7 @@ function buildSelectedTransaction(currency: string, groupCurrency?: string, grou action: CONST.SEARCH.ACTION_TYPES.VIEW, policyID: undefined, amount: 100, + displayAmount: 100, currency, groupCurrency, groupAmount, @@ -178,6 +179,19 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 0})); }); + it('nets a selected credit report against a selected expense report', async () => { + // Report rows carry no transaction of their own, so their displayAmount comes from the report's own total. + mockSelectedTransactions.current = { + report1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: 10000}, + report2: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -4000}, + }; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 6000})); + }); + it("offers the user's live payment currency as the Reset target when there is no active workspace", async () => { // A fresh no-workspace account: the active policy is the personal policy, and the only selected expense // happens to be in a different currency (JPY) from the live payment currency (GBP). diff --git a/tests/unit/Search/SearchSelectionProviderTest.tsx b/tests/unit/Search/SearchSelectionProviderTest.tsx index dfad6d4729cf..b6594539c325 100644 --- a/tests/unit/Search/SearchSelectionProviderTest.tsx +++ b/tests/unit/Search/SearchSelectionProviderTest.tsx @@ -47,6 +47,7 @@ function buildSelected(...keys: string[]): SelectedTransactions { reportID: 'report_1', policyID: 'policy_1', amount: 100, + displayAmount: 100, currency: 'USD', }, ]), diff --git a/tests/unit/Search/useRowSelectionTest.tsx b/tests/unit/Search/useRowSelectionTest.tsx index a6acb762045b..9cf8ec54f2e0 100644 --- a/tests/unit/Search/useRowSelectionTest.tsx +++ b/tests/unit/Search/useRowSelectionTest.tsx @@ -44,6 +44,7 @@ function buildSelected(...keys: string[]): SelectedTransactions { reportID: 'report_1', policyID: 'policy_1', amount: 100, + displayAmount: 100, currency: 'USD', }; return acc; diff --git a/tests/unit/Search/useSyncSelectedReportsTest.tsx b/tests/unit/Search/useSyncSelectedReportsTest.tsx index 9c4b8f2a0650..275c23aacd67 100644 --- a/tests/unit/Search/useSyncSelectedReportsTest.tsx +++ b/tests/unit/Search/useSyncSelectedReportsTest.tsx @@ -54,6 +54,7 @@ function buildSelected(...keys: string[]): SelectedTransactions { reportID: 'report_1', policyID: 'policy_1', amount: 100, + displayAmount: 100, currency: 'USD', }; return acc; diff --git a/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts b/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts index 3d3d24735a6d..d33f602c93fd 100644 --- a/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsDeleteTest.ts @@ -292,6 +292,7 @@ function makeSelectedTransaction(overrides: Partial { reportID: '1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', isFromOneTransactionReport: false, }, @@ -299,6 +300,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', isFromOneTransactionReport: false, }, @@ -336,6 +338,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', isFromOneTransactionReport: false, }, @@ -378,6 +381,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', isFromOneTransactionReport: false, }, @@ -394,6 +398,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '2', policyID: 'policy1', amount: 200, + displayAmount: 200, currency: 'USD', isFromOneTransactionReport: false, }, @@ -429,6 +434,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '1', policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', isFromOneTransactionReport: false, }, @@ -445,6 +451,7 @@ describe('useSearchBulkActions - Download as PDF', () => { reportID: '2', policyID: 'policy1', amount: 200, + displayAmount: 200, currency: 'USD', isFromOneTransactionReport: false, }, diff --git a/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts b/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts index 393d2917d9c8..2a26eb50713c 100644 --- a/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts @@ -189,6 +189,7 @@ function makeSelectedTransaction(overrides: Partial Date: Mon, 7 Sep 2026 12:12:13 +0200 Subject: [PATCH 6/9] fix: sort Total per attendee by displayed sign and align the group conversion stamp --- .../Search/SearchSelectionFooter.tsx | 2 +- src/libs/SearchUIUtils.ts | 3 +- .../unit/Search/SearchSelectionFooterTest.tsx | 12 ++++++ tests/unit/Search/selectionBuildersTest.ts | 43 +++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 tests/unit/Search/selectionBuildersTest.ts diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 0f376e13ada3..deda0dcb2623 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -216,7 +216,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { } const group: unknown = data[key]; if (group && typeof group === 'object' && 'total' in group && typeof group.total === 'number') { - sources[key] = -Math.abs(group.total); + sources[key] = -group.total; } } return sources; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 8e36817ebc07..c0f1059e28ab 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -208,7 +208,6 @@ import { import StringUtils from './StringUtils'; import {getIOUPayerAndReceiver} from './TransactionPreviewUtils'; import { - getAmount, getAttendees, getCategory, getDescription, @@ -4416,7 +4415,7 @@ function getSortedTransactionData( if (!attendeesCount) { return 0; } - const totalAmount = getAmount(t, t.report?.type === CONST.REPORT.TYPE.EXPENSE); + const totalAmount = getTransactionDisplayAmount(t, t.report, t.policy); return totalAmount / attendeesCount; }; diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index ed998a127164..69b56b375906 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -192,6 +192,18 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 6000})); }); + it('adds back an excluded credit rather than subtracting it from the server total', async () => { + // The server total already counts the credit as -$100, so dropping it from the selection raises the total. + mockSelectedTransactions.current = {}; + mockExcludedTransactions.current = {transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -10000}}; + mockAreAllMatchingItemsSelected.current = true; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 171, total: 46000, currency: CONST.CURRENCY.USD})); + }); + it("offers the user's live payment currency as the Reset target when there is no active workspace", async () => { // A fresh no-workspace account: the active policy is the personal policy, and the only selected expense // happens to be in a different currency (JPY) from the live payment currency (GBP). diff --git a/tests/unit/Search/selectionBuildersTest.ts b/tests/unit/Search/selectionBuildersTest.ts new file mode 100644 index 000000000000..5cd1d66deada --- /dev/null +++ b/tests/unit/Search/selectionBuildersTest.ts @@ -0,0 +1,43 @@ +import type {TransactionGroupListItemType, TransactionReportGroupListItemType} from '@components/Search/SearchList/ListItem/types'; +import {mapEmptyReportToSelectedEntry} from '@components/Search/selectionBuilders'; + +import CONST from '@src/CONST'; + +import createMock from '../../utils/createMock'; + +describe('selectionBuilders', () => { + describe('mapEmptyReportToSelectedEntry', () => { + it('takes displayAmount from the report-signed total for a report row', () => { + // totalDisplaySpend is already negated for expense reports, so a credit report keeps its negative sign. + const item = createMock({ + keyForList: 'report1', + reportID: 'report1', + policyID: 'policy1', + currency: CONST.CURRENCY.USD, + groupedBy: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + transactions: [], + total: 10000, + totalDisplaySpend: -10000, + }); + + const [, entry] = mapEmptyReportToSelectedEntry(item); + + expect(entry.displayAmount).toBe(-10000); + }); + + it('takes displayAmount from the group total for a group row', () => { + const item = createMock({ + keyForList: `${CONST.SEARCH.GROUP_PREFIX}category1`, + reportID: undefined, + policyID: 'policy1', + currency: CONST.CURRENCY.USD, + transactions: [], + total: -4000, + }); + + const [, entry] = mapEmptyReportToSelectedEntry(item); + + expect(entry.displayAmount).toBe(-4000); + }); + }); +}); From 31ada8c54e8fd6d28ec603a931ee8695bc016dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Mon, 7 Sep 2026 13:02:45 +0200 Subject: [PATCH 7/9] revert: keep Total per attendee sorting on its rendered calculation --- src/libs/SearchUIUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index c0f1059e28ab..8e36817ebc07 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -208,6 +208,7 @@ import { import StringUtils from './StringUtils'; import {getIOUPayerAndReceiver} from './TransactionPreviewUtils'; import { + getAmount, getAttendees, getCategory, getDescription, @@ -4415,7 +4416,7 @@ function getSortedTransactionData( if (!attendeesCount) { return 0; } - const totalAmount = getTransactionDisplayAmount(t, t.report, t.policy); + const totalAmount = getAmount(t, t.report?.type === CONST.REPORT.TYPE.EXPENSE); return totalAmount / attendeesCount; }; From ec5eabc494d4c4854df9a121d291112d4baca7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Wed, 9 Sep 2026 08:50:06 +0200 Subject: [PATCH 8/9] docs: correct the amount field doc and the footer test name --- src/components/Search/types.ts | 2 +- tests/unit/Search/SearchSelectionFooterTest.tsx | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index ab3683b227a3..35dfbb292295 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -69,7 +69,7 @@ type SelectedTransactionInfo = { /** The policyID tied to the report the transaction is reported on */ policyID: string | undefined; - /** The transaction amount, in the raw stored sign convention */ + /** The transaction amount as a magnitude, used for bulk pay. Signed only on the reconcile path. */ amount: number; /** The signed amount the row displays */ diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 69b56b375906..fd178d0d8499 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -179,11 +179,10 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 0})); }); - it('nets a selected credit report against a selected expense report', async () => { - // Report rows carry no transaction of their own, so their displayAmount comes from the report's own total. + it('nets a selected credit against a selected expense when the amounts differ', async () => { mockSelectedTransactions.current = { - report1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: 10000}, - report2: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -4000}, + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: 10000}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -4000}, }; render(); From 0e90b49e92c2d2c4d7294ca282bd20f6e00b7819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Wed, 9 Sep 2026 16:14:04 +0200 Subject: [PATCH 9/9] test: add displayAmount to the SearchActions selection fixture --- tests/unit/SearchActionsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/SearchActionsTest.ts b/tests/unit/SearchActionsTest.ts index 3d3cc5897a42..e9a86febd5e0 100644 --- a/tests/unit/SearchActionsTest.ts +++ b/tests/unit/SearchActionsTest.ts @@ -494,6 +494,7 @@ describe('getPayOption', () => { reportID, policyID: 'policy1', amount: 100, + displayAmount: 100, currency: 'USD', isFromOneTransactionReport: false, };