diff --git a/changelog/refactor-ece-to-use-confirmation-tokens-instead-of-payment-methods b/changelog/refactor-ece-to-use-confirmation-tokens-instead-of-payment-methods new file mode 100644 index 00000000000..4d30805edb0 --- /dev/null +++ b/changelog/refactor-ece-to-use-confirmation-tokens-instead-of-payment-methods @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +refactor: ECE to use confirmation tokens instead of payment methods diff --git a/client/express-checkout/__tests__/event-handlers.test.js b/client/express-checkout/__tests__/event-handlers.test.js index 1724bf531e3..899db72fcec 100644 --- a/client/express-checkout/__tests__/event-handlers.test.js +++ b/client/express-checkout/__tests__/event-handlers.test.js @@ -295,8 +295,8 @@ describe( 'Express checkout event handlers', () => { confirmIntent: jest.fn(), }; stripe = { - createPaymentMethod: jest.fn().mockResolvedValue( { - paymentMethod: { id: 'pm_123' }, + createConfirmationToken: jest.fn().mockResolvedValue( { + confirmationToken: { id: 'ctoken_123' }, } ), }; elements = { @@ -351,13 +351,13 @@ describe( 'Express checkout event handlers', () => { ); expect( completePayment ).not.toHaveBeenCalled(); - expect( stripe.createPaymentMethod ).not.toHaveBeenCalled(); + expect( stripe.createConfirmationToken ).not.toHaveBeenCalled(); expect( abortPayment ).toHaveBeenCalledWith( 'Submit error' ); } ); - it( 'should abort payment if stripe.createPaymentMethod fails', async () => { - stripe.createPaymentMethod.mockResolvedValue( { - error: { message: 'Payment method error' }, + it( 'should abort payment if stripe.createConfirmationToken fails', async () => { + stripe.createConfirmationToken.mockResolvedValue( { + error: { message: 'Confirmation token error' }, } ); await onConfirmHandler( @@ -370,11 +370,11 @@ describe( 'Express checkout event handlers', () => { ); expect( completePayment ).not.toHaveBeenCalled(); - expect( stripe.createPaymentMethod ).toHaveBeenCalledWith( { + expect( stripe.createConfirmationToken ).toHaveBeenCalledWith( { elements, } ); expect( abortPayment ).toHaveBeenCalledWith( - 'Payment method error' + 'Confirmation token error' ); } ); diff --git a/client/express-checkout/__tests__/tokenized-express-checkout--order-pay-page.test.js b/client/express-checkout/__tests__/tokenized-express-checkout--order-pay-page.test.js index 2995d275c08..9c22cd83472 100644 --- a/client/express-checkout/__tests__/tokenized-express-checkout--order-pay-page.test.js +++ b/client/express-checkout/__tests__/tokenized-express-checkout--order-pay-page.test.js @@ -301,9 +301,9 @@ describe( 'Tokenized Express Checkout Element - Pay-for-order page logic', () => mode: 'payment', amount: 6510, currency: 'eur', - paymentMethodCreation: 'manual', appearance: expect.anything(), locale: 'it', + paymentMethodTypes: [ 'card' ], } ); // triggering the `ready` event on the ECE button, to test its callback. diff --git a/client/express-checkout/__tests__/tokenized-express-checkout--product-page.test.js b/client/express-checkout/__tests__/tokenized-express-checkout--product-page.test.js index fc6efc37257..1cf4df510cd 100644 --- a/client/express-checkout/__tests__/tokenized-express-checkout--product-page.test.js +++ b/client/express-checkout/__tests__/tokenized-express-checkout--product-page.test.js @@ -158,9 +158,9 @@ describe( 'Tokenized Express Checkout Element - Product page logic', () => { mode: 'payment', amount: 1100, currency: 'usd', - paymentMethodCreation: 'manual', appearance: expect.anything(), locale: 'it', + paymentMethodTypes: [ 'card' ], } ); // triggering the `ready` event on the ECE button, to test its callback. @@ -187,9 +187,9 @@ describe( 'Tokenized Express Checkout Element - Product page logic', () => { mode: 'payment', amount: 1100, currency: 'usd', - paymentMethodCreation: 'manual', appearance: expect.anything(), locale: 'it', + paymentMethodTypes: [ 'card' ], } ); // triggering the `ready` event on the ECE button, to test its callback. diff --git a/client/express-checkout/__tests__/tokenized-express-checkout--shortcode-checkout-page.test.js b/client/express-checkout/__tests__/tokenized-express-checkout--shortcode-checkout-page.test.js index 9b91d853b70..357c02a8496 100644 --- a/client/express-checkout/__tests__/tokenized-express-checkout--shortcode-checkout-page.test.js +++ b/client/express-checkout/__tests__/tokenized-express-checkout--shortcode-checkout-page.test.js @@ -135,9 +135,9 @@ describe( 'Tokenized Express Checkout Element - Shortcode checkout page logic', mode: 'payment', amount: 3697, currency: 'usd', - paymentMethodCreation: 'manual', appearance: expect.anything(), locale: 'it', + paymentMethodTypes: [ 'card' ], } ); // triggering the `ready` event on the ECE button, to test its callback. diff --git a/client/express-checkout/blocks/components/express-checkout-container.js b/client/express-checkout/blocks/components/express-checkout-container.js index f9abeebd65c..b8e680bbd02 100644 --- a/client/express-checkout/blocks/components/express-checkout-container.js +++ b/client/express-checkout/blocks/components/express-checkout-container.js @@ -24,7 +24,7 @@ const ExpressCheckoutContainer = ( props ) => { const options = { mode: 'payment', - paymentMethodCreation: 'manual', + paymentMethodTypes: [ 'card' ], // ensuring that the total amount is transformed to the correct format. amount: ! isPreview ? transformPrice( billing.cartTotal.value, { diff --git a/client/express-checkout/event-handlers.js b/client/express-checkout/event-handlers.js index 9a146e1ecb4..d74c1be08a1 100644 --- a/client/express-checkout/event-handlers.js +++ b/client/express-checkout/event-handlers.js @@ -108,7 +108,7 @@ export const onConfirmHandler = async ( return abortPayment( submitError.message ); } - const { paymentMethod, error } = await stripe.createPaymentMethod( { + const { confirmationToken, error } = await stripe.createConfirmationToken( { elements, } ); @@ -123,7 +123,7 @@ export const onConfirmHandler = async ( // so that we make it harder for external plugins to modify or intercept checkout data. ...transformStripePaymentMethodForStoreApi( event, - paymentMethod.id + confirmationToken.id ), extensions: applyFilters( 'wcpay.express-checkout.cart-place-order-extension-data', diff --git a/client/express-checkout/index.js b/client/express-checkout/index.js index f062d1a7432..04e4202b9a2 100644 --- a/client/express-checkout/index.js +++ b/client/express-checkout/index.js @@ -213,7 +213,7 @@ jQuery( ( $ ) => { mode: 'payment', amount: creationOptions.total, currency: creationOptions.currency, - paymentMethodCreation: 'manual', + paymentMethodTypes: [ 'card' ], appearance: getExpressCheckoutButtonAppearance(), locale: getExpressCheckoutData( 'stripe' )?.locale ?? 'en', } ); diff --git a/client/express-checkout/transformers/stripe-to-wc.js b/client/express-checkout/transformers/stripe-to-wc.js index bc57f3929dc..85755af8dd5 100644 --- a/client/express-checkout/transformers/stripe-to-wc.js +++ b/client/express-checkout/transformers/stripe-to-wc.js @@ -26,13 +26,13 @@ export const transformStripeShippingAddressForStoreApi = ( * Transform order data from Stripe's object to the expected format for WC. * * @param {Object} paymentData Stripe's order object. - * @param {string} paymentMethodId Stripe's payment method id. + * @param {string} confirmationTokenId Stripe's confirmation token id. * * @return {Object} Order object in the format WooCommerce expects. */ export const transformStripePaymentMethodForStoreApi = ( paymentData, - paymentMethodId + confirmationTokenId ) => { const name = paymentData.billingDetails?.name || ''; const billing = paymentData.billingDetails?.address ?? {}; @@ -82,8 +82,8 @@ export const transformStripePaymentMethodForStoreApi = ( value: window.wcpayFraudPreventionToken ?? '', }, { - key: 'wcpay-payment-method', - value: paymentMethodId, + key: 'wcpay-confirmation-token', + value: confirmationTokenId, }, { key: 'express_payment_type', diff --git a/client/express-checkout/utils/checkPaymentMethodIsAvailable.tsx b/client/express-checkout/utils/checkPaymentMethodIsAvailable.tsx index 3899785ab7f..825b239f5fe 100644 --- a/client/express-checkout/utils/checkPaymentMethodIsAvailable.tsx +++ b/client/express-checkout/utils/checkPaymentMethodIsAvailable.tsx @@ -55,7 +55,7 @@ const checkPaymentMethodIsAvailableInternal = ( stripe={ api.loadStripeForExpressCheckout() } options={ { mode: 'payment', - paymentMethodCreation: 'manual', + paymentMethodTypes: [ 'card' ], amount: Number( totalPrice ), currency: currencyCode.toLowerCase(), } } diff --git a/includes/class-payment-information.php b/includes/class-payment-information.php index a948554e985..c44c83d687f 100644 --- a/includes/class-payment-information.php +++ b/includes/class-payment-information.php @@ -183,9 +183,9 @@ public function is_merchant_initiated(): bool { } /** - * Returns the payment method ID. + * Returns the payment method ID or confirmation token. * - * @return string The payment method ID. + * @return string The payment method ID or confirmation token. */ public function get_payment_method(): string { // Use the token if we have it. @@ -196,6 +196,17 @@ public function get_payment_method(): string { return $this->payment_method; } + /** + * Returns whether the payment is using a confirmation token or a payment method. + * + * @see https://docs.stripe.com/payments/mobile/migration-confirmation-tokens + * + * @return bool True if using a confirmation token, false otherwise. + */ + public function is_using_confirmation_token(): bool { + return 0 === strpos( $this->get_payment_method(), 'ctoken_' ); + } + /** * Returns the order object. * @@ -287,14 +298,15 @@ public static function from_payment_request( } /** - * Extracts the payment method from the provided request. + * Extracts the payment method or confirmation token from the provided request. * * @param array $request Associative array containing payment request information. * * @return string */ public static function get_payment_method_from_request( array $request ): string { - foreach ( [ 'wcpay-payment-method', 'wcpay-payment-method-sepa' ] as $key ) { + // Check for confirmation token first (new ECE flow), then fall back to payment method (legacy flow). + foreach ( [ 'wcpay-confirmation-token', 'wcpay-payment-method', 'wcpay-payment-method-sepa' ] as $key ) { if ( ! empty( $request[ $key ] ) ) { $normalized = wc_clean( $request[ $key ] ); return is_string( $normalized ) ? $normalized : ''; diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 24bb43f5fe5..fd59a059353 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -1584,7 +1584,12 @@ public function process_payment_for_order( $cart, $payment_information, $schedul $request = Create_And_Confirm_Intention::create(); $request->set_amount( $converted_amount ); $request->set_currency_code( $currency ); - $request->set_payment_method( $payment_information->get_payment_method() ); + $payment_credential = $payment_information->get_payment_method(); + if ( $payment_information->is_using_confirmation_token() ) { + $request->set_confirmation_token( $payment_credential ); + } else { + $request->set_payment_method( $payment_credential ); + } $request->set_customer( $customer_id ); $request->set_capture_method( $payment_information->is_using_manual_capture() ); $request->set_metadata( $metadata ); @@ -2136,6 +2141,12 @@ public function get_payment_method_to_use_for_intent() { * @return array List of payment methods. */ public function get_payment_method_types( $payment_information ): array { + // For Express Checkout payments, use the payment method types sent by the client. + // These must match the types used to initialize Stripe Elements on the frontend. + if ( $payment_information->is_using_confirmation_token() ) { + return [ 'card' ]; + } + $requested_payment_method = sanitize_text_field( wp_unslash( $_POST['payment_method'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification $token = $payment_information->get_payment_token(); diff --git a/includes/core/server/request/class-create-and-confirm-intention.php b/includes/core/server/request/class-create-and-confirm-intention.php index ddf94e9e1e0..d3909976e00 100644 --- a/includes/core/server/request/class-create-and-confirm-intention.php +++ b/includes/core/server/request/class-create-and-confirm-intention.php @@ -20,6 +20,7 @@ class Create_And_Confirm_Intention extends Create_Intention { 'amount', 'currency', 'payment_method', + 'confirmation_token', 'payment_method_update_data', 'return_url', ]; @@ -27,7 +28,6 @@ class Create_And_Confirm_Intention extends Create_Intention { const REQUIRED_PARAMS = [ 'amount', 'currency', - 'payment_method', 'customer', 'metadata', ]; @@ -114,7 +114,7 @@ public function set_cvc_confirmation( $cvc_confirmation = null ) { /** * Return URL setter. * - * @param string $return_url The URL to redirect the customer back to after they authenticate their payment on the payment method’s site. + * @param string $return_url The URL to redirect the customer back to after they authenticate their payment on the payment method's site. */ public function set_return_url( $return_url ) { $this->set_param( 'return_url', $return_url ); diff --git a/includes/core/server/request/class-create-intention.php b/includes/core/server/request/class-create-intention.php index 917089eb5d7..66d6b9368c6 100644 --- a/includes/core/server/request/class-create-intention.php +++ b/includes/core/server/request/class-create-intention.php @@ -59,6 +59,19 @@ public function set_payment_method( string $payment_method_id ) { $this->set_param( 'payment_method', $payment_method_id ); } + /** + * Confirmation token setter. + * + * @param string $confirmation_token The confirmation token. + * + * @return void + * @throws Invalid_Request_Parameter_Exception + */ + public function set_confirmation_token( string $confirmation_token ) { + $this->validate_stripe_id( $confirmation_token, 'ctoken' ); + $this->set_param( 'confirmation_token', $confirmation_token ); + } + /** * Payment methods type setter. * diff --git a/tests/unit/core/server/request/test-class-core-create-and-confirm-intention-request.php b/tests/unit/core/server/request/test-class-core-create-and-confirm-intention-request.php index 9f7f3a694c2..bc4d1fe357c 100644 --- a/tests/unit/core/server/request/test-class-core-create-and-confirm-intention-request.php +++ b/tests/unit/core/server/request/test-class-core-create-and-confirm-intention-request.php @@ -66,14 +66,40 @@ public function test_exception_will_throw_if_currency_is_not_set() { $request->get_params(); } - public function test_exception_will_throw_if_payment_method_is_not_set() { + public function test_request_is_valid_with_payment_method() { $request = new Create_And_Confirm_Intention( $this->mock_api_client, $this->mock_wc_payments_http_client ); - $this->expectException( Invalid_Request_Parameter_Exception::class ); $request->set_amount( 1 ); $request->set_customer( 'cus_1' ); $request->set_metadata( [ 'order_number' => 1 ] ); $request->set_currency_code( 'usd' ); - $request->get_params(); + $request->set_payment_method( 'pm_1' ); + + $params = $request->get_params(); + + $this->assertIsArray( $params ); + $this->assertArrayHasKey( 'payment_method', $params ); + $this->assertSame( 'pm_1', $params['payment_method'] ); + } + + public function test_confirmation_token_can_replace_payment_method() { + $amount = 1; + $currency = 'usd'; + $confirmation_token = 'ctoken_123'; + $customer = 'cus_1'; + + $request = new Create_And_Confirm_Intention( $this->mock_api_client, $this->mock_wc_payments_http_client ); + $request->set_amount( $amount ); + $request->set_currency_code( $currency ); + $request->set_confirmation_token( $confirmation_token ); + $request->set_customer( $customer ); + $request->set_metadata( [ 'order_number' => 1 ] ); + + $params = $request->get_params(); + + $this->assertIsArray( $params ); + $this->assertArrayHasKey( 'confirmation_token', $params ); + $this->assertSame( $confirmation_token, $params['confirmation_token'] ); + $this->assertArrayNotHasKey( 'payment_method', $params ); } public function test_exception_will_throw_if_payment_method_is_invalid() { diff --git a/tests/unit/core/server/request/test-class-core-create-intention-request.php b/tests/unit/core/server/request/test-class-core-create-intention-request.php index 39cae5a710c..ff5c2863d48 100644 --- a/tests/unit/core/server/request/test-class-core-create-intention-request.php +++ b/tests/unit/core/server/request/test-class-core-create-intention-request.php @@ -116,4 +116,26 @@ public function test_woopay_create_intent_request_will_be_created() { $this->assertSame( 'POST', $request->get_method() ); $this->assertSame( WC_Payments_API_Client::INTENTIONS_API, $request->get_api() ); } + + public function test_set_confirmation_token() { + $amount = 1; + $currency = 'usd'; + $confirmation_token = 'ctoken_123'; + $request = new Create_Intention( $this->mock_api_client, $this->mock_wc_payments_http_client ); + $request->set_amount( $amount ); + $request->set_currency_code( $currency ); + $request->set_confirmation_token( $confirmation_token ); + + $params = $request->get_params(); + + $this->assertIsArray( $params ); + $this->assertArrayHasKey( 'confirmation_token', $params ); + $this->assertSame( $confirmation_token, $params['confirmation_token'] ); + } + + public function test_exception_will_throw_if_confirmation_token_is_invalid() { + $request = new Create_Intention( $this->mock_api_client, $this->mock_wc_payments_http_client ); + $this->expectException( Invalid_Request_Parameter_Exception::class ); + $request->set_confirmation_token( 'pm_invalid' ); + } } diff --git a/tests/unit/test-class-payment-information.php b/tests/unit/test-class-payment-information.php index 1395a584f59..318f9993edb 100644 --- a/tests/unit/test-class-payment-information.php +++ b/tests/unit/test-class-payment-information.php @@ -14,10 +14,12 @@ * Payment_Information unit tests. */ class Payment_Information_Test extends WCPAY_UnitTestCase { - const PAYMENT_METHOD_REQUEST_KEY = 'wcpay-payment-method'; - const PAYMENT_METHOD = 'pm_mock'; - const CARD_TOKEN_REQUEST_KEY = 'wc-' . WC_Payment_Gateway_WCPay::GATEWAY_ID . '-payment-token'; - const TOKEN = 'pm_mock_token'; + const PAYMENT_METHOD_REQUEST_KEY = 'wcpay-payment-method'; + const CONFIRMATION_TOKEN_REQUEST_KEY = 'wcpay-confirmation-token'; + const PAYMENT_METHOD = 'pm_mock'; + const CONFIRMATION_TOKEN = 'ctoken_mock'; + const CARD_TOKEN_REQUEST_KEY = 'wc-' . WC_Payment_Gateway_WCPay::GATEWAY_ID . '-payment-token'; + const TOKEN = 'pm_mock_token'; /** * WC token to be used in tests. @@ -78,6 +80,16 @@ public function test_get_payment_method_returns_token_if_present() { $this->assertEquals( self::TOKEN, $payment_information->get_payment_method() ); } + public function test_is_using_confirmation_token_returns_true_for_confirmation_token() { + $payment_information = new Payment_Information( self::CONFIRMATION_TOKEN ); + $this->assertTrue( $payment_information->is_using_confirmation_token() ); + } + + public function test_is_using_confirmation_token_returns_false_for_payment_method() { + $payment_information = new Payment_Information( self::PAYMENT_METHOD ); + $this->assertFalse( $payment_information->is_using_confirmation_token() ); + } + public function test_get_payment_token_returns_token() { $payment_information = new Payment_Information( self::PAYMENT_METHOD, null, Payment_Type::SINGLE(), $this->card_token ); $this->assertEquals( $this->card_token, $payment_information->get_payment_token() ); @@ -121,6 +133,23 @@ public function test_get_payment_method_from_request() { $this->assertEquals( self::PAYMENT_METHOD, $payment_method ); } + public function test_get_payment_method_from_request_with_confirmation_token() { + $payment_method = Payment_Information::get_payment_method_from_request( + [ self::CONFIRMATION_TOKEN_REQUEST_KEY => self::CONFIRMATION_TOKEN ] + ); + $this->assertEquals( self::CONFIRMATION_TOKEN, $payment_method ); + } + + public function test_get_payment_method_from_request_prefers_confirmation_token_over_payment_method() { + $payment_method = Payment_Information::get_payment_method_from_request( + [ + self::CONFIRMATION_TOKEN_REQUEST_KEY => self::CONFIRMATION_TOKEN, + self::PAYMENT_METHOD_REQUEST_KEY => self::PAYMENT_METHOD, + ] + ); + $this->assertEquals( self::CONFIRMATION_TOKEN, $payment_method ); + } + public function test_get_token_from_request_returns_null_when_not_set() { $token = Payment_Information::get_token_from_request( [] ); $this->assertNull( $token );