blob: e8e789fa57f0245f2258959d9f39e241add2754b [file] [log] [blame]
swissChilif0cbdc32023-01-05 17:21:38 -05001<?php
2if ( ! defined( 'ABSPATH' ) ) {
3 exit;
4}
5
6/**
7 * Class WC_Stripe_Webhook_Handler.
8 *
9 * Handles webhooks from Stripe on sources that are not immediately chargeable.
10 *
11 * @since 4.0.0
12 */
13class WC_Stripe_Webhook_Handler extends WC_Stripe_Payment_Gateway {
14 /**
15 * Is test mode active?
16 *
17 * @var bool
18 */
19 public $testmode;
20
21 /**
22 * The secret to use when verifying webhooks.
23 *
24 * @var string
25 */
26 protected $secret;
27
28 /**
29 * Constructor.
30 *
31 * @since 4.0.0
32 * @version 5.0.0
33 */
34 public function __construct() {
35 $this->retry_interval = 2;
36 $stripe_settings = get_option( 'woocommerce_stripe_settings', [] );
37 $this->testmode = ( ! empty( $stripe_settings['testmode'] ) && 'yes' === $stripe_settings['testmode'] ) ? true : false;
38 $secret_key = ( $this->testmode ? 'test_' : '' ) . 'webhook_secret';
39 $this->secret = ! empty( $stripe_settings[ $secret_key ] ) ? $stripe_settings[ $secret_key ] : false;
40
41 add_action( 'woocommerce_api_wc_stripe', [ $this, 'check_for_webhook' ] );
42
43 // Get/set the time we began monitoring the health of webhooks by fetching it.
44 // This should be roughly the same as the activation time of the version of the
45 // plugin when this code first appears.
46 WC_Stripe_Webhook_State::get_monitoring_began_at();
47 }
48
49 /**
50 * Check incoming requests for Stripe Webhook data and process them.
51 *
52 * @since 4.0.0
53 * @version 5.0.0
54 */
55 public function check_for_webhook() {
56 if ( ! isset( $_SERVER['REQUEST_METHOD'] )
57 || ( 'POST' !== $_SERVER['REQUEST_METHOD'] )
58 || ! isset( $_GET['wc-api'] )
59 || ( 'wc_stripe' !== $_GET['wc-api'] )
60 ) {
61 return;
62 }
63
64 $request_body = file_get_contents( 'php://input' );
65 $request_headers = array_change_key_case( $this->get_request_headers(), CASE_UPPER );
66
67 // Validate it to make sure it is legit.
68 $validation_result = $this->validate_request( $request_headers, $request_body );
69 if ( WC_Stripe_Webhook_State::VALIDATION_SUCCEEDED === $validation_result ) {
70 $this->process_webhook( $request_body );
71
72 $notification = json_decode( $request_body );
73 WC_Stripe_Webhook_State::set_last_webhook_success_at( $notification->created );
74
75 status_header( 200 );
76 exit;
77 } else {
78 WC_Stripe_Logger::log( 'Incoming webhook failed validation: ' . print_r( $request_body, true ) );
79 WC_Stripe_Webhook_State::set_last_webhook_failure_at( time() );
80 WC_Stripe_Webhook_State::set_last_error_reason( $validation_result );
81
82 // A webhook endpoint must return a 2xx HTTP status code to prevent future webhook
83 // delivery failures.
84 // @see https://stripe.com/docs/webhooks/build#acknowledge-events-immediately
85 status_header( 204 );
86 exit;
87 }
88 }
89
90 /**
91 * Verify the incoming webhook notification to make sure it is legit.
92 *
93 * @since 4.0.0
94 * @version 5.0.0
95 * @param array $request_headers The request headers from Stripe.
96 * @param array $request_body The request body from Stripe.
97 * @return string The validation result (e.g. self::VALIDATION_SUCCEEDED )
98 */
99 public function validate_request( $request_headers, $request_body ) {
100 if ( empty( $request_headers ) ) {
101 return WC_Stripe_Webhook_State::VALIDATION_FAILED_EMPTY_HEADERS;
102 }
103 if ( empty( $request_body ) ) {
104 return WC_Stripe_Webhook_State::VALIDATION_FAILED_EMPTY_BODY;
105 }
106
107 if ( empty( $this->secret ) ) {
108 return $this->validate_request_user_agent( $request_headers );
109 }
110
111 // Check for a valid signature.
112 $signature_format = '/^t=(?P<timestamp>\d+)(?P<signatures>(,v\d+=[a-z0-9]+){1,2})$/';
113 if ( empty( $request_headers['STRIPE-SIGNATURE'] ) || ! preg_match( $signature_format, $request_headers['STRIPE-SIGNATURE'], $matches ) ) {
114 return WC_Stripe_Webhook_State::VALIDATION_FAILED_SIGNATURE_INVALID;
115 }
116
117 // Verify the timestamp.
118 $timestamp = intval( $matches['timestamp'] );
119 if ( abs( $timestamp - time() ) > 5 * MINUTE_IN_SECONDS ) {
120 return WC_Stripe_Webhook_State::VALIDATION_FAILED_TIMESTAMP_MISMATCH;
121 }
122
123 // Generate the expected signature.
124 $signed_payload = $timestamp . '.' . $request_body;
125 $expected_signature = hash_hmac( 'sha256', $signed_payload, $this->secret );
126
127 // Check if the expected signature is present.
128 if ( ! preg_match( '/,v\d+=' . preg_quote( $expected_signature, '/' ) . '/', $matches['signatures'] ) ) {
129 return WC_Stripe_Webhook_State::VALIDATION_FAILED_SIGNATURE_MISMATCH;
130 }
131
132 return WC_Stripe_Webhook_State::VALIDATION_SUCCEEDED;
133 }
134
135 /**
136 * Verify User Agent of the incoming webhook notification. Used as fallback for the cases when webhook secret is missing.
137 *
138 * @since 5.0.0
139 * @version 5.0.0
140 * @param array $request_headers The request headers from Stripe.
141 * @return string The validation result (e.g. self::VALIDATION_SUCCEEDED )
142 */
143 private function validate_request_user_agent( $request_headers ) {
144 $ua_is_valid = empty( $request_headers['USER-AGENT'] ) || preg_match( '/Stripe/', $request_headers['USER-AGENT'] );
145 $ua_is_valid = apply_filters( 'wc_stripe_webhook_is_user_agent_valid', $ua_is_valid, $request_headers );
146
147 return $ua_is_valid ? WC_Stripe_Webhook_State::VALIDATION_SUCCEEDED : WC_Stripe_Webhook_State::VALIDATION_FAILED_USER_AGENT_INVALID;
148 }
149
150 /**
151 * Gets the incoming request headers. Some servers are not using
152 * Apache and "getallheaders()" will not work so we may need to
153 * build our own headers.
154 *
155 * @since 4.0.0
156 * @version 4.0.0
157 */
158 public function get_request_headers() {
159 if ( ! function_exists( 'getallheaders' ) ) {
160 $headers = [];
161
162 foreach ( $_SERVER as $name => $value ) {
163 if ( 'HTTP_' === substr( $name, 0, 5 ) ) {
164 $headers[ str_replace( ' ', '-', ucwords( strtolower( str_replace( '_', ' ', substr( $name, 5 ) ) ) ) ) ] = $value;
165 }
166 }
167
168 return $headers;
169 } else {
170 return getallheaders();
171 }
172 }
173
174 /**
175 * Process webhook payments.
176 * This is where we charge the source.
177 *
178 * @since 4.0.0
179 * @version 4.0.0
180 * @param object $notification
181 * @param bool $retry
182 */
183 public function process_webhook_payment( $notification, $retry = true ) {
184 // The following 3 payment methods are synchronous so does not need to be handle via webhook.
185 if ( 'card' === $notification->data->object->type || 'sepa_debit' === $notification->data->object->type || 'three_d_secure' === $notification->data->object->type ) {
186 return;
187 }
188
189 $order = WC_Stripe_Helper::get_order_by_source_id( $notification->data->object->id );
190
191 if ( ! $order ) {
192 WC_Stripe_Logger::log( 'Could not find order via source ID: ' . $notification->data->object->id );
193 return;
194 }
195
196 $order_id = $order->get_id();
197
198 $is_pending_receiver = ( 'receiver' === $notification->data->object->flow );
199
200 try {
201 if ( $order->has_status( [ 'processing', 'completed' ] ) ) {
202 return;
203 }
204
205 if ( $order->has_status( 'on-hold' ) && ! $is_pending_receiver ) {
206 return;
207 }
208
209 // Result from Stripe API request.
210 $response = null;
211
212 // This will throw exception if not valid.
213 $this->validate_minimum_order_amount( $order );
214
215 WC_Stripe_Logger::log( "Info: (Webhook) Begin processing payment for order $order_id for the amount of {$order->get_total()}" );
216
217 // Prep source object.
218 $prepared_source = $this->prepare_order_source( $order );
219
220 // Make the request.
221 $response = WC_Stripe_API::request( $this->generate_payment_request( $order, $prepared_source ), 'charges', 'POST', true );
222 $headers = $response['headers'];
223 $response = $response['body'];
224
225 if ( ! empty( $response->error ) ) {
226 // Customer param wrong? The user may have been deleted on stripe's end. Remove customer_id. Can be retried without.
227 if ( $this->is_no_such_customer_error( $response->error ) ) {
228 delete_user_option( $order->get_customer_id(), '_stripe_customer_id' );
229 $order->delete_meta_data( '_stripe_customer_id' );
230 $order->save();
231 }
232
233 if ( $this->is_no_such_token_error( $response->error ) && $prepared_source->token_id ) {
234 // Source param wrong? The CARD may have been deleted on stripe's end. Remove token and show message.
235 $wc_token = WC_Payment_Tokens::get( $prepared_source->token_id );
236 $wc_token->delete();
237 $localized_message = __( 'This card is no longer available and has been removed.', 'woocommerce-gateway-stripe' );
238 $order->add_order_note( $localized_message );
239 throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message );
240 }
241
242 // We want to retry.
243 if ( $this->is_retryable_error( $response->error ) ) {
244 if ( $retry ) {
245 // Don't do anymore retries after this.
246 if ( 5 <= $this->retry_interval ) {
247
248 return $this->process_webhook_payment( $notification, false );
249 }
250
251 sleep( $this->retry_interval );
252
253 $this->retry_interval++;
254 return $this->process_webhook_payment( $notification, true );
255 } else {
256 $localized_message = __( 'Sorry, we are unable to process your payment at this time. Please retry later.', 'woocommerce-gateway-stripe' );
257 $order->add_order_note( $localized_message );
258 throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message );
259 }
260 }
261
262 $localized_messages = WC_Stripe_Helper::get_localized_messages();
263
264 if ( 'card_error' === $response->error->type ) {
265 $localized_message = isset( $localized_messages[ $response->error->code ] ) ? $localized_messages[ $response->error->code ] : $response->error->message;
266 } else {
267 $localized_message = isset( $localized_messages[ $response->error->type ] ) ? $localized_messages[ $response->error->type ] : $response->error->message;
268 }
269
270 $order->add_order_note( $localized_message );
271
272 throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message );
273 }
274
275 // To prevent double processing the order on WC side.
276 if ( ! $this->is_original_request( $headers ) ) {
277 return;
278 }
279
280 do_action( 'wc_gateway_stripe_process_webhook_payment', $response, $order );
281
282 $this->process_response( $response, $order );
283
284 } catch ( WC_Stripe_Exception $e ) {
285 WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() );
286
287 do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification, $e );
288
289 $statuses = [ 'pending', 'failed' ];
290
291 if ( $order->has_status( $statuses ) ) {
292 $this->send_failed_order_email( $order_id );
293 }
294 }
295 }
296
297 /**
298 * Process webhook dispute that is created.
299 * This is triggered when fraud is detected or customer processes chargeback.
300 * We want to put the order into on-hold and add an order note.
301 *
302 * @since 4.0.0
303 * @param object $notification
304 */
305 public function process_webhook_dispute( $notification ) {
306 $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->charge );
307
308 if ( ! $order ) {
309 WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->charge );
310 return;
311 }
312
313 $order->update_meta_data( '_stripe_status_before_hold', $order->get_status() );
314
315 $message = sprintf(
316 /* translators: 1) HTML anchor open tag 2) HTML anchor closing tag */
317 __( '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' ),
318 '<a href="' . esc_url( $this->get_transaction_url( $order ) ) . '" title="Stripe Dashboard" target="_blank">',
319 '</a>'
320 );
321
322 if ( ! $order->get_meta( '_stripe_status_final', false ) ) {
323 $order->update_status( 'on-hold', $message );
324 } else {
325 $order->add_order_note( $message );
326 }
327
328 do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
329
330 $order_id = $order->get_id();
331 $this->send_failed_order_email( $order_id );
332 }
333
334 /**
335 * Process webhook dispute that is closed.
336 *
337 * @since 4.4.1
338 * @param object $notification
339 */
340 public function process_webhook_dispute_closed( $notification ) {
341 $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->charge );
342 $status = $notification->data->object->status;
343
344 if ( ! $order ) {
345 WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->charge );
346 return;
347 }
348
349 if ( 'lost' === $status ) {
350 $message = __( 'The dispute was lost or accepted.', 'woocommerce-gateway-stripe' );
351 } elseif ( 'won' === $status ) {
352 $message = __( 'The dispute was resolved in your favor.', 'woocommerce-gateway-stripe' );
353 } elseif ( 'warning_closed' === $status ) {
354 $message = __( 'The inquiry or retrieval was closed.', 'woocommerce-gateway-stripe' );
355 } else {
356 return;
357 }
358
359 if ( apply_filters( 'wc_stripe_webhook_dispute_change_order_status', true, $order, $notification ) ) {
360 // Mark final so that order status is not overridden by out-of-sequence events.
361 $order->update_meta_data( '_stripe_status_final', true );
362
363 // Fail order if dispute is lost, or else revert to pre-dispute status.
364 $order_status = 'lost' === $status ? 'failed' : $order->get_meta( '_stripe_status_before_hold', 'processing' );
365 $order->update_status( $order_status, $message );
366 } else {
367 $order->add_order_note( $message );
368 }
369 }
370
371 /**
372 * Process webhook capture. This is used for an authorized only
373 * transaction that is later captured via Stripe not WC.
374 *
375 * @since 4.0.0
376 * @version 4.0.0
377 * @param object $notification
378 */
379 public function process_webhook_capture( $notification ) {
380 $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
381
382 if ( ! $order ) {
383 WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
384 return;
385 }
386
387 if ( 'stripe' === $order->get_payment_method() ) {
388 $charge = $order->get_transaction_id();
389 $captured = $order->get_meta( '_stripe_charge_captured', true );
390
391 if ( $charge && 'no' === $captured ) {
392 $order->update_meta_data( '_stripe_charge_captured', 'yes' );
393
394 // Store other data such as fees
395 $order->set_transaction_id( $notification->data->object->id );
396
397 if ( isset( $notification->data->object->balance_transaction ) ) {
398 $this->update_fees( $order, $notification->data->object->balance_transaction );
399 }
400
401 // Check and see if capture is partial.
402 if ( $this->is_partial_capture( $notification ) ) {
403 $partial_amount = $this->get_partial_amount_to_charge( $notification );
404 $order->set_total( $partial_amount );
405 $this->update_fees( $order, $notification->data->object->refunds->data[0]->balance_transaction );
406 /* translators: partial captured amount */
407 $order->add_order_note( sprintf( __( 'This charge was partially captured via Stripe Dashboard in the amount of: %s', 'woocommerce-gateway-stripe' ), $partial_amount ) );
408 } else {
409 $order->payment_complete( $notification->data->object->id );
410
411 /* translators: transaction id */
412 $order->add_order_note( sprintf( __( 'Stripe charge complete (Charge ID: %s)', 'woocommerce-gateway-stripe' ), $notification->data->object->id ) );
413 }
414
415 if ( is_callable( [ $order, 'save' ] ) ) {
416 $order->save();
417 }
418 }
419 }
420 }
421
422 /**
423 * Process webhook charge succeeded. This is used for payment methods
424 * that takes time to clear which is asynchronous. e.g. SEPA, Sofort.
425 *
426 * @since 4.0.0
427 * @version 4.0.0
428 * @param object $notification
429 */
430 public function process_webhook_charge_succeeded( $notification ) {
431 // The following payment methods are synchronous so does not need to be handle via webhook.
432 if ( ( isset( $notification->data->object->source->type ) && 'card' === $notification->data->object->source->type ) || ( isset( $notification->data->object->source->type ) && 'three_d_secure' === $notification->data->object->source->type ) ) {
433 return;
434 }
435
436 $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
437
438 if ( ! $order ) {
439 WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
440 return;
441 }
442
443 if ( ! $order->has_status( 'on-hold' ) ) {
444 return;
445 }
446
447 // When the plugin's "Issue an authorization on checkout, and capture later"
448 // setting is enabled, Stripe API still sends a "charge.succeeded" webhook but
449 // the payment has not been captured, yet. This ensures that the payment has been
450 // captured, before completing the payment.
451 if ( ! $notification->data->object->captured ) {
452 return;
453 }
454
455 // Store other data such as fees
456 $order->set_transaction_id( $notification->data->object->id );
457
458 if ( isset( $notification->data->object->balance_transaction ) ) {
459 $this->update_fees( $order, $notification->data->object->balance_transaction );
460 }
461
462 $order->payment_complete( $notification->data->object->id );
463
464 /* translators: transaction id */
465 $order->add_order_note( sprintf( __( 'Stripe charge complete (Charge ID: %s)', 'woocommerce-gateway-stripe' ), $notification->data->object->id ) );
466
467 if ( is_callable( [ $order, 'save' ] ) ) {
468 $order->save();
469 }
470 }
471
472 /**
473 * Process webhook charge failed.
474 *
475 * @since 4.0.0
476 * @since 4.1.5 Can handle any fail payments from any methods.
477 * @param object $notification
478 */
479 public function process_webhook_charge_failed( $notification ) {
480 $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
481
482 if ( ! $order ) {
483 WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
484 return;
485 }
486
487 // If order status is already in failed status don't continue.
488 if ( $order->has_status( 'failed' ) ) {
489 return;
490 }
491
492 $message = __( 'This payment failed to clear.', 'woocommerce-gateway-stripe' );
493 if ( ! $order->get_meta( '_stripe_status_final', false ) ) {
494 $order->update_status( 'failed', $message );
495 } else {
496 $order->add_order_note( $message );
497 }
498
499 do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
500 }
501
502 /**
503 * Process webhook source canceled. This is used for payment methods
504 * that redirects and awaits payments from customer.
505 *
506 * @since 4.0.0
507 * @since 4.1.15 Add check to make sure order is processed by Stripe.
508 * @param object $notification
509 */
510 public function process_webhook_source_canceled( $notification ) {
511 $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
512
513 // If can't find order by charge ID, try source ID.
514 if ( ! $order ) {
515 $order = WC_Stripe_Helper::get_order_by_source_id( $notification->data->object->id );
516
517 if ( ! $order ) {
518 WC_Stripe_Logger::log( 'Could not find order via charge/source ID: ' . $notification->data->object->id );
519 return;
520 }
521 }
522
523 // Don't proceed if payment method isn't Stripe.
524 if ( 'stripe' !== $order->get_payment_method() ) {
525 WC_Stripe_Logger::log( 'Canceled webhook abort: Order was not processed by Stripe: ' . $order->get_id() );
526 return;
527 }
528
529 $message = __( 'This payment was cancelled.', 'woocommerce-gateway-stripe' );
530 if ( ! $order->has_status( 'cancelled' ) && ! $order->get_meta( '_stripe_status_final', false ) ) {
531 $order->update_status( 'cancelled', $message );
532 } else {
533 $order->add_order_note( $message );
534 }
535
536 do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
537 }
538
539 /**
540 * Process webhook refund.
541 *
542 * @since 4.0.0
543 * @version 4.9.0
544 * @param object $notification
545 */
546 public function process_webhook_refund( $notification ) {
547 $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
548
549 if ( ! $order ) {
550 WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
551 return;
552 }
553
554 $order_id = $order->get_id();
555
556 if ( 'stripe' === $order->get_payment_method() ) {
557 $charge = $order->get_transaction_id();
558 $captured = $order->get_meta( '_stripe_charge_captured' );
559 $refund_id = $order->get_meta( '_stripe_refund_id' );
560 $currency = $order->get_currency();
561 $raw_amount = $notification->data->object->refunds->data[0]->amount;
562
563 if ( ! in_array( strtolower( $currency ), WC_Stripe_Helper::no_decimal_currencies(), true ) ) {
564 $raw_amount /= 100;
565 }
566
567 $amount = wc_price( $raw_amount, [ 'currency' => $currency ] );
568
569 // If charge wasn't captured, skip creating a refund.
570 if ( 'yes' !== $captured ) {
571 // If the process was initiated from wp-admin,
572 // the order was already cancelled, so we don't need a new note.
573 if ( 'cancelled' !== $order->get_status() ) {
574 /* translators: amount (including currency symbol) */
575 $order->add_order_note( sprintf( __( 'Pre-Authorization for %s voided from the Stripe Dashboard.', 'woocommerce-gateway-stripe' ), $amount ) );
576 $order->update_status( 'cancelled' );
577 }
578
579 return;
580 }
581
582 // If the refund ID matches, don't continue to prevent double refunding.
583 if ( $notification->data->object->refunds->data[0]->id === $refund_id ) {
584 return;
585 }
586
587 if ( $charge ) {
588 $reason = __( 'Refunded via Stripe Dashboard', 'woocommerce-gateway-stripe' );
589
590 // Create the refund.
591 $refund = wc_create_refund(
592 [
593 'order_id' => $order_id,
594 'amount' => $this->get_refund_amount( $notification ),
595 'reason' => $reason,
596 ]
597 );
598
599 if ( is_wp_error( $refund ) ) {
600 WC_Stripe_Logger::log( $refund->get_error_message() );
601 }
602
603 $order->update_meta_data( '_stripe_refund_id', $notification->data->object->refunds->data[0]->id );
604
605 if ( isset( $notification->data->object->refunds->data[0]->balance_transaction ) ) {
606 $this->update_fees( $order, $notification->data->object->refunds->data[0]->balance_transaction );
607 }
608
609 /* translators: 1) amount (including currency symbol) 2) transaction id 3) refund message */
610 $order->add_order_note( sprintf( __( 'Refunded %1$s - Refund ID: %2$s - %3$s', 'woocommerce-gateway-stripe' ), $amount, $notification->data->object->refunds->data[0]->id, $reason ) );
611 }
612 }
613 }
614
615 /**
616 * Process a refund update.
617 *
618 * @param object $notification
619 */
620 public function process_webhook_refund_updated( $notification ) {
621 $refund_object = $notification->data->object;
622 $order = WC_Stripe_Helper::get_order_by_charge_id( $refund_object->charge );
623
624 if ( ! $order ) {
625 WC_Stripe_Logger::log( 'Could not find order to update refund via charge ID: ' . $refund_object->charge );
626 return;
627 }
628
629 $order_id = $order->get_id();
630
631 if ( 'stripe' === $order->get_payment_method() ) {
632 $charge = $order->get_transaction_id();
633 $refund_id = $order->get_meta( '_stripe_refund_id' );
634 $currency = $order->get_currency();
635 $raw_amount = $refund_object->amount;
636
637 if ( ! in_array( strtolower( $currency ), WC_Stripe_Helper::no_decimal_currencies(), true ) ) {
638 $raw_amount /= 100;
639 }
640
641 $amount = wc_price( $raw_amount, [ 'currency' => $currency ] );
642
643 // If the refund IDs do not match stop.
644 if ( $refund_object->id !== $refund_id ) {
645 return;
646 }
647
648 if ( $charge ) {
649 $refunds = wc_get_orders(
650 [
651 'limit' => 1,
652 'parent' => $order_id,
653 ]
654 );
655
656 if ( empty( $refunds ) ) {
657 // No existing refunds nothing to update.
658 return;
659 }
660
661 $refund = $refunds[0];
662
663 if ( in_array( $refund_object->status, [ 'failed', 'canceled' ], true ) ) {
664 if ( isset( $refund_object->failure_balance_transaction ) ) {
665 $this->update_fees( $order, $refund_object->failure_balance_transaction );
666 }
667 $refund->delete( true );
668 do_action( 'woocommerce_refund_deleted', $refund_id, $order_id );
669 if ( 'failed' === $refund_object->status ) {
670 /* translators: 1) amount (including currency symbol) 2) transaction id 3) refund failure code */
671 $note = sprintf( __( 'Refund failed for %1$s - Refund ID: %2$s - Reason: %3$s', 'woocommerce-gateway-stripe' ), $amount, $refund_object->id, $refund_object->failure_reason );
672 } else {
673 /* translators: 1) amount (including currency symbol) 2) transaction id 3) refund failure code */
674 $note = sprintf( __( 'Refund canceled for %1$s - Refund ID: %2$s - Reason: %3$s', 'woocommerce-gateway-stripe' ), $amount, $refund_object->id, $refund_object->failure_reason );
675 }
676
677 $order->add_order_note( $note );
678 }
679 }
680 }
681 }
682
683 /**
684 * Process webhook reviews that are opened. i.e Radar.
685 *
686 * @since 4.0.6
687 * @param object $notification
688 */
689 public function process_review_opened( $notification ) {
690 if ( isset( $notification->data->object->payment_intent ) ) {
691 $order = WC_Stripe_Helper::get_order_by_intent_id( $notification->data->object->payment_intent );
692
693 if ( ! $order ) {
694 WC_Stripe_Logger::log( '[Review Opened] Could not find order via intent ID: ' . $notification->data->object->payment_intent );
695 return;
696 }
697 } else {
698 $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->charge );
699
700 if ( ! $order ) {
701 WC_Stripe_Logger::log( '[Review Opened] Could not find order via charge ID: ' . $notification->data->object->charge );
702 return;
703 }
704 }
705
706 $order->update_meta_data( '_stripe_status_before_hold', $order->get_status() );
707
708 $message = sprintf(
709 /* translators: 1) HTML anchor open tag 2) HTML anchor closing tag 3) The reason type. */
710 __( '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' ),
711 '<a href="' . esc_url( $this->get_transaction_url( $order ) ) . '" title="Stripe Dashboard" target="_blank">',
712 '</a>',
713 esc_html( $notification->data->object->reason )
714 );
715
716 if ( apply_filters( 'wc_stripe_webhook_review_change_order_status', true, $order, $notification ) && ! $order->get_meta( '_stripe_status_final', false ) ) {
717 $order->update_status( 'on-hold', $message );
718 } else {
719 $order->add_order_note( $message );
720 }
721 }
722
723 /**
724 * Process webhook reviews that are closed. i.e Radar.
725 *
726 * @since 4.0.6
727 * @param object $notification
728 */
729 public function process_review_closed( $notification ) {
730 if ( isset( $notification->data->object->payment_intent ) ) {
731 $order = WC_Stripe_Helper::get_order_by_intent_id( $notification->data->object->payment_intent );
732
733 if ( ! $order ) {
734 WC_Stripe_Logger::log( '[Review Closed] Could not find order via intent ID: ' . $notification->data->object->payment_intent );
735 return;
736 }
737 } else {
738 $order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->charge );
739
740 if ( ! $order ) {
741 WC_Stripe_Logger::log( '[Review Closed] Could not find order via charge ID: ' . $notification->data->object->charge );
742 return;
743 }
744 }
745
746 /* translators: 1) The reason type. */
747 $message = sprintf( __( 'The opened review for this order is now closed. Reason: (%s)', 'woocommerce-gateway-stripe' ), $notification->data->object->reason );
748
749 if (
750 $order->has_status( 'on-hold' ) &&
751 apply_filters( 'wc_stripe_webhook_review_change_order_status', true, $order, $notification ) &&
752 ! $order->get_meta( '_stripe_status_final', false )
753 ) {
754 $order->update_status( $order->get_meta( '_stripe_status_before_hold', 'processing' ), $message );
755 } else {
756 $order->add_order_note( $message );
757 }
758 }
759
760 /**
761 * Checks if capture is partial.
762 *
763 * @since 4.0.0
764 * @version 4.0.0
765 * @param object $notification
766 */
767 public function is_partial_capture( $notification ) {
768 return 0 < $notification->data->object->amount_refunded;
769 }
770
771 /**
772 * Gets the amount refunded.
773 *
774 * @since 4.0.0
775 * @version 4.0.0
776 * @param object $notification
777 */
778 public function get_refund_amount( $notification ) {
779 if ( $this->is_partial_capture( $notification ) ) {
780 $amount = $notification->data->object->refunds->data[0]->amount / 100;
781
782 if ( in_array( strtolower( $notification->data->object->currency ), WC_Stripe_Helper::no_decimal_currencies() ) ) {
783 $amount = $notification->data->object->refunds->data[0]->amount;
784 }
785
786 return $amount;
787 }
788
789 return false;
790 }
791
792 /**
793 * Gets the amount we actually charge.
794 *
795 * @since 4.0.0
796 * @version 4.0.0
797 * @param object $notification
798 */
799 public function get_partial_amount_to_charge( $notification ) {
800 if ( $this->is_partial_capture( $notification ) ) {
801 $amount = ( $notification->data->object->amount - $notification->data->object->amount_refunded ) / 100;
802
803 if ( in_array( strtolower( $notification->data->object->currency ), WC_Stripe_Helper::no_decimal_currencies() ) ) {
804 $amount = ( $notification->data->object->amount - $notification->data->object->amount_refunded );
805 }
806
807 return $amount;
808 }
809
810 return false;
811 }
812
813 public function process_payment_intent_success( $notification ) {
814 $intent = $notification->data->object;
815 $order = WC_Stripe_Helper::get_order_by_intent_id( $intent->id );
816
817 if ( ! $order ) {
818 WC_Stripe_Logger::log( 'Could not find order via intent ID: ' . $intent->id );
819 return;
820 }
821
822 if ( ! $order->has_status(
823 apply_filters(
824 'wc_stripe_allowed_payment_processing_statuses',
825 [ 'pending', 'failed' ],
826 $order
827 )
828 ) ) {
829 return;
830 }
831
832 if ( $this->lock_order_payment( $order, $intent ) ) {
833 return;
834 }
835
836 $order_id = $order->get_id();
837 $is_voucher_payment = in_array( $order->get_meta( '_stripe_upe_payment_type' ), [ 'boleto', 'oxxo' ] );
838
839 switch ( $notification->type ) {
840 case 'payment_intent.requires_action':
841 if ( $is_voucher_payment ) {
842 $order->update_status( 'on-hold', __( 'Awaiting payment.', 'woocommerce-gateway-stripe' ) );
843 wc_reduce_stock_levels( $order_id );
844 }
845 break;
846 case 'payment_intent.succeeded':
847 case 'payment_intent.amount_capturable_updated':
848 $charge = end( $intent->charges->data );
849 WC_Stripe_Logger::log( "Stripe PaymentIntent $intent->id succeeded for order $order_id" );
850
851 do_action( 'wc_gateway_stripe_process_payment', $charge, $order );
852
853 // Process valid response.
854 $this->process_response( $charge, $order );
855 break;
856 default:
857 if ( $is_voucher_payment && 'payment_intent.payment_failed' === $notification->type ) {
858 $order->update_status( 'failed', __( 'Payment not completed in time', 'woocommerce-gateway-stripe' ) );
859 wc_increase_stock_levels( $order_id );
860 break;
861 }
862
863 $error_message = $intent->last_payment_error ? $intent->last_payment_error->message : '';
864
865 /* translators: 1) The error message that was received from Stripe. */
866 $message = sprintf( __( 'Stripe SCA authentication failed. Reason: %s', 'woocommerce-gateway-stripe' ), $error_message );
867
868 if ( ! $order->get_meta( '_stripe_status_final', false ) ) {
869 $order->update_status( 'failed', $message );
870 } else {
871 $order->add_order_note( $message );
872 }
873
874 do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
875
876 $this->send_failed_order_email( $order_id );
877 break;
878 }
879
880 $this->unlock_order_payment( $order );
881 }
882
883 public function process_setup_intent( $notification ) {
884 $intent = $notification->data->object;
885 $order = WC_Stripe_Helper::get_order_by_setup_intent_id( $intent->id );
886
887 if ( ! $order ) {
888 WC_Stripe_Logger::log( 'Could not find order via setup intent ID: ' . $intent->id );
889 return;
890 }
891
892 if ( ! $order->has_status(
893 apply_filters(
894 'wc_gateway_stripe_allowed_payment_processing_statuses',
895 [ 'pending', 'failed' ]
896 )
897 ) ) {
898 return;
899 }
900
901 if ( $this->lock_order_payment( $order, $intent ) ) {
902 return;
903 }
904
905 $order_id = $order->get_id();
906 if ( 'setup_intent.succeeded' === $notification->type ) {
907 WC_Stripe_Logger::log( "Stripe SetupIntent $intent->id succeeded for order $order_id" );
908 if ( $this->has_pre_order( $order ) ) {
909 $this->mark_order_as_pre_ordered( $order );
910 } else {
911 $order->payment_complete();
912 }
913 } else {
914 $error_message = $intent->last_setup_error ? $intent->last_setup_error->message : '';
915
916 /* translators: 1) The error message that was received from Stripe. */
917 $message = sprintf( __( 'Stripe SCA authentication failed. Reason: %s', 'woocommerce-gateway-stripe' ), $error_message );
918
919 if ( ! $order->get_meta( '_stripe_status_final', false ) ) {
920 $order->update_status( 'failed', $message );
921 } else {
922 $order->add_order_note( $message );
923 }
924
925 $this->send_failed_order_email( $order_id );
926 }
927
928 $this->unlock_order_payment( $order );
929 }
930
931 /**
932 * Processes the incoming webhook.
933 *
934 * @since 4.0.0
935 * @version 4.0.0
936 * @param string $request_body
937 */
938 public function process_webhook( $request_body ) {
939 $notification = json_decode( $request_body );
940
941 switch ( $notification->type ) {
942 case 'source.chargeable':
943 $this->process_webhook_payment( $notification );
944 break;
945
946 case 'source.canceled':
947 $this->process_webhook_source_canceled( $notification );
948 break;
949
950 case 'charge.succeeded':
951 $this->process_webhook_charge_succeeded( $notification );
952 break;
953
954 case 'charge.failed':
955 $this->process_webhook_charge_failed( $notification );
956 break;
957
958 case 'charge.captured':
959 $this->process_webhook_capture( $notification );
960 break;
961
962 case 'charge.dispute.created':
963 $this->process_webhook_dispute( $notification );
964 break;
965
966 case 'charge.dispute.closed':
967 $this->process_webhook_dispute_closed( $notification );
968 break;
969
970 case 'charge.refunded':
971 $this->process_webhook_refund( $notification );
972 break;
973
974 case 'charge.refund.updated':
975 $this->process_webhook_refund_updated( $notification );
976 break;
977
978 case 'review.opened':
979 $this->process_review_opened( $notification );
980 break;
981
982 case 'review.closed':
983 $this->process_review_closed( $notification );
984 break;
985
986 case 'payment_intent.succeeded':
987 case 'payment_intent.payment_failed':
988 case 'payment_intent.amount_capturable_updated':
989 case 'payment_intent.requires_action':
990 $this->process_payment_intent_success( $notification );
991 break;
992
993 case 'setup_intent.succeeded':
994 case 'setup_intent.setup_failed':
995 $this->process_setup_intent( $notification );
996
997 }
998 }
999}
1000
1001new WC_Stripe_Webhook_Handler();