File manager - Edit - /home/verseaumee/lauvia/wp-content/plugins/woocommerce-gateway-stripe/includes/class-wc-stripe-webhook-handler.php
Back
<?php use Automattic\WooCommerce\Enums\OrderStatus; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Class WC_Stripe_Webhook_Handler. * * Handles webhooks from Stripe on sources that are not immediately chargeable. * * @since 4.0.0 */ class WC_Stripe_Webhook_Handler extends WC_Stripe_Payment_Gateway { /** * Is test mode active? * * @var bool */ public $testmode; /** * The secret to use when verifying webhooks. * * @var string */ protected $secret; /** * The Action Scheduler service. * * @var WC_Stripe_Action_Scheduler_Service */ protected $action_scheduler_service; /** * How long to wait before processing a deferred webhook. * * @var int */ protected $deferred_webhook_delay = 2 * MINUTE_IN_SECONDS; /** * The Action Scheduler hook to use when retrying a webhook. * * @var string */ protected $deferred_webhook_action = 'wc_stripe_deferred_webhook'; /** * How long to wait before processing checkout session metadata after a webhook. * * @var int */ protected $process_checkout_session_metadata_delay = 2 * MINUTE_IN_SECONDS; /** * The Action Scheduler hook to use when processing checkout session metadata after a webhook. * * @var string */ protected $process_checkout_session_metadata_action = 'wc_stripe_process_checkout_session_metadata'; /** * The order object being processed. * * @var WC_Order|null */ protected $resolved_order = null; /** * Constructor. * * @since 4.0.0 * @version 5.0.0 */ public function __construct() { $this->retry_interval = 2; $stripe_settings = WC_Stripe_Helper::get_stripe_settings(); $this->testmode = WC_Stripe_Mode::is_test(); $secret_key = ( $this->testmode ? 'test_' : '' ) . 'webhook_secret'; $this->secret = ! empty( $stripe_settings[ $secret_key ] ) ? $stripe_settings[ $secret_key ] : false; $this->action_scheduler_service = new WC_Stripe_Action_Scheduler_Service(); add_action( 'woocommerce_api_wc_stripe', [ $this, 'check_for_webhook' ] ); // Get/set the time we began monitoring the health of webhooks by fetching it. // This should be roughly the same as the activation time of the version of the // 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, 3 ); add_action( $this->process_checkout_session_metadata_action, [ $this, 'process_checkout_session_metadata' ], 10, 2 ); } /** * Check incoming requests for Stripe Webhook data and process them. * * @since 4.0.0 * @version 5.0.0 */ public function check_for_webhook() { if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || ( 'POST' !== $_SERVER['REQUEST_METHOD'] ) || ! isset( $_GET['wc-api'] ) || ( 'wc_stripe' !== $_GET['wc-api'] ) ) { return; } try { $request_body = file_get_contents( 'php://input' ); $event = json_decode( $request_body ); $event_type = $event->type ?? 'No event type found'; } catch ( Exception $e ) { WC_Stripe_Logger::error( 'Webhook body could not be retrieved', [ 'error' => $e ] ); return; } $is_agentic_hook = 0 === strpos( $event_type, 'v1.delegated_checkout.' ); $secret = $is_agentic_hook ? (string) get_option( WC_Stripe_Agentic_Commerce_Integration::WEBHOOK_SECRET_OPTION, '' ) : $this->secret; // Validate it to make sure it is legit. $request_headers = array_change_key_case( $this->get_request_headers(), CASE_UPPER ); $validation_result = $this->validate_request( $request_headers, $request_body, $secret ); if ( WC_Stripe_Webhook_State::VALIDATION_SUCCEEDED !== $validation_result ) { WC_Stripe_Logger::error( 'Webhook validation failed (' . $validation_result . ')', [ 'request_headers' => $request_headers, 'event' => $event, ] ); WC_Stripe_Webhook_State::set_last_webhook_failure_at( time() ); if ( WC_Stripe_Webhook_State::VALIDATION_FAILED_SIGNATURE_MISMATCH === $validation_result && $this->has_duplicate_webhooks_setup() ) { WC_Stripe_Webhook_State::set_last_error_reason( WC_Stripe_Webhook_State::VALIDATION_FAILED_DUPLICATE_WEBHOOKS ); // Return a 400 HTTP status code to notify Stripe about a misconfigured webhook when the signature does not match. // @see https://docs.stripe.com/webhooks#disable status_header( 400 ); exit; } WC_Stripe_Webhook_State::set_last_error_reason( $validation_result ); // A webhook endpoint must return a 2xx HTTP status code to prevent future webhook // delivery failures. // @see https://docs.stripe.com/webhooks#acknowledge-events-immediately status_header( 204 ); exit; } if ( $is_agentic_hook ) { $this->process_agentic_hook( $event ); return; } WC_Stripe_Webhook_State::set_pending_webhooks_count( $event->pending_webhooks ?? 0 ); WC_Stripe_Logger::debug( 'Webhook received (' . $event_type . ')', [ 'event' => $event ] ); $this->process_webhook( $request_body ); WC_Stripe_Webhook_State::set_last_webhook_success_at( $event->created ); status_header( 200 ); exit; } /** * Check if the Stripe account has duplicate webhooks setup for this site. * * @since 9.1.0 */ public function has_duplicate_webhooks_setup() { $webhook_url = WC_Stripe_Helper::get_webhook_url(); $webhooks = WC_Stripe_API::retrieve( 'webhook_endpoints' ); if ( is_wp_error( $webhooks ) || ! isset( $webhooks->data ) || empty( $webhooks->data ) ) { return false; } $number_of_webhooks = 0; foreach ( $webhooks->data as $webhook ) { if ( ! isset( $webhook->url ) ) { continue; } if ( $webhook->url === $webhook_url ) { ++$number_of_webhooks; } } return $number_of_webhooks > 1; } /** * Verify the incoming webhook notification to make sure it is legit. * * @since 4.0.0 * @version 5.0.0 * @param array $request_headers The request headers from Stripe. * @param array $request_body The request body from Stripe. * @param string $secret The secret key for the webhook. * @return string The validation result (e.g. self::VALIDATION_SUCCEEDED ) */ public function validate_request( $request_headers, $request_body, $secret ) { if ( empty( $request_headers ) ) { return WC_Stripe_Webhook_State::VALIDATION_FAILED_EMPTY_HEADERS; } if ( empty( $request_body ) ) { return WC_Stripe_Webhook_State::VALIDATION_FAILED_EMPTY_BODY; } // Skip validation for E2E tests in test mode. if ( filter_var( getenv( 'E2E_TESTING' ), FILTER_VALIDATE_BOOLEAN ) && WC_Stripe_Mode::is_test() ) { return WC_Stripe_Webhook_State::VALIDATION_SUCCEEDED; } if ( empty( $secret ) ) { return WC_Stripe_Webhook_State::VALIDATION_FAILED_EMPTY_SECRET; } // Check for a valid signature. $signature_format = '/^t=(?P<timestamp>\d+)(?P<signatures>(,v\d+=[a-z0-9]+){1,2})$/'; if ( empty( $request_headers['STRIPE-SIGNATURE'] ) || ! preg_match( $signature_format, $request_headers['STRIPE-SIGNATURE'], $matches ) ) { return WC_Stripe_Webhook_State::VALIDATION_FAILED_SIGNATURE_INVALID; } // Verify the timestamp. $timestamp = intval( $matches['timestamp'] ); if ( abs( $timestamp - time() ) > 5 * MINUTE_IN_SECONDS ) { return WC_Stripe_Webhook_State::VALIDATION_FAILED_TIMESTAMP_MISMATCH; } // Generate the expected signature. $signed_payload = $timestamp . '.' . $request_body; $expected_signature = hash_hmac( 'sha256', $signed_payload, $secret ); // Check if the expected signature is present. if ( ! preg_match( '/,v\d+=' . preg_quote( $expected_signature, '/' ) . '/', $matches['signatures'] ) ) { return WC_Stripe_Webhook_State::VALIDATION_FAILED_SIGNATURE_MISMATCH; } return WC_Stripe_Webhook_State::VALIDATION_SUCCEEDED; } /** * Gets the incoming request headers. Some servers are not using * Apache and "getallheaders()" will not work so we may need to * build our own headers. * * @since 4.0.0 * @version 4.0.0 */ public function get_request_headers() { if ( ! function_exists( 'getallheaders' ) ) { $headers = []; foreach ( $_SERVER as $name => $value ) { if ( 'HTTP_' === substr( $name, 0, 5 ) ) { $headers[ str_replace( ' ', '-', ucwords( strtolower( str_replace( '_', ' ', substr( $name, 5 ) ) ) ) ) ] = $value; } } return $headers; } else { return getallheaders(); } } /** * Process webhook payments. * This is where we charge the source. * * @since 4.0.0 * @version 4.0.0 * @param object $notification * @param bool $retry */ public function process_webhook_payment( $notification, $retry = true ) { // The following 3 payment methods are synchronous so does not need to be handle via webhook. if ( WC_Stripe_Payment_Methods::CARD === $notification->data->object->type || WC_Stripe_Payment_Methods::SEPA_DEBIT === $notification->data->object->type || 'three_d_secure' === $notification->data->object->type ) { return; } $order = WC_Stripe_Helper::get_order_by_source_id( $notification->data->object->id ); if ( ! $order ) { WC_Stripe_Logger::warning( 'Could not find order via source ID: ' . $notification->data->object->id ); 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 ); $order_helper = WC_Stripe_Order_Helper::get_instance(); if ( $order_helper->lock_order_payment( $order ) ) { return; } try { if ( $order->has_status( [ OrderStatus::PROCESSING, OrderStatus::COMPLETED ] ) ) { return; } if ( $order->has_status( OrderStatus::ON_HOLD ) && ! $is_pending_receiver ) { return; } // Result from Stripe API request. $response = null; // This will throw exception if not valid. $order_helper->validate_minimum_order_amount( $order ); WC_Stripe_Logger::info( "Info: (Webhook) Begin processing payment for order $order_id for the amount of {$order->get_total()}" ); // Prep source object. $prepared_source = $this->prepare_order_source( $order ); // Make the request. $response = WC_Stripe_API::request( $this->generate_payment_request( $order, $prepared_source ), 'charges', 'POST', true ); $headers = $response['headers']; $response = $response['body']; if ( ! empty( $response->error ) ) { // Customer param wrong? The user may have been deleted on stripe's end. Remove customer_id. Can be retried without. if ( $this->is_no_such_customer_error( $response->error ) ) { delete_user_option( $order->get_customer_id(), '_stripe_customer_id' ); $order_helper->delete_stripe_customer_id( $order ); $order->save(); } if ( $this->is_no_such_token_error( $response->error ) && $prepared_source->token_id ) { // Source param wrong? The CARD may have been deleted on stripe's end. Remove token and show message. $wc_token = WC_Payment_Tokens::get( $prepared_source->token_id ); $wc_token->delete(); $localized_message = __( 'This card is no longer available and has been removed.', 'woocommerce-gateway-stripe' ); $order->add_order_note( $localized_message ); throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message ); } // We want to retry. if ( $this->is_retryable_error( $response->error ) ) { // Unlock the order before retrying. $order_helper->unlock_order_payment( $order ); if ( $retry ) { // Don't do anymore retries after this. if ( 5 <= $this->retry_interval ) { return $this->process_webhook_payment( $notification, false ); } sleep( $this->retry_interval ); ++$this->retry_interval; return $this->process_webhook_payment( $notification, true ); } else { $localized_message = __( 'Sorry, we are unable to process your payment at this time. Please retry later.', 'woocommerce-gateway-stripe' ); $order->add_order_note( $localized_message ); throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message ); } } $localized_message = WC_Stripe_Helper::get_localized_error_message_from_response( $response ); $order->add_order_note( $localized_message ); throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message ); } // To prevent double processing the order on WC side. if ( ! $this->is_original_request( $headers ) ) { return; } do_action_deprecated( 'wc_gateway_stripe_process_webhook_payment', [ $response, $order ], '9.7.0', 'wc_gateway_stripe_process_payment_charge', 'The wc_gateway_stripe_process_webhook_payment action is deprecated. Use wc_gateway_stripe_process_payment_charge instead.' ); $response->is_webhook_response = true; $this->process_response( $response, $order ); } catch ( WC_Stripe_Exception $e ) { WC_Stripe_Logger::error( 'Error processing webhook payment for order: ' . $order_id, [ 'error_message' => $e->getMessage() ] ); do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification, $e ); $statuses = [ OrderStatus::PENDING, OrderStatus::FAILED ]; if ( $order->has_status( $statuses ) ) { $this->send_failed_order_email( $order_id ); } } $order_helper->unlock_order_payment( $order ); } /** * Process webhook dispute that is created. * This is triggered when fraud is detected or customer processes chargeback. * We want to put the order into on-hold and add an order note. * * @since 4.0.0 * @param object $notification */ public function process_webhook_dispute( $notification ) { $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->charge ); if ( ! $order ) { WC_Stripe_Logger::warning( 'Could not find order via charge ID: ' . $notification->data->object->charge ); 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 ); if ( $needs_response ) { $message = sprintf( /* translators: 1) HTML anchor open tag 2) HTML anchor closing tag */ __( 'A dispute was created for this order. Response is needed. Please go to your %1$sStripe Dashboard%2$s to review this dispute.', 'woocommerce-gateway-stripe' ), '<a href="' . esc_url( $this->get_transaction_url( $order ) ) . '" title="Stripe Dashboard" target="_blank">', '</a>' ); } else { $message = __( 'A dispute was created for this order.', 'woocommerce-gateway-stripe' ); } if ( ! $order->has_status( OrderStatus::CANCELLED ) && ! WC_Stripe_Order_Helper::get_instance()->is_stripe_status_final( $order ) ) { $order->update_status( OrderStatus::ON_HOLD, $message ); } else { $order->add_order_note( $message ); $order->save(); } do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification ); $order_id = $order->get_id(); $this->send_failed_order_email( $order_id ); } /** * Process webhook dispute that is closed. * * @since 4.4.1 * @param object $notification */ public function process_webhook_dispute_closed( $notification ) { $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->charge ); $status = $notification->data->object->status; if ( ! $order ) { WC_Stripe_Logger::warning( 'Could not find order via charge ID: ' . $notification->data->object->charge ); 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 ) { $message = __( 'The dispute was resolved in your favor.', 'woocommerce-gateway-stripe' ); } elseif ( 'warning_closed' === $status ) { $message = __( 'The inquiry or retrieval was closed.', 'woocommerce-gateway-stripe' ); } else { return; } if ( apply_filters( 'wc_stripe_webhook_dispute_change_order_status', true, $order, $notification ) ) { // Mark final so that order status is not overridden by out-of-sequence events. WC_Stripe_Order_Helper::get_instance()->set_stripe_status_final( $order, true ); // Fail order if dispute is lost, or else revert to pre-dispute status. $order_status = 'lost' === $status ? OrderStatus::FAILED : $this->get_stripe_order_status_before_hold( $order ); // Do not re-send "Processing Order" email to customer after a dispute win. if ( OrderStatus::PROCESSING === $order_status ) { $emails = WC()->mailer()->get_emails(); if ( isset( $emails['WC_Email_Customer_Processing_Order'] ) ) { $callback = [ $emails['WC_Email_Customer_Processing_Order'], 'trigger' ]; remove_action( 'woocommerce_order_status_on-hold_to_processing_notification', $callback ); } } $order->update_status( $order_status, $message ); } else { $order->add_order_note( $message ); } } /** * Process webhook capture. This is used for an authorized only * transaction that is later captured via Stripe not WC. * * @since 4.0.0 * @version 4.0.0 * @param object $notification */ public function process_webhook_capture( $notification ) { $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id ); $order_helper = WC_Stripe_Order_Helper::get_instance(); if ( ! $order ) { WC_Stripe_Logger::warning( 'Could not find order via charge ID: ' . $notification->data->object->id ); 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_helper->is_stripe_charge_captured( $order ); if ( $charge && ! $captured ) { $order_helper->set_stripe_charge_captured( $order, true ); // Store other data such as fees $order->set_transaction_id( $notification->data->object->id ); if ( isset( $notification->data->object->balance_transaction ) ) { $this->update_fees( $order, $notification->data->object->balance_transaction, true ); } // Check and see if capture is partial. if ( $this->is_partial_capture( $notification ) ) { $partial_amount = $this->get_partial_amount_to_charge( $notification ); $order->set_total( $partial_amount ); $refund_object = $this->get_refund_object( $notification ); $this->update_fees( $order, $refund_object->balance_transaction ); /* translators: partial captured amount */ $order->add_order_note( sprintf( __( 'This charge was partially captured via Stripe Dashboard in the amount of: %s', 'woocommerce-gateway-stripe' ), $partial_amount ) ); } else { $order->payment_complete( $notification->data->object->id ); /* translators: transaction id */ $order->add_order_note( sprintf( __( 'Stripe charge complete (Charge ID: %s) (via webhook)', 'woocommerce-gateway-stripe' ), $notification->data->object->id ) ); } if ( is_callable( [ $order, 'save' ] ) ) { $order->save(); } } } } /** * Process webhook charge succeeded. This is used for payment methods * that takes time to clear which is asynchronous. e.g. SEPA, Sofort. * * @since 4.0.0 * @version 4.0.0 * @param object $notification */ public function process_webhook_charge_succeeded( $notification ) { if ( empty( $notification->data->object ) ) { WC_Stripe_Logger::error( 'Missing charge object in charge.succeeded webhook, Event ID: %s', $notification->id ?? 'unknown' ); return; } // https://docs.stripe.com/api/events/types#event_types-charge.succeeded $charge = $notification->data->object; // The following payment methods are synchronous so does not need to be handled via webhook. $payment_method_type = $this->get_payment_method_type_from_charge( $charge ); $synchronous_methods = [ WC_Stripe_Payment_Methods::CARD, WC_Stripe_Payment_Methods::AMAZON_PAY, 'three_d_secure', ]; if ( in_array( $payment_method_type, $synchronous_methods, true ) ) { return; } $order = WC_Stripe_Helper::get_order_by_charge_id( $charge->id ); if ( ! $order ) { WC_Stripe_Logger::debug( 'Could not find order via charge ID: ' . $charge->id ); 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; } // When the plugin's "Issue an authorization on checkout, and capture later" // setting is enabled, Stripe API still sends a "charge.succeeded" webhook but // the payment has not been captured, yet. This ensures that the payment has been // captured, before completing the payment. if ( ! $charge->captured ) { return; } // Store other data such as fees $order->set_transaction_id( $charge->id ); if ( isset( $charge->balance_transaction ) ) { $this->update_fees( $order, $charge->balance_transaction, true ); } /** * If the response has a succeeded status but also has a risk/fraud outcome that requires manual review, don't mark the order as * processing/completed. This will be handled by the incoming review.open webhook. * * Depending on when Stripe sends their events and how quickly it is processed by the store, the review.open webhook (which marks orders as on-hold) * can be processed before or after the payment_intent.success webhook. This difference can lead to orders being incorrectly marked as processing/completed * in WooCommerce, but flagged for manual renewal in Stripe. * * If the review.open webhook was processed before the payment_intent.success, set the processing/completed status in `_stripe_status_before_hold` * to ensure the review.closed event handler will update the status to the proper status. */ if ( 'manual_review' !== $this->get_risk_outcome( $notification ) ) { $order->payment_complete( $charge->id ); /* translators: transaction id */ $order->add_order_note( sprintf( __( 'Stripe charge complete (Charge ID: %s) (via webhook)', 'woocommerce-gateway-stripe' ), $charge->id ) ); } if ( is_callable( [ $order, 'save' ] ) ) { $order->save(); } } /** * Process webhook charge failed. * * @since 4.0.0 * @since 4.1.5 Can handle any fail payments from any methods. * @since 9.0.0 Can handle payment expiration. * @param object $notification */ public function process_webhook_charge_failed( $notification ) { $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id ); if ( ! $order ) { WC_Stripe_Logger::warning( 'Could not find order via charge ID: ' . $notification->data->object->id ); 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; } if ( 'charge.expired' === $notification->type ) { $message = __( 'This payment has expired.', 'woocommerce-gateway-stripe' ); } else { $message = __( 'This payment failed to clear.', 'woocommerce-gateway-stripe' ); } if ( ! WC_Stripe_Order_Helper::get_instance()->is_stripe_status_final( $order ) ) { $order->update_status( OrderStatus::FAILED, $message ); } else { $order->add_order_note( $message ); } do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification ); } /** * Process webhook source canceled. This is used for payment methods * that redirects and awaits payments from customer. * * @since 4.0.0 * @since 4.1.15 Add check to make sure order is processed by Stripe. * @param object $notification */ public function process_webhook_source_canceled( $notification ) { $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id ); // If can't find order by charge ID, try source ID. if ( ! $order ) { $order = WC_Stripe_Helper::get_order_by_source_id( $notification->data->object->id ); if ( ! $order ) { WC_Stripe_Logger::warning( 'Could not find order via charge/source ID: ' . $notification->data->object->id ); return; } } // 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::warning( 'Canceled webhook abort: Order was not processed by Stripe: ' . $order->get_id() ); return; } $message = __( 'This payment was cancelled.', 'woocommerce-gateway-stripe' ); if ( ! $order->has_status( OrderStatus::CANCELLED ) && ! WC_Stripe_Order_Helper::get_instance()->is_stripe_status_final( $order ) ) { $order->update_status( OrderStatus::CANCELLED, $message ); } else { $order->add_order_note( $message ); } do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification ); } /** * Process webhook refund. * * @since 4.0.0 * @version 4.9.0 * @param object $notification */ public function process_webhook_refund( $notification ) { $refund_object = $this->get_refund_object( $notification ); $order = WC_Stripe_Helper::get_order_by_refund_id( $refund_object->id ); if ( ! $order ) { WC_Stripe_Logger::debug( 'Could not find order via refund ID: ' . $refund_object->id ); $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id ); } if ( ! $order ) { WC_Stripe_Logger::warning( "Could not find order via refund ID ({$refund_object->id}) or charge ID ({$notification->data->object->id})" ); return; } // Set the order being processed for the `wc_stripe_webhook_received` action later. $this->resolved_order = $order; $order_id = $order->get_id(); $order_helper = WC_Stripe_Order_Helper::get_instance(); if ( $order_helper->is_stripe_gateway_order( $order ) ) { $charge = $order->get_transaction_id(); $captured = $order_helper->is_stripe_charge_captured( $order ); $refund_id = $order_helper->get_stripe_refund_id( $order ); $currency = $order->get_currency(); $raw_amount = $refund_object->amount; if ( ! in_array( strtoupper( $currency ), WC_Stripe_Currency_Code::NO_DECIMAL_CURRENCY_CODES, true ) ) { $raw_amount /= 100; } $amount = wc_price( $raw_amount, [ 'currency' => $currency ] ); // If charge wasn't captured, skip creating a refund. if ( ! $captured ) { // If the process was initiated from wp-admin, // the order was already cancelled, so we don't need a new note. if ( OrderStatus::CANCELLED !== $order->get_status() ) { /* translators: amount (including currency symbol) */ $order->add_order_note( sprintf( __( 'Pre-Authorization for %s voided from the Stripe Dashboard.', 'woocommerce-gateway-stripe' ), $amount ) ); $order->update_status( OrderStatus::CANCELLED ); } return; } if ( $order_helper->lock_order_refund( $order ) ) { return; } // If the refund ID matches, don't continue to prevent double refunding. if ( $refund_object->id === $refund_id ) { return; } if ( $charge ) { $reason = __( 'Refunded via Stripe Dashboard', 'woocommerce-gateway-stripe' ); $this->set_stripe_order_status_before_refund( $order, $order->get_status() ); // Create the refund. $refund = wc_create_refund( [ 'order_id' => $order_id, 'amount' => $this->get_refund_amount( $notification ), 'reason' => $reason, ] ); if ( is_wp_error( $refund ) ) { WC_Stripe_Logger::error( 'Error creating refund for order: ' . $order_id, [ 'error_message' => $refund->get_error_message() ] ); } $order_helper->update_stripe_refund_id( $order, $refund_object->id ); if ( isset( $refund_object->balance_transaction ) ) { $this->update_fees( $order, $refund_object->balance_transaction ); } $order_helper->unlock_order_refund( $order ); /* translators: 1) amount (including currency symbol) 2) transaction id 3) refund message */ $order->add_order_note( sprintf( __( 'Refunded %1$s - Refund ID: %2$s - %3$s', 'woocommerce-gateway-stripe' ), $amount, $refund_object->id, $reason ) ); } } } /** * Process a refund update. * * @param object $notification */ public function process_webhook_refund_updated( $notification ) { $refund_object = $notification->data->object; $order = WC_Stripe_Helper::get_order_by_charge_id( $refund_object->charge ); if ( ! $order ) { WC_Stripe_Logger::warning( 'Could not find order to update refund via charge ID: ' . $refund_object->charge ); return; } // Set the order being processed for the `wc_stripe_webhook_received` action later. $this->resolved_order = $order; $order_id = $order->get_id(); $order_helper = WC_Stripe_Order_Helper::get_instance(); if ( $order_helper->is_stripe_gateway_order( $order ) ) { $charge = $order->get_transaction_id(); $refund_id = $order_helper->get_stripe_refund_id( $order ); $currency = $order->get_currency(); $raw_amount = $refund_object->amount; if ( ! in_array( strtoupper( $currency ), WC_Stripe_Currency_Code::NO_DECIMAL_CURRENCY_CODES, true ) ) { $raw_amount /= 100; } $amount = wc_price( $raw_amount, [ 'currency' => $currency ] ); // If the refund IDs do not match stop. if ( $refund_object->id !== $refund_id ) { return; } if ( $charge ) { $refunds = wc_get_orders( [ 'limit' => 1, 'parent' => $order_id, ] ); if ( empty( $refunds ) ) { // No existing refunds nothing to update. return; } $refund = $refunds[0]; if ( in_array( $refund_object->status, [ 'failed', 'canceled' ], true ) ) { if ( isset( $refund_object->failure_balance_transaction ) ) { $this->update_fees( $order, $refund_object->failure_balance_transaction ); } $refund->delete( true ); do_action( 'woocommerce_refund_deleted', $refund_id, $order_id ); $order_helper->update_stripe_refund_status( $order, $refund_object->status ); $friendly_failure_reason = WC_Stripe_Helper::get_refund_reason_description( $refund_object->failure_reason ); if ( 'failed' === $refund_object->status ) { /* translators: 1) amount (including currency symbol) 2) transaction id 3) refund failure code */ $note = sprintf( __( 'Refund failed for %1$s - Refund ID: %2$s - Reason: %3$s', 'woocommerce-gateway-stripe' ), $amount, $refund_object->id, $friendly_failure_reason ); } else { /* translators: 1) amount (including currency symbol) 2) transaction id 3) refund failure code */ $note = sprintf( __( 'Refund canceled for %1$s - Refund ID: %2$s - Reason: %3$s', 'woocommerce-gateway-stripe' ), $amount, $refund_object->id, $friendly_failure_reason ); } // Store the raw failure reason if ( isset( $refund_object->failure_reason ) ) { $order_helper->update_stripe_refund_failure_reason( $order, $refund_object->failure_reason ); } else { $order_helper->delete_stripe_refund_failure_reason( $order ); } // Revert to previous status $status_before_refund = $this->get_stripe_order_status_before_refund( $order ); $valid_payment_complete_statuses = apply_filters( 'woocommerce_valid_order_statuses_for_payment_complete', [ OrderStatus::ON_HOLD, OrderStatus::PENDING, OrderStatus::FAILED, OrderStatus::CANCELLED ], $order ); if ( ! in_array( $status_before_refund, $valid_payment_complete_statuses, true ) ) { $default_status = $order->needs_processing() ? OrderStatus::PROCESSING : OrderStatus::COMPLETED; $status_before_refund = apply_filters( 'woocommerce_payment_complete_order_status', $default_status, $order->get_id(), $order ); } // If the order has the same status before refund, just add a note. if ( $order->has_status( $status_before_refund ) ) { $order->add_order_note( $note ); } else { $order->update_status( $status_before_refund, $note ); } $this->send_failed_refund_emails( $order ); } } } } /** * Process webhook reviews that are opened. i.e Radar. * * @since 4.0.6 * @param object $notification */ public function process_review_opened( $notification ) { if ( isset( $notification->data->object->payment_intent ) ) { $order = WC_Stripe_Helper::get_order_by_intent_id( $notification->data->object->payment_intent ); if ( ! $order ) { WC_Stripe_Logger::warning( '[Review Opened] Could not find order via intent ID: ' . $notification->data->object->payment_intent ); return; } } else { $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->charge ); if ( ! $order ) { WC_Stripe_Logger::warning( '[Review Opened] Could not find order via charge ID: ' . $notification->data->object->charge ); 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() ); $message = sprintf( /* translators: 1) HTML anchor open tag 2) HTML anchor closing tag 3) The reason type. */ __( 'A review has been opened for this order. Action is needed. Please go to your %1$sStripe Dashboard%2$s to review the issue. Reason: (%3$s).', 'woocommerce-gateway-stripe' ), '<a href="' . esc_url( $this->get_transaction_url( $order ) ) . '" title="Stripe Dashboard" target="_blank">', '</a>', esc_html( $notification->data->object->reason ) ); if ( apply_filters( 'wc_stripe_webhook_review_change_order_status', true, $order, $notification ) && ! WC_Stripe_Order_Helper::get_instance()->is_stripe_status_final( $order ) ) { $order->update_status( OrderStatus::ON_HOLD, $message ); } else { $order->add_order_note( $message ); $order->save(); // update_status() calls save on the order, so make sure we manually call save() when not updating the status to ensure meta is saved. } } /** * Process webhook reviews that are closed. i.e Radar. * * @since 4.0.6 * @param object $notification */ public function process_review_closed( $notification ) { if ( isset( $notification->data->object->payment_intent ) ) { $order = WC_Stripe_Helper::get_order_by_intent_id( $notification->data->object->payment_intent ); if ( ! $order ) { WC_Stripe_Logger::warning( '[Review Closed] Could not find order via intent ID: ' . $notification->data->object->payment_intent ); return; } } else { $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->charge ); if ( ! $order ) { WC_Stripe_Logger::warning( '[Review Closed] Could not find order via charge ID: ' . $notification->data->object->charge ); return; } } // Set the order being processed for the `wc_stripe_webhook_received` action later. $this->resolved_order = $order; $order_helper = WC_Stripe_Order_Helper::get_instance(); /* 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 ); // Only change the status if the charge was captured, status is not final, the order is on-hold and the review was approved. if ( $order_helper->is_stripe_charge_captured( $order ) && ! $order_helper->is_stripe_status_final( $order ) && $order->has_status( OrderStatus::ON_HOLD ) && ( ! empty( $notification->data->object->closed_reason ) && 'approved' === $notification->data->object->closed_reason ) && apply_filters( 'wc_stripe_webhook_review_change_order_status', true, $order, $notification ) ) { // If the status we stored before hold is an incomplete status, restore the status to processing/completed instead. $status_after_review = $this->get_stripe_order_status_before_hold( $order ); if ( in_array( $status_after_review, apply_filters( 'woocommerce_valid_order_statuses_for_payment_complete', [ OrderStatus::ON_HOLD, OrderStatus::PENDING, OrderStatus::FAILED, OrderStatus::CANCELLED ], $order ), true ) ) { $status_after_review = apply_filters( 'woocommerce_payment_complete_order_status', $order->needs_processing() ? OrderStatus::PROCESSING : OrderStatus::COMPLETED, $order->get_id(), $order ); } $order->update_status( $status_after_review, $message ); } else { $order->add_order_note( $message ); } } /** * Checks if capture is partial. * * @since 4.0.0 * @version 4.0.0 * @param object $notification */ public function is_partial_capture( $notification ) { return 0 < $notification->data->object->amount_refunded; } /** * Gets the first refund object from charge notification. * * @since 7.0.2 * @param object $notification * * @return object */ public function get_refund_object( $notification ) { // Since API version 2022-11-15, the Charge object no longer expands `refunds` by default. // We can remove this once we drop support for API versions prior to 2022-11-15. if ( ! empty( $notification->data->object->refunds->data[0] ) ) { return $notification->data->object->refunds->data[0]; } $charge = $this->get_charge_object( $notification->data->object->id, [ 'expand' => [ 'refunds' ] ] ); return $charge->refunds->data[0]; } /** * Gets the amount refunded. * * @since 4.0.0 * @version 4.0.0 * @param object $notification */ public function get_refund_amount( $notification ) { if ( $this->is_partial_capture( $notification ) ) { $refund_object = $this->get_refund_object( $notification ); $amount = $refund_object->amount / 100; if ( in_array( strtoupper( $notification->data->object->currency ), WC_Stripe_Currency_Code::NO_DECIMAL_CURRENCY_CODES, true ) ) { $amount = $refund_object->amount; } return $amount; } return false; } /** * Gets the amount we actually charge. * * @since 4.0.0 * @version 4.0.0 * @param object $notification */ public function get_partial_amount_to_charge( $notification ) { if ( $this->is_partial_capture( $notification ) ) { $amount = ( $notification->data->object->amount - $notification->data->object->amount_refunded ) / 100; if ( in_array( strtoupper( $notification->data->object->currency ), WC_Stripe_Currency_Code::NO_DECIMAL_CURRENCY_CODES, true ) ) { $amount = ( $notification->data->object->amount - $notification->data->object->amount_refunded ); } return $amount; } return false; } /** * Handles the processing of a payment intent webhook. * * @param stdClass $notification The webhook notification from Stripe. */ public function process_payment_intent( $notification ) { $intent = $notification->data->object; $order = $this->get_order_from_intent( $intent ); if ( ! $order ) { WC_Stripe_Logger::warning( 'Could not find order via intent ID: ' . $intent->id ); return; } if ( ! $order->has_status( apply_filters( 'wc_stripe_allowed_payment_processing_statuses', [ OrderStatus::PENDING, OrderStatus::FAILED ], $order ) ) ) { return; } // Set the order being processed for the `wc_stripe_webhook_received` action later. $this->resolved_order = $order; $order_helper = WC_Stripe_Order_Helper::get_instance(); if ( $order_helper->lock_order_payment( $order ) ) { return; } $order_id = $order->get_id(); $payment_type_meta = $order_helper->get_stripe_upe_payment_type( $order ); $is_voucher_payment = in_array( $payment_type_meta, WC_Stripe_Payment_Methods::VOUCHER_PAYMENT_METHODS, true ); $is_wallet_payment = in_array( $payment_type_meta, WC_Stripe_Payment_Methods::WALLET_PAYMENT_METHODS, true ); $is_blik_payment = WC_Stripe_Payment_Methods::BLIK === $payment_type_meta; switch ( $notification->type ) { // Asynchronous payment methods such as bank debits will only provide a charge ID at `payment_intent.processing`, once the required actions are taken by the customer. // We need to update the order transaction ID, so that the `payment_intent.succeeded` webhook is able to process the order. case 'payment_intent.processing': $charge = $this->get_latest_charge_from_intent( $intent ); if ( $charge ) { $order->set_transaction_id( $charge->id ); /* translators: transaction id */ $order->update_status( OrderStatus::ON_HOLD, sprintf( __( 'Stripe charge awaiting payment: %s.', 'woocommerce-gateway-stripe' ), $charge->id ) ); } break; case 'payment_intent.requires_action': do_action( 'wc_gateway_stripe_process_payment_intent_requires_action', $order, $notification->data->object ); if ( $is_voucher_payment ) { $order->update_status( OrderStatus::ON_HOLD, __( 'Awaiting payment.', 'woocommerce-gateway-stripe' ) ); wc_reduce_stock_levels( $order_id ); } break; case 'payment_intent.succeeded': case 'payment_intent.amount_capturable_updated': WC_Stripe_Logger::debug( "Stripe PaymentIntent $intent->id succeeded for order $order_id" ); $process_webhook_async = apply_filters( 'wc_stripe_process_payment_intent_webhook_async', true, $order, $intent, $notification ); $is_awaiting_action = $order_helper->get_stripe_upe_waiting_for_redirect( $order ) ?? false; // Process the webhook now if it's for a voucher, wallet, or BLIK payment, or if filtered to process immediately and order is not awaiting action. if ( $is_voucher_payment || $is_wallet_payment || $is_blik_payment || ( ! $process_webhook_async && ! $is_awaiting_action ) ) { $charge = $this->get_latest_charge_from_intent( $intent ); do_action_deprecated( 'wc_gateway_stripe_process_payment', [ $charge, $order ], '9.7.0', 'wc_gateway_stripe_process_payment_charge', 'The wc_gateway_stripe_process_payment action is deprecated. Use wc_gateway_stripe_process_payment_charge instead.' ); $charge->is_webhook_response = true; $this->process_response( $charge, $order ); $this->run_webhook_received_action( (string) $notification->type, $notification ); } else { WC_Stripe_Logger::debug( "Processing $notification->type ($intent->id) asynchronously for order $order_id." ); // Schedule a job to check on the status of this intent. $this->defer_webhook_processing( $notification, [ 'order_id' => $order_id, 'intent_id' => $intent->id, ] ); if ( $is_awaiting_action ) { do_action( 'wc_gateway_stripe_process_payment_intent_incomplete', $order ); } } break; default: if ( $is_voucher_payment && 'payment_intent.payment_failed' === $notification->type ) { $order->update_status( OrderStatus::FAILED, __( 'Payment not completed in time', 'woocommerce-gateway-stripe' ) ); wc_increase_stock_levels( $order_id ); break; } $error_message = $intent->last_payment_error->message ?? ''; /* translators: 1) The error message that was received from Stripe. */ $message = sprintf( __( 'Stripe SCA authentication failed. Reason: %s', 'woocommerce-gateway-stripe' ), $error_message ); $status_update = []; if ( ! $order_helper->is_stripe_status_final( $order ) ) { $status_update['from'] = $order->get_status(); $status_update['to'] = OrderStatus::FAILED; $order->update_status( OrderStatus::FAILED, $message ); } else { $order->add_order_note( $message ); } do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification ); $this->send_failed_order_email( $order_id, $status_update ); break; } $order_helper->unlock_order_payment( $order ); } public function process_setup_intent( $notification ) { $intent = $notification->data->object; $order = WC_Stripe_Helper::get_order_by_setup_intent_id( $intent->id ); if ( ! $order ) { WC_Stripe_Logger::warning( 'Could not find order via setup intent ID: ' . $intent->id ); 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( 'wc_gateway_stripe_allowed_payment_processing_statuses', [ $allowed_payment_processing_statuses ], '9.7.0', 'wc_stripe_allowed_payment_processing_statuses', 'The wc_gateway_stripe_allowed_payment_processing_statuses filter is deprecated since WooCommerce Stripe Gateway 9.7.0, and will be removed in a future version.' ); /** * Filters the valid order statuses for payment processing. * * @since 9.7.0 * * @param array $allowed_payment_processing_statuses The allowed payment processing statuses. * @param WC_Order $order The order object. */ $allowed_payment_processing_statuses = apply_filters( 'wc_stripe_allowed_payment_processing_statuses', $allowed_payment_processing_statuses, $order ); if ( ! $order->has_status( $allowed_payment_processing_statuses ) ) { return; } $order_helper = WC_Stripe_Order_Helper::get_instance(); if ( $order_helper->lock_order_payment( $order ) ) { return; } $order_id = $order->get_id(); if ( 'setup_intent.succeeded' === $notification->type ) { WC_Stripe_Logger::info( "Stripe SetupIntent $intent->id succeeded for order $order_id" ); if ( $this->has_pre_order( $order ) ) { $this->mark_order_as_pre_ordered( $order ); } else { $order->payment_complete(); } } else { $error_message = $intent->last_setup_error ? $intent->last_setup_error->message : ''; /* translators: 1) The error message that was received from Stripe. */ $message = sprintf( __( 'Stripe SCA authentication failed. Reason: %s', 'woocommerce-gateway-stripe' ), $error_message ); $status_update = []; if ( ! $order_helper->is_stripe_status_final( $order ) ) { $status_update['from'] = $order->get_status(); $status_update['to'] = OrderStatus::FAILED; $order->update_status( OrderStatus::FAILED, $message ); } else { $order->add_order_note( $message ); } $this->send_failed_order_email( $order_id, $status_update ); } $order_helper->unlock_order_payment( $order ); } /** * Schedules a job to run in the future to check on the status of a webhook. * * Each Webhook type which is deferred should be supported by @see process_deferred_webhook(). * * @param stdClass $webhook_notification The webhook payload received from Stripe. * @param array $additional_data Additional data to pass to the scheduled job. */ protected function defer_webhook_processing( $webhook_notification, $additional_data ) { $this->action_scheduler_service->schedule_job( time() + $this->deferred_webhook_delay, $this->deferred_webhook_action, [ 'type' => $webhook_notification->type, 'data' => $additional_data, 'notification' => $webhook_notification, ] ); } /** * Restores a webhook notification to an object after Action Scheduler deserialization. * * Action Scheduler in defer_webhook_processing() serializes job args; stdClass becomes a nested array. process_deferred_webhook() expects Stripe's * object-shaped payload. * * @param array|object $notification Raw notification from the scheduled job. * @return object The normalized notification object. * @throws Exception When the payload cannot be normalized. */ private function normalize_deferred_webhook_notification_to_object( $notification ) { if ( is_object( $notification ) ) { return $notification; } if ( is_array( $notification ) ) { $json = wp_json_encode( $notification ); if ( false === $json ) { throw new Exception( 'Failed to encode deferred webhook notification for object restoration.' ); } $object = json_decode( $json ); if ( ! is_object( $object ) ) { throw new Exception( 'Failed to restore deferred webhook notification to an object.' ); } return $object; } throw new Exception( 'Deferred webhook notification data is missing or invalid.' ); } /** * Processes a deferred webhook event. * * Deferred webhooks are scheduled by @see defer_webhook_processing(). * * @param string $webhook_type The webhook event name/type. * @param array $additional_data Additional data passed to the scheduled job. * @param array|object|null $notification The webhook notification payload (arrays after AS deserialization). */ public function process_deferred_webhook( $webhook_type, $additional_data, $notification = null ) { try { if ( null === $notification ) { throw new Exception( 'Missing deferred webhook notification.' ); } $notification = $this->normalize_deferred_webhook_notification_to_object( $notification ); switch ( $webhook_type ) { case 'payment_intent.succeeded': case 'payment_intent.amount_capturable_updated': $order = isset( $additional_data['order_id'] ) ? wc_get_order( $additional_data['order_id'] ) : null; $intent_id = $additional_data['intent_id'] ?? ''; if ( ! $order instanceof \WC_Order ) { 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." ); } // Check if the order is still in a valid state to process the webhook. if ( ! $order->has_status( apply_filters( 'wc_stripe_allowed_payment_processing_statuses', [ OrderStatus::PENDING, OrderStatus::FAILED ], $order ) ) ) { WC_Stripe_Logger::debug( "Skipped processing deferred webhook for Stripe PaymentIntent {$intent_id} for order {$order->get_id()} - payment already complete." ); return; } $this->handle_deferred_payment_intent_succeeded( $order, $intent_id ); break; case 'checkout.session.completed': case 'checkout.session.async_payment_succeeded': $this->handle_checkout_session_success( $notification ); break; case 'checkout.session.expired': case 'checkout.session.async_payment_failed': $this->handle_checkout_session_failure( $notification ); break; default: throw new Exception( "Unsupported webhook type: {$webhook_type}" ); break; } $this->run_webhook_received_action( (string) $webhook_type, $notification ); } catch ( Exception $e ) { WC_Stripe_Logger::error( 'Error processing deferred webhook.', [ 'webhook_type' => $webhook_type, 'additional_data' => $additional_data, 'error_message' => $e->getMessage(), ] ); // This will be caught by Action Scheduler and logged as an error. throw $e; } } /** * Processes the checkout session metadata update event to store additional metadata on the checkout session object. * * @param string $checkout_session_id The checkout session ID. * @param array $metadata The metadata from the checkout session. * @return void */ public function process_checkout_session_metadata( string $checkout_session_id, array $metadata ): void { try { $response = WC_Stripe_API::request( [ 'metadata' => $metadata ], 'checkout/sessions/' . $checkout_session_id, 'POST' ); if ( ! empty( $response->error->message ) ) { throw new WC_Stripe_Exception( $response->error->message ); } } catch ( Exception $e ) { WC_Stripe_Logger::error( 'Failed to update checkout session metadata: ' . $e->getMessage() ); // This will be caught by Action Scheduler and logged as an error. throw $e; } } /** * Handles a deferred payment_intent.succeeded event. * * @param WC_Order $order The order object. * @param string $intent_id The payment intent ID. */ protected function handle_deferred_payment_intent_succeeded( $order, $intent_id ) { $intent = $this->get_intent_from_order( $order ); if ( ! $intent || $intent->id !== $intent_id ) { WC_Stripe_Logger::debug( "Skipped processing deferred webhook for Stripe PaymentIntent {$intent_id} for order {$order->get_id()} - intent ID stored on order ({$intent->id}) doesn't match." ); return; } $charge = $this->get_latest_charge_from_intent( $intent ); if ( ! $charge ) { WC_Stripe_Logger::debug( "Skipped processing deferred webhook for Stripe PaymentIntent {$intent_id} for order {$order->get_id()} - no charge found." ); return; } WC_Stripe_Logger::info( "Processing Stripe PaymentIntent {$intent_id} for order {$order->get_id()} via deferred webhook." ); do_action_deprecated( 'wc_gateway_stripe_process_payment', [ $charge, $order ], '9.7.0', 'wc_gateway_stripe_process_payment_charge', 'The wc_gateway_stripe_process_payment action is deprecated. Use wc_gateway_stripe_process_payment_charge instead.' ); $charge->is_webhook_response = true; $this->process_response( $charge, $order ); } /** * Process webhook account updated event. * This is triggered when the account details are updated in Stripe's end. * We want to clear the cached account data to fetch fresh data on next request. * * @param object $notification The notification from Stripe */ public function process_account_updated( $notification ) { WC_Stripe::get_instance()->account->clear_cache(); WC_Stripe_Logger::debug( 'Cleared account cache after receiving account.updated webhook.' ); } /** * Processes the checkout session completed event. * * This webhook exists for both standard post-payment checkout * sessions, as well as agentic checkout sessions. * * @param object $notification The notification from Stripe * @return void * * @deprecated 10.6.0 Use process_checkout_session_success instead. */ public function process_checkout_session( object $notification ): void { wc_deprecated_function( __METHOD__, '10.6.0', 'process_checkout_session_success' ); $this->process_checkout_session_success( $notification ); } /** * Processes the checkout session success events. * This includes: * - checkout.session.completed event; Fires when a Stripe Checkout session is completed. * - checkout.session.async_payment_succeeded event; Fires when an asynchronous payment method on a Stripe Checkout session succeeds. * * This webhook exists for both standard post-payment checkout * sessions, as well as agentic checkout sessions. * * @param object $notification The notification from Stripe * @return bool True if the event was deferred for async processing, false if handled inline. */ public function process_checkout_session_success( object $notification ): bool { $checkout_session = $notification->data->object; if ( ! isset( $checkout_session->id ) ) { WC_Stripe_Logger::error( 'Checkout session ID is missing from the event data.' ); return false; } $session_id = $checkout_session->id; // Look for an order. If order exists, process the webhook immediately. $order = WC_Stripe_Helper::get_order_by_checkout_session_id( $session_id ); // If order does not exist, defer the webhook processing. // This is either an agentic hook or a webhook arrived before the order metadata was stored. if ( ! $order instanceof \WC_Order ) { WC_Stripe_Logger::debug( "Deferring processing of {$notification->type} ($session_id) asynchronously." ); $this->defer_webhook_processing( $notification, [ 'session_id' => $session_id, ] ); return true; } // If order exists, process the webhook immediately. $this->handle_checkout_session_success( $notification ); return false; } /** * Handles a deferred checkout session success event. * * @param object $notification The Stripe notification containing the checkout session data. * @return void */ protected function handle_checkout_session_success( object $notification ): void { $checkout_session = $notification->data->object; $session_id = $checkout_session->id; // Refresh the cached checkout session with the latest data from the webhook so that // subsequent reads (e.g. presentment details on the order page) reflect the final state. WC_Stripe_Database_Cache::set( 'checkout_session_' . $session_id, $checkout_session, HOUR_IN_SECONDS ); // Acquire a lock to prevent duplicate order creation from concurrent agentic sessions. $lock_key = 'checkout_session_lock_' . $session_id; if ( null !== WC_Stripe_Database_Cache::get( $lock_key ) ) { WC_Stripe_Logger::info( 'Checkout session is already being processed.', [ 'session_id' => $session_id ] ); return; } WC_Stripe_Database_Cache::set( $lock_key, time(), 5 * MINUTE_IN_SECONDS ); // Look for an order. If one does not exists, this is probably an agentic hook. $order = WC_Stripe_Helper::get_order_by_checkout_session_id( $checkout_session->id ); if ( ! $order instanceof \WC_Order ) { try { $this->handle_agentic_checkout_session( $notification ); } finally { WC_Stripe_Database_Cache::delete( $lock_key ); } return; } WC_Stripe_Database_Cache::delete( $lock_key ); /** * Filters the valid order statuses for payment processing. * * @since 9.7.0 * * @param array $allowed_payment_processing_statuses The allowed payment processing statuses. * @param WC_Order $order The order object. */ $allowed_payment_processing_statuses = apply_filters( 'wc_stripe_allowed_payment_processing_statuses', [ OrderStatus::PENDING, OrderStatus::FAILED, OrderStatus::ON_HOLD ], $order ); if ( ! $order->has_status( $allowed_payment_processing_statuses ) ) { return; } // Set the order being processed for the `wc_stripe_webhook_received` action later. $this->resolved_order = $order; $order_helper = WC_Stripe_Order_Helper::get_instance(); // Lock the order if ( $order_helper->lock_order_payment( $order ) ) { return; } try { $intent_id = isset( $checkout_session->payment_intent ) ? $checkout_session->payment_intent : null; // Store the payment intent ID on the order. if ( ! empty( $intent_id ) ) { $order_helper->add_payment_intent_to_order( $intent_id, $order ); } // Add presentment details if available. $presentment_details = $checkout_session->presentment_details ?? null; if ( $presentment_details && isset( $presentment_details->presentment_currency, $presentment_details->presentment_amount ) ) { $order_helper->update_stripe_presentment_currency( $order, $presentment_details->presentment_currency ); $order_helper->update_stripe_presentment_amount( $order, $presentment_details->presentment_amount ); $amount = WC_Stripe_Helper::get_woocommerce_amount_from_stripe_amount( $presentment_details->presentment_amount, $presentment_details->presentment_currency ); $order->add_order_note( sprintf( /* translators: 1) presentment currency 2) presentment amount */ __( 'Local currency purchase via Adaptive Pricing. Amount paid was: %1$s %2$s', 'woocommerce-gateway-stripe' ), strtoupper( $presentment_details->presentment_currency ), $amount ) ); } $intent = $this->get_intent_from_order( $order ); if ( ! $intent ) { WC_Stripe_Logger::error( 'Could not find intent for order: ' . $order->get_id() ); return; } $payment_method_id = is_object( $intent->payment_method ) ? $intent->payment_method->id : $intent->payment_method; // Update the order with the payment method ID if it's not already set. if ( ! $order_helper->get_stripe_source_id( $order ) && ! empty( $payment_method_id ) ) { $order_helper->update_stripe_source_id( $order, $payment_method_id ); } // Save payment method to store if the customer requested it during checkout. if ( $order_helper->get_should_save_stripe_payment_method( $order ) && ! empty( $payment_method_id ) ) { $upe_gateway = WC_Stripe::get_instance()->get_main_stripe_gateway(); $payment_method_object = is_object( $intent->payment_method ) ? $intent->payment_method : WC_Stripe_API::retrieve( 'payment_methods/' . $payment_method_id ); if ( ! is_wp_error( $payment_method_object ) && empty( $payment_method_object->error ) && ! empty( $payment_method_object ) ) { $upe_gateway->handle_saving_payment_method( $order, $payment_method_object, $payment_method_object->type ); // Clear the flag so it does not run again on webhook retries. $order_helper->delete_should_save_stripe_payment_method( $order ); } } // Set the payment method title on the order based on the actual payment method used. $upe_gateway = WC_Stripe::get_instance()->get_main_stripe_gateway(); if ( $upe_gateway instanceof WC_Stripe_UPE_Payment_Gateway ) { $payment_method_type = is_object( $intent->payment_method ) && isset( $intent->payment_method->type ) ? $intent->payment_method->type : ''; if ( ! empty( $payment_method_type ) ) { $upe_gateway->set_payment_method_title_for_order( $order, $payment_method_type, $intent->payment_method ?? false ); } } $order->save(); $charge = $this->get_latest_charge_from_intent( $intent ); $charge->is_webhook_response = true; $this->process_response( $charge, $order ); // Schedule a job to store the remaining metadata to the checkout session. $this->action_scheduler_service->schedule_job( time() + $this->process_checkout_session_metadata_delay, $this->process_checkout_session_metadata_action, [ 'checkout_session_id' => $checkout_session->id, 'metadata' => [ 'order_id' => $order->get_order_number(), 'order_key' => $order->get_order_key(), 'signature' => $this->get_order_signature( $order ), 'tax_amount' => WC_Stripe_Helper::get_stripe_amount( $order->get_total_tax(), strtolower( $order->get_currency() ) ), ], ] ); } catch ( Exception $e ) { WC_Stripe_Logger::error( 'Error processing checkout session for order: ' . $order->get_id(), [ 'error_message' => $e->getMessage() ] ); do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification, $e ); $status_update = []; if ( ! $order_helper->is_stripe_status_final( $order ) ) { /* translators: 1) Error message from the exception */ $message = sprintf( __( 'Checkout session could not be processed. %s', 'woocommerce-gateway-stripe' ), $e->getMessage() ); $status_update['from'] = $order->get_status(); $status_update['to'] = OrderStatus::FAILED; $order->update_status( OrderStatus::FAILED, $message ); } else { $order->add_order_note( sprintf( /* translators: 1) Error message from the exception */ __( 'Checkout session processing error: %s', 'woocommerce-gateway-stripe' ), $e->getMessage() ) ); } $this->send_failed_order_email( $order->get_id(), $status_update ); } finally { // Unlock the order $order_helper->unlock_order_payment( $order ); } } /** * Processes checkout session failure events. * This includes: * - checkout.session.expired event; Fires when a Stripe Checkout session expires before the customer completes payment. * - checkout.session.async_payment_failed event; Fires when an asynchronous payment method on a Stripe Checkout session fails. * Marks the associated WooCommerce order as failed. * * @param object $notification The Stripe notification containing the checkout session data. */ public function process_checkout_session_failure( object $notification ): bool { $checkout_session = $notification->data->object; if ( ! isset( $checkout_session->id ) ) { WC_Stripe_Logger::debug( 'Checkout session ID is missing from the event data.' ); return false; } $session_id = $checkout_session->id; // Look for an order. If order exists, process the webhook immediately. $order = WC_Stripe_Helper::get_order_by_checkout_session_id( $session_id ); // If order does not exist, defer the webhook processing. // This might happen if a webhook arrived before the order metadata was stored. if ( ! $order instanceof \WC_Order ) { WC_Stripe_Logger::debug( "Deferring processing of {$notification->type} ($session_id) asynchronously." ); $this->defer_webhook_processing( $notification, [ 'session_id' => $session_id, ] ); return true; } // If order exists, process the webhook immediately. $this->handle_checkout_session_failure( $notification ); return false; } /** * Handles a deferred checkout session failure event. * * @param object $notification The Stripe notification containing the checkout session data. * @return void */ protected function handle_checkout_session_failure( object $notification ): void { $checkout_session = $notification->data->object; $order = WC_Stripe_Helper::get_order_by_checkout_session_id( $checkout_session->id ); if ( ! $order instanceof \WC_Order ) { WC_Stripe_Logger::debug( 'Could not find order via checkout session ID: ' . $checkout_session->id ); return; } $this->resolved_order = $order; $order_helper = WC_Stripe_Order_Helper::get_instance(); if ( $order_helper->lock_order_payment( $order ) ) { return; } try { if ( $order_helper->is_stripe_status_final( $order ) ) { return; } if ( $order->has_status( [ OrderStatus::PROCESSING, OrderStatus::COMPLETED, OrderStatus::FAILED ] ) ) { return; } $message = 'checkout.session.expired' === $notification->type ? __( 'The checkout session has expired.', 'woocommerce-gateway-stripe' ) : __( 'The async payment for this checkout session has failed.', 'woocommerce-gateway-stripe' ); $status_update = []; $status_update['from'] = $order->get_status(); $status_update['to'] = OrderStatus::FAILED; $order->update_status( OrderStatus::FAILED, $message ); do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification ); $this->send_failed_order_email( $order->get_id(), $status_update ); } finally { $order_helper->unlock_order_payment( $order ); } } /** * Processes the incoming webhook. * * @since 4.0.0 * @version 4.0.0 * @param string $request_body */ 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 ); break; case 'source.chargeable': $this->process_webhook_payment( $notification ); break; case 'source.canceled': $this->process_webhook_source_canceled( $notification ); break; case 'charge.succeeded': $this->process_webhook_charge_succeeded( $notification ); break; case 'charge.failed': case 'charge.expired': $this->process_webhook_charge_failed( $notification ); break; case 'charge.captured': $this->process_webhook_capture( $notification ); break; case 'charge.dispute.created': $this->process_webhook_dispute( $notification ); break; case 'charge.dispute.closed': $this->process_webhook_dispute_closed( $notification ); break; case 'charge.refunded': $this->process_webhook_refund( $notification ); break; case 'charge.refund.updated': $this->process_webhook_refund_updated( $notification ); break; case 'review.opened': $this->process_review_opened( $notification ); break; case 'review.closed': $this->process_review_closed( $notification ); break; case 'payment_intent.processing': case 'payment_intent.succeeded': case 'payment_intent.payment_failed': case 'payment_intent.amount_capturable_updated': case 'payment_intent.requires_action': $this->process_payment_intent( $notification ); break; case 'setup_intent.succeeded': case 'setup_intent.setup_failed': $this->process_setup_intent( $notification ); break; case 'checkout.session.completed': case 'checkout.session.async_payment_succeeded': $checkout_session_deferred = $this->process_checkout_session_success( $notification ); break; case 'checkout.session.expired': case 'checkout.session.async_payment_failed': $checkout_session_deferred = $this->process_checkout_session_failure( $notification ); break; } // payment_intent.succeeded and payment_intent.amount_capturable_updated are always deferred via // process_payment_intent(). checkout.session.* events may be handled inline or deferred depending // on whether the order exists at webhook arrival time. Only skip the action when actually deferred. $always_deferred_types = [ 'payment_intent.succeeded', 'payment_intent.amount_capturable_updated', ]; if ( ( $checkout_session_deferred ?? false ) || in_array( $notification->type, $always_deferred_types, true ) ) { return; } $this->run_webhook_received_action( $notification->type, $notification ); } /** * 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. */ private function run_webhook_received_action( string $webhook_type, object $notification ): 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. * * @param stdClass $intent The Stripe PaymentIntent object. * @return WC_Order|false The order object, or false if not found. */ private function get_order_from_intent( $intent ) { // Attempt to get the order from the intent metadata. if ( isset( $intent->metadata ) ) { // Try to retrieve from the signature if ( isset( $intent->metadata->signature ) ) { $signature = wc_clean( $intent->metadata->signature ); $data = explode( ':', $signature ); // Verify we received the order ID and signature (hash). $order = isset( $data[0], $data[1] ) ? wc_get_order( absint( $data[0] ) ) : false; if ( $order ) { // Ensure we have a valid order, not a refund or other object. if ( ! $order instanceof WC_Order ) { return false; } $intent_id = WC_Stripe_Order_Helper::get_instance()->get_intent_id_from_order( $order ); // Return the order if the intent ID matches. if ( $intent->id === $intent_id ) { return $order; } /** * If the order has no intent ID stored, we may have failed to store it during the initial payment request. * Confirm that the signature matches the order, otherwise fall back to finding the order via the intent ID. */ if ( empty( $intent_id ) && $this->get_order_signature( $order ) === $signature ) { return $order; } } } // Try to retrieve from the metadata order ID. if ( isset( $intent->metadata->order_id ) ) { return wc_get_order( absint( $intent->metadata->order_id ) ); } } // Try to retrieve from the charges array. if ( ! empty( $intent->charges ) && is_array( $intent->charges ) ) { $charge = $intent->charges[0] ?? []; $order_id = $charge->metadata->order_id ?? null; return $order_id ? wc_get_order( $order_id ) : false; } // Fall back to finding the order via the intent ID. return WC_Stripe_Helper::get_order_by_intent_id( $intent->id ); } /** * Get the payment method type from the charge object. * https://docs.stripe.com/api/charges/object * * @param object $charge The charge object from Stripe * @return string|null The payment method type, or null if not found */ private function get_payment_method_type_from_charge( $charge ) { // We don't expect $charge->source to be set, // but we keep it here to ensure backwards compatibility. if ( isset( $charge->source->type ) ) { return $charge->source->type; } if ( isset( $charge->payment_method_details->type ) ) { return $charge->payment_method_details->type; } return null; } /** * Processes an agentic hook. * * @since 10.6.0 * @param stdClass $event The webhook event from Stripe. * @return void */ private function process_agentic_hook( stdClass $event ) { $event_type = $event->type ?? 'No event type found'; try { switch ( $event_type ) { case 'v1.delegated_checkout.customize_checkout': $response = $this->process_agentic_customization_hook( $event ); break; case 'v1.delegated_checkout.finalize_checkout': $response = $this->process_agentic_finalize_checkout_hook( $event ); break; default: WC_Stripe_Logger::error( 'Unsupported agentic hook type: ' . $event_type ); status_header( 400 ); exit; } if ( ! headers_sent() ) { header( 'Content-Type: application/json' ); } status_header( 200 ); echo wp_json_encode( $response ); } catch ( Throwable $e ) { WC_Stripe_Logger::error( 'Agentic hook failed.', [ 'error' => $e->getMessage(), 'event' => $event, ] ); status_header( 400 ); } exit; } /** * Handle the Agentic Checkout customization hook. * * This parameter is expected to generate both an HTTP status code and a JSON response. * * @since 10.6.0 * @param stdClass $event The webhook event from Stripe. * @return array * @throws Exception */ private function process_agentic_customization_hook( stdClass $event ): array { $event = new WC_Stripe_Agentic_Customize_Checkout_Event( $event ); $tax_calculator = new WC_Stripe_Agentic_Commerce_Tax_Calculator(); $shipping_calculator = new WC_Stripe_Agentic_Shipping_Calculator(); $line_items_with_tax = $tax_calculator->calculate( $event, $tax_calculator->extract_line_items_from_customization_hook( $event ) ); $shipping_options = $shipping_calculator->calculate( $event, $event->get_currency() ); return array_merge( $line_items_with_tax, $shipping_options ); } /** * Handle the Agentic Checkout finalize (manual approval) hook. * * @since 10.6.0 * @param stdClass $event The webhook event from Stripe. * @return array * @throws Exception When product resolution fails. */ private function process_agentic_finalize_checkout_hook( stdClass $event ): array { $event = new WC_Stripe_Agentic_Customize_Checkout_Event( $event ); $manual_approval = new WC_Stripe_Agentic_Commerce_Manual_Approval(); return $manual_approval->validate( $event ); } /** * Processes an agentic checkout session after the concurrency lock is acquired. * * @since 10.6.0 * @param object $notification The webhook notification from Stripe. */ private function handle_agentic_checkout_session( $notification ): void { $checkout_session = $notification->data->object; if ( ! WC_Stripe_Feature_Flags::is_agentic_commerce_enabled() ) { WC_Stripe_Logger::error( 'Agentic commerce is disabled, skipping agentic checkout session: ' . $checkout_session->id ); return; } WC_Stripe_Logger::info( 'Webhook checkout.session.completed received.', [ 'session_id' => $notification->data->object->id ?? 'unknown', 'payment_intent_id' => $notification->data->object->payment_intent ?? 'unknown', ] ); // Temporarily override the API version to get the right fields. $override_version = function ( $headers ) { $headers['Stripe-Version'] = WC_Stripe_API::AGENTIC_COMMERCE_API_VERSION; return $headers; }; add_filter( 'wc_stripe_request_headers', $override_version ); try { $url = $this->build_checkout_session_retrieve_url( $notification->data->object->id, WC_Stripe_Agentic_Checkout_Session::get_fields_to_expand() ); $raw_session = WC_Stripe_API::retrieve( $url ); if ( is_wp_error( $raw_session ) || ! is_object( $raw_session ) ) { WC_Stripe_Logger::error( 'Failed to retrieve checkout session with expand params.', [ 'url' => $url, 'error' => is_wp_error( $raw_session ) ? $raw_session->get_error_message() : 'Unexpected response from Stripe API.', ] ); return; } assert( $raw_session instanceof stdClass ); $session = new WC_Stripe_Agentic_Checkout_Session( $raw_session ); if ( ! $session->is_agentic() ) { WC_Stripe_Logger::info( 'Checkout session is not agentic, skipping agentic processing: ' . $session->get_id() ); return; } $payment_intent_id = $session->get_payment_intent_id(); if ( null === $payment_intent_id || empty( $payment_intent_id ) ) { WC_Stripe_Logger::error( 'Checkout session is missing the payment intent id.', [ 'session_id' => $session->get_id(), ] ); return; } try { $order_mapper = new WC_Stripe_Agentic_Commerce_Order_Mapper(); $order = $order_mapper->create_order_from_checkout_session( $session ); $this->resolved_order = $order; WC_Stripe_Logger::info( 'Agentic order created from checkout session.', [ 'session_id' => $session->get_id(), 'order_id' => $order->get_id(), ] ); /** * Fires after an agentic commerce order is created from a checkout session. * * @since 10.6.0 * @param WC_Order $order The created order. * @param WC_Stripe_Agentic_Checkout_Session $session The checkout session wrapper. */ do_action( 'wc_stripe_agentic_order_created', $order, $session ); } catch ( Throwable $e ) { // Cap trace length to avoid overwhelming log handlers that may // truncate or reject very large context fields. $trace = $e->getTraceAsString(); if ( strlen( $trace ) > 4000 ) { $trace = substr( $trace, 0, 4000 ) . '... [truncated]'; } WC_Stripe_Logger::error( 'Failed to create agentic order from checkout session.', [ 'session_id' => $session->get_id(), 'error' => $e->getMessage(), 'exception' => get_class( $e ), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $trace, ] ); /** * Fires when agentic commerce order creation fails. * * @since 10.6.0 * @param Throwable $e The throwable that was thrown. * @param WC_Stripe_Agentic_Checkout_Session $session The checkout session wrapper. */ do_action( 'wc_stripe_agentic_order_creation_failed', $e, $session ); // Re-throw so Action Scheduler marks the job as failed. The inner // catch exists to log with full context and fire the failure hook; // swallowing here would make AS report the run as complete. throw $e; } } finally { remove_filter( 'wc_stripe_request_headers', $override_version ); } } /** * Builds the Stripe API URL for retrieving a checkout session with expanded fields. * * Expands the payment intent's agent_details (to detect agentic sessions) * and any additional fields required by the checkout session wrapper. * * @since 10.6.0 * @param string $session_id The Stripe checkout session ID. * @param string[] $additional_expand Additional fields to expand beyond payment_intent.agent_details. * @return string The API URL with expand query parameters. * * @see https://docs.stripe.com/agentic-commerce/enable-in-context-selling-on-ai-agents?order-monitoring=webhooks#checkout-session-field-reference */ private function build_checkout_session_retrieve_url( string $session_id, array $additional_expand = [] ): string { $url = 'checkout/sessions/' . rawurlencode( $session_id ); $expand = array_merge( [ 'payment_intent.agent_details' ], $additional_expand ); $params = []; foreach ( $expand as $field ) { $params[] = 'expand[]=' . rawurlencode( $field ); } return $url . '?' . implode( '&', $params ); } }
| ver. 1.4 |
Github
|
.
| PHP 8.5.7 | Generation time: 0 |
proxy
|
phpinfo
|
Settings