diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 1e8b277803c6..33af22a54d63 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -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); @@ -155,6 +158,7 @@ function MoneyReportView({ const shouldShowReportField = !isClosedExpenseReportWithNoExpenses && (isGroupPolicyExpenseReport || isInvoiceReport) && + !!policy?.areReportFieldsEnabled && (!isCombinedReport || !isOnlyTitleFieldEnabled) && !sortedPolicyReportFields.every(shouldHideSingleReportField); @@ -231,7 +235,7 @@ function MoneyReportView({ ); })} - {shouldShowTotal && ( + {shouldShowTotalRow && ( {[ - {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}) => ( @@ -305,7 +309,7 @@ function MoneyReportView({ )} - {(shouldShowReportField || shouldShowBreakdown || shouldShowTotal) && renderThreadDivider} + {(shouldShowReportField || shouldShowBreakdown || shouldShowTotalRow) && renderThreadDivider} ); } diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 2777962ff331..bc785fb5e4d5 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -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; @@ -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 @@ -1240,7 +1248,7 @@ function MoneyRequestView({ = {}): 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, @@ -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}); @@ -127,7 +157,7 @@ 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}); @@ -135,21 +165,26 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { 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}); @@ -157,12 +192,25 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { 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}); diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index cdd66c5ed557..9d8991a0eb56 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -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; @@ -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(); + }); + }); });