From 29810cd5d86ab604c774388f97baf692c7a4cfdd Mon Sep 17 00:00:00 2001 From: Valery Sukhomlinov <683297+dmvrtx@users.noreply.github.com> Date: Wed, 29 Jan 2025 16:30:11 +0100 Subject: [PATCH] Draft implementation of the transaction breakdown --- client/data/index.ts | 11 + client/data/timeline/types.d.ts | 63 ++++ .../payment-details/payment-details/index.tsx | 5 + .../transaction-breakdown/index.tsx | 319 ++++++++++++++++++ .../transaction-breakdown/style.scss | 35 ++ .../client/utils/currency/index.js | 26 +- 6 files changed, 454 insertions(+), 5 deletions(-) create mode 100644 client/data/timeline/types.d.ts create mode 100644 client/payment-details/transaction-breakdown/index.tsx create mode 100644 client/payment-details/transaction-breakdown/style.scss 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..c8025c218be --- /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 > = ( { ) } + + + + = ( { + paymentIntentId, +} ) => { + const { timeline, timelineError, isLoading } = useTimeline( + paymentIntentId + ); + + let captureEvents: TimelineItem[] = []; + + if ( timeline ) { + captureEvents = filter( timeline, function ( item: TimelineItem ) { + return item.type === 'captured'; + } ); + } + + let captureEvent: TimelineItem | undefined; + if ( captureEvents.length > 0 ) { + captureEvent = captureEvents[ 0 ]; + } + + if ( + undefined === captureEvent || + undefined === captureEvent.transaction_details || + undefined === captureEvent.fee_rates + ) { + return
; + } + + const formattedStoreAmount = + formatCurrency( + captureEvent.transaction_details.store_amount, + captureEvent.transaction_details.store_currency + ) + + ' ' + + captureEvent.transaction_details.store_currency; + + const formattedCustomerAmount = + formatCurrency( + captureEvent.transaction_details.customer_amount, + captureEvent.transaction_details.customer_currency, + captureEvent.transaction_details.store_currency + ) + + ' ' + + captureEvent.transaction_details.customer_currency; + + const isMultiCurrency = + captureEvent.transaction_details.store_currency !== + captureEvent.transaction_details.customer_currency; + + const formattedAmount = + formattedStoreAmount + + ( isMultiCurrency ? ` → ${ formattedCustomerAmount }` : '' ); + + const feeExchangeRate = captureEvent.fee_rates.fee_exchange_rate?.rate || 1; + + const conversionRate = isMultiCurrency ? ( + + { ' @ 1 ' } + { captureEvent.transaction_details.store_currency } + { ' → ' } + { Math.round( feeExchangeRate * 1000000 ) / 1000000 } + { ' ' } + { captureEvent.transaction_details.customer_currency } + + ) : ( + '' + ); + + function formatFeeType( + type: string, + additionalType: string | undefined + ): string { + if ( 'total' === type ) { + return __( 'Total transaction fee', 'woocommerce-payments' ); + } + if ( 'base' === type ) { + return __( 'Base fee', 'woocommerce-payments' ); + } + if ( 'additional' === type && 'international' === additionalType ) { + return __( 'International card fee', 'woocommerce-payments' ); + } + if ( 'additional' === type && 'fx' === additionalType ) { + return __( 'Currency conversion fee', 'woocommerce-payments' ); + } + return __( 'Fee', 'woocommerce-payments' ); + } + + function formatFeeRate( + percentage: number, + fixed: number, + currency: string, + storeCurrency: string + ): string { + const formattedPercentage = percentage + ? Math.round( percentage * 10000 ) / 100 + '%' + : ''; + const formattedFixed = fixed + ? formatCurrency( fixed, currency, storeCurrency ) + : ''; + return join( + filter( [ formattedPercentage, formattedFixed ], Boolean ), + ' + ' + ); + } + + function formatFee( + type: string, + additionalType: string | undefined, + percentage: number, + fixed: number, + currency: string, + storeCurrency: string, + amount?: number + ): JSX.Element[] { + const formattedFeeType = formatFeeType( type, additionalType ); + const formattedFeeRate = formatFeeRate( + percentage, + fixed, + currency, + storeCurrency + ); + const formattedFeeAmount = amount + ? ' - ' + formatCurrency( amount, currency, storeCurrency ) + : ''; + + return [ + + + { formattedFeeType } + + + { formattedFeeRate } + + + { formattedFeeAmount } + + , + ]; + } + + function formatFees( event: TimelineItem ): JSX.Element[] { + if ( + undefined === event.fee_rates || + undefined === event.transaction_details + ) { + return []; + } + + const storeCurrency = event.transaction_details.store_currency; + + const fees = []; + + if ( undefined === event.fee_rates.history ) { + fees.push( + formatFee( + 'base', + '', + event.fee_rates.percentage, + event.fee_rates.fixed, + event.fee_rates.fixed_currency, + storeCurrency + ) + ); + } else { + event.fee_rates.history.map( ( fee: TimelineFeeRate ) => + fees.push( + formatFee( + fee.type, + fee.additional_type, + fee.percentage_rate, + fee.fixed_rate, + fee.currency, + storeCurrency + ) + ) + ); + } + + fees.push( + formatFee( + 'total', + '', + event.fee_rates.percentage, + event.fee_rates.fixed, + event.fee_rates.fixed_currency, + storeCurrency, + event.transaction_details.store_fee + ) + ); + + return flatten( fees ); + } + + return captureEvent ? ( + + + + + + + { timelineError instanceof Error ? ( +
+ { __( + 'Error while loading payment details', + 'woocommerce-payments' + ) } +
+ ) : ( + + + + { __( + 'Authorized payment', + 'woocommerce-payments' + ) } + + + + { formattedAmount } + { conversionRate } + + + + + + { __( + 'Transaction fee', + 'woocommerce-payments' + ) } + + + + { formatFees( captureEvent ) } + + + ) } +
+
+ + + { timelineError instanceof Error ? ( +
+ ) : ( + + + { __( '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/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; }