From ef86b025e0a0f2da8a29a0cd86c14e3d71cf9758 Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 13 May 2026 11:54:15 +0500 Subject: [PATCH 1/3] =?UTF-8?q?Hide=20redundant=20Reimbursable=20rows;=20s?= =?UTF-8?q?how=20"=C2=B7=20Non-reimbursable"=20on=20Amount=20+=20add=20rel?= =?UTF-8?q?ated=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReportActionItem/MoneyReportView.tsx | 6 +++--- .../ReportActionItem/MoneyRequestView.tsx | 4 ++++ tests/ui/MoneyRequestViewTest.tsx | 21 +++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 23a815f49bc6..b17d7b6e0ad5 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -105,7 +105,7 @@ function MoneyReportView({ const {billableTotal, taxTotal} = getBillableAndTaxTotal(report, transactions); const isTaxEnabled = isPolicyTaxEnabled(policy); - const shouldShowBreakdown = nonReimbursableSpend || !!billableTotal || (!!taxTotal && isTaxEnabled); + const shouldShowBreakdown = (!!reimbursableSpend && !!nonReimbursableSpend) || !!billableTotal || (!!taxTotal && isTaxEnabled); const formattedTotalAmount = convertToDisplayString(totalDisplaySpend, report?.currency); const formattedOutOfPocketAmount = convertToDisplayString(reimbursableSpend, report?.currency); const formattedCompanySpendAmount = convertToDisplayString(nonReimbursableSpend, report?.currency); @@ -259,8 +259,8 @@ function MoneyReportView({ {!!shouldShowBreakdown && ( <> {[ - {label: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, show: !!nonReimbursableSpend}, - {label: 'cardTransactions.companySpend', value: formattedCompanySpendAmount, show: !!nonReimbursableSpend}, + {label: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, show: !!reimbursableSpend && !!nonReimbursableSpend}, + {label: 'cardTransactions.companySpend', value: formattedCompanySpendAmount, show: !!reimbursableSpend && !!nonReimbursableSpend}, {label: 'common.billable', value: formattedBillableAmount, show: !!billableTotal}, {label: 'common.tax', value: formattedTaxAmount, show: !!taxTotal && isTaxEnabled}, ] diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 03cba0a7d862..10e78f4b6d6f 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -561,6 +561,10 @@ function MoneyRequestView({ if (shouldShowConvertedAmount) { amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${convertToDisplayString(transactionConvertedAmount, moneyRequestReport?.currency)}`; } + const isCurrentTransactionReimbursable = updatedTransaction?.reimbursable ?? !!transactionReimbursable; + if (!isCurrentTransactionReimbursable) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.nonReimbursable'))}`; + } if (isFromMergeTransaction && !rateName) { // Because we lack the necessary data in policy.customUnits to determine the rate in merge flow, diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 74c06d8cb8e7..befe10f0f961 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -281,4 +281,25 @@ describe('MoneyRequestView edit fields', () => { expect(screen.getByTestId('menu-item-common.merchant')).toHaveTextContent('readonly'); }); }); + + it('should append "Non-reimbursable" to the Amount description when the transaction is non-reimbursable', async () => { + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + + await setupTestData(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {reimbursable: false}); + }); + await waitForBatchedUpdatesWithAct(); + + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); + }); + }); }); From 37836405a5abde38ac4b89cb9bc4a505a78ceb30 Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 13 May 2026 12:32:19 +0500 Subject: [PATCH 2/3] undefined is counted as reimbursable --- .../ReportActionItem/MoneyReportView.tsx | 8 +- tests/ui/MoneyReportViewTest.tsx | 140 ++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 tests/ui/MoneyReportViewTest.tsx diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index b17d7b6e0ad5..9902994759ea 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -105,7 +105,9 @@ function MoneyReportView({ const {billableTotal, taxTotal} = getBillableAndTaxTotal(report, transactions); const isTaxEnabled = isPolicyTaxEnabled(policy); - const shouldShowBreakdown = (!!reimbursableSpend && !!nonReimbursableSpend) || !!billableTotal || (!!taxTotal && isTaxEnabled); + // Use transaction presence (not summed amounts) so reports with reimbursable expenses + credits that net to 0 still show the breakdown. + const hasMixedReimbursability = transactions.some((t) => t.reimbursable !== false) && transactions.some((t) => t.reimbursable === false); + const shouldShowBreakdown = hasMixedReimbursability || !!billableTotal || (!!taxTotal && isTaxEnabled); const formattedTotalAmount = convertToDisplayString(totalDisplaySpend, report?.currency); const formattedOutOfPocketAmount = convertToDisplayString(reimbursableSpend, report?.currency); const formattedCompanySpendAmount = convertToDisplayString(nonReimbursableSpend, report?.currency); @@ -259,8 +261,8 @@ function MoneyReportView({ {!!shouldShowBreakdown && ( <> {[ - {label: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, show: !!reimbursableSpend && !!nonReimbursableSpend}, - {label: 'cardTransactions.companySpend', value: formattedCompanySpendAmount, show: !!reimbursableSpend && !!nonReimbursableSpend}, + {label: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, show: hasMixedReimbursability}, + {label: 'cardTransactions.companySpend', value: formattedCompanySpendAmount, show: hasMixedReimbursability}, {label: 'common.billable', value: formattedBillableAmount, show: !!billableTotal}, {label: 'common.tax', value: formattedTaxAmount, show: !!taxTotal && isTaxEnabled}, ] diff --git a/tests/ui/MoneyReportViewTest.tsx b/tests/ui/MoneyReportViewTest.tsx new file mode 100644 index 000000000000..42a746359934 --- /dev/null +++ b/tests/ui/MoneyReportViewTest.tsx @@ -0,0 +1,140 @@ +import type * as NativeNavigation from '@react-navigation/native'; +import {act, render, screen, waitFor} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import MoneyReportView from '@components/ReportActionItem/MoneyReportView'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn((key: string) => key), + numberFormat: jest.fn((num: number) => num.toString()), + toLocaleDigit: jest.fn((digit: string) => digit), + })), +); + +jest.mock('@react-navigation/native', () => ({ + ...((): typeof NativeNavigation => jest.requireActual('@react-navigation/native'))(), + useNavigation: jest.fn(() => ({navigate: jest.fn(), addListener: jest.fn(() => jest.fn())})), + useIsFocused: jest.fn(() => true), + useRoute: jest.fn(() => ({key: '', name: '', params: {reportID: '1'}})), +})); + +jest.mock('@pages/inbox/report/AnimatedEmptyStateBackground', () => { + const RN = jest.requireActual>>('react-native'); + return () => ; +}); + +TestHelper.setupGlobalFetchMock(); + +const policyID = 'policy_mrv_breakdown'; +const reportID = 'report_mrv_breakdown'; + +const buildExpenseReport = (overrides: Partial = {}): OnyxTypes.Report => ({ + ...LHNTestUtils.getFakeReport([1, 2]), + reportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + currency: CONST.CURRENCY.USD, + total: 0, + ...overrides, +}); + +const buildTransaction = (id: string, amount: number, reimbursable: boolean | undefined): OnyxTypes.Transaction => + ({ + transactionID: id, + reportID, + amount, + currency: CONST.CURRENCY.USD, + created: '2025-06-01', + merchant: 'Merchant', + comment: {}, + reimbursable, + }) as OnyxTypes.Transaction; + +const seedReportAndTransactions = async (transactions: OnyxTypes.Transaction[], reportOverrides: Partial = {}) => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + name: 'Policy', + outputCurrency: CONST.CURRENCY.USD, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, buildExpenseReport(reportOverrides)); + for (const transaction of transactions) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + } + }); + await waitForBatchedUpdatesWithAct(); +}; + +const renderMoneyReportView = (report: OnyxTypes.Report) => + render( + + + , + ); + +describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('hides both breakdown rows for a single non-reimbursable expense', async () => { + const transactions = [buildTransaction('t1', 5000, false)]; + await seedReportAndTransactions(transactions, {nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000}); + + renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000})); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.queryByText('cardTransactions.outOfPocket')).not.toBeOnTheScreen(); + expect(screen.queryByText('cardTransactions.companySpend')).not.toBeOnTheScreen(); + }); + }); + + it('shows both breakdown rows when reimbursable and non-reimbursable transactions coexist', async () => { + const transactions = [buildTransaction('t1', 5000, true), buildTransaction('t2', 3000, false)]; + await seedReportAndTransactions(transactions, {nonReimbursableTotal: -3000, unheldNonReimbursableTotal: -3000, unheldTotal: -8000, total: -8000}); + + renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -3000, unheldNonReimbursableTotal: -3000, unheldTotal: -8000, total: -8000})); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText('cardTransactions.outOfPocket')).toBeOnTheScreen(); + expect(screen.getByText('cardTransactions.companySpend')).toBeOnTheScreen(); + }); + }); + + it('still shows both breakdown rows when reimbursable expenses + credits net to zero alongside non-reimbursable spend', async () => { + const transactions = [buildTransaction('t1', 5000, true), buildTransaction('t2', -5000, true), buildTransaction('t3', 3000, false)]; + await seedReportAndTransactions(transactions, {nonReimbursableTotal: -3000, unheldNonReimbursableTotal: -3000, unheldTotal: -3000, total: -3000}); + + renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -3000, unheldNonReimbursableTotal: -3000, unheldTotal: -3000, total: -3000})); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText('cardTransactions.outOfPocket')).toBeOnTheScreen(); + expect(screen.getByText('cardTransactions.companySpend')).toBeOnTheScreen(); + }); + }); +}); From c986dcdb38782ed0ad28056277ae018798256e13 Mon Sep 17 00:00:00 2001 From: Mukher Date: Fri, 15 May 2026 17:43:30 +0500 Subject: [PATCH 3/3] have non-imbursable text logic only single expense report --- .../ReportActionItem/MoneyReportView.tsx | 10 +++--- .../ReportActionItem/MoneyRequestView.tsx | 5 ++- tests/ui/MoneyRequestViewTest.tsx | 34 ++++++++++++++++++- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 9902994759ea..bdc625961cf1 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -20,6 +20,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {resolveReportFieldValue} from '@libs/Formula'; +import {isSingleTransactionReport} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isPolicyTaxEnabled} from '@libs/PolicyUtils'; import { @@ -105,9 +106,8 @@ function MoneyReportView({ const {billableTotal, taxTotal} = getBillableAndTaxTotal(report, transactions); const isTaxEnabled = isPolicyTaxEnabled(policy); - // Use transaction presence (not summed amounts) so reports with reimbursable expenses + credits that net to 0 still show the breakdown. - const hasMixedReimbursability = transactions.some((t) => t.reimbursable !== false) && transactions.some((t) => t.reimbursable === false); - const shouldShowBreakdown = hasMixedReimbursability || !!billableTotal || (!!taxTotal && isTaxEnabled); + const isSingleNonReimbursableExpense = isSingleTransactionReport(report, transactions) && transactions.at(0)?.reimbursable === false; + const shouldShowBreakdown = !isSingleNonReimbursableExpense && (!!nonReimbursableSpend || !!billableTotal || (!!taxTotal && isTaxEnabled)); const formattedTotalAmount = convertToDisplayString(totalDisplaySpend, report?.currency); const formattedOutOfPocketAmount = convertToDisplayString(reimbursableSpend, report?.currency); const formattedCompanySpendAmount = convertToDisplayString(nonReimbursableSpend, report?.currency); @@ -261,8 +261,8 @@ function MoneyReportView({ {!!shouldShowBreakdown && ( <> {[ - {label: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, show: hasMixedReimbursability}, - {label: 'cardTransactions.companySpend', value: formattedCompanySpendAmount, show: hasMixedReimbursability}, + {label: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, show: !!nonReimbursableSpend}, + {label: 'cardTransactions.companySpend', value: formattedCompanySpendAmount, show: !!nonReimbursableSpend}, {label: 'common.billable', value: formattedBillableAmount, show: !!billableTotal}, {label: 'common.tax', value: formattedTaxAmount, show: !!taxTotal && isTaxEnabled}, ] diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 10e78f4b6d6f..aa35e0525621 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -32,6 +32,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportTransactions from '@hooks/useReportTransactions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -47,6 +48,7 @@ import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getRateFromMerchant} from '@libs/MergeTransactionUtils'; +import {isSingleTransactionReport} from '@libs/MoneyRequestReportUtils'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import { @@ -244,6 +246,7 @@ function MoneyRequestView({ const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const isP2PDistanceRequest = isCustomUnitRateIDForP2P(transaction); const moneyRequestReport = parentReport; + const parentReportTransactions = useReportTransactions(moneyRequestReport?.reportID); const isApproved = isReportApproved({report: moneyRequestReport}); const isInvoice = isInvoiceReport(moneyRequestReport); const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(transactionThreadReport, moneyRequestReport, parentReportAction); @@ -562,7 +565,7 @@ function MoneyRequestView({ amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${convertToDisplayString(transactionConvertedAmount, moneyRequestReport?.currency)}`; } const isCurrentTransactionReimbursable = updatedTransaction?.reimbursable ?? !!transactionReimbursable; - if (!isCurrentTransactionReimbursable) { + if (!isCurrentTransactionReimbursable && isSingleTransactionReport(moneyRequestReport, parentReportTransactions)) { amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.nonReimbursable'))}`; } diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index befe10f0f961..6816b904dccb 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -282,7 +282,7 @@ describe('MoneyRequestView edit fields', () => { }); }); - it('should append "Non-reimbursable" to the Amount description when the transaction is non-reimbursable', async () => { + it('should append "Non-reimbursable" to the Amount description when the transaction is non-reimbursable in a single-expense report', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, @@ -302,4 +302,36 @@ describe('MoneyRequestView edit fields', () => { expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); }); }); + + it('should NOT append "Non-reimbursable" to the Amount description when the parent report has multiple expenses', async () => { + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + + await setupTestData(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {reimbursable: false}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}_sibling`, { + transactionID: `${transactionID}_sibling`, + reportID: expenseReportID, + amount: 2500, + currency: CONST.CURRENCY.USD, + created: '2025-06-02', + merchant: 'Sibling', + comment: {}, + reimbursable: true, + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId(/^menu-item-iou\.amount/)).toBeOnTheScreen(); + expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); + }); + }); });