diff --git a/changelog/add-1342-transaction-breakdown-block b/changelog/add-1342-transaction-breakdown-block
new file mode 100644
index 00000000000..49b86e40d39
--- /dev/null
+++ b/changelog/add-1342-transaction-breakdown-block
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Transaction Fees breakdown component in the Payment details.
diff --git a/client/data/index.ts b/client/data/index.ts
index 878ecdc11f6..82e1c72df8b 100644
--- a/client/data/index.ts
+++ b/client/data/index.ts
@@ -25,3 +25,14 @@ export * from './payment-intents/hooks';
export * from './authorizations/hooks';
export * from './files/hooks';
export * from './payment-activity/hooks';
+
+import { TimelineItem } from './timeline/types';
+import { ApiError } from '../types/errors';
+
+export declare function useTimeline(
+ transactionId: string
+): {
+ timeline: Array< TimelineItem >;
+ timelineError: ApiError | undefined;
+ isLoading: boolean;
+};
diff --git a/client/data/timeline/types.d.ts b/client/data/timeline/types.d.ts
new file mode 100644
index 00000000000..fdd210b49c5
--- /dev/null
+++ b/client/data/timeline/types.d.ts
@@ -0,0 +1,63 @@
+export interface TimelineFeeRate {
+ type: string;
+ additional_type?: string;
+ fee_id: string;
+ percentage_rate: number;
+ fixed_rate: number;
+ currency: string;
+}
+
+export interface TimelineFeeExchangeRate {
+ from_currency: string;
+ to_currency: string;
+ from_amount: number;
+ to_amount: number;
+ rate: number;
+}
+
+export interface TimelineFeeRates {
+ percentage: number;
+ fixed: number;
+ fixed_currency: string;
+ history?: Array< TimelineFeeRate >;
+ fee_exchange_rate?: TimelineFeeExchangeRate;
+}
+
+export interface TimelineTransactionDetails {
+ customer_currency: string;
+ customer_amount: number;
+ customer_amount_captured: number;
+ customer_fee: number;
+ store_currency: string;
+ store_amount: number;
+ store_amount_captured: number;
+ store_fee: number;
+}
+
+export interface TimelineDeposit {
+ id: string;
+ arrival_date: number;
+}
+
+export interface TimelineItem {
+ type: string;
+ datetime: number;
+ acquirer_reference_number?: string;
+ acquirer_reference_number_status?: string;
+ amount?: number;
+ amount_captured?: number;
+ amount_refunded?: number;
+ currency?: string;
+ deposit?: TimelineDeposit;
+ dispute_id?: string;
+ evidence_due_by?: number;
+ failure_reason?: string;
+ failure_transaction_id?: string;
+ fee?: number;
+ fee_rates?: TimelineFeeRates;
+ loan_id?: string;
+ reason?: string;
+ transaction_details?: TimelineTransactionDetails;
+ transaction_id?: string;
+ user_id?: number;
+}
diff --git a/client/payment-details/payment-details/index.tsx b/client/payment-details/payment-details/index.tsx
index cdf568520f2..22120008e9b 100644
--- a/client/payment-details/payment-details/index.tsx
+++ b/client/payment-details/payment-details/index.tsx
@@ -14,6 +14,7 @@ import ErrorBoundary from '../../components/error-boundary';
import PaymentDetailsSummary from '../summary';
import PaymentDetailsTimeline from '../timeline';
import PaymentDetailsPaymentMethod from '../payment-method';
+import PaymentTransactionBreakdown from '../transaction-breakdown';
import { ApiError } from '../../types/errors';
import { Charge } from '../../types/charges';
import { PaymentIntent } from '../../types/payment-intents';
@@ -72,6 +73,10 @@ const PaymentDetails: React.FC< PaymentDetailsProps > = ( {
) }
+
+
+
+
= ( { event } ) => {
+ if ( ! event.fee_rates || ! event.transaction_details ) {
+ return null;
+ }
+
+ const storeCurrency = event.transaction_details.store_currency;
+ const feeExchangeRate = event.fee_rates.fee_exchange_rate?.rate || 1;
+ const discountFee = event.fee_rates.history
+ ? find(
+ event.fee_rates.history,
+ ( fee: TimelineFeeRate ) => fee.type === 'discount'
+ )
+ : undefined;
+
+ let remainingPercentageDiscount = Math.abs(
+ discountFee?.percentage_rate || 0
+ );
+ let remainingFixedDiscount = Math.abs( discountFee?.fixed_rate || 0 );
+
+ const FeeRow: React.FC< {
+ type: string;
+ additionalType?: string;
+ percentage: number;
+ fixed: number;
+ currency: string;
+ amount?: number;
+ isDiscounted?: boolean;
+ } > = ( {
+ type,
+ additionalType,
+ percentage,
+ fixed,
+ currency,
+ amount,
+ isDiscounted,
+ } ) => {
+ const formattedFeeType = formatFeeType(
+ type,
+ additionalType,
+ isDiscounted
+ );
+ const formattedFeeRate = formatFeeRate(
+ percentage,
+ fixed,
+ currency,
+ storeCurrency
+ );
+ const formattedFeeAmount = amount
+ ? ` - ${ formatCurrency( amount, storeCurrency, storeCurrency ) }`
+ : '';
+ const feeType = type + ( additionalType ? `_${ additionalType }` : '' );
+
+ return (
+
+
+ { formattedFeeType }
+
+
+ { formattedFeeRate }
+
+
+ { formattedFeeAmount }
+
+
+ );
+ };
+
+ const fees = [];
+
+ if ( ! event.fee_rates.history ) {
+ fees.push(
+
+ );
+ } else {
+ event.fee_rates.history.map( ( fee: TimelineFeeRate ) => {
+ if ( 'discount' === fee.type ) {
+ return null;
+ }
+
+ let percentage = fee.percentage_rate;
+ let fixed = fee.fixed_rate;
+ let isDiscounted = false;
+
+ if ( remainingPercentageDiscount > 0 ) {
+ const percentageDiscount = Math.min(
+ remainingPercentageDiscount,
+ percentage
+ );
+ percentage = percentage - percentageDiscount;
+ remainingPercentageDiscount =
+ remainingPercentageDiscount - percentageDiscount;
+ isDiscounted = true;
+ }
+
+ if ( remainingFixedDiscount > 0 ) {
+ const fixedDiscount = Math.min( remainingFixedDiscount, fixed );
+ fixed = fixed - fixedDiscount;
+ remainingFixedDiscount = remainingFixedDiscount - fixedDiscount;
+ isDiscounted = true;
+ }
+
+ const feeType =
+ fee.type +
+ ( fee.additional_type ? `_${ fee.additional_type }` : '' );
+
+ fees.push(
+
+ );
+ return null;
+ } );
+ }
+
+ fees.push(
+
+ );
+
+ return <>{ fees }>;
+};
+
+export default FeesBreakdown;
diff --git a/client/payment-details/transaction-breakdown/fees-breakdown/tests/index.test.tsx b/client/payment-details/transaction-breakdown/fees-breakdown/tests/index.test.tsx
new file mode 100644
index 00000000000..3fbc8a3596b
--- /dev/null
+++ b/client/payment-details/transaction-breakdown/fees-breakdown/tests/index.test.tsx
@@ -0,0 +1,270 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+/** Internal dependencies */
+import FeesBreakdown from '../';
+import { TimelineItem } from 'wcpay/data/timeline/types';
+
+declare const global: {
+ wcpaySettings: {
+ zeroDecimalCurrencies: string[];
+ currencyData: Record< string, any >;
+ connect: {
+ country: string;
+ };
+ };
+};
+
+describe( 'FeesBreakdown', () => {
+ const baseEvent: TimelineItem = {
+ type: 'capture',
+ datetime: 1713100800,
+ fee_rates: {
+ percentage: 0.029,
+ fixed: 30,
+ fixed_currency: 'USD',
+ fee_exchange_rate: {
+ from_currency: 'USD',
+ to_currency: 'USD',
+ from_amount: 100,
+ to_amount: 100,
+ rate: 1,
+ },
+ },
+ transaction_details: {
+ store_currency: 'USD',
+ store_fee: 10,
+ customer_currency: 'USD',
+ customer_amount: 100,
+ customer_amount_captured: 100,
+ customer_fee: 10,
+ store_amount: 100,
+ store_amount_captured: 100,
+ },
+ };
+
+ beforeEach( () => {
+ global.wcpaySettings = {
+ zeroDecimalCurrencies: [],
+ connect: {
+ country: 'US',
+ },
+ currencyData: {
+ US: {
+ code: 'USD',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ },
+ };
+ } );
+
+ it( 'should render null when fee_rates or transaction_details are missing', () => {
+ const { container } = render(
+
+ );
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'should render base fee when fee_history is undefined', () => {
+ render( );
+
+ expect( screen.getByText( 'Base fee' ) ).toBeInTheDocument();
+ expect(
+ screen.getByText( '2.9% + $0.30', {
+ selector: '.wcpay-transaction-breakdown__total_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should render fees with discount when fee_history contains a discount', () => {
+ const eventWithDiscount: TimelineItem = {
+ ...baseEvent,
+ fee_rates: {
+ ...baseEvent.fee_rates,
+ percentage: 0.029,
+ fixed: 30,
+ fixed_currency: 'USD',
+ history: [
+ {
+ type: 'base',
+ percentage_rate: 0.029,
+ fixed_rate: 30,
+ currency: 'USD',
+ fee_id: 'base',
+ },
+ {
+ type: 'additional',
+ additional_type: 'international',
+ percentage_rate: 0.01,
+ fixed_rate: 0,
+ currency: 'USD',
+ fee_id: 'additional',
+ },
+ {
+ type: 'discount',
+ percentage_rate: -0.035,
+ fixed_rate: -25,
+ currency: 'USD',
+ fee_id: 'discount',
+ },
+ ],
+ },
+ };
+
+ render( );
+
+ expect(
+ screen.getByText( 'Base fee (discounted)' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( '$0.05', {
+ selector: '.wcpay-transaction-breakdown__base_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'International card fee (discounted)', {
+ selector:
+ '.wcpay-transaction-breakdown__additional_international_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( '0.4%', {
+ selector:
+ '.wcpay-transaction-breakdown__additional_international_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'Total transaction fee' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( /- \$0.10$/, {
+ selector: '.wcpay-transaction-breakdown__total_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should render fees without discount when fee_history has no discount', () => {
+ const eventWithoutDiscount: TimelineItem = {
+ ...baseEvent,
+ fee_rates: {
+ ...baseEvent.fee_rates,
+ percentage: 0.029,
+ fixed: 30,
+ fixed_currency: 'USD',
+ history: [
+ {
+ type: 'base',
+ percentage_rate: 0.029,
+ fixed_rate: 30,
+ currency: 'USD',
+ fee_id: 'base',
+ },
+ {
+ type: 'additional',
+ additional_type: 'international',
+ percentage_rate: 0.01,
+ fixed_rate: 0,
+ currency: 'USD',
+ fee_id: 'additional',
+ },
+ ],
+ },
+ };
+
+ render( );
+
+ expect( screen.getByText( 'Base fee' ) ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'International card fee' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( '2.9% + $0.30', {
+ selector: '.wcpay-transaction-breakdown__base_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( '1%', {
+ selector:
+ '.wcpay-transaction-breakdown__additional_international_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'Total transaction fee' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( /- \$0.10$/, {
+ selector: '.wcpay-transaction-breakdown__total_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should render fee line with 0% when fee is fully discounted', () => {
+ const eventWithoutDiscount: TimelineItem = {
+ ...baseEvent,
+ fee_rates: {
+ ...baseEvent.fee_rates,
+ percentage: 0.029,
+ fixed: 30,
+ fixed_currency: 'USD',
+ history: [
+ {
+ type: 'base',
+ percentage_rate: 0.029,
+ fixed_rate: 30,
+ currency: 'USD',
+ fee_id: 'base',
+ },
+ {
+ type: 'additional',
+ additional_type: 'international',
+ percentage_rate: 0.01,
+ fixed_rate: 0,
+ currency: 'USD',
+ fee_id: 'additional',
+ },
+ {
+ type: 'discount',
+ percentage_rate: -0.039,
+ fixed_rate: -30,
+ currency: 'USD',
+ fee_id: 'discount',
+ },
+ ],
+ },
+ };
+
+ render( );
+
+ expect(
+ screen.getByText( 'Base fee (discounted)' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'International card fee (discounted)' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( '0%', {
+ selector: '.wcpay-transaction-breakdown__base_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( '0%', {
+ selector:
+ '.wcpay-transaction-breakdown__additional_international_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ } );
+} );
diff --git a/client/payment-details/transaction-breakdown/hooks.ts b/client/payment-details/transaction-breakdown/hooks.ts
new file mode 100644
index 00000000000..0175dc3a8c1
--- /dev/null
+++ b/client/payment-details/transaction-breakdown/hooks.ts
@@ -0,0 +1,53 @@
+/**
+ * External dependencies
+ */
+import { useMemo } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { TimelineItem } from 'wcpay/data/timeline/types';
+import { formatCurrency } from 'multi-currency/interface/functions';
+
+interface TransactionAmounts {
+ formattedStoreAmount: string;
+ formattedCustomerAmount: string;
+ isMultiCurrency: boolean;
+ formattedAmount: string;
+}
+
+export const useTransactionAmounts = (
+ captureEvent: TimelineItem | undefined
+): TransactionAmounts | null => {
+ return useMemo( () => {
+ if ( undefined === captureEvent ) {
+ return null;
+ }
+
+ const { transaction_details: details } = captureEvent;
+ if ( ! details ) return null;
+
+ const formattedStoreAmount = `${ formatCurrency(
+ details.store_amount,
+ details.store_currency
+ ) } ${ details.store_currency }`;
+
+ const formattedCustomerAmount = `${ formatCurrency(
+ details.customer_amount,
+ details.customer_currency,
+ details.store_currency
+ ) } ${ details.customer_currency }`;
+
+ const isMultiCurrency =
+ details.store_currency !== details.customer_currency;
+
+ return {
+ formattedStoreAmount,
+ formattedCustomerAmount,
+ isMultiCurrency,
+ formattedAmount: `${ formattedCustomerAmount }${
+ isMultiCurrency ? ` → ${ formattedStoreAmount }` : ''
+ }`,
+ };
+ }, [ captureEvent ] );
+};
diff --git a/client/payment-details/transaction-breakdown/index.tsx b/client/payment-details/transaction-breakdown/index.tsx
new file mode 100644
index 00000000000..0ccdc94b5ab
--- /dev/null
+++ b/client/payment-details/transaction-breakdown/index.tsx
@@ -0,0 +1,142 @@
+/** @format **/
+
+/**
+ * External dependencies
+ */
+import React from 'react';
+import { __ } from '@wordpress/i18n';
+import { find } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import { useTimeline } from 'wcpay/data';
+import {
+ Card,
+ CardBody,
+ CardHeader,
+ CardFooter,
+ Flex,
+ FlexItem,
+} from '@wordpress/components';
+import { TimelineItem } from 'wcpay/data/timeline/types';
+import Loadable, { LoadableBlock } from 'components/loadable';
+import { formatCurrency } from 'multi-currency/interface/functions';
+import { useTransactionAmounts } from './hooks';
+import FeesBreakdown from './fees-breakdown';
+import './style.scss';
+
+interface PaymentTransactionBreakdownProps {
+ paymentIntentId: string;
+}
+
+const PaymentTransactionBreakdown: React.FC< PaymentTransactionBreakdownProps > = ( {
+ paymentIntentId,
+} ) => {
+ const { timeline, isLoading } = useTimeline( paymentIntentId );
+
+ /**
+ * Right now there is no support for multi-capture in the WooPayments and
+ * we retrieve information about fees from the first available capture
+ * event. This should be updated if multi capture becomes reality.
+ */
+ const captureEvent: TimelineItem | undefined = find(
+ timeline,
+ ( item: TimelineItem ) => item.type === 'captured'
+ );
+
+ const transactionAmounts = useTransactionAmounts( captureEvent );
+
+ if (
+ ! captureEvent?.transaction_details ||
+ ! captureEvent?.fee_rates ||
+ ! transactionAmounts
+ ) {
+ return null;
+ }
+
+ const { formattedAmount, isMultiCurrency } = transactionAmounts;
+ const feeExchangeRate = captureEvent.fee_rates.fee_exchange_rate?.rate || 1;
+
+ const conversionRate = isMultiCurrency ? (
+
+ { ' @ 1 ' }
+ { captureEvent.transaction_details.customer_currency }
+ { ' → ' }
+ { Math.round( ( 1 / feeExchangeRate ) * 1000000 ) / 1000000 }
+ { ' ' }
+ { captureEvent.transaction_details.store_currency }
+
+ ) : (
+ ''
+ );
+
+ return captureEvent ? (
+
+
+
+
+
+
+
+
+
+ { __(
+ 'Authorized payment',
+ 'woocommerce-payments'
+ ) }
+
+
+
+ { formattedAmount }
+ { conversionRate }
+
+
+
+
+
+ { __(
+ 'Transaction fee',
+ 'woocommerce-payments'
+ ) }
+
+
+
+
+
+
+
+
+
+
+
+
+ { __( 'Net deposit', 'woocommerce-payments' ) }
+
+
+ { formatCurrency(
+ captureEvent.transaction_details
+ .store_amount_captured -
+ captureEvent.transaction_details.store_fee,
+ captureEvent.transaction_details.store_currency
+ ) }
+
+
+
+
+
+ ) : (
+
+ );
+};
+
+export default PaymentTransactionBreakdown;
diff --git a/client/payment-details/transaction-breakdown/style.scss b/client/payment-details/transaction-breakdown/style.scss
new file mode 100644
index 00000000000..6af055bc196
--- /dev/null
+++ b/client/payment-details/transaction-breakdown/style.scss
@@ -0,0 +1,35 @@
+/** @format */
+
+.wcpay-transaction-breakdown,
+.wcpay-transaction-breakdown__footer,
+.wcpay-transaction-breakdown div,
+.wcpay-transaction-breakdown__footer div {
+ font-size: 16px;
+}
+
+.wcpay-transaction-breakdown__conversion_rate {
+ margin-top: 0 !important;
+ font-size: 12px !important;
+ color: $wp-gray-40;
+ text-align: right;
+ line-height: 16px;
+}
+.wcpay-transaction-breakdown__fees {
+ margin-left: 5%;
+ color: $wp-gray-40;
+}
+.wcpay-transaction-breakdown__fees .wcpay-transaction-breakdown__fee_name {
+ width: 35%;
+}
+.wcpay-transaction-breakdown__fees .wcpay-transaction-breakdown__fee_amount,
+.wcpay-transaction-breakdown__fees .wcpay-transaction-breakdown__fee_rate {
+ width: 30%;
+ text-align: right;
+}
+.wcpay-transaction-breakdown__fees
+ .wcpay-transaction-breakdown__total_fee_info {
+ font-weight: 600;
+}
+.wcpay-transaction-breakdown__footer_amount {
+ font-weight: 600;
+}
diff --git a/client/payment-details/transaction-breakdown/tests/hooks.test.ts b/client/payment-details/transaction-breakdown/tests/hooks.test.ts
new file mode 100644
index 00000000000..074e9b20ea6
--- /dev/null
+++ b/client/payment-details/transaction-breakdown/tests/hooks.test.ts
@@ -0,0 +1,88 @@
+/**
+ * External dependencies
+ */
+import { renderHook } from '@testing-library/react-hooks';
+
+/**
+ * Internal dependencies
+ */
+import { useTransactionAmounts } from '../hooks';
+import { formatCurrency } from 'multi-currency/interface/functions';
+
+jest.mock( 'multi-currency/interface/functions', () => ( {
+ formatCurrency: jest.fn(),
+} ) );
+
+describe( 'useTransactionAmounts', () => {
+ beforeEach( () => {
+ ( formatCurrency as jest.Mock ).mockReset();
+ } );
+
+ it( 'returns null when capture event is undefined', () => {
+ const { result } = renderHook( () =>
+ useTransactionAmounts( undefined )
+ );
+ expect( result.current ).toBeNull();
+ } );
+
+ it( 'returns null when transaction details are missing', () => {
+ const { result } = renderHook( () =>
+ useTransactionAmounts( {
+ type: 'captured',
+ } as any )
+ );
+ expect( result.current ).toBeNull();
+ } );
+
+ it( 'formats amounts for same currency transaction', () => {
+ ( formatCurrency as jest.Mock ).mockReturnValue( '$100.00' );
+
+ const captureEvent = {
+ type: 'captured',
+ transaction_details: {
+ store_amount: 10000,
+ store_currency: 'USD',
+ customer_amount: 10000,
+ customer_currency: 'USD',
+ },
+ };
+
+ const { result } = renderHook( () =>
+ useTransactionAmounts( captureEvent as any )
+ );
+
+ expect( result.current ).toEqual( {
+ formattedStoreAmount: '$100.00 USD',
+ formattedCustomerAmount: '$100.00 USD',
+ isMultiCurrency: false,
+ formattedAmount: '$100.00 USD',
+ } );
+ } );
+
+ it( 'formats amounts for multi-currency transaction', () => {
+ ( formatCurrency as jest.Mock )
+ .mockReturnValueOnce( '€85.00' ) // store amount
+ .mockReturnValueOnce( '$100.00' ); // customer amount
+
+ const captureEvent = {
+ type: 'captured',
+ transaction_details: {
+ store_amount: 8500,
+ store_currency: 'EUR',
+ customer_amount: 10000,
+ customer_currency: 'USD',
+ },
+ };
+
+ const { result } = renderHook( () =>
+ useTransactionAmounts( captureEvent as any )
+ );
+
+ expect( result.current ).toEqual( {
+ formattedStoreAmount: '€85.00 EUR',
+ formattedCustomerAmount: '$100.00 USD',
+ isMultiCurrency: true,
+ formattedAmount: '$100.00 USD → €85.00 EUR',
+ } );
+ } );
+} );
diff --git a/client/payment-details/transaction-breakdown/tests/index.test.tsx b/client/payment-details/transaction-breakdown/tests/index.test.tsx
new file mode 100644
index 00000000000..13e0dd5d20b
--- /dev/null
+++ b/client/payment-details/transaction-breakdown/tests/index.test.tsx
@@ -0,0 +1,309 @@
+/**
+ * External dependencies
+ */
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import PaymentTransactionBreakdown from '../';
+import { useTimeline } from 'wcpay/data';
+import { useTransactionAmounts } from '../hooks';
+import { TimelineItem } from 'wcpay/data/timeline/types';
+import { TransactionDetails } from '../types';
+
+jest.mock( '@wordpress/i18n', () => ( {
+ __: jest.fn().mockImplementation( ( str ) => str ),
+} ) );
+
+jest.mock( 'wcpay/data', () => ( {
+ useTimeline: jest.fn(),
+} ) );
+
+jest.mock( '../hooks', () => ( {
+ useTransactionAmounts: jest.fn(),
+} ) );
+
+declare const global: {
+ wcpaySettings: {
+ zeroDecimalCurrencies: string[];
+ currencyData: Record< string, any >;
+ connect: {
+ country: string;
+ };
+ };
+};
+
+describe( 'PaymentTransactionBreakdown', () => {
+ beforeEach( () => {
+ ( useTimeline as jest.Mock ).mockReset();
+ ( useTransactionAmounts as jest.Mock ).mockReset();
+ global.wcpaySettings = {
+ zeroDecimalCurrencies: [],
+ connect: {
+ country: 'US',
+ },
+ currencyData: {
+ US: {
+ code: 'USD',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ },
+ };
+ } );
+
+ it( 'renders nothing when no capture event is found', () => {
+ ( useTimeline as jest.Mock ).mockReturnValue( {
+ timeline: [],
+ isLoading: false,
+ } );
+
+ const { container } = render(
+
+ );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'renders nothing in loading state', () => {
+ ( useTimeline as jest.Mock ).mockReturnValue( {
+ timeline: [],
+ isLoading: true,
+ } );
+
+ const { container } = render(
+
+ );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'renders empty state on error', () => {
+ ( useTimeline as jest.Mock ).mockReturnValue( {
+ timeline: [],
+ timelineError: new Error( 'Failed to load' ),
+ isLoading: false,
+ } );
+
+ const { container } = render(
+
+ );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'renders transaction breakdown with single fee without history', () => {
+ const mockTransactionDetails: TransactionDetails = {
+ store_amount: 10000,
+ store_amount_captured: 10000,
+ store_currency: 'USD',
+ customer_amount: 10000,
+ customer_currency: 'USD',
+ customer_amount_captured: 10000,
+ customer_fee: 320,
+ store_fee: 320,
+ };
+
+ const mockCaptureEvent: TimelineItem = {
+ type: 'captured',
+ datetime: 1717334400,
+ transaction_details: mockTransactionDetails,
+ fee_rates: {
+ percentage: 0.029,
+ fixed: 30,
+ fixed_currency: 'USD',
+ },
+ };
+
+ ( useTimeline as jest.Mock ).mockReturnValue( {
+ timeline: [ mockCaptureEvent ],
+ isLoading: false,
+ } );
+
+ ( useTransactionAmounts as jest.Mock ).mockReturnValue( {
+ formattedAmount: '$100.00 USD',
+ formattedStoreAmount: '$100.00 USD',
+ formattedCustomerAmount: '$100.00 USD',
+ isMultiCurrency: false,
+ } );
+
+ render( );
+
+ expect( screen.getByText( 'Base fee' ) ).toBeInTheDocument();
+ expect(
+ screen.getByText( '2.9% + $0.30', {
+ selector: '.wcpay-transaction-breakdown__base_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'Total transaction fee' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( /- \$3.20$/, {
+ selector: '.wcpay-transaction-breakdown__total_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders transaction breakdown with multiple fees in history', () => {
+ const mockTransactionDetails: TransactionDetails = {
+ store_amount: 10000,
+ store_amount_captured: 10000,
+ store_currency: 'USD',
+ customer_amount: 10000,
+ customer_currency: 'USD',
+ customer_amount_captured: 10000,
+ customer_fee: 520,
+ store_fee: 520,
+ };
+
+ const mockCaptureEvent: TimelineItem = {
+ type: 'captured',
+ datetime: 1717334400,
+ transaction_details: mockTransactionDetails,
+ fee_rates: {
+ percentage: 0.049,
+ fixed: 30,
+ fixed_currency: 'USD',
+ history: [
+ {
+ type: 'base',
+ fee_id: 'base',
+ percentage_rate: 0.029,
+ fixed_rate: 30,
+ currency: 'USD',
+ },
+ {
+ type: 'additional',
+ additional_type: 'international',
+ fee_id: 'international',
+ percentage_rate: 0.01,
+ fixed_rate: 0,
+ currency: 'USD',
+ },
+ {
+ type: 'additional',
+ additional_type: 'fx',
+ fee_id: 'fx',
+ percentage_rate: 0.01,
+ fixed_rate: 0,
+ currency: 'USD',
+ },
+ ],
+ },
+ };
+
+ ( useTimeline as jest.Mock ).mockReturnValue( {
+ timeline: [ mockCaptureEvent ],
+ isLoading: false,
+ } );
+
+ ( useTransactionAmounts as jest.Mock ).mockReturnValue( {
+ formattedAmount: '$100.00 USD',
+ formattedStoreAmount: '$100.00 USD',
+ formattedCustomerAmount: '$100.00 USD',
+ isMultiCurrency: false,
+ } );
+
+ render( );
+
+ expect( screen.getByText( 'Base fee' ) ).toBeInTheDocument();
+ expect(
+ screen.getByText( '2.9% + $0.30', {
+ selector: '.wcpay-transaction-breakdown__base_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'International card fee' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( '1%', {
+ selector:
+ '.wcpay-transaction-breakdown__additional_international_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'Currency conversion fee' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( '1%', {
+ selector:
+ '.wcpay-transaction-breakdown__additional_fx_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'Total transaction fee' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( /- \$5.20$/, {
+ selector: '.wcpay-transaction-breakdown__total_fee_info div',
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders transaction breakdown with conversion rate for multi-currency payment', () => {
+ const mockTransactionDetails: TransactionDetails = {
+ store_amount: 8500,
+ store_amount_captured: 8500,
+ store_currency: 'EUR',
+ customer_amount: 10000,
+ customer_currency: 'USD',
+ customer_amount_captured: 10000,
+ customer_fee: 320,
+ store_fee: 320,
+ };
+
+ const mockCaptureEvent: TimelineItem = {
+ type: 'captured',
+ datetime: 1717334400,
+ transaction_details: mockTransactionDetails,
+ fee_rates: {
+ percentage: 0.029,
+ fixed: 30,
+ fixed_currency: 'USD',
+ fee_exchange_rate: {
+ from_currency: 'USD',
+ to_currency: 'EUR',
+ from_amount: 10000,
+ to_amount: 8500,
+ rate: 0.85,
+ },
+ },
+ };
+
+ ( useTimeline as jest.Mock ).mockReturnValue( {
+ timeline: [ mockCaptureEvent ],
+ isLoading: false,
+ } );
+
+ ( useTransactionAmounts as jest.Mock ).mockReturnValue( {
+ formattedAmount: '$100.00 USD → €85.00 EUR',
+ formattedStoreAmount: '€85.00 EUR',
+ formattedCustomerAmount: '$100.00 USD',
+ isMultiCurrency: true,
+ } );
+
+ render( );
+
+ expect(
+ screen.getByText( 'Transaction breakdown' )
+ ).toBeInTheDocument();
+ expect( screen.getByText( 'Authorized payment' ) ).toBeInTheDocument();
+ expect(
+ screen.getByText( '$100.00 USD → €85.00 EUR' )
+ ).toBeInTheDocument();
+
+ const conversionRateText = screen.getByText( 'USD', {
+ selector: 'div.wcpay-transaction-breakdown__conversion_rate',
+ exact: false,
+ } );
+ expect( conversionRateText ).toHaveTextContent(
+ '@ 1 USD → 1.176471 EUR'
+ );
+ } );
+} );
diff --git a/client/payment-details/transaction-breakdown/tests/utils.test.ts b/client/payment-details/transaction-breakdown/tests/utils.test.ts
new file mode 100644
index 00000000000..e84bee7f712
--- /dev/null
+++ b/client/payment-details/transaction-breakdown/tests/utils.test.ts
@@ -0,0 +1,73 @@
+/**
+ * External dependencies
+ */
+
+/**
+ * Internal dependencies
+ */
+import { formatFeeType, formatFeeRate } from '../utils';
+import { formatCurrency } from 'multi-currency/interface/functions';
+
+jest.mock( '@wordpress/i18n', () => ( {
+ __: jest.fn().mockImplementation( ( str ) => str ),
+} ) );
+
+jest.mock( 'multi-currency/interface/functions', () => ( {
+ formatCurrency: jest.fn(),
+} ) );
+
+describe( 'formatFeeType', () => {
+ it( 'returns total transaction fee text for total type', () => {
+ expect( formatFeeType( 'total' ) ).toBe( 'Total transaction fee' );
+ } );
+
+ it( 'returns base fee text for base type', () => {
+ expect( formatFeeType( 'base' ) ).toBe( 'Base fee' );
+ } );
+
+ it( 'returns international card fee text for additional international type', () => {
+ expect( formatFeeType( 'additional', 'international' ) ).toBe(
+ 'International card fee'
+ );
+ } );
+
+ it( 'returns currency conversion fee text for additional fx type', () => {
+ expect( formatFeeType( 'additional', 'fx' ) ).toBe(
+ 'Currency conversion fee'
+ );
+ } );
+
+ it( 'returns generic fee text for unknown type', () => {
+ expect( formatFeeType( 'unknown' ) ).toBe( 'Fee' );
+ } );
+} );
+
+describe( 'formatFeeRate', () => {
+ beforeEach( () => {
+ ( formatCurrency as jest.Mock ).mockReset();
+ } );
+
+ it( 'formats percentage only', () => {
+ expect( formatFeeRate( 0.029, 0, 'USD', 'USD' ) ).toBe( '2.9%' );
+ } );
+
+ it( 'formats percentage only using all decimals', () => {
+ expect( formatFeeRate( 0.0295, 0, 'USD', 'USD' ) ).toBe( '2.95%' );
+ } );
+
+ it( 'formats fixed amount only', () => {
+ ( formatCurrency as jest.Mock ).mockReturnValue( '$0.30' );
+ expect( formatFeeRate( 0, 30, 'USD', 'USD' ) ).toBe( '$0.30' );
+ } );
+
+ it( 'combines percentage and fixed amount', () => {
+ ( formatCurrency as jest.Mock ).mockReturnValue( '$0.30' );
+ expect( formatFeeRate( 0.029, 30, 'USD', 'USD' ) ).toBe(
+ '2.9% + $0.30'
+ );
+ } );
+
+ it( 'returns 0% when both percentage and fixed are 0', () => {
+ expect( formatFeeRate( 0, 0, 'USD', 'USD' ) ).toBe( '0%' );
+ } );
+} );
diff --git a/client/payment-details/transaction-breakdown/types.ts b/client/payment-details/transaction-breakdown/types.ts
new file mode 100644
index 00000000000..78d50ff454f
--- /dev/null
+++ b/client/payment-details/transaction-breakdown/types.ts
@@ -0,0 +1,39 @@
+export interface TransactionDetails {
+ /** The amount of the transaction in the store's currency */
+ store_amount: number;
+ /** The store's currency */
+ store_currency: string;
+ /** The amount of the transaction in the customer's currency */
+ customer_amount: number;
+ /** The customer's currency */
+ customer_currency: string;
+ /** The fee of the transaction in the store's currency */
+ store_fee: number;
+ /** The fee of the transaction in the customer's currency */
+ customer_fee: number;
+ /**
+ * The amount of the transaction that was captured in the store's currency
+ */
+ store_amount_captured: number;
+ /**
+ * The amount of the transaction that was captured in the customer's
+ * currency
+ */
+ customer_amount_captured: number;
+}
+
+export interface FeeRate {
+ /** The type of fee, e.g. "base", "additional", "discount" */
+ type: string;
+ /**
+ * The additional type of fee, e.g. "international", "fx". At the moment
+ * it is used if `type` is "additional", otherwise it is empty.
+ */
+ additional_type?: string;
+ /** The percentage rate of the fee, as a share of 1, e.g. 0.029 for 2.9% */
+ percentage_rate: number;
+ /** The fixed rate of the fee in the minimum unit of the currency, e.g. 30 for USD 0.30 */
+ fixed_rate: number;
+ /** The currency of the fee, e.g. "USD" */
+ currency: string;
+}
diff --git a/client/payment-details/transaction-breakdown/utils.ts b/client/payment-details/transaction-breakdown/utils.ts
new file mode 100644
index 00000000000..eed70a7646c
--- /dev/null
+++ b/client/payment-details/transaction-breakdown/utils.ts
@@ -0,0 +1,64 @@
+/**
+ * External dependencies
+ */
+import { filter, join } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { formatCurrency } from 'multi-currency/interface/functions';
+
+export const formatFeeType = (
+ type: string,
+ additionalType?: string,
+ isDiscounted?: boolean
+): string => {
+ const feeTypes: Record< string, string | Record< string, string > > = {
+ total: __( 'Total transaction fee', 'woocommerce-payments' ),
+ base: __( 'Base fee', 'woocommerce-payments' ),
+ additional: {
+ international: __(
+ 'International card fee',
+ 'woocommerce-payments'
+ ),
+ fx: __( 'Currency conversion fee', 'woocommerce-payments' ),
+ },
+ };
+
+ const suffix = isDiscounted ? ' (discounted)' : '';
+
+ if ( type === 'additional' && additionalType ) {
+ const additionalFees = feeTypes.additional as Record< string, string >;
+ return (
+ ( additionalFees[ additionalType ] ||
+ __( 'Fee', 'woocommerce-payments' ) ) + suffix
+ );
+ }
+
+ const baseType = feeTypes[ type ];
+ return typeof baseType === 'string'
+ ? baseType + suffix
+ : __( 'Fee', 'woocommerce-payments' ) + suffix;
+};
+
+export const formatFeeRate = (
+ percentage: number,
+ fixed: number,
+ currency: string,
+ storeCurrency: string
+): string => {
+ const formattedPercentage = percentage
+ ? `${ Number.parseFloat( ( percentage * 100 ).toFixed( 2 ) ) }%`
+ : '';
+ const formattedFixed = fixed
+ ? formatCurrency( fixed, currency, storeCurrency )
+ : '';
+
+ return (
+ join(
+ filter( [ formattedPercentage, formattedFixed ], Boolean ),
+ ' + '
+ ) || '0%'
+ );
+};
diff --git a/includes/multi-currency/client/utils/currency/index.js b/includes/multi-currency/client/utils/currency/index.js
index 8ccf1300a35..1282a2f3c83 100644
--- a/includes/multi-currency/client/utils/currency/index.js
+++ b/includes/multi-currency/client/utils/currency/index.js
@@ -252,11 +252,11 @@ export const formatExplicitCurrency = (
* @param {Object} to Target currency and amount for exchange rate calculation.
* @param {string} to.currency Target currency code.
* @param {number} to.amount Target amount.
+ * @param {number|undefined} rate Exchange rate.
*
* @return {string?} formatted string like `€1,00 → $1,19: $29.99`.
- *
- * */
-export const formatFX = ( from, to ) => {
+ */
+export const formatFX = ( from, to, rate ) => {
if ( ! from.currency || ! to.currency ) {
return;
}
@@ -266,13 +266,26 @@ export const formatFX = ( from, to ) => {
fromAmount,
from.currency,
true
- ) } → ${ formatExchangeRate( from, to ) }: ${ formatExplicitCurrency(
+ ) } → ${ formatExchangeRate( from, to, rate ) }: ${ formatExplicitCurrency(
Math.abs( to.amount ),
to.currency
) }`;
};
-function formatExchangeRate( from, to ) {
+/**
+ * Formats exchange rate value string from one currency to another.
+ *
+ * @param {Object} from Source currency and amount for exchange rate calculation.
+ * @param {string} from.currency Source currency code.
+ * @param {number} from.amount Source amount.
+ * @param {Object} to Target currency and amount for exchange rate calculation.
+ * @param {string} to.currency Target currency code.
+ * @param {number} to.amount Target amount.
+ * @param {number|undefined} rate Exchange rate.
+ *
+ * @return {string?} formatted string like `1,19`.
+ */
+function formatExchangeRate( from, to, rate ) {
const { currencyData } = wcpaySettings;
let exchangeRate =
@@ -281,6 +294,9 @@ function formatExchangeRate( from, to ) {
from.amount !== 0
? Math.abs( to.amount / from.amount )
: 0;
+ if ( typeof rate === 'number' ) {
+ exchangeRate = rate;
+ }
if ( isZeroDecimalCurrency( to.currency ) ) {
exchangeRate *= 100;
}