Completed
Push — master ( bb9186...e1e9c9 )
by Roy
03:08
created

process_webhook_refund()   D

Complexity

Conditions 13
Paths 163

Size

Total Lines 39
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 13
eloc 21
nc 163
nop 1
dl 0
loc 39
rs 4.8178
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
if ( ! 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
 * @since 4.0.0
11
 */
12
class WC_Stripe_Webhook_Handler extends WC_Stripe_Payment_Gateway {
13
	/**
14
	 * Constructor.
15
	 *
16
	 * @since 4.0.0
17
	 * @version 4.0.0
18
	 */
19
	public function __construct() {
20
		add_action( 'woocommerce_api_wc_stripe', array( $this, 'check_for_webhook' ) );
21
	}
22
23
	/**
24
	 * Check incoming requests for Stripe Webhook data and process them.
25
	 *
26
	 * @since 4.0.0
27
	 * @version 4.0.0
28
	 */
29
	public function check_for_webhook() {
30
		if ( ( 'POST' !== $_SERVER['REQUEST_METHOD'] )
31
			|| ! isset( $_GET['wc-api'] )
32
			|| ( 'wc_stripe' !== $_GET['wc-api'] )
33
		) {
34
			return;
35
		}
36
37
		$request_body    = file_get_contents( 'php://input' );
38
		$request_headers = array_change_key_case( $this->get_request_headers(), CASE_UPPER );
39
40
		// Validate it to make sure it is legit.
41
		if ( $this->is_valid_request( $request_headers, $request_body ) ) {
42
			$this->process_webhook( $request_body );
43
			status_header( 200 );
44
			exit;
45
		} else {
46
			WC_Stripe_Logger::log( 'Incoming webhook failed validation: ' . print_r( $request_body, true ) );
47
			status_header( 400 );
48
			exit;
49
		}
50
	}
51
52
	/**
53
	 * Verify the incoming webhook notification to make sure it is legit.
54
	 *
55
	 * @since 4.0.0
56
	 * @version 4.0.0
57
	 * @todo Implement proper webhook signature validation. Ref https://stripe.com/docs/webhooks#signatures
58
	 * @param string $request_headers The request headers from Stripe.
59
	 * @param string $request_body The request body from Stripe.
60
	 * @return bool
61
	 */
62
	public function is_valid_request( $request_headers = null, $request_body = null ) {
63
		if ( null === $request_headers || null === $request_body ) {
64
			return false;
65
		}
66
67
		if ( ! empty( $request_headers['USER-AGENT'] ) && ! preg_match( '/Stripe/', $request_headers['USER-AGENT'] ) ) {
68
			return false;
69
		}
70
71
		return true;
72
	}
73
74
	/**
75
	 * Gets the incoming request headers. Some servers are not using
76
	 * Apache and "getallheaders()" will not work so we may need to
77
	 * build our own headers.
78
	 *
79
	 * @since 4.0.0
80
	 * @version 4.0.0
81
	 */
82
	public function get_request_headers() {
83
		if ( ! function_exists( 'getallheaders' ) ) {
84
			$headers = [];
85
			foreach ( $_SERVER as $name => $value ) {
86
				if ( 'HTTP_' === substr( $name, 0, 5 ) ) {
87
					$headers[ str_replace( ' ', '-', ucwords( strtolower( str_replace( '_', ' ', substr( $name, 5 ) ) ) ) ) ] = $value;
88
				}
89
			}
90
91
			return $headers;
92
		} else {
93
			return getallheaders();
94
		}
95
	}
96
97
	/**
98
	 * Process webhook payments.
99
	 * This is where we charge the source.
100
	 *
101
	 * @since 4.0.0
102
	 * @version 4.0.0
103
	 * @param object $notification
104
	 * @param bool $retry
105
	 */
106
	public function process_webhook_payment( $notification, $retry = true ) {
107
		// The following 2 payment methods are synchronous so does not need to be handle via webhook.
108
		if ( 'card' === $notification->data->object->type || 'sepa_debit' === $notification->data->object->type ) {
109
			return;
110
		}
111
112
		$order = WC_Stripe_Helper::get_order_by_source_id( $notification->data->object->id );
113
114
		if ( ! $order ) {
115
			WC_Stripe_Logger::log( 'Could not find order via source ID: ' . $notification->data->object->id );
116
			return;
117
		}
118
119
		$order_id  = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
120
		$source_id = $notification->data->object->id;
121
122
		$is_pending_receiver = ( 'receiver' === $notification->data->object->flow );
123
124
		try {
125
			if ( 'processing' === $order->get_status() || 'completed' === $order->get_status() ) {
126
				return;
127
			}
128
129
			if ( 'on-hold' === $order->get_status() && ! $is_pending_receiver ) {
130
				return;
131
			}
132
133
			// Result from Stripe API request.
134
			$response = null;
135
136
			// This will throw exception if not valid.
137
			$this->validate_minimum_order_amount( $order );
138
139
			WC_Stripe_Logger::log( "Info: (Webhook) Begin processing payment for order $order_id for the amount of {$order->get_total()}" );
140
141
			// Prep source object.
142
			$source_object           = new stdClass();
143
			$source_object->token_id = '';
144
			$source_object->customer = $this->get_stripe_customer_id( $order );
145
			$source_object->source   = $source_id;
146
147
			// Make the request.
148
			$response = WC_Stripe_API::request( $this->generate_payment_request( $order, $source_object ) );
149
150 View Code Duplication
			if ( ! empty( $response->error ) ) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
151
				// If it is an API error such connection or server, let's retry.
152
				if ( 'api_connection_error' === $response->error->type || 'api_error' === $response->error->type ) {
153
					if ( $retry ) {
154
						sleep( 5 );
155
						return $this->process_payment( $order_id, false );
156
					} else {
157
						$localized_message = 'API connection error and retries exhausted.';
158
						$order->add_order_note( $localized_message );
159
						throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message );
160
					}
161
				}
162
163
				// Customer param wrong? The user may have been deleted on stripe's end. Remove customer_id. Can be retried without.
164
				if ( preg_match( '/No such customer/i', $response->error->message ) && $retry ) {
165
					if ( WC_Stripe_Helper::is_pre_30() ) {
166
						delete_user_meta( $order->customer_user, '_stripe_customer_id' );
167
						delete_post_meta( $order_id, '_stripe_customer_id' );
168
					} else {
169
						delete_user_meta( $order->get_customer_id(), '_stripe_customer_id' );
170
						$order->delete_meta_data( '_stripe_customer_id' );
171
						$order->save();
172
					}
173
174
					return $this->process_payment( $order_id, false );
175
176
				} elseif ( preg_match( '/No such token/i', $response->error->message ) && $source_object->token_id ) {
177
					// Source param wrong? The CARD may have been deleted on stripe's end. Remove token and show message.
178
					$wc_token = WC_Payment_Tokens::get( $source_object->token_id );
179
					$wc_token->delete();
180
					$message = __( 'This card is no longer available and has been removed.', 'woocommerce-gateway-stripe' );
181
					$order->add_order_note( $message );
182
					throw new WC_Stripe_Exception( print_r( $response, true ), $message );
183
				}
184
185
				$localized_messages = WC_Stripe_Helper::get_localized_messages();
186
187
				if ( 'card_error' === $response->error->type ) {
188
					$localized_message = isset( $localized_messages[ $response->error->code ] ) ? $localized_messages[ $response->error->code ] : $response->error->message;
189
				} else {
190
					$localized_message = isset( $localized_messages[ $response->error->type ] ) ? $localized_messages[ $response->error->type ] : $response->error->message;
191
				}
192
193
				$order->add_order_note( $localized_message );
194
195
				throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message );
196
			}
197
198
			do_action( 'wc_gateway_stripe_process_webhook_payment', $response, $order );
199
200
			$this->process_response( $response, $order );
201
202
		} catch ( WC_Stripe_Exception $e ) {
203
			WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() );
204
205
			do_action( 'wc_gateway_stripe_process_webhook_payment_error', $e, $order );
206
207
			$statuses = array( 'pending', 'failed' );
208
209
			if ( $order->has_status( $statuses ) ) {
210
				$this->send_failed_order_email( $order_id );
211
			}
212
		}
213
	}
214
215
	/**
216
	 * Process webhook disputes that is created.
217
	 * This is trigger when a fraud is detected or customer processes chargeback.
218
	 * We want to put the order into on-hold and add an order note.
219
	 *
220
	 * @since 4.0.0
221
	 * @param object $notification
222
	 */
223 View Code Duplication
	public function process_webhook_dispute( $notification ) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
224
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->charge );
225
226
		if ( ! $order ) {
227
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->charge );
228
			return;
229
		}
230
231
		$order->update_status( 'on-hold', __( 'A dispute was created for this order. Response is needed. Please go to your Stripe Dashboard to review this dispute.', 'woocommerce-gateway-stripe' ) );
232
233
		do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
234
235
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
236
		$this->send_failed_order_email( $order_id );
237
	}
238
239
	/**
240
	 * Process webhook capture. This is used for an authorized only
241
	 * transaction that is later captured via Stripe not WC.
242
	 *
243
	 * @since 4.0.0
244
	 * @version 4.0.0
245
	 * @param object $notification
246
	 */
247
	public function process_webhook_capture( $notification ) {
248
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
249
250
		if ( ! $order ) {
251
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
252
			return;
253
		}
254
255
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
256
257
		if ( 'stripe' === ( WC_Stripe_Helper::is_pre_30() ? $order->payment_method : $order->get_payment_method() ) ) {
258
			$charge   = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_transaction_id', true ) : $order->get_transaction_id();
259
			$captured = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_stripe_charge_captured', true ) : $order->get_meta( '_stripe_charge_captured', true );
260
261
			if ( $charge && 'no' === $captured ) {
262
				WC_Stripe_Helper::is_pre_30() ? update_post_meta( $order_id, '_stripe_charge_captured', 'yes' ) : $order->update_meta_data( '_stripe_charge_captured', 'yes' );
263
264
				// Store other data such as fees
265
				WC_Stripe_Helper::is_pre_30() ? update_post_meta( $order_id, '_transaction_id', $notification->data->object->id ) : $order->set_transaction_id( $notification->data->object->id );
266
267
				if ( isset( $notification->data->object->balance_transaction ) ) {
268
					$this->update_fees( $order, $notification->data->object->balance_transaction );
269
				}
270
271
				if ( is_callable( array( $order, 'save' ) ) ) {
272
					$order->save();
273
				}
274
275
				/* translators: transaction id */
276
				$order->update_status( $order->needs_processing() ? 'processing' : 'completed', sprintf( __( 'Stripe charge complete (Charge ID: %s)', 'woocommerce-gateway-stripe' ), $notification->data->object->id ) );
277
278
				// Check and see if capture is partial.
279
				if ( $this->is_partial_capture( $notification ) ) {
280
					$order->set_total( $this->get_partial_amount_to_charge( $notification ) );
281
					$order->add_note( __( 'This charge was partially captured via Stripe Dashboard', 'woocommerce-gateway-stripe' ) );
282
					$order->save();
283
				}
284
			}
285
		}
286
	}
287
288
	/**
289
	 * Process webhook charge succeeded. This is used for payment methods
290
	 * that takes time to clear which is asynchronous. e.g. SEPA, SOFORT.
291
	 *
292
	 * @since 4.0.0
293
	 * @version 4.0.0
294
	 * @param object $notification
295
	 */
296
	public function process_webhook_charge_succeeded( $notification ) {
297
		// The following payment methods are synchronous so does not need to be handle via webhook.
298
		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 ) ) {
299
			return;
300
		}
301
302
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
303
304
		if ( ! $order ) {
305
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
306
			return;
307
		}
308
309
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
310
311
		if ( 'on-hold' !== $order->get_status() ) {
312
			return;
313
		}
314
315
		// Store other data such as fees
316
		WC_Stripe_Helper::is_pre_30() ? update_post_meta( $order_id, '_transaction_id', $notification->data->object->id ) : $order->set_transaction_id( $notification->data->object->id );
317
318
		if ( isset( $notification->data->object->balance_transaction ) ) {
319
			$this->update_fees( $order, $notification->data->object->balance_transaction );
320
		}
321
322
		if ( is_callable( array( $order, 'save' ) ) ) {
323
			$order->save();
324
		}
325
326
		/* translators: transaction id */
327
		$order->update_status( $order->needs_processing() ? 'processing' : 'completed', sprintf( __( 'Stripe charge complete (Charge ID: %s)', 'woocommerce-gateway-stripe' ), $notification->data->object->id ) );
328
	}
329
330
	/**
331
	 * Process webhook charge failed. This is used for payment methods
332
	 * that takes time to clear which is asynchronous. e.g. SEPA, SOFORT.
333
	 *
334
	 * @since 4.0.0
335
	 * @version 4.0.0
336
	 * @param object $notification
337
	 */
338 View Code Duplication
	public function process_webhook_charge_failed( $notification ) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
339
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
340
341
		if ( ! $order ) {
342
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
343
			return;
344
		}
345
346
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
0 ignored issues
show
Unused Code introduced by
$order_id is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
347
348
		if ( 'on-hold' !== $order->get_status() ) {
349
			return;
350
		}
351
352
		$order->update_status( 'failed', __( 'This payment failed to clear.', 'woocommerce-gateway-stripe' ) );
353
354
		do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
355
	}
356
357
	/**
358
	 * Process webhook source canceled. This is used for payment methods
359
	 * that redirects and awaits payments from customer.
360
	 *
361
	 * @since 4.0.0
362
	 * @version 4.0.0
363
	 * @param object $notification
364
	 */
365 View Code Duplication
	public function process_webhook_source_canceled( $notification ) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
366
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
367
368
		if ( ! $order ) {
369
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
370
			return;
371
		}
372
373
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
0 ignored issues
show
Unused Code introduced by
$order_id is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
374
375
		if ( 'on-hold' !== $order->get_status() || 'cancelled' !== $order->get_status() ) {
376
			return;
377
		}
378
379
		$order->update_status( 'cancelled', __( 'This payment has cancelled.', 'woocommerce-gateway-stripe' ) );
380
381
		do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
382
	}
383
384
	/**
385
	 * Process webhook refund.
386
	 * Note currently only support 1 time refund.
387
	 *
388
	 * @since 4.0.0
389
	 * @version 4.0.0
390
	 * @param object $notification
391
	 */
392
	public function process_webhook_refund( $notification ) {
393
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
394
395
		if ( ! $order ) {
396
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
397
			return;
398
		}
399
400
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
401
402
		if ( 'stripe' === ( WC_Stripe_Helper::is_pre_30() ? $order->payment_method : $order->get_payment_method() ) ) {
403
			$charge    = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_transaction_id', true ) : $order->get_transaction_id();
404
			$captured  = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_stripe_charge_captured', true ) : $order->get_meta( '_stripe_charge_captured', true );
405
			$refund_id = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_stripe_refund_id', true ) : $order->get_meta( '_stripe_refund_id', true );
406
407
			// If the refund ID matches, don't continue to prevent double refunding.
408
			if ( $notification->data->object->refunds->data[0]->id === $refund_id ) {
409
				return;
410
			}
411
412
			// Only refund captured charge.
413
			if ( $charge ) {
414
				$reason = ( isset( $captured ) && 'yes' === $captured ) ? __( 'Refunded via Stripe Dashboard', 'woocommerce-gateway-stripe' ) : __( 'Pre-Authorization Released via Stripe Dashboard', 'woocommerce-gateway-stripe' );
415
416
				// Create the refund.
417
				$refund = wc_create_refund( array(
418
					'order_id'       => $order_id,
419
					'amount'         => $this->get_refund_amount( $notification ),
420
					'reason'         => $reason,
421
				) );
422
423
				if ( is_wp_error( $refund ) ) {
424
					WC_Stripe_Logger::log( $refund->get_error_message() );
425
				}
426
427
				$order->add_order_note( $reason );
428
			}
429
		}
430
	}
431
432
	/**
433
	 * Checks if capture is partial.
434
	 *
435
	 * @since 4.0.0
436
	 * @version 4.0.0
437
	 * @param object $notification
438
	 */
439
	public function is_partial_capture( $notification ) {
440
		return 0 < $notification->data->object->amount_refunded;
441
	}
442
443
	/**
444
	 * Gets the amount refunded.
445
	 *
446
	 * @since 4.0.0
447
	 * @version 4.0.0
448
	 * @param object $notification
449
	 */
450
	public function get_refund_amount( $notification ) {
451
		if ( $this->is_partial_capture( $notification ) ) {
452
			$amount = $notification->data->object->amount_refunded / 100;
453
454
			if ( in_array( strtolower( $notification->data->object->currency ), WC_Stripe_Helper::no_decimal_currencies() ) ) {
455
				$amount = $notification->data->object->amount_refunded;
456
			}
457
458
			return $amount;
459
		}
460
461
		return false;
462
	}
463
464
	/**
465
	 * Gets the amount we actually charge.
466
	 *
467
	 * @since 4.0.0
468
	 * @version 4.0.0
469
	 * @param object $notification
470
	 */
471
	public function get_partial_amount_to_charge( $notification ) {
472
		if ( $this->is_partial_capture( $notification ) ) {
473
			$amount = ( $notification->data->object->amount - $notification->data->object->amount_refunded ) / 100;
474
475
			if ( in_array( strtolower( $notification->data->object->currency ), WC_Stripe_Helper::no_decimal_currencies() ) ) {
476
				$amount = ( $notification->data->object->amount - $notification->data->object->amount_refunded );
477
			}
478
479
			return $amount;
480
		}
481
482
		return false;
483
	}
484
485
	/**
486
	 * Processes the incoming webhook.
487
	 *
488
	 * @since 4.0.0
489
	 * @version 4.0.0
490
	 * @param string $request_body
491
	 */
492
	public function process_webhook( $request_body ) {
493
		$notification = json_decode( $request_body );
494
495
		/*
496
		 * Hacky way to possibly prevent duplicate requests due to
497
		 * frontend request and webhook payment firing at the same
498
		 * time.
499
		 */
500
		sleep( 10 );
501
502
		switch ( $notification->type ) {
503
			case 'source.chargeable':
504
				$this->process_webhook_payment( $notification );
505
				break;
506
507
			case 'source.canceled':
508
				$this->process_webhook_source_canceled( $notification );
509
				break;
510
511
			case 'charge.succeeded':
512
				$this->process_webhook_charge_succeeded( $notification );
513
				break;
514
515
			case 'charge.failed':
516
				$this->process_webhook_charge_failed( $notification );
517
				break;
518
519
			case 'charge.captured':
520
				$this->process_webhook_capture( $notification );
521
				break;
522
523
			case 'charge.dispute.created':
524
				$this->process_webhook_dispute( $notification );
525
				break;
526
527
			case 'charge.refunded':
528
				$this->process_webhook_refund( $notification );
529
				break;
530
531
		}
532
	}
533
}
534
535
new WC_Stripe_Webhook_Handler();
536