Completed
Push — master ( 9046cf...a728c7 )
by Roy
02:12
created

process_webhook_refund()   C

Complexity

Conditions 12
Paths 67

Size

Total Lines 35
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 12
eloc 19
nc 67
nop 1
dl 0
loc 35
rs 5.1612
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
					delete_user_meta( WC_Stripe_Helper::is_pre_30() ? $order->customer_user : $order->get_customer_id(), '_stripe_customer_id' );
166
167
					return $this->process_payment( $order_id, false );
168
169
				} elseif ( preg_match( '/No such token/i', $response->error->message ) && $source_object->token_id ) {
170
					// Source param wrong? The CARD may have been deleted on stripe's end. Remove token and show message.
171
					$wc_token = WC_Payment_Tokens::get( $source_object->token_id );
172
					$wc_token->delete();
173
					$message = __( 'This card is no longer available and has been removed.', 'woocommerce-gateway-stripe' );
174
					$order->add_order_note( $message );
175
					throw new WC_Stripe_Exception( print_r( $response, true ), $message );
176
				}
177
178
				$localized_messages = WC_Stripe_Helper::get_localized_messages();
179
180
				if ( 'card_error' === $response->error->type ) {
181
					$localized_message = isset( $localized_messages[ $response->error->code ] ) ? $localized_messages[ $response->error->code ] : $response->error->message;
182
				} else {
183
					$localized_message = isset( $localized_messages[ $response->error->type ] ) ? $localized_messages[ $response->error->type ] : $response->error->message;
184
				}
185
186
				$order->add_order_note( $localized_message );
187
188
				throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message );
189
			}
190
191
			do_action( 'wc_gateway_stripe_process_webhook_payment', $response, $order );
192
193
			$this->process_response( $response, $order );
194
195
		} catch ( WC_Stripe_Exception $e ) {
196
			WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() );
197
198
			do_action( 'wc_gateway_stripe_process_webhook_payment_error', $e, $order );
199
200
			$statuses = array( 'pending', 'failed' );
201
202
			if ( $order->has_status( $statuses ) ) {
203
				$this->send_failed_order_email( $order_id );
204
			}
205
		}
206
	}
207
208
	/**
209
	 * Process webhook disputes that is created.
210
	 * This is trigger when a fraud is detected or customer processes chargeback.
211
	 * We want to put the order into on-hold and add an order note.
212
	 *
213
	 * @since 4.0.0
214
	 * @version 4.0.0
215
	 * @param object $notification
216
	 */
217
	public function process_webhook_dispute( $notification ) {
218
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
219
220
		if ( ! $order ) {
221
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
222
			return;
223
		}
224
225
		$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' ) );
226
227
		do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
228
		$this->send_failed_order_email( $order_id );
0 ignored issues
show
Bug introduced by
The variable $order_id does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
229
	}
230
231
	/**
232
	 * Process webhook capture. This is used for an authorized only
233
	 * transaction that is later captured via Stripe not WC.
234
	 *
235
	 * @since 4.0.0
236
	 * @version 4.0.0
237
	 * @param object $notification
238
	 */
239
	public function process_webhook_capture( $notification ) {
240
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
241
242
		if ( ! $order ) {
243
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
244
			return;
245
		}
246
247
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
248
249
		if ( 'stripe' === ( WC_Stripe_Helper::is_pre_30() ? $order->payment_method : $order->get_payment_method() ) ) {
250
			$charge   = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_transaction_id', true ) : $order->get_transaction_id();
251
			$captured = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_stripe_charge_captured', true ) : $order->get_meta( '_stripe_charge_captured', true );
252
253
			if ( $charge && 'no' === $captured ) {
254
				WC_Stripe_Helper::is_pre_30() ? update_post_meta( $order_id, '_stripe_charge_captured', 'yes' ) : $order->update_meta_data( '_stripe_charge_captured', 'yes' );
255
256
				// Store other data such as fees
257
				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 );
258
259
				if ( isset( $notification->data->object->balance_transaction ) ) {
260
					$this->update_fees( $order, $notification->data->object->balance_transaction );
261
				}
262
263
				if ( is_callable( array( $order, 'save' ) ) ) {
264
					$order->save();
265
				}
266
267
				/* translators: transaction id */
268
				$order->update_status( $order->needs_processing() ? 'processing' : 'completed', sprintf( __( 'Stripe charge complete (Charge ID: %s)', 'woocommerce-gateway-stripe' ), $notification->data->object->id ) );
269
270
				// Check and see if capture is partial.
271
				if ( $this->is_partial_capture( $notification ) ) {
272
					$order->set_total( $this->get_partial_amount_to_charge( $notification ) );
273
					$order->add_note( __( 'This charge was partially captured via Stripe Dashboard', 'woocommerce-gateway-stripe' ) );
274
					$order->save();
275
				}
276
			}
277
		}
278
	}
279
280
	/**
281
	 * Process webhook charge succeeded. This is used for payment methods
282
	 * that takes time to clear which is asynchronous. e.g. SEPA, SOFORT.
283
	 *
284
	 * @since 4.0.0
285
	 * @version 4.0.0
286
	 * @param object $notification
287
	 */
288
	public function process_webhook_charge_succeeded( $notification ) {
289
		// The following payment methods are synchronous so does not need to be handle via webhook.
290
		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 ) ) {
291
			return;
292
		}
293
294
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
295
296
		if ( ! $order ) {
297
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
298
			return;
299
		}
300
301
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
302
303
		if ( 'on-hold' !== $order->get_status() ) {
304
			return;
305
		}
306
307
		// Store other data such as fees
308
		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 );
309
310
		if ( isset( $notification->data->object->balance_transaction ) ) {
311
			$this->update_fees( $order, $notification->data->object->balance_transaction );
312
		}
313
314
		if ( is_callable( array( $order, 'save' ) ) ) {
315
			$order->save();
316
		}
317
318
		/* translators: transaction id */
319
		$order->update_status( $order->needs_processing() ? 'processing' : 'completed', sprintf( __( 'Stripe charge complete (Charge ID: %s)', 'woocommerce-gateway-stripe' ), $notification->data->object->id ) );
320
	}
321
322
	/**
323
	 * Process webhook charge failed. This is used for payment methods
324
	 * that takes time to clear which is asynchronous. e.g. SEPA, SOFORT.
325
	 *
326
	 * @since 4.0.0
327
	 * @version 4.0.0
328
	 * @param object $notification
329
	 */
330 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...
331
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
332
333
		if ( ! $order ) {
334
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
335
			return;
336
		}
337
338
		$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...
339
340
		if ( 'on-hold' !== $order->get_status() ) {
341
			return;
342
		}
343
344
		$order->update_status( 'failed', __( 'This payment failed to clear.', 'woocommerce-gateway-stripe' ) );
345
346
		do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
347
	}
348
349
	/**
350
	 * Process webhook source canceled. This is used for payment methods
351
	 * that redirects and awaits payments from customer.
352
	 *
353
	 * @since 4.0.0
354
	 * @version 4.0.0
355
	 * @param object $notification
356
	 */
357 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...
358
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
359
360
		if ( ! $order ) {
361
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
362
			return;
363
		}
364
365
		$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...
366
367
		if ( 'on-hold' !== $order->get_status() || 'cancelled' !== $order->get_status() ) {
368
			return;
369
		}
370
371
		$order->update_status( 'cancelled', __( 'This payment has cancelled.', 'woocommerce-gateway-stripe' ) );
372
373
		do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
374
	}
375
376
	/**
377
	 * Process webhook refund.
378
	 * Note currently only support 1 time refund.
379
	 *
380
	 * @since 4.0.0
381
	 * @version 4.0.0
382
	 * @param object $notification
383
	 */
384
	public function process_webhook_refund( $notification ) {
385
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
386
387
		if ( ! $order ) {
388
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
389
			return;
390
		}
391
392
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
393
394
		if ( 'stripe' === ( WC_Stripe_Helper::is_pre_30() ? $order->payment_method : $order->get_payment_method() ) ) {
395
			$charge    = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_transaction_id', true ) : $order->get_transaction_id();
396
			$captured  = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_stripe_charge_captured', true ) : $order->get_meta( '_stripe_charge_captured', true );
397
			$refund_id = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_stripe_refund_id', true ) : $order->get_meta( '_stripe_refund_id', true );
398
399
			// If the refund ID matches, don't continue to prevent double refunding.
400
			if ( $notification->data->object->refunds->data[0]->id === $refund_id ) {
401
				return;
402
			}
403
404
			// Only refund captured charge.
405
			if ( $charge && 'yes' === $captured ) {
406
				// Create the refund.
407
				$refund = wc_create_refund( array(
408
					'order_id'       => $order_id,
409
					'amount'         => $this->get_refund_amount( $notification ),
410
					'reason'         => __( 'Refunded via Stripe Dashboard', 'woocommerce-gateway-stripe' ),
411
				) );
412
413
				if ( is_wp_error( $refund ) ) {
414
					WC_Stripe_Logger::log( $refund->get_error_message() );
415
				}
416
			}
417
		}
418
	}
419
420
	/**
421
	 * Checks if capture is partial.
422
	 *
423
	 * @since 4.0.0
424
	 * @version 4.0.0
425
	 * @param object $notification
426
	 */
427
	public function is_partial_capture( $notification ) {
428
		return 0 < $notification->data->object->amount_refunded;
429
	}
430
431
	/**
432
	 * Gets the amount refunded.
433
	 *
434
	 * @since 4.0.0
435
	 * @version 4.0.0
436
	 * @param object $notification
437
	 */
438
	public function get_refund_amount( $notification ) {
439
		if ( $this->is_partial_capture( $notification ) ) {
440
			$amount = $notification->data->object->amount_refunded / 100;
441
442
			if ( in_array( strtolower( $notification->data->object->currency ), WC_Stripe_Helper::no_decimal_currencies() ) ) {
443
				$amount = $notification->data->object->amount_refunded;
444
			}
445
446
			return $amount;
447
		}
448
449
		return false;
450
	}
451
452
	/**
453
	 * Gets the amount we actually charge.
454
	 *
455
	 * @since 4.0.0
456
	 * @version 4.0.0
457
	 * @param object $notification
458
	 */
459
	public function get_partial_amount_to_charge( $notification ) {
460
		if ( $this->is_partial_capture( $notification ) ) {
461
			$amount = ( $notification->data->object->amount - $notification->data->object->amount_refunded ) / 100;
462
463
			if ( in_array( strtolower( $notification->data->object->currency ), WC_Stripe_Helper::no_decimal_currencies() ) ) {
464
				$amount = ( $notification->data->object->amount - $notification->data->object->amount_refunded );
465
			}
466
467
			return $amount;
468
		}
469
470
		return false;
471
	}
472
473
	/**
474
	 * Processes the incoming webhook.
475
	 *
476
	 * @since 4.0.0
477
	 * @version 4.0.0
478
	 * @param string $request_body
479
	 */
480
	public function process_webhook( $request_body ) {
481
		$notification = json_decode( $request_body );
482
483
		switch ( $notification->type ) {
484
			case 'source.chargeable':
485
				$this->process_webhook_payment( $notification );
486
				break;
487
488
			case 'source.canceled':
489
				$this->process_webhook_source_canceled( $notification );
490
				break;
491
492
			case 'charge.succeeded':
493
				$this->process_webhook_charge_succeeded( $notification );
494
				break;
495
496
			case 'charge.failed':
497
				$this->process_webhook_charge_failed( $notification );
498
				break;
499
500
			case 'charge.captured':
501
				$this->process_webhook_capture( $notification );
502
				break;
503
504
			case 'charge.dispute.created':
505
				$this->process_webhook_dispute( $notification );
506
				break;
507
508
			case 'charge.refunded':
509
				$this->process_webhook_refund( $notification );
510
				break;
511
512
		}
513
	}
514
}
515
516
new WC_Stripe_Webhook_Handler();
517