diff --git a/changelog.txt b/changelog.txt index 0e28df81df..ad2a48a1ba 100644 --- a/changelog.txt +++ b/changelog.txt @@ -10,6 +10,7 @@ * Tweak - Use more specific selector in express checkout e2e tests * Tweak - Small improvements to e2e tests * Fix - Fix unnecessary Stripe API calls when rendering subscription details +* Add - Adds a new action (`wc_stripe_webhook_received`) to allow additional actions to be taken for webhook notifications from Stripe = 9.8.1 - 2025-08-15 = * Fix - Remove connection type requirement from PMC sync migration attempt diff --git a/includes/class-wc-stripe-webhook-handler.php b/includes/class-wc-stripe-webhook-handler.php index f2f8befd6f..b0a3505e01 100644 --- a/includes/class-wc-stripe-webhook-handler.php +++ b/includes/class-wc-stripe-webhook-handler.php @@ -49,6 +49,13 @@ class WC_Stripe_Webhook_Handler extends WC_Stripe_Payment_Gateway { */ protected $deferred_webhook_action = 'wc_stripe_deferred_webhook'; + /** + * The order object being processed. + * + * @var WC_Order|null + */ + protected $resolved_order = null; + /** * Constructor. * @@ -71,7 +78,7 @@ public function __construct() { // plugin when this code first appears. WC_Stripe_Webhook_State::get_monitoring_began_at(); - add_action( $this->deferred_webhook_action, [ $this, 'process_deferred_webhook' ], 10, 2 ); + add_action( $this->deferred_webhook_action, [ $this, 'process_deferred_webhook' ], 10, 3 ); } /** @@ -269,6 +276,9 @@ public function process_webhook_payment( $notification, $retry = true ) { return; } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + $order_id = $order->get_id(); $is_pending_receiver = ( 'receiver' === $notification->data->object->flow ); @@ -402,6 +412,9 @@ public function process_webhook_dispute( $notification ) { return; } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + $this->set_stripe_order_status_before_hold( $order, $order->get_status() ); $needs_response = in_array( $notification->data->object->status, [ 'needs_response', 'warning_needs_response' ], true ); @@ -444,6 +457,9 @@ public function process_webhook_dispute_closed( $notification ) { return; } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + if ( 'lost' === $status ) { $message = __( 'The dispute was lost or accepted.', 'woocommerce-gateway-stripe' ); } elseif ( 'won' === $status ) { @@ -495,6 +511,9 @@ public function process_webhook_capture( $notification ) { return; } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + if ( WC_Stripe_Helper::payment_method_allows_manual_capture( $order->get_payment_method() ) ) { $charge = $order->get_transaction_id(); $captured = $order->get_meta( '_stripe_charge_captured', true ); @@ -567,6 +586,9 @@ public function process_webhook_charge_succeeded( $notification ) { return; } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + if ( ! $order->has_status( OrderStatus::ON_HOLD ) ) { return; } @@ -625,6 +647,9 @@ public function process_webhook_charge_failed( $notification ) { return; } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + // If order status is already in failed status don't continue. if ( $order->has_status( OrderStatus::FAILED ) ) { return; @@ -665,6 +690,9 @@ public function process_webhook_source_canceled( $notification ) { } } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + // Don't proceed if payment method isn't Stripe. if ( 'stripe' !== $order->get_payment_method() ) { WC_Stripe_Logger::log( 'Canceled webhook abort: Order was not processed by Stripe: ' . $order->get_id() ); @@ -697,6 +725,9 @@ public function process_webhook_refund( $notification ) { $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id ); } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + if ( ! $order ) { WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id ); return; @@ -785,6 +816,9 @@ public function process_webhook_refund_updated( $notification ) { return; } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + $order_id = $order->get_id(); if ( 'stripe' === substr( (string) $order->get_payment_method(), 0, 6 ) ) { @@ -888,6 +922,9 @@ public function process_review_opened( $notification ) { } } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + $this->set_stripe_order_status_before_hold( $order, $order->get_status() ); $message = sprintf( @@ -929,6 +966,9 @@ public function process_review_closed( $notification ) { } } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + /* translators: 1) The reason type. */ $message = sprintf( __( 'The opened review for this order is now closed. Reason: (%s)', 'woocommerce-gateway-stripe' ), $notification->data->object->reason ); @@ -1047,6 +1087,9 @@ public function process_payment_intent( $notification ) { return; } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + if ( $this->lock_order_payment( $order, $intent ) ) { return; } @@ -1082,7 +1125,7 @@ public function process_payment_intent( $notification ) { $process_webhook_async = apply_filters( 'wc_stripe_process_payment_intent_webhook_async', true, $order, $intent, $notification ); $is_awaiting_action = $order->get_meta( '_stripe_upe_waiting_for_redirect' ) ?? false; - // Process the webhook now if it's for a voucher or wallet payment , or if filtered to process immediately and order is not awaiting action. + // Process the webhook now if it's for a voucher or wallet payment, or if filtered to process immediately and order is not awaiting action. if ( $is_voucher_payment || $is_wallet_payment || ( ! $process_webhook_async && ! $is_awaiting_action ) ) { $charge = $this->get_latest_charge_from_intent( $intent ); @@ -1096,6 +1139,8 @@ public function process_payment_intent( $notification ) { $charge->is_webhook_response = true; $this->process_response( $charge, $order ); + + $this->run_webhook_received_action( (string) $notification->type, $notification, $this->resolved_order ); } else { WC_Stripe_Logger::log( "Processing $notification->type ($intent->id) asynchronously for order $order_id." ); @@ -1112,7 +1157,6 @@ public function process_payment_intent( $notification ) { do_action( 'wc_gateway_stripe_process_payment_intent_incomplete', $order ); } } - break; default: if ( $is_voucher_payment && 'payment_intent.payment_failed' === $notification->type ) { @@ -1153,6 +1197,9 @@ public function process_setup_intent( $notification ) { return; } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + $allowed_payment_processing_statuses = [ OrderStatus::PENDING, OrderStatus::FAILED ]; $allowed_payment_processing_statuses = apply_filters_deprecated( @@ -1227,8 +1274,9 @@ protected function defer_webhook_processing( $webhook_notification, $additional_ time() + $this->deferred_webhook_delay, $this->deferred_webhook_action, [ - 'type' => $webhook_notification->type, - 'data' => $additional_data, + 'type' => $webhook_notification->type, + 'data' => $additional_data, + 'notification' => $webhook_notification, ] ); } @@ -1240,8 +1288,9 @@ protected function defer_webhook_processing( $webhook_notification, $additional_ * * @param string $webhook_type The webhook event name/type. * @param array $additional_data Additional data passed to the scheduled job. + * @param stdClass $notification The webhook notification payload. */ - public function process_deferred_webhook( $webhook_type, $additional_data ) { + public function process_deferred_webhook( $webhook_type, $additional_data, $notification = null ) { try { switch ( $webhook_type ) { case 'payment_intent.succeeded': @@ -1253,6 +1302,9 @@ public function process_deferred_webhook( $webhook_type, $additional_data ) { throw new Exception( "Missing required data. 'order_id' is invalid or not found for the deferred '{$webhook_type}' event." ); } + // Set the order being processed for the `wc_stripe_webhook_received` action later. + $this->resolved_order = $order; + if ( empty( $intent_id ) ) { throw new Exception( "Missing required data. 'intent_id' is missing for the deferred '{$webhook_type}' event." ); } @@ -1269,6 +1321,8 @@ public function process_deferred_webhook( $webhook_type, $additional_data ) { throw new Exception( "Unsupported webhook type: {$webhook_type}" ); break; } + + $this->run_webhook_received_action( (string) $webhook_type, $notification, $this->resolved_order ); } catch ( Exception $e ) { WC_Stripe_Logger::log( 'Error processing deferred webhook: ' . $e->getMessage() ); @@ -1334,6 +1388,8 @@ public function process_account_updated( $notification ) { public function process_webhook( $request_body ) { $notification = json_decode( $request_body ); + $this->resolved_order = null; + switch ( $notification->type ) { case 'account.updated': $this->process_account_updated( $notification ); @@ -1397,8 +1453,41 @@ public function process_webhook( $request_body ) { $this->process_setup_intent( $notification ); } + + // These events might be processed async. Skip the action trigger for them here. The trigger will be called inside the specific methods. + if ( 'payment_intent.succeeded' === $notification->type || 'payment_intent.amount_capturable_updated' === $notification->type ) { + return; + } + + $this->run_webhook_received_action( $notification->type, $notification, $this->resolved_order ); } + /** + * Helper function to run the `wc_stripe_webhook_received` action consistently. + * + * @param string $webhook_type The type of webhook that was processed. + * @param object $notification The webhook data sent from Stripe. + * @param WC_Order|null $order The order being processed by the webhook. + */ + private function run_webhook_received_action( string $webhook_type, object $notification, ?WC_Order $order = null ): void { + try { + /** + * Fires after a webhook has been processed, but before we respond to Stripe. + * This allows for custom processing of the webhook after it has been processed. + * Note that the $order parameter may be null in various cases, especially when processing + * webhooks unrelated to orders, such as account updates. + * + * @since 9.8.0 + * + * @param string $webhook_type The type of webhook that was processed. + * @param object $notification The webhook data sent from Stripe. + * @param WC_Order|null $order The order being processed by the webhook. + */ + do_action( 'wc_stripe_webhook_received', $webhook_type, $notification, $this->resolved_order ); + } catch ( Throwable $e ) { + WC_Stripe_Logger::error( 'Error in wc_stripe_webhook_received action: ' . $e->getMessage(), [ 'error' => $e ] ); + } + } /** * Fetches an order from a payment intent. * diff --git a/readme.txt b/readme.txt index 42b47de961..c52dd85d93 100644 --- a/readme.txt +++ b/readme.txt @@ -120,5 +120,6 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o * Tweak - Use more specific selector in express checkout e2e tests * Tweak - Small improvements to e2e tests * Fix - Fix unnecessary Stripe API calls when rendering subscription details +* Add - Adds a new action (`wc_stripe_webhook_received`) to allow additional actions to be taken for webhook notifications from Stripe [See changelog for full details across versions](https://raw.githubusercontent.com/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt). diff --git a/tests/phpunit/WC_Stripe_Webhook_Handler_Test.php b/tests/phpunit/WC_Stripe_Webhook_Handler_Test.php index f5a04cef77..48dd0cd2b7 100644 --- a/tests/phpunit/WC_Stripe_Webhook_Handler_Test.php +++ b/tests/phpunit/WC_Stripe_Webhook_Handler_Test.php @@ -97,7 +97,7 @@ public function test_process_deferred_webhook_invalid_type() { ->method( 'handle_deferred_payment_intent_succeeded' ); $this->expectExceptionMessage( 'Unsupported webhook type: event-id' ); - $this->mock_webhook_handler->process_deferred_webhook( 'event-id', [] ); + $this->mock_webhook_handler->process_deferred_webhook( 'event-id', [], (object) [] ); } /** @@ -107,11 +107,27 @@ public function test_process_deferred_webhook_invalid_args() { $this->mock_webhook_handler->expects( $this->never() ) ->method( 'handle_deferred_payment_intent_succeeded' ); + $notification = (object) [ + 'type' => 'payment_intent.succeeded', + 'data' => (object) [ + 'object' => (object) [ + 'id' => 'pi_mock_1234', + 'charges' => (object) [ + 'total_count' => 1, + 'data' => [ + (object) self::MOCK_PAYMENT_INTENT['charges']['data'][0], + ], + ], + 'last_payment_error' => null, + ], + ], + ]; + // No data. $data = []; $this->expectExceptionMessage( "Missing required data. 'order_id' is invalid or not found for the deferred 'payment_intent.succeeded' event." ); - $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data ); + $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data, $notification ); // Invalid order_id. $data = [ @@ -119,26 +135,41 @@ public function test_process_deferred_webhook_invalid_args() { ]; $this->expectExceptionMessage( "Missing required data. 'order_id' is invalid or not found for the deferred 'payment_intent.succeeded' event." ); - $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data ); + $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data, $notification ); // No payment intent. $order = WC_Helper_Order::create_order(); $data['order_id'] = $order->get_id(); $this->expectExceptionMessage( "Missing required data. 'intent_id' is missing for the deferred 'payment_intent.succeeded' event." ); - $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data ); + $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data, $notification ); } /** * Test process_deferred_webhook with valid args. */ public function test_process_deferred_webhook() { - $order = WC_Helper_Order::create_order(); - $intent_id = 'pi_mock_1234'; - $data = [ + $order = WC_Helper_Order::create_order(); + $intent_id = 'pi_mock_1234'; + $data = [ 'order_id' => $order->get_id(), 'intent_id' => $intent_id, ]; + $notification = (object) [ + 'type' => 'payment_intent.succeeded', + 'data' => (object) [ + 'object' => (object) [ + 'id' => $intent_id, + 'charges' => (object) [ + 'total_count' => 1, + 'data' => [ + (object) self::MOCK_PAYMENT_INTENT['charges']['data'][0], + ], + ], + 'last_payment_error' => null, + ], + ], + ]; $this->mock_webhook_handler->expects( $this->once() ) ->method( 'handle_deferred_payment_intent_succeeded' ) @@ -151,18 +182,33 @@ function ( $passed_order ) use ( $order ) { $this->equalTo( $intent_id ), ); - $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data ); + $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data, $notification ); } /** * Test deferred webhook where the intent is no longer stored on the order. */ public function test_mismatch_intent_id_process_deferred_webhook() { - $order = WC_Helper_Order::create_order(); - $data = [ + $order = WC_Helper_Order::create_order(); + $data = [ 'order_id' => $order->get_id(), 'intent_id' => 'pi_wrong_id', ]; + $notification = (object) [ + 'type' => 'payment_intent.succeeded', + 'data' => (object) [ + 'object' => (object) [ + 'id' => 'pi_mock_1234', + 'charges' => (object) [ + 'total_count' => 1, + 'data' => [ + (object) self::MOCK_PAYMENT_INTENT['charges']['data'][0], + ], + ], + 'last_payment_error' => null, + ], + ], + ]; $this->mock_webhook_handler( [ 'handle_deferred_payment_intent_succeeded' ] ); @@ -185,18 +231,24 @@ function ( $passed_order ) use ( $order ) { $this->mock_webhook_handler->expects( $this->never() ) ->method( 'process_response' ); - $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data ); + $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data, $notification ); } /** * Test successful deferred webhook. */ public function test_process_of_successful_payment_intent_deferred_webhook() { - $order = WC_Helper_Order::create_order(); - $data = [ + $order = WC_Helper_Order::create_order(); + $data = [ 'order_id' => $order->get_id(), 'intent_id' => self::MOCK_PAYMENT_INTENT['id'], ]; + $notification = (object) [ + 'type' => 'payment_intent.succeeded', + 'data' => (object) [ + 'object' => (object) self::MOCK_PAYMENT_INTENT, + ], + ]; $this->mock_webhook_handler( [ 'handle_deferred_payment_intent_succeeded' ] ); @@ -226,7 +278,7 @@ function ( $passed_order ) use ( $order ) { ) ); - $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data ); + $this->mock_webhook_handler->process_deferred_webhook( 'payment_intent.succeeded', $data, $notification ); } /**