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
26 changes: 15 additions & 11 deletions src/components/ReportActionItem/MoneyReportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,14 @@ function MoneyReportView({
// instead of waiting for the optimistic delete to be removed from Onyx.
// While offline the deleted expense is still rendered, so keep counting it to stay consistent with the visible transaction list.
const visibleTransactions = transactions.filter((transaction) => isOffline || !isTransactionPendingDelete(transaction));
const isSingleNonReimbursableExpense = isSingleTransactionReport(report, visibleTransactions) && visibleTransactions.at(0)?.reimbursable === false;
// The reimbursable/non-reimbursable rows duplicate the Total for a single non-reimbursable expense, so suppress only those rows.
// Billable and tax rows convey distinct information and must still show.
const shouldShowReimbursabilityBreakdown = !isSingleNonReimbursableExpense && !!nonReimbursableSpend;
const shouldShowBreakdown = shouldShowReimbursabilityBreakdown || !!billableTotal || (!!taxTotal && isTaxEnabled);
const isSingleExpenseReport = isSingleTransactionReport(report, visibleTransactions);
// For a one-expense report the Total/Billable/Tax rows just repeat the expense's own amount (shown on its Amount field,
// including the converted value), so hide the whole report-level breakdown block.
const shouldShowReimbursabilityRow = !!nonReimbursableSpend;
const shouldShowBillableRow = !!billableTotal;
const shouldShowTaxRow = !!taxTotal && isTaxEnabled;
const shouldShowBreakdown = !isSingleExpenseReport && (shouldShowReimbursabilityRow || shouldShowBillableRow || shouldShowTaxRow);
const shouldShowTotalRow = shouldShowTotal && !isSingleExpenseReport;
const formattedTotalAmount = convertToDisplayString(totalDisplaySpend, report?.currency);
const formattedOutOfPocketAmount = convertToDisplayString(reimbursableSpend, report?.currency);
const formattedCompanySpendAmount = convertToDisplayString(nonReimbursableSpend, report?.currency);
Expand Down Expand Up @@ -155,6 +158,7 @@ function MoneyReportView({
const shouldShowReportField =
!isClosedExpenseReportWithNoExpenses &&
(isGroupPolicyExpenseReport || isInvoiceReport) &&
!!policy?.areReportFieldsEnabled &&
(!isCombinedReport || !isOnlyTitleFieldEnabled) &&
!sortedPolicyReportFields.every(shouldHideSingleReportField);

Expand Down Expand Up @@ -231,7 +235,7 @@ function MoneyReportView({
</OfflineWithFeedback>
);
})}
{shouldShowTotal && (
{shouldShowTotalRow && (
<View style={[styles.flexRow, styles.pointerEventsNone, styles.containerWithSpaceBetween, styles.ph5, styles.pv2]}>
<View style={[styles.flex1, styles.justifyContentCenter]}>
<Text
Expand Down Expand Up @@ -271,10 +275,10 @@ function MoneyReportView({
{!!shouldShowBreakdown && (
<>
{[
{label: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, show: shouldShowReimbursabilityBreakdown},
{label: 'cardTransactions.companySpend', value: formattedCompanySpendAmount, show: shouldShowReimbursabilityBreakdown},
{label: 'common.billable', value: formattedBillableAmount, show: !!billableTotal},
{label: 'common.tax', value: formattedTaxAmount, show: !!taxTotal && isTaxEnabled},
{label: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, show: shouldShowReimbursabilityRow},
{label: 'cardTransactions.companySpend', value: formattedCompanySpendAmount, show: shouldShowReimbursabilityRow},
{label: 'common.billable', value: formattedBillableAmount, show: shouldShowBillableRow},
{label: 'common.tax', value: formattedTaxAmount, show: shouldShowTaxRow},
]
.filter(({show}) => show)
.map(({label, value}) => (
Expand Down Expand Up @@ -305,7 +309,7 @@ function MoneyReportView({
</>
)}
</View>
{(shouldShowReportField || shouldShowBreakdown || shouldShowTotal) && renderThreadDivider}
{(shouldShowReportField || shouldShowBreakdown || shouldShowTotalRow) && renderThreadDivider}
</>
);
}
Expand Down
10 changes: 9 additions & 1 deletion src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,8 @@ function MoneyRequestView({
updatedTransaction?.taxAmount !== undefined
? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), actualCurrency)
: convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency);
// Skip a zero converted tax (e.g. tax exempt) so we don't render a redundant "Converted 0.00".
const formattedConvertedTaxAmount = transaction?.convertedTaxAmount ? convertToDisplayString(Math.abs(transaction.convertedTaxAmount), moneyRequestReport?.currency) : '';

const taxRatesDescription = taxRates?.name;

Expand Down Expand Up @@ -631,6 +633,12 @@ function MoneyRequestView({
amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.nonReimbursable'))}`;
}

// Show the converted tax here since the redundant report-level Tax total is hidden for a single-expense report.
let taxAmountDescription = translate('iou.taxAmount');
if (shouldShowConvertedAmount && formattedConvertedTaxAmount) {
taxAmountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${formattedConvertedTaxAmount}`;
}

if (isFromMergeTransaction && !rateName) {
// Because we lack the necessary data in policy.customUnits to determine the rate in merge flow,
// We need to extract the rate from the merchant string
Expand Down Expand Up @@ -1240,7 +1248,7 @@ function MoneyRequestView({
<OfflineWithFeedback pendingAction={getPendingFieldAction('taxAmount')}>
<MenuItemWithTopDescription
title={taxAmountTitle}
description={translate('iou.taxAmount')}
description={taxAmountDescription}
numberOfLinesTitle={2}
interactive={canEditTaxFields}
shouldShowRightIcon={canEditTaxFields}
Expand Down
72 changes: 60 additions & 12 deletions tests/ui/MoneyReportViewTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ const buildExpenseReport = (overrides: Partial<OnyxTypes.Report> = {}): OnyxType
...overrides,
});

const buildTaxPolicy = (): OnyxTypes.Policy => ({
...LHNTestUtils.getFakePolicy(policyID, 'Policy'),
outputCurrency: CONST.CURRENCY.USD,
tax: {trackingEnabled: true},
});

const buildTransaction = (id: string, amount: number, reimbursable: boolean | undefined, billable = false, taxAmount = 0): OnyxTypes.Transaction =>
({
transactionID: id,
Expand Down Expand Up @@ -114,6 +120,30 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => {
});
});

it('hides the Total row for a single 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('common.total')).not.toBeOnTheScreen();
});
});

it('shows the Total row when multiple expenses exist', async () => {
const transactions = [buildTransaction('t1', 5000, false), buildTransaction('t2', 3000, false)];
await seedReportAndTransactions(transactions, {nonReimbursableTotal: -8000, unheldNonReimbursableTotal: -8000, unheldTotal: -8000, total: -8000});

renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -8000, unheldNonReimbursableTotal: -8000, unheldTotal: -8000, total: -8000}));
await waitForBatchedUpdatesWithAct();

await waitFor(() => {
expect(screen.getByText('common.total')).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});
Expand All @@ -127,42 +157,60 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => {
});
});

it('shows the billable row but still hides the redundant rows for a single non-reimbursable billable expense', async () => {
it('hides every report-level row for a single billable expense (the amount lives on the expense field)', async () => {
const transactions = [buildTransaction('t1', 5000, false, true)];
await seedReportAndTransactions(transactions, {nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000});

renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000}));
await waitForBatchedUpdatesWithAct();

await waitFor(() => {
expect(screen.getByText('common.billable')).toBeOnTheScreen();
expect(screen.queryByText('common.billable')).not.toBeOnTheScreen();
expect(screen.queryByText('cardTransactions.outOfPocket')).not.toBeOnTheScreen();
expect(screen.queryByText('cardTransactions.companySpend')).not.toBeOnTheScreen();
});
});

it('shows the tax row but still hides the redundant rows for a single non-reimbursable taxed expense', async () => {
const policy = {
id: policyID,
type: CONST.POLICY.TYPE.TEAM,
role: CONST.POLICY.ROLE.ADMIN,
name: 'Policy',
outputCurrency: CONST.CURRENCY.USD,
tax: {trackingEnabled: true},
} as OnyxTypes.Policy;
it('shows the billable row when multiple expenses exist', async () => {
const transactions = [buildTransaction('t1', 5000, false, true), buildTransaction('t2', 3000, false, true)];
await seedReportAndTransactions(transactions, {nonReimbursableTotal: -8000, unheldNonReimbursableTotal: -8000});

renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -8000, unheldNonReimbursableTotal: -8000}));
await waitForBatchedUpdatesWithAct();

await waitFor(() => {
expect(screen.getByText('common.billable')).toBeOnTheScreen();
});
});

it('hides the report-level tax row for a single taxed expense (the converted tax is shown on the expense field instead)', async () => {
const policy = buildTaxPolicy();
const transactions = [buildTransaction('t1', 5000, false, false, 500)];
await seedReportAndTransactions(transactions, {nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000});

renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000}), policy);
await waitForBatchedUpdatesWithAct();

await waitFor(() => {
expect(screen.getByText('common.tax')).toBeOnTheScreen();
expect(screen.queryByText('common.tax')).not.toBeOnTheScreen();
expect(screen.queryByText('cardTransactions.outOfPocket')).not.toBeOnTheScreen();
expect(screen.queryByText('cardTransactions.companySpend')).not.toBeOnTheScreen();
});
});

it('shows the report-level tax row when multiple taxed expenses exist', async () => {
const policy = buildTaxPolicy();
const transactions = [buildTransaction('t1', 5000, false, false, 500), buildTransaction('t2', 3000, false, false, 300)];
await seedReportAndTransactions(transactions, {nonReimbursableTotal: -8000, unheldNonReimbursableTotal: -8000});

renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -8000, unheldNonReimbursableTotal: -8000}), policy);
await waitForBatchedUpdatesWithAct();

await waitFor(() => {
expect(screen.getByText('common.tax')).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});
Expand Down
98 changes: 98 additions & 0 deletions tests/ui/MoneyRequestViewTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ jest.mock('@components/MenuItem', () => {

jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}})));

// The real `convertToDisplayString` needs a seeded currency list/locale and otherwise returns '',
// which would hide the "Converted" suffix. Return a deterministic non-empty string instead.
jest.mock('@hooks/useCurrencyList', () => ({
useCurrencyListActions: jest.fn(() => ({
convertToDisplayString: jest.fn((amountInCents = 0, currency = '') => `${currency}${amountInCents}`),
getCurrencySymbol: jest.fn((currency = '') => `${currency}`),
getCurrencyDecimals: jest.fn(() => 2),
convertToDisplayStringWithoutCurrency: jest.fn((amountInCents = 0) => `${amountInCents}`),
})),
useCurrencyListState: jest.fn(() => ({})),
}));

TestHelper.setupGlobalFetchMock();

const currentUserAccountID = 10;
Expand Down Expand Up @@ -400,4 +412,90 @@ describe('MoneyRequestView edit fields', () => {
expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen();
});
});

it('appends "Converted" to the Tax amount description for a foreign-currency taxed expense', async () => {
const threadReport = {
...LHNTestUtils.getFakeReport(),
parentReportID: expenseReportID,
parentReportActionID,
};

await setupTestData();
await act(async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, {currency: CONST.CURRENCY.USD});
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
currency: 'UZS',
convertedAmount: 27410265,
taxCode: 'TAX_10',
taxAmount: 1110,
convertedTaxAmount: 1332281,
});
});
await waitForBatchedUpdatesWithAct();

renderMoneyRequestView(threadReport, {tax: {trackingEnabled: true}});
await waitForBatchedUpdatesWithAct();

await waitFor(() => {
expect(screen.getByTestId(/^menu-item-iou\.taxAmount.*common\.converted/i)).toBeOnTheScreen();
});
});

it('does NOT append "Converted" to the Tax amount description when the converted tax is zero (tax exempt)', async () => {
const threadReport = {
...LHNTestUtils.getFakeReport(),
parentReportID: expenseReportID,
parentReportActionID,
};

await setupTestData();
await act(async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, {currency: CONST.CURRENCY.USD});
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
currency: 'UZS',
convertedAmount: 27410265,
taxCode: 'TAX_EXEMPT',
taxAmount: 0,
convertedTaxAmount: 0,
});
});
await waitForBatchedUpdatesWithAct();

renderMoneyRequestView(threadReport, {tax: {trackingEnabled: true}});
await waitForBatchedUpdatesWithAct();

await waitFor(() => {
expect(screen.getByTestId('menu-item-iou.taxAmount')).toBeOnTheScreen();
expect(screen.queryByTestId(/^menu-item-iou\.taxAmount.*common\.converted/i)).not.toBeOnTheScreen();
});
});

it('does NOT append "Converted" to the Tax amount description when the expense currency matches the report currency', async () => {
const threadReport = {
...LHNTestUtils.getFakeReport(),
parentReportID: expenseReportID,
parentReportActionID,
};

await setupTestData();
await act(async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, {currency: CONST.CURRENCY.USD});
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
currency: CONST.CURRENCY.USD,
convertedAmount: 27410265,
taxCode: 'TAX_10',
taxAmount: 1110,
convertedTaxAmount: 1332281,
});
});
await waitForBatchedUpdatesWithAct();

renderMoneyRequestView(threadReport, {tax: {trackingEnabled: true}});
await waitForBatchedUpdatesWithAct();

await waitFor(() => {
expect(screen.getByTestId('menu-item-iou.taxAmount')).toBeOnTheScreen();
expect(screen.queryByTestId(/^menu-item-iou\.taxAmount.*common\.converted/i)).not.toBeOnTheScreen();
});
});
});
Loading