Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/components/ReportActionItem/MoneyReportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -105,7 +106,8 @@ function MoneyReportView({
const {billableTotal, taxTotal} = getBillableAndTaxTotal(report, transactions);

const isTaxEnabled = isPolicyTaxEnabled(policy);
const shouldShowBreakdown = nonReimbursableSpend || !!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);
Expand Down
7 changes: 7 additions & 0 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -561,6 +564,10 @@ function MoneyRequestView({
if (shouldShowConvertedAmount) {
amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${convertToDisplayString(transactionConvertedAmount, moneyRequestReport?.currency)}`;
}
const isCurrentTransactionReimbursable = updatedTransaction?.reimbursable ?? !!transactionReimbursable;
if (!isCurrentTransactionReimbursable && isSingleTransactionReport(moneyRequestReport, parentReportTransactions)) {
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,
Expand Down
140 changes: 140 additions & 0 deletions tests/ui/MoneyReportViewTest.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, React.ComponentType<{testID?: string}>>>('react-native');
return () => <RN.View testID="animated-bg" />;
});

TestHelper.setupGlobalFetchMock();

const policyID = 'policy_mrv_breakdown';
const reportID = 'report_mrv_breakdown';

const buildExpenseReport = (overrides: Partial<OnyxTypes.Report> = {}): 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<OnyxTypes.Report> = {}) => {
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(
<ComposeProviders components={[OnyxListItemProvider]}>
<MoneyReportView
report={report}
policy={undefined}
shouldHideThreadDividerLine={false}
shouldShowAnimatedBackground={false}
/>
</ComposeProviders>,
);

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();
});
});
});
53 changes: 53 additions & 0 deletions tests/ui/MoneyRequestViewTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,57 @@ 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 in a single-expense report', 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();
});
});

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();
});
});
});
Loading