Skip to content

Commit

Permalink
Display payment error message in the Payment context with Blocks (#9017)
Browse files Browse the repository at this point in the history
  • Loading branch information
timur27 authored Jun 28, 2024
1 parent 858ad7e commit 50a2090
Show file tree
Hide file tree
Showing 9 changed files with 302 additions and 162 deletions.
4 changes: 4 additions & 0 deletions changelog/fix-error-message-location-in-blocks
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: fix

Display payment error message in the Payment context with Blocks.
22 changes: 22 additions & 0 deletions client/checkout/blocks/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ export const usePaymentCompleteHandler = (
);
};

/**
* Handles onCheckoutFail event emitter which fires after Blocks checkout processor responds with error.
*
* Displays the error message returned from checkout processor in the noticeContexts.PAYMENTS area.
*
* @param {Function} onCheckoutFail The onCheckoutFail event emitter.
* @param {Object} emitResponse Various helpers for usage with observer.
*/
export const usePaymentFailHandler = ( onCheckoutFail, emitResponse ) => {
useEffect(
() =>
onCheckoutFail( ( { processingResponse: { paymentDetails } } ) => {
return {
type: 'failure',
message: paymentDetails.errorMessage,
messageContext: emitResponse.noticeContexts.PAYMENTS,
};
} ),
[ onCheckoutFail, emitResponse ]
);
};

export const useFingerprint = () => {
const [ fingerprint, setFingerprint ] = useState( '' );
const [ error, setError ] = useState( null );
Expand Down
6 changes: 4 additions & 2 deletions client/checkout/blocks/payment-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useEffect, useRef } from 'react';
/**
* Internal dependencies
*/
import { usePaymentCompleteHandler } from './hooks';
import { usePaymentCompleteHandler, usePaymentFailHandler } from './hooks';
import {
getStripeElementOptions,
blocksShowLinkButtonHandler,
Expand Down Expand Up @@ -55,7 +55,7 @@ const PaymentProcessor = ( {
api,
activePaymentMethod,
testingInstructions,
eventRegistration: { onPaymentSetup, onCheckoutSuccess },
eventRegistration: { onPaymentSetup, onCheckoutSuccess, onCheckoutFail },
emitResponse,
paymentMethodId,
upeMethods,
Expand Down Expand Up @@ -237,6 +237,8 @@ const PaymentProcessor = ( {
shouldSavePayment
);

usePaymentFailHandler( onCheckoutFail, emitResponse );

const setHasLoadError = ( event ) => {
hasLoadErrorRef.current = true;
onLoadError( event );
Expand Down
113 changes: 113 additions & 0 deletions client/checkout/blocks/test/hooks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* External dependencies
*/
import { renderHook, act } from '@testing-library/react-hooks';

/**
* Internal dependencies
*/
import { usePaymentFailHandler, useFingerprint } from '../hooks';
import * as fingerprintModule from '../../utils/fingerprint';
// import { act } from '@testing-library/react';

describe( 'usePaymentFailHandler', () => {
let mockOnCheckoutFail;
let mockEmitResponse;

beforeEach( () => {
mockOnCheckoutFail = jest.fn();
mockEmitResponse = {
noticeContexts: {
PAYMENTS: 'payments_context',
},
};
} );

it( 'should return the correct failure response checkout processor payment failure', () => {
const errorMessage = 'Your card was declined.';
const paymentDetails = {
errorMessage: errorMessage,
};

renderHook( () =>
usePaymentFailHandler( mockOnCheckoutFail, mockEmitResponse )
);

expect( mockOnCheckoutFail ).toHaveBeenCalled();
const failureResponse = mockOnCheckoutFail.mock.calls[ 0 ][ 0 ]( {
processingResponse: { paymentDetails },
} );

expect( failureResponse ).toEqual( {
type: 'failure',
message: errorMessage,
messageContext: 'payments_context',
} );
} );
} );

describe( 'useFingerprint', () => {
it( 'should return fingerprint', async () => {
const mockVisitorId = 'test-visitor-id';
const mockGetFingerprint = jest
.fn()
.mockResolvedValue( { visitorId: mockVisitorId } );

jest.spyOn( fingerprintModule, 'getFingerprint' ).mockImplementation(
mockGetFingerprint
);

let hook;

await act( async () => {
hook = renderHook( () => useFingerprint() );
} );

const [ fingerprint, error ] = hook.result.current;

expect( mockGetFingerprint ).toHaveBeenCalledTimes( 1 );
expect( fingerprint ).toBe( mockVisitorId );
expect( error ).toBeNull();
} );

it( 'should handle errors when getting fingerprint fails', async () => {
const mockError = new Error( 'Test error' );
const mockGetFingerprint = jest.fn().mockRejectedValue( mockError );

jest.spyOn( fingerprintModule, 'getFingerprint' ).mockImplementation(
mockGetFingerprint
);

let hook;

await act( async () => {
hook = renderHook( () => useFingerprint() );
} );

const [ fingerprint, error ] = hook.result.current;

expect( mockGetFingerprint ).toHaveBeenCalledTimes( 1 );
expect( fingerprint ).toBe( '' );
expect( error ).toBe( mockError.message );
} );

it( 'should use generic error message when error has no message', async () => {
const mockGetFingerprint = jest.fn().mockRejectedValue( {} );

jest.spyOn( fingerprintModule, 'getFingerprint' ).mockImplementation(
mockGetFingerprint
);

let hook;

await act( async () => {
hook = renderHook( () => useFingerprint() );
} );

const [ fingerprint, error ] = hook.result.current;

expect( mockGetFingerprint ).toHaveBeenCalledTimes( 1 );
expect( fingerprint ).toBe( '' );
expect( error ).toBe( fingerprintModule.FINGERPRINT_GENERIC_ERROR );
} );
} );
1 change: 1 addition & 0 deletions client/checkout/blocks/test/payment-processor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jest.mock( 'wcpay/checkout/blocks/utils', () => ( {
} ) );
jest.mock( '../hooks', () => ( {
usePaymentCompleteHandler: () => null,
usePaymentFailHandler: () => null,
} ) );
jest.mock( '@woocommerce/blocks-registry', () => ( {
getPaymentMethods: () => ( {
Expand Down
35 changes: 32 additions & 3 deletions includes/class-wc-payment-gateway-wcpay.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
exit; // Exit if accessed directly.
}

use Automattic\WooCommerce\StoreApi\Payments\PaymentContext;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use WCPay\Constants\Country_Code;
use WCPay\Constants\Fraud_Meta_Box_Type;
use WCPay\Constants\Order_Mode;
Expand Down Expand Up @@ -560,6 +562,7 @@ public function init_hooks() {
add_filter( 'woocommerce_billing_fields', [ $this, 'checkout_update_email_field_priority' ], 50 );

add_action( 'woocommerce_update_order', [ $this, 'schedule_order_tracking' ], 10, 2 );
add_action( 'woocommerce_rest_checkout_process_payment_with_context', [ $this, 'setup_payment_error_handler' ], 10, 2 );

add_filter( 'rest_request_before_callbacks', [ $this, 'remove_all_actions_on_preflight_check' ], 10, 3 );

Expand Down Expand Up @@ -1296,9 +1299,13 @@ public function process_payment( $order_id ) {

// This allows WC to check if WP_DEBUG mode is enabled before returning previous Exception and expose Exception class name to frontend.
add_filter( 'woocommerce_return_previous_exceptions', '__return_true' );
// Re-throw the exception after setting everything up.
// This makes the error notice show up both in the regular and block checkout.
throw new Exception( WC_Payments_Utils::get_filtered_error_message( $e, $blocked_by_fraud_rules ), 0, $e );
wc_add_notice( wp_strip_all_tags( WC_Payments_Utils::get_filtered_error_message( $e, $blocked_by_fraud_rules ) ), 'error' );
do_action( 'update_payment_result_on_error', $e, $order );

return [
'result' => 'fail',
'redirect' => '',
];
}
}

Expand Down Expand Up @@ -1365,6 +1372,28 @@ public function update_customer_with_order_data( $order, $customer_id, $is_test_
$this->customer_service->update_customer_for_user( $customer_id, $user, $customer_data );
}

/**
* Sets up a handler to add error details to the payment result.
* Registers an action to handle 'update_payment_result_on_error',
* using the payment result object from 'woocommerce_rest_checkout_process_payment_with_context'.
*
* @param PaymentContext $context The payment context.
* @param PaymentResult $result The payment result, passed by reference.
*/
public function setup_payment_error_handler( PaymentContext $context, PaymentResult &$result ) {
add_action(
'update_payment_result_on_error',
function ( $error ) use ( &$result ) {
$result->set_payment_details(
array_merge(
$result->payment_details,
[ 'errorMessage' => wp_strip_all_tags( $error->getMessage() ) ]
)
);
}
);
}

/**
* Manages customer details held on WCPay server for WordPress user associated with an order.
*
Expand Down
11 changes: 7 additions & 4 deletions tests/unit/payment-methods/test-class-upe-payment-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -927,13 +927,16 @@ public function test_create_token_from_setup_intent_adds_token() {
$this->assertEquals( $mock_token, $this->mock_gateway->create_token_from_setup_intent( $mock_setup_intent_id, $mock_user ) );
}

public function test_exception_will_be_thrown_if_phone_number_is_invalid() {
public function test_failure_result_returned_if_phone_number_is_invalid() {
$order = WC_Helper_Order::create_order();
$order->set_billing_phone( '+1123456789123456789123' );
$order->save();
$this->expectException( Exception::class );
$this->expectExceptionMessage( 'Invalid phone number.' );
$this->mock_gateway->process_payment( $order->get_id() );
$result = $this->mock_gateway->process_payment( $order->get_id() );
$this->assertEquals( 'fail', $result['result'] );
$error_notices = WC()->session->get( 'wc_notices' );
$this->assertNotEmpty( $error_notices );
$this->assertEquals( 'Invalid phone number.', $error_notices['error'][0]['notice'] );
WC()->session->set( 'wc_notices', [] );
}

public function test_remove_link_payment_method_if_card_disabled() {
Expand Down
Loading

0 comments on commit 50a2090

Please sign in to comment.