diff --git a/changelog/fix-method-title-availability b/changelog/fix-method-title-availability new file mode 100644 index 00000000000..d9d2a0c0217 --- /dev/null +++ b/changelog/fix-method-title-availability @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Set payment method title once title is known. diff --git a/changelog/fix-tokenized-cart-multiple-variations b/changelog/fix-tokenized-cart-multiple-variations new file mode 100644 index 00000000000..5d155cd5513 --- /dev/null +++ b/changelog/fix-tokenized-cart-multiple-variations @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: fix: tokenized cart & multiple variations. + + diff --git a/changelog/fix-tokenized-cart-subscription-signup-fee b/changelog/fix-tokenized-cart-subscription-signup-fee new file mode 100644 index 00000000000..5abe9f0226b --- /dev/null +++ b/changelog/fix-tokenized-cart-subscription-signup-fee @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: fix: tokenized cart subscription signup fee price + + diff --git a/changelog/update-5002-authorizations-improve-message-shown-to-merchants-when-there-is-an-error-capturing-authorizations b/changelog/update-5002-authorizations-improve-message-shown-to-merchants-when-there-is-an-error-capturing-authorizations new file mode 100644 index 00000000000..b76d70e1cf9 --- /dev/null +++ b/changelog/update-5002-authorizations-improve-message-shown-to-merchants-when-there-is-an-error-capturing-authorizations @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Update error messages for payment authorization actions to provide more specific and user-friendly feedback. diff --git a/client/data/authorizations/actions.ts b/client/data/authorizations/actions.ts index 0eaf50f105d..0885da1cbfe 100644 --- a/client/data/authorizations/actions.ts +++ b/client/data/authorizations/actions.ts @@ -19,6 +19,51 @@ import { import { STORE_NAME } from '../constants'; import { ApiError } from 'wcpay/types/errors'; +const getErrorMessage = ( apiError: { + code?: string; + message?: string; +} ): string => { + // Map specific error codes to user-friendly messages + const errorMessages: Record< string, string > = { + wcpay_missing_order: __( + 'The order could not be found.', + 'woocommerce-payments' + ), + wcpay_refunded_order_uncapturable: __( + 'Payment cannot be processed for partially or fully refunded orders.', + 'woocommerce-payments' + ), + wcpay_intent_order_mismatch: __( + 'The payment cannot be processed due to a mismatch with order details.', + 'woocommerce-payments' + ), + wcpay_payment_uncapturable: __( + 'This payment cannot be processed in its current state.', + 'woocommerce-payments' + ), + wcpay_capture_error: __( + 'The payment capture failed to complete.', + 'woocommerce-payments' + ), + wcpay_cancel_error: __( + 'The payment cancellation failed to complete.', + 'woocommerce-payments' + ), + wcpay_server_error: __( + 'An unexpected error occurred. Please try again later.', + 'woocommerce-payments' + ), + }; + + return ( + errorMessages[ apiError.code ?? '' ] ?? + __( + 'Unable to process the payment. Please try again later.', + 'woocommerce-payments' + ) + ); +}; + export function updateAuthorizations( query: Query, data: Authorization[] @@ -165,17 +210,29 @@ export function* submitCaptureAuthorization( ) ); } catch ( error ) { + const baseErrorMessage = sprintf( + // translators: %s Order id + __( + 'There has been an error capturing the payment for order #%s.', + 'woocommerce-payments' + ), + orderId + ); + + const apiError = error as { + code?: string; + message?: string; + data?: { + status?: number; + }; + }; + + const errorDetails = getErrorMessage( apiError ); + yield controls.dispatch( 'core/notices', 'createErrorNotice', - sprintf( - // translators: %s Order id - __( - 'There has been an error capturing the payment for order #%s. Please try again later.', - 'woocommerce-payments' - ), - orderId - ) + `${ baseErrorMessage } ${ errorDetails }` ); } finally { yield controls.dispatch( @@ -184,6 +241,7 @@ export function* submitCaptureAuthorization( 'getAuthorization', [ paymentIntentId ] ); + yield controls.dispatch( STORE_NAME, 'setIsRequestingAuthorization', @@ -278,17 +336,29 @@ export function* submitCancelAuthorization( ) ); } catch ( error ) { + const baseErrorMessage = sprintf( + // translators: %s Order id + __( + 'There has been an error canceling the payment for order #%s.', + 'woocommerce-payments' + ), + orderId + ); + + const apiError = error as { + code?: string; + message?: string; + data?: { + status?: number; + }; + }; + + const errorDetails = getErrorMessage( apiError ); + yield controls.dispatch( 'core/notices', 'createErrorNotice', - sprintf( - // translators: %s Order id - __( - 'There has been an error canceling the payment for order #%s. Please try again later.', - 'woocommerce-payments' - ), - orderId - ) + `${ baseErrorMessage } ${ errorDetails }` ); } finally { yield controls.dispatch( diff --git a/client/data/authorizations/test/actions.test.ts b/client/data/authorizations/test/actions.test.ts index 1c73ab5d7a2..171ef6dd5ad 100644 --- a/client/data/authorizations/test/actions.test.ts +++ b/client/data/authorizations/test/actions.test.ts @@ -16,6 +16,7 @@ import { updateAuthorization, } from '../actions'; import authorizationsFixture from './authorizations.fixture.json'; +import { STORE_NAME } from 'wcpay/data/constants'; describe( 'Authorizations actions', () => { describe( 'submitCaptureAuthorization', () => { @@ -153,10 +154,117 @@ describe( 'Authorizations actions', () => { controls.dispatch( 'core/notices', 'createErrorNotice', - 'There has been an error capturing the payment for order #42. Please try again later.' + 'There has been an error capturing the payment for order #42. Unable to process the payment. Please try again later.' ) ); } ); + + describe( 'error handling', () => { + it( 'should create error notice with API error message', () => { + const generator = submitCaptureAuthorization( 'pi_123', 123 ); + + // Mock the start of the capture process + expect( generator.next().value ).toEqual( + controls.dispatch( + STORE_NAME, + 'startResolution', + 'getAuthorization', + [ 'pi_123' ] + ) + ); + + expect( generator.next().value ).toEqual( + controls.dispatch( + STORE_NAME, + 'setIsRequestingAuthorization', + true + ) + ); + + // Mock API error response + const apiError = { + code: 'wcpay_refunded_order_uncapturable', + message: + 'Payment cannot be captured for partially or fully refunded orders.', + data: { status: 400 }, + }; + + // Simulate API error + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error capturing the payment for order #123. Payment cannot be processed for partially or fully refunded orders.' + ) + ); + + // Verify cleanup in finally block + expect( generator.next().value ).toEqual( + controls.dispatch( + STORE_NAME, + 'finishResolution', + 'getAuthorization', + [ 'pi_123' ] + ) + ); + + expect( generator.next().value ).toEqual( + controls.dispatch( + STORE_NAME, + 'setIsRequestingAuthorization', + false + ) + ); + } ); + + it( 'should create error notice with fallback message when API error has no message', () => { + const generator = submitCaptureAuthorization( 'pi_123', 123 ); + + // Skip initial dispatch calls + generator.next(); + generator.next(); + + // Mock API error without message + const apiError = { + code: 'unknown_error', + data: { status: 500 }, + }; + + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error capturing the payment for order #123. Unable to process the payment. Please try again later.' + ) + ); + } ); + + it( 'should show default error notice for unknown error code', () => { + const generator = submitCaptureAuthorization( + 'pi_unknown', + 999 + ); + + // Start the generator to the point where it would throw an error + generator.next(); + generator.next(); + + // Mock an API error with an unknown error code + const apiError = { + code: 'unknown_error_code', + data: { status: 500 }, + }; + + // Expect the default error message to be dispatched + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error capturing the payment for order #999. Unable to process the payment. Please try again later.' + ) + ); + } ); + } ); } ); describe( 'submitCancelAuthorization', () => { @@ -294,9 +402,56 @@ describe( 'Authorizations actions', () => { controls.dispatch( 'core/notices', 'createErrorNotice', - 'There has been an error canceling the payment for order #42. Please try again later.' + 'There has been an error canceling the payment for order #42. Unable to process the payment. Please try again later.' ) ); } ); + + describe( 'error handling', () => { + it( 'should create error notice with API error message', () => { + const generator = submitCancelAuthorization( 'pi_123', 123 ); + + // Skip initial dispatch calls + generator.next(); + generator.next(); + + // Mock API error response + const apiError = { + code: 'wcpay_payment_uncapturable', + message: 'The payment cannot be canceled at this time.', + data: { status: 400 }, + }; + + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error canceling the payment for order #123. This payment cannot be processed in its current state.' + ) + ); + } ); + + it( 'should create error notice with fallback message when API error has no message', () => { + const generator = submitCancelAuthorization( 'pi_123', 123 ); + + // Skip initial dispatch calls + generator.next(); + generator.next(); + + // Mock API error without message + const apiError = { + code: 'unknown_error', + data: { status: 500 }, + }; + + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error canceling the payment for order #123. Unable to process the payment. Please try again later.' + ) + ); + } ); + } ); } ); } ); diff --git a/client/tokenized-express-checkout/index.js b/client/tokenized-express-checkout/index.js index eca8a2f8a36..3b2eb1a8b46 100644 --- a/client/tokenized-express-checkout/index.js +++ b/client/tokenized-express-checkout/index.js @@ -326,6 +326,21 @@ jQuery( ( $ ) => { 'wcpay.express-checkout.update-button-data', 'automattic/wcpay/express-checkout', async () => { + // if the product cannot be added to cart (because of missing variation selection, etc), + // don't try to add it to the cart to get new data - the call will likely fail. + if ( + getExpressCheckoutData( 'button_context' ) === 'product' + ) { + const addToCartButton = $( + '.single_add_to_cart_button' + ); + + // First check if product can be added to cart. + if ( addToCartButton.is( '.disabled' ) ) { + return; + } + } + try { expressCheckoutButtonUi.blockButton(); @@ -375,7 +390,7 @@ jQuery( ( $ ) => { expressCheckoutButtonUi.unblockButton(); } catch ( e ) { - expressCheckoutButtonUi.hide(); + expressCheckoutButtonUi.hideContainer(); } } ); diff --git a/client/tokenized-express-checkout/transformers/wc-to-stripe.js b/client/tokenized-express-checkout/transformers/wc-to-stripe.js index 867a389006b..6d9b39035ce 100644 --- a/client/tokenized-express-checkout/transformers/wc-to-stripe.js +++ b/client/tokenized-express-checkout/transformers/wc-to-stripe.js @@ -40,8 +40,8 @@ export const transformPrice = ( price, priceObject ) => { export const transformCartDataForDisplayItems = ( cartData ) => { const displayItems = cartData.items.map( ( item ) => ( { amount: transformPrice( - parseInt( item.prices.price, 10 ), - item.prices + parseInt( item.totals?.line_subtotal || item.prices.price, 10 ), + item.totals || item.prices ), name: [ item.name, @@ -96,7 +96,7 @@ export const transformCartDataForDisplayItems = ( cartData ) => { * @return {{id: string, label: string, amount: integer, deliveryEstimate: string}} `shippingRates` for Stripe. */ export const transformCartDataForShippingRates = ( cartData ) => - cartData.shipping_rates?.[ 0 ].shipping_rates + cartData.shipping_rates?.[ 0 ]?.shipping_rates .sort( ( rateA, rateB ) => { if ( rateA.selected === rateB.selected ) { return 0; // Keep relative order if both have the same value for 'selected' diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 3e02aa6acf5..4f579caf2b1 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -321,8 +321,7 @@ public function __construct( ]; if ( 'card' !== $this->stripe_id ) { - $this->id = self::GATEWAY_ID . '_' . $this->stripe_id; - $this->method_title = "WooPayments ($this->title)"; + $this->id = self::GATEWAY_ID . '_' . $this->stripe_id; } // Capabilities have different keys than the payment method ID's, @@ -366,7 +365,8 @@ public function __construct( * @return string */ public function get_title() { - $this->title = $this->payment_method->get_title(); + $this->title = $this->payment_method->get_title(); + $this->method_title = "WooPayments ($this->title)"; return parent::get_title(); }