Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: update

refactor: ECE to use confirmation tokens instead of payment methods
16 changes: 8 additions & 8 deletions client/express-checkout/__tests__/event-handlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand All @@ -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'
);
} );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
4 changes: 2 additions & 2 deletions client/express-checkout/event-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
} );

Expand All @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion client/express-checkout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ jQuery( ( $ ) => {
mode: 'payment',
amount: creationOptions.total,
currency: creationOptions.currency,
paymentMethodCreation: 'manual',
paymentMethodTypes: [ 'card' ],
appearance: getExpressCheckoutButtonAppearance(),
locale: getExpressCheckoutData( 'stripe' )?.locale ?? 'en',
} );
Expand Down
8 changes: 4 additions & 4 deletions client/express-checkout/transformers/stripe-to-wc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {};
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const checkPaymentMethodIsAvailableInternal = (
stripe={ api.loadStripeForExpressCheckout() }
options={ {
mode: 'payment',
paymentMethodCreation: 'manual',
paymentMethodTypes: [ 'card' ],
amount: Number( totalPrice ),
currency: currencyCode.toLowerCase(),
} }
Expand Down
20 changes: 16 additions & 4 deletions includes/class-payment-information.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
*
Expand Down Expand Up @@ -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 : '';
Expand Down
13 changes: 12 additions & 1 deletion includes/class-wc-payment-gateway-wcpay.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ class Create_And_Confirm_Intention extends Create_Intention {
'amount',
'currency',
'payment_method',
'confirmation_token',
'payment_method_update_data',
'return_url',
];

const REQUIRED_PARAMS = [
'amount',
'currency',
'payment_method',
'customer',
'metadata',
];
Comment on lines 28 to 33
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing payment_method from REQUIRED_PARAMS means the request can now be sent without either a payment_method or a confirmation_token, which would likely fail at the Stripe API level. Consider adding custom validation in the get_params() method or before calling send() to ensure at least one of these parameters is set. For example, you could override get_params() to check that either payment_method or confirmation_token is present in the params before the request is sent.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send() and get_params() are final

Expand Down Expand Up @@ -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 methods 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 );
Expand Down
13 changes: 13 additions & 0 deletions includes/core/server/request/class-create-intention.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
}
}
Loading
Loading