From 03298a0bded2d0a8ffd9d3071a40b2bd662f6b37 Mon Sep 17 00:00:00 2001 From: Mukher Date: Fri, 29 May 2026 10:08:10 +0500 Subject: [PATCH 1/9] Fix stray thread divider above single non-reimbursable expense --- .../ReportActionItem/MoneyReportView.tsx | 2 + tests/ui/MoneyReportViewTest.tsx | 61 ++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index b3408917b50a..dae7060edc57 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -152,9 +152,11 @@ function MoneyReportView({ const isPaidGroupPolicyExpenseReport = isPaidGroupPolicyExpenseReportUtils(report); const isInvoiceReport = isInvoiceReportUtils(report); + // Must match the report-field render guard below, otherwise the divider can show with no field above it. const shouldShowReportField = !isClosedExpenseReportWithNoExpenses && (isPaidGroupPolicyExpenseReport || isInvoiceReport) && + !!policy?.areReportFieldsEnabled && (!isCombinedReport || !isOnlyTitleFieldEnabled) && !sortedPolicyReportFields.every(shouldHideSingleReportField); diff --git a/tests/ui/MoneyReportViewTest.tsx b/tests/ui/MoneyReportViewTest.tsx index 4dddb883e3a7..8472dcf5ac1d 100644 --- a/tests/ui/MoneyReportViewTest.tsx +++ b/tests/ui/MoneyReportViewTest.tsx @@ -78,7 +78,7 @@ const seedReportAndTransactions = async (transactions: OnyxTypes.Transaction[], await waitForBatchedUpdatesWithAct(); }; -const renderMoneyReportView = (report: OnyxTypes.Report, policy: OnyxTypes.Policy | undefined = undefined) => +const renderMoneyReportView = (report: OnyxTypes.Report, policy: OnyxTypes.Policy | undefined = undefined, extraProps: Partial> = {}) => render( , ); +// The divider has no testID, so find it by its `reportHorizontalRule` style in the tree. +const hasThreadDivider = (node: unknown): boolean => { + if (Array.isArray(node)) { + return node.some(hasThreadDivider); + } + if (!node || typeof node !== 'object') { + return false; + } + const {props, children} = node as {props?: {style?: unknown}; children?: unknown}; + const styleStr = JSON.stringify(props?.style ?? ''); + if (styleStr.includes('borderColor') && styleStr.includes('borderBottomWidth')) { + return true; + } + return hasThreadDivider(children); +}; + describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); @@ -210,4 +227,46 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { expect(screen.getByText('cardTransactions.companySpend')).toBeOnTheScreen(); }); }); + + it('does not render a stray thread divider for a single non-reimbursable expense when the policy has report fields but report fields are disabled', async () => { + // Report field exists but is disabled, so no field row renders — and neither should the divider. + const reportField = { + fieldID: 'field_text_1', + name: 'Custom Field', + type: 'text', + target: CONST.REPORT.TYPE.EXPENSE, + value: 'Some value', + defaultValue: '', + orderWeight: 1, + disabledOptions: [], + values: [], + deletable: false, + } as unknown as OnyxTypes.PolicyReportField; + const policy = { + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + name: 'Policy', + outputCurrency: CONST.CURRENCY.USD, + areReportFieldsEnabled: false, + // eslint-disable-next-line @typescript-eslint/naming-convention + fieldList: {expensify_field_text_1: reportField}, + } as unknown as OnyxTypes.Policy; + + const transactions = [buildTransaction('t1', 5000, false)]; + await seedReportAndTransactions(transactions, {nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000}); + + // Combined report with Total hidden, as `MoneyReportContentCreated` renders a single-expense thread. + const {toJSON} = renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000}), policy, { + isCombinedReport: true, + shouldShowTotal: false, + }); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.queryByText('cardTransactions.outOfPocket')).not.toBeOnTheScreen(); + expect(screen.queryByText('cardTransactions.companySpend')).not.toBeOnTheScreen(); + expect(hasThreadDivider(toJSON())).toBe(false); + }); + }); }); From 39da57df8d8e6f7e46027154fdbe6f3a4207bc72 Mon Sep 17 00:00:00 2001 From: Mukher Date: Fri, 29 May 2026 10:15:35 +0500 Subject: [PATCH 2/9] removed unnecessary comments --- src/components/ReportActionItem/MoneyReportView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index dae7060edc57..87e9fb8e8b31 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -152,7 +152,6 @@ function MoneyReportView({ const isPaidGroupPolicyExpenseReport = isPaidGroupPolicyExpenseReportUtils(report); const isInvoiceReport = isInvoiceReportUtils(report); - // Must match the report-field render guard below, otherwise the divider can show with no field above it. const shouldShowReportField = !isClosedExpenseReportWithNoExpenses && (isPaidGroupPolicyExpenseReport || isInvoiceReport) && From 737665fd75df6bc40cd7ed84e8bb18cfddf3cc84 Mon Sep 17 00:00:00 2001 From: Mukher Date: Fri, 29 May 2026 10:19:49 +0500 Subject: [PATCH 3/9] added unnecessary comments to eslint --- tests/ui/MoneyReportViewTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/MoneyReportViewTest.tsx b/tests/ui/MoneyReportViewTest.tsx index 8472dcf5ac1d..d70d5a0331be 100644 --- a/tests/ui/MoneyReportViewTest.tsx +++ b/tests/ui/MoneyReportViewTest.tsx @@ -249,7 +249,7 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { name: 'Policy', outputCurrency: CONST.CURRENCY.USD, areReportFieldsEnabled: false, - // eslint-disable-next-line @typescript-eslint/naming-convention + // eslint-disable-next-line @typescript-eslint/naming-convention -- the field key uses snake_case to match the backend API format fieldList: {expensify_field_text_1: reportField}, } as unknown as OnyxTypes.Policy; From f7bdd0f62a1d7213e14d66d9969f95b52a009f23 Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 2 Jun 2026 14:21:52 +0500 Subject: [PATCH 4/9] moved tax total from top to related row --- .../ReportActionItem/MoneyReportView.tsx | 10 ++++---- .../ReportActionItem/MoneyRequestView.tsx | 9 ++++++- tests/ui/MoneyReportViewTest.tsx | 24 +++++++++++++++++-- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 87e9fb8e8b31..1946f90c04c3 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -111,11 +111,13 @@ 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; + const isSingleExpenseReport = isSingleTransactionReport(report, visibleTransactions); + const isSingleNonReimbursableExpense = isSingleExpenseReport && 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); + // For a single expense the report-level Tax total is shown as a "Converted" dot separator on the expense's Tax amount field, so hide it here. + const shouldShowTaxRow = !!taxTotal && isTaxEnabled && !isSingleExpenseReport; + const shouldShowBreakdown = shouldShowReimbursabilityBreakdown || !!billableTotal || shouldShowTaxRow; const formattedTotalAmount = convertToDisplayString(totalDisplaySpend, report?.currency); const formattedOutOfPocketAmount = convertToDisplayString(reimbursableSpend, report?.currency); const formattedCompanySpendAmount = convertToDisplayString(nonReimbursableSpend, report?.currency); @@ -275,7 +277,7 @@ function MoneyReportView({ {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: 'common.tax', value: formattedTaxAmount, show: shouldShowTaxRow}, ] .filter(({show}) => show) .map(({label, value}) => ( diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 7d1ade75020a..277451efc759 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -321,6 +321,7 @@ function MoneyRequestView({ updatedTransaction?.taxAmount !== undefined ? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), actualCurrency) : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency); + const formattedConvertedTaxAmount = transaction?.convertedTaxAmount !== undefined ? convertToDisplayString(Math.abs(transaction.convertedTaxAmount), moneyRequestReport?.currency) : ''; const taxRatesDescription = taxRates?.name; @@ -582,6 +583,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 @@ -1170,7 +1177,7 @@ function MoneyRequestView({ { }); }); - it('shows the tax row but still hides the redundant rows for a single non-reimbursable taxed expense', async () => { + 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 = { id: policyID, type: CONST.POLICY.TYPE.TEAM, @@ -174,12 +174,32 @@ 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 = { + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + name: 'Policy', + outputCurrency: CONST.CURRENCY.USD, + tax: {trackingEnabled: true}, + } as OnyxTypes.Policy; + 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}); From 03e4a5c38e1bbfe8b8528d815a1ed610dcf9409c Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 2 Jun 2026 15:15:31 +0500 Subject: [PATCH 5/9] Hide report-level Total/Billable/Tax rows and divider for single-expense reports --- .../ReportActionItem/MoneyReportView.tsx | 14 ++++--- tests/ui/MoneyReportViewTest.tsx | 41 ++++++++++++++++++- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 1946f90c04c3..7bbb48162f4b 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -113,11 +113,13 @@ function MoneyReportView({ const visibleTransactions = transactions.filter((transaction) => isOffline || !isTransactionPendingDelete(transaction)); const isSingleExpenseReport = isSingleTransactionReport(report, visibleTransactions); const isSingleNonReimbursableExpense = isSingleExpenseReport && visibleTransactions.at(0)?.reimbursable === false; - // The reimbursable/non-reimbursable rows duplicate the Total for a single non-reimbursable expense, so suppress only those rows. + // 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 header block and let the expense be the source of truth. const shouldShowReimbursabilityBreakdown = !isSingleNonReimbursableExpense && !!nonReimbursableSpend; - // For a single expense the report-level Tax total is shown as a "Converted" dot separator on the expense's Tax amount field, so hide it here. + const shouldShowBillableRow = !!billableTotal && !isSingleExpenseReport; const shouldShowTaxRow = !!taxTotal && isTaxEnabled && !isSingleExpenseReport; - const shouldShowBreakdown = shouldShowReimbursabilityBreakdown || !!billableTotal || shouldShowTaxRow; + const shouldShowBreakdown = shouldShowReimbursabilityBreakdown || shouldShowBillableRow || shouldShowTaxRow; + const shouldShowTotalRow = shouldShowTotal && !isSingleExpenseReport; const formattedTotalAmount = convertToDisplayString(totalDisplaySpend, report?.currency); const formattedOutOfPocketAmount = convertToDisplayString(reimbursableSpend, report?.currency); const formattedCompanySpendAmount = convertToDisplayString(nonReimbursableSpend, report?.currency); @@ -234,7 +236,7 @@ function MoneyReportView({ ); })} - {shouldShowTotal && ( + {shouldShowTotalRow && ( show) @@ -308,7 +310,7 @@ function MoneyReportView({ )} - {(shouldShowReportField || shouldShowBreakdown || shouldShowTotal) && renderThreadDivider} + {(shouldShowReportField || shouldShowBreakdown || shouldShowTotalRow) && renderThreadDivider} ); } diff --git a/tests/ui/MoneyReportViewTest.tsx b/tests/ui/MoneyReportViewTest.tsx index 19124b69c7bc..afd0065da802 100644 --- a/tests/ui/MoneyReportViewTest.tsx +++ b/tests/ui/MoneyReportViewTest.tsx @@ -131,6 +131,31 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { }); }); + it('hides the Total row and the divider for a single expense with no report-level rows above it', async () => { + const transactions = [buildTransaction('t1', 5000, false)]; + await seedReportAndTransactions(transactions, {nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000}); + + const {toJSON} = renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000})); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.queryByText('common.total')).not.toBeOnTheScreen(); + expect(hasThreadDivider(toJSON())).toBe(false); + }); + }); + + 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}); @@ -144,7 +169,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}); @@ -152,12 +177,24 @@ 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 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 = { id: policyID, From ae6e95162fdae969fafc765c9698ac5a16787ef0 Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 2 Jun 2026 15:33:44 +0500 Subject: [PATCH 6/9] removed unnecessary tests --- tests/ui/MoneyReportViewTest.tsx | 66 ++------------------------------ 1 file changed, 3 insertions(+), 63 deletions(-) diff --git a/tests/ui/MoneyReportViewTest.tsx b/tests/ui/MoneyReportViewTest.tsx index afd0065da802..bcdb0abe75df 100644 --- a/tests/ui/MoneyReportViewTest.tsx +++ b/tests/ui/MoneyReportViewTest.tsx @@ -78,7 +78,7 @@ const seedReportAndTransactions = async (transactions: OnyxTypes.Transaction[], await waitForBatchedUpdatesWithAct(); }; -const renderMoneyReportView = (report: OnyxTypes.Report, policy: OnyxTypes.Policy | undefined = undefined, extraProps: Partial> = {}) => +const renderMoneyReportView = (report: OnyxTypes.Report, policy: OnyxTypes.Policy | undefined = undefined) => render( , ); -// The divider has no testID, so find it by its `reportHorizontalRule` style in the tree. -const hasThreadDivider = (node: unknown): boolean => { - if (Array.isArray(node)) { - return node.some(hasThreadDivider); - } - if (!node || typeof node !== 'object') { - return false; - } - const {props, children} = node as {props?: {style?: unknown}; children?: unknown}; - const styleStr = JSON.stringify(props?.style ?? ''); - if (styleStr.includes('borderColor') && styleStr.includes('borderBottomWidth')) { - return true; - } - return hasThreadDivider(children); -}; - describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); @@ -131,16 +114,15 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { }); }); - it('hides the Total row and the divider for a single expense with no report-level rows above it', async () => { + it('hides the Total row for a single expense', async () => { const transactions = [buildTransaction('t1', 5000, false)]; await seedReportAndTransactions(transactions, {nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000}); - const {toJSON} = renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000})); + renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000})); await waitForBatchedUpdatesWithAct(); await waitFor(() => { expect(screen.queryByText('common.total')).not.toBeOnTheScreen(); - expect(hasThreadDivider(toJSON())).toBe(false); }); }); @@ -284,46 +266,4 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { expect(screen.getByText('cardTransactions.companySpend')).toBeOnTheScreen(); }); }); - - it('does not render a stray thread divider for a single non-reimbursable expense when the policy has report fields but report fields are disabled', async () => { - // Report field exists but is disabled, so no field row renders — and neither should the divider. - const reportField = { - fieldID: 'field_text_1', - name: 'Custom Field', - type: 'text', - target: CONST.REPORT.TYPE.EXPENSE, - value: 'Some value', - defaultValue: '', - orderWeight: 1, - disabledOptions: [], - values: [], - deletable: false, - } as unknown as OnyxTypes.PolicyReportField; - const policy = { - id: policyID, - type: CONST.POLICY.TYPE.TEAM, - role: CONST.POLICY.ROLE.ADMIN, - name: 'Policy', - outputCurrency: CONST.CURRENCY.USD, - areReportFieldsEnabled: false, - // eslint-disable-next-line @typescript-eslint/naming-convention -- the field key uses snake_case to match the backend API format - fieldList: {expensify_field_text_1: reportField}, - } as unknown as OnyxTypes.Policy; - - const transactions = [buildTransaction('t1', 5000, false)]; - await seedReportAndTransactions(transactions, {nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000}); - - // Combined report with Total hidden, as `MoneyReportContentCreated` renders a single-expense thread. - const {toJSON} = renderMoneyReportView(buildExpenseReport({nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000}), policy, { - isCombinedReport: true, - shouldShowTotal: false, - }); - await waitForBatchedUpdatesWithAct(); - - await waitFor(() => { - expect(screen.queryByText('cardTransactions.outOfPocket')).not.toBeOnTheScreen(); - expect(screen.queryByText('cardTransactions.companySpend')).not.toBeOnTheScreen(); - expect(hasThreadDivider(toJSON())).toBe(false); - }); - }); }); From 4667593940923966d8c8fb7ea97c332e3af6a7ea Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 2 Jun 2026 16:36:19 +0500 Subject: [PATCH 7/9] removed converted from tax row if it's 0 --- .../ReportActionItem/MoneyRequestView.tsx | 3 +- tests/ui/MoneyRequestViewTest.tsx | 98 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 5dae6a7e0af1..a08aee5c1639 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -324,7 +324,8 @@ function MoneyRequestView({ updatedTransaction?.taxAmount !== undefined ? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), actualCurrency) : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency); - const formattedConvertedTaxAmount = transaction?.convertedTaxAmount !== undefined ? convertToDisplayString(Math.abs(transaction.convertedTaxAmount), moneyRequestReport?.currency) : ''; + // 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; 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(); + }); + }); }); From d56f4655dea118d7e55289d41c7dd2cf7f39fe0a Mon Sep 17 00:00:00 2001 From: Mukher Date: Wed, 3 Jun 2026 08:41:07 +0500 Subject: [PATCH 8/9] simplify reimbursable row logic --- .../ReportActionItem/MoneyReportView.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 7bbb48162f4b..f0eddf694f13 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -112,13 +112,12 @@ function MoneyReportView({ // 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 isSingleExpenseReport = isSingleTransactionReport(report, visibleTransactions); - const isSingleNonReimbursableExpense = isSingleExpenseReport && visibleTransactions.at(0)?.reimbursable === false; // 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 header block and let the expense be the source of truth. - const shouldShowReimbursabilityBreakdown = !isSingleNonReimbursableExpense && !!nonReimbursableSpend; - const shouldShowBillableRow = !!billableTotal && !isSingleExpenseReport; - const shouldShowTaxRow = !!taxTotal && isTaxEnabled && !isSingleExpenseReport; - const shouldShowBreakdown = shouldShowReimbursabilityBreakdown || shouldShowBillableRow || shouldShowTaxRow; + // 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); @@ -276,8 +275,8 @@ function MoneyReportView({ {!!shouldShowBreakdown && ( <> {[ - {label: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, show: shouldShowReimbursabilityBreakdown}, - {label: 'cardTransactions.companySpend', value: formattedCompanySpendAmount, show: shouldShowReimbursabilityBreakdown}, + {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}, ] From a557c66ec75f8c64dff55dcb79aba137425b1b66 Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 11 Jun 2026 23:22:55 +0500 Subject: [PATCH 9/9] fixed eslint --- tests/ui/MoneyReportViewTest.tsx | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/tests/ui/MoneyReportViewTest.tsx b/tests/ui/MoneyReportViewTest.tsx index bcdb0abe75df..728813acab10 100644 --- a/tests/ui/MoneyReportViewTest.tsx +++ b/tests/ui/MoneyReportViewTest.tsx @@ -47,6 +47,12 @@ const buildExpenseReport = (overrides: Partial = {}): 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, @@ -178,14 +184,7 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { }); 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 = { - id: policyID, - type: CONST.POLICY.TYPE.TEAM, - role: CONST.POLICY.ROLE.ADMIN, - name: 'Policy', - outputCurrency: CONST.CURRENCY.USD, - tax: {trackingEnabled: true}, - } as OnyxTypes.Policy; + const policy = buildTaxPolicy(); const transactions = [buildTransaction('t1', 5000, false, false, 500)]; await seedReportAndTransactions(transactions, {nonReimbursableTotal: -5000, unheldNonReimbursableTotal: -5000}); @@ -200,14 +199,7 @@ describe('MoneyReportView reimbursable/non-reimbursable breakdown rows', () => { }); it('shows the report-level tax row when multiple taxed expenses exist', 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; + const policy = buildTaxPolicy(); const transactions = [buildTransaction('t1', 5000, false, false, 500), buildTransaction('t2', 3000, false, false, 300)]; await seedReportAndTransactions(transactions, {nonReimbursableTotal: -8000, unheldNonReimbursableTotal: -8000});