Skip to content

Commit

Permalink
Potential fix for duplicate charges (#3725)
Browse files Browse the repository at this point in the history
* Reuse existing successful payment intent to prevent duplicate charges.

* Add changelog entries

* Add unit tests

* Only consider payment intents

* Minor cleanup

* Remove a nesting level

---------

Co-authored-by: Diego Curbelo <[email protected]>
  • Loading branch information
malithsen and diegocurbelo authored Jan 16, 2025
1 parent e0cb176 commit 8db4fde
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 1 deletion.
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Fix - Fixes deprecation warnings related to nullable method parameters when using PHP 8.4, and increases the minimum PHP version Code Sniffer considers to 7.4.
* Fix - Adds support for the Reunion country when checking out using the new checkout experience.
* Add - Support zero-amount refunds.
* Fix - A potential fix to prevent duplicate charges.

= 9.1.1 - 2025-01-10 =
* Fix - Fixes the webhook order retrieval by intent charges. The processed event is an object, not an array.
Expand Down
13 changes: 13 additions & 0 deletions includes/payment-methods/class-wc-stripe-upe-payment-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -1901,6 +1901,19 @@ private function get_address_data_for_payment_request( $order ) {
* @return stdClass
*/
private function process_payment_intent_for_order( WC_Order $order, array $payment_information, $retry = true ) {
// Check if order already has a successful payment intent
$existing_intent = $this->get_intent_from_order( $order );
if ( $existing_intent && isset( $existing_intent->id ) && 'pi_' === substr( $existing_intent->id, 0, 3 ) ) {
// Fetch the latest intent data from Stripe
$intent = $this->stripe_request( 'payment_intents/' . $existing_intent->id );

// If the intent is already successful, return it to prevent duplicate charges
if ( isset( $intent->status ) && in_array( $intent->status, self::SUCCESSFUL_INTENT_STATUS, true ) ) {
return $intent;
}
}

// Check if the order has a payment intent that is compatible with the current payment method types.
$payment_intent = $this->get_existing_compatible_payment_intent( $order, $payment_information['payment_method_types'] );

// If the payment intent is not compatible, we need to create a new one. Throws an exception on error.
Expand Down
1 change: 1 addition & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -114,5 +114,6 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o
* Fix - Fixes deprecation warnings related to nullable method parameters when using PHP 8.4, and increases the minimum PHP version Code Sniffer considers to 7.4.
* Fix - Adds support for the Reunion country when checking out using the new checkout experience.
* Add - Support zero-amount refunds.
* Fix - A potential fix to prevent duplicate charges.

[See changelog for all versions](https://raw.githubusercontent.com/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt).
187 changes: 186 additions & 1 deletion tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -2148,6 +2148,8 @@ public function test_process_payment_deferred_intent_with_existing_intent() {
self::MOCK_CARD_PAYMENT_INTENT_TEMPLATE
);

$mock_payment_method = (object) self::MOCK_CARD_PAYMENT_METHOD_TEMPLATE;

// Set the appropriate POST flag to trigger a deferred intent request.
$_POST = [
'payment_method' => 'stripe',
Expand All @@ -2161,10 +2163,22 @@ public function test_process_payment_deferred_intent_with_existing_intent() {
->willReturn( $mock_intent );

$this->mock_gateway
->expects( $this->once() )
->expects( $this->exactly( 2 ) )
->method( 'get_intent_from_order' )
->willReturn( $mock_intent );

$this->mock_gateway
->expects( $this->exactly( 2 ) )
->method( 'stripe_request' )
->withConsecutive(
[ 'payment_methods/pm_mock' ],
[ 'payment_intents/' . $mock_intent->id ]
)
->willReturnOnConsecutiveCalls(
$mock_payment_method,
$mock_intent
);

$this->mock_gateway
->expects( $this->once() )
->method( 'get_stripe_customer_id' )
Expand All @@ -2176,6 +2190,177 @@ public function test_process_payment_deferred_intent_with_existing_intent() {
$this->assertMatchesRegularExpression( "/#wc-stripe-confirm-pi:{$order_id}:{$mock_intent->client_secret}/", $response['redirect'] );
}


/**
* Test that a successful payment intent is reused instead of creating a new one.
* This prevents duplicate charges when the shopper retries a payment after
* a successful charge but failed order completion.
*
* @return void
* @throws Exception If test fails.
*/
public function test_process_payment_reuses_successful_payment_intent() {
$customer_id = 'cus_mock';
$order = WC_Helper_Order::create_order();
$order_id = $order->get_id();

$mock_intent = (object) wp_parse_args(
[
'id' => 'pi_mock',
'payment_method' => 'pm_mock',
'payment_method_types' => [ WC_Stripe_Payment_Methods::CARD ],
'charges' => (object) [
'data' => [
(object) [
'id' => $order_id,
'captured' => 'yes',
'status' => 'succeeded',
],
],
],
'status' => WC_Stripe_Intent_Status::SUCCEEDED,
],
self::MOCK_CARD_PAYMENT_INTENT_TEMPLATE
);

$mock_payment_method = (object) self::MOCK_CARD_PAYMENT_METHOD_TEMPLATE;

// Set the appropriate POST flag to trigger a deferred intent request.
$_POST = [
'payment_method' => 'stripe',
'wc-stripe-payment-method' => 'pm_mock',
'wc-stripe-is-deferred-intent' => '1',
];

// Mock that we find an existing successful intent on the order
$this->mock_gateway
->expects( $this->exactly( 1 ) )
->method( 'get_intent_from_order' )
->willReturn( $mock_intent );

// Mock both the payment method retrieval and payment intent retrieval
$this->mock_gateway
->expects( $this->exactly( 2 ) )
->method( 'stripe_request' )
->withConsecutive(
[ 'payment_methods/pm_mock' ],
[ "payment_intents/{$mock_intent->id}", null, null, 'POST' ]
)
->willReturnOnConsecutiveCalls(
$mock_payment_method,
$mock_intent
);

// We should never try to create a new intent since we have a successful one
$this->mock_gateway->intent_controller
->expects( $this->never() )
->method( 'create_and_confirm_payment_intent' );

$this->mock_gateway
->expects( $this->once() )
->method( 'get_stripe_customer_id' )
->willReturn( $customer_id );

$response = $this->mock_gateway->process_payment( $order_id );

// Verify the response indicates success
$this->assertEquals( 'success', $response['result'] );
}

/**
* Test that a failed payment intent is not reused and a new one is created instead.
*
* @return void
* @throws Exception If test fails.
*/
public function test_process_payment_creates_new_intent_when_existing_intent_failed() {
$customer_id = 'cus_mock';
$order = WC_Helper_Order::create_order();
$order_id = $order->get_id();

$mock_payment_method = (object) self::MOCK_CARD_PAYMENT_METHOD_TEMPLATE;

// Create a mock failed payment intent that would be attached to the order
$mock_failed_intent = (object) wp_parse_args(
[
'id' => 'pi_mock_failed',
'payment_method' => 'pm_mock',
'status' => WC_Stripe_Intent_Status::CANCELED,
'payment_method_types' => [ WC_Stripe_UPE_Payment_Method_CC::STRIPE_ID ],
'charges' => (object) [
'data' => [],
],
],
self::MOCK_CARD_PAYMENT_INTENT_TEMPLATE
);

// Create a mock successful payment intent that will be created
$mock_success_intent = (object) wp_parse_args(
[
'id' => 'pi_mock_new',
'payment_method' => 'pm_mock',
'status' => WC_Stripe_Intent_Status::SUCCEEDED,
'payment_method_types' => [ WC_Stripe_UPE_Payment_Method_CC::STRIPE_ID ],
'charges' => (object) [
'data' => [
(object) [
'id' => 'ch_mock',
'captured' => true,
'status' => 'succeeded',
],
],
],
],
self::MOCK_CARD_PAYMENT_INTENT_TEMPLATE
);

// Set the appropriate POST flag to trigger a deferred intent request
$_POST = [
'payment_method' => 'stripe',
'wc-stripe-payment-method' => 'pm_mock',
'wc-stripe-is-deferred-intent' => '1',
];

// Save the failed intent ID to the order
$order->update_meta_data( '_stripe_intent_id', $mock_failed_intent->id );
$order->save();

// Mock that we find an existing failed intent on the order
$this->mock_gateway
->expects( $this->exactly( 2 ) )
->method( 'get_intent_from_order' )
->willReturn( $mock_failed_intent );

// Mock both the payment method retrieval and payment intent retrieval
$this->mock_gateway
->expects( $this->exactly( 2 ) )
->method( 'stripe_request' )
->withConsecutive(
[ 'payment_methods/pm_mock' ],
[ "payment_intents/{$mock_failed_intent->id}", null, null, 'POST' ]
)
->willReturnOnConsecutiveCalls(
$mock_payment_method,
$mock_failed_intent
);

// We should create a new intent since the existing one failed
$this->mock_gateway->intent_controller
->expects( $this->once() )
->method( 'create_and_confirm_payment_intent' )
->willReturn( $mock_success_intent );

$this->mock_gateway
->expects( $this->once() )
->method( 'get_stripe_customer_id' )
->willReturn( $customer_id );

$response = $this->mock_gateway->process_payment( $order_id );

// Verify the response indicates success
$this->assertEquals( 'success', $response['result'] );
}

/**
* Test for `process_payment` with a co-branded credit card and preferred brand set.
*
Expand Down

0 comments on commit 8db4fde

Please sign in to comment.