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; }