Skip to content
Open
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
12 changes: 11 additions & 1 deletion includes/class-wc-gocardless-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -1894,11 +1894,21 @@ protected function _process_payment_event( array $event ) {
return false;
}

wc_gocardless()->log( sprintf( '%s - Handling payment event with action "%s" for order #%s', __METHOD__, $event['action'], $order->get_order_number() ) );
wc_gocardless()->log( sprintf( '%s - Handling payment event with action "%s" for order #%s (current status: %s)', __METHOD__, $event['action'], $order->get_order_number(), $order->get_status() ) );
$new_status = '';
switch ( $event['action'] ) {
case 'paid_out':
case 'confirmed':
// Handle case where paid_out event arrives after failed event
// This fixes issue #7: https://github.com/gocardless/woocommerce-gateway-gocardless/issues/7
$current_status = $order->get_status();
if ( 'failed' === $current_status ) {
wc_gocardless()->log( sprintf( '%s - Order #%s is in failed status, transitioning to processing before payment_complete()', __METHOD__, $order->get_order_number() ) );
$order->update_status( 'processing', __( 'Payment received after initial failure - updating status', 'woocommerce-gateway-gocardless' ) );
} elseif ( 'cancelled' === $current_status ) {
wc_gocardless()->log( sprintf( '%s - Order #%s is in cancelled status, transitioning to processing before payment_complete()', __METHOD__, $order->get_order_number() ) );
$order->update_status( 'processing', __( 'Payment received after cancellation - updating status', 'woocommerce-gateway-gocardless' ) );
}
Comment on lines +1905 to +1911
Copy link
Collaborator

Choose a reason for hiding this comment

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

@iamdharmesh Code here looks fine to me but curious for your thoughts on this, if there's any scenario where we wouldn't want to change a failed or cancelled order to processing?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks for the PR, @sidshas03.

@dkotter I don’t see an issue with changing a failed or cancelled order to processing. However, I’m not sure what we are fixing here. We already have a $order->payment_complete() call after these changes, so the order status will be updated to processing, and we don’t need to update it explicitly here. Also, updating it to processing directly will skip the $order->payment_complete() logic due to the internal check, and it will also skip updating the transaction ID in the order.

where a paid_out event arrives after a failed event, causing orders to remain in failed status even though the payment was successful.

@sidshas03 Could you please provide detailed steps to reproduce the issue? I’ve tried running different scenarios here, but I’m not able to reproduce it on trunk. Any help in reproducing the issue would be greatly appreciated.

Thanks!

$order->payment_complete( $event['links']['payment'] );
break;
case 'failed':
Expand Down
171 changes: 171 additions & 0 deletions tests/phpunit/test-webhook-race-condition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php

use PHPUnit\Framework\TestCase;

/**
* Test class for webhook race condition fix.
* Tests the scenario where a paid_out event arrives after a failed event.
*/
class WebhookRaceConditionTests extends TestCase {
/**
* Set up our mocked WP functions.
*
* @return void
*/
public function setUp() : void {
\WP_Mock::setUp();
}

/**
* Tear down WP Mock.
*
* @return void
*/
public function tearDown() : void {
\WP_Mock::tearDown();
}

/**
* Test that paid_out event after failed event properly transitions order status.
*/
public function test_paid_out_after_failed_event() {
// Mock the order object
$order = \Mockery::mock( 'WC_Order' );
$order->shouldReceive( 'get_status' )->andReturn( 'failed' );
$order->shouldReceive( 'get_order_number' )->andReturn( '12345' );
$order->shouldReceive( 'update_status' )->with( 'processing', 'Payment received after initial failure - updating status' )->once();
$order->shouldReceive( 'payment_complete' )->with( 'payment_123' )->once();

// Mock the gateway
$gateway = \Mockery::mock( 'WC_GoCardless_Gateway' );
$gateway->shouldReceive( 'get_order_from_resource' )->andReturn( $order );

// Mock wc_gocardless_get_order_prop
\WP_Mock::userFunction( 'wc_gocardless_get_order_prop' )
->with( $order, 'id' )
->andReturn( 123 );
\WP_Mock::userFunction( 'wc_gocardless_get_order_prop' )
->with( $order, 'payment_method' )
->andReturn( 'gocardless' );

// Mock wc_gocardless function
$wc_gocardless_mock = \Mockery::mock();
$wc_gocardless_mock->shouldReceive( 'log' )->andReturn( true );
\WP_Mock::userFunction( 'wc_gocardless' )->andReturn( $wc_gocardless_mock );

// Create the event payload
$event = array(
'action' => 'paid_out',
'links' => array(
'payment' => 'payment_123',
),
);

// Use reflection to access the protected method
$reflection = new ReflectionClass( $gateway );
$method = $reflection->getMethod( '_process_payment_event' );
$method->setAccessible( true );

// Execute the method
$result = $method->invoke( $gateway, $event );

// Assert the method returns true
$this->assertTrue( $result );
}

/**
* Test that paid_out event after cancelled event properly transitions order status.
*/
public function test_paid_out_after_cancelled_event() {
// Mock the order object
$order = \Mockery::mock( 'WC_Order' );
$order->shouldReceive( 'get_status' )->andReturn( 'cancelled' );
$order->shouldReceive( 'get_order_number' )->andReturn( '12345' );
$order->shouldReceive( 'update_status' )->with( 'processing', 'Payment received after cancellation - updating status' )->once();
$order->shouldReceive( 'payment_complete' )->with( 'payment_123' )->once();

// Mock the gateway
$gateway = \Mockery::mock( 'WC_GoCardless_Gateway' );
$gateway->shouldReceive( 'get_order_from_resource' )->andReturn( $order );

// Mock wc_gocardless_get_order_prop
\WP_Mock::userFunction( 'wc_gocardless_get_order_prop' )
->with( $order, 'id' )
->andReturn( 123 );
\WP_Mock::userFunction( 'wc_gocardless_get_order_prop' )
->with( $order, 'payment_method' )
->andReturn( 'gocardless' );

// Mock wc_gocardless function
$wc_gocardless_mock = \Mockery::mock();
$wc_gocardless_mock->shouldReceive( 'log' )->andReturn( true );
\WP_Mock::userFunction( 'wc_gocardless' )->andReturn( $wc_gocardless_mock );

// Create the event payload
$event = array(
'action' => 'paid_out',
'links' => array(
'payment' => 'payment_123',
),
);

// Use reflection to access the protected method
$reflection = new ReflectionClass( $gateway );
$method = $reflection->getMethod( '_process_payment_event' );
$method->setAccessible( true );

// Execute the method
$result = $method->invoke( $gateway, $event );

// Assert the method returns true
$this->assertTrue( $result );
}

/**
* Test that paid_out event with normal status doesn't trigger status change.
*/
public function test_paid_out_with_normal_status() {
// Mock the order object
$order = \Mockery::mock( 'WC_Order' );
$order->shouldReceive( 'get_status' )->andReturn( 'processing' );
$order->shouldReceive( 'get_order_number' )->andReturn( '12345' );
$order->shouldReceive( 'update_status' )->never();
$order->shouldReceive( 'payment_complete' )->with( 'payment_123' )->once();

// Mock the gateway
$gateway = \Mockery::mock( 'WC_GoCardless_Gateway' );
$gateway->shouldReceive( 'get_order_from_resource' )->andReturn( $order );

// Mock wc_gocardless_get_order_prop
\WP_Mock::userFunction( 'wc_gocardless_get_order_prop' )
->with( $order, 'id' )
->andReturn( 123 );
\WP_Mock::userFunction( 'wc_gocardless_get_order_prop' )
->with( $order, 'payment_method' )
->andReturn( 'gocardless' );

// Mock wc_gocardless function
$wc_gocardless_mock = \Mockery::mock();
$wc_gocardless_mock->shouldReceive( 'log' )->andReturn( true );
\WP_Mock::userFunction( 'wc_gocardless' )->andReturn( $wc_gocardless_mock );

// Create the event payload
$event = array(
'action' => 'paid_out',
'links' => array(
'payment' => 'payment_123',
),
);

// Use reflection to access the protected method
$reflection = new ReflectionClass( $gateway );
$method = $reflection->getMethod( '_process_payment_event' );
$method->setAccessible( true );

// Execute the method
$result = $method->invoke( $gateway, $event );

// Assert the method returns true
$this->assertTrue( $result );
}
}