Completed
Push — master ( 6d8164...bf7249 )
by Roy
03:26
created

WC_Stripe_Webhook_Handler::process_webhook()   C

Complexity

Conditions 8
Paths 8

Size

Total Lines 34
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 24
nc 8
nop 1
dl 0
loc 34
rs 5.3846
c 0
b 0
f 0
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
						$message = 'API connection error and retries exhausted.';
158
						$order->add_order_note( $message );
159
						throw new Exception( $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 Exception( $message );
176
				}
177
178
				$localized_messages = WC_Stripe_Helper::get_localized_messages();
179
180
				$message = isset( $localized_messages[ $response->error->type ] ) ? $localized_messages[ $response->error->type ] : $response->error->message;
181
182
				$order->add_order_note( $message );
183
184
				throw new Exception( $message );
185
			}
186
187
			do_action( 'wc_gateway_stripe_process_webhook_payment', $response, $order );
188
189
			$this->process_response( $response, $order );
190
191
		} catch ( Exception $e ) {
192
			WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() );
193
194
			do_action( 'wc_gateway_stripe_process_webhook_payment_error', $e, $order );
195
196
			$statuses = array( 'pending', 'failed' );
197
198
			if ( $order->has_status( $statuses ) ) {
199
				$this->send_failed_order_email( $order_id );
200
			}
201
		}
202
	}
203
204
	/**
205
	 * Process webhook disputes that is created.
206
	 * This is trigger when a fraud is detected or customer processes chargeback.
207
	 * We want to put the order into on-hold and add an order note.
208
	 *
209
	 * @since 4.0.0
210
	 * @version 4.0.0
211
	 * @param object $notification
212
	 */
213
	public function process_webhook_dispute( $notification ) {
214
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
215
216
		if ( ! $order ) {
217
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
218
			return;
219
		}
220
221
		$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' ) );
222
223
		do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
224
		$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...
225
	}
226
227
	/**
228
	 * Process webhook capture. This is used for an authorized only
229
	 * transaction that is later captured via Stripe not WC.
230
	 *
231
	 * @since 4.0.0
232
	 * @version 4.0.0
233
	 * @param object $notification
234
	 */
235
	public function process_webhook_capture( $notification ) {
236
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
237
238
		if ( ! $order ) {
239
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
240
			return;
241
		}
242
243
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
244
245
		if ( 'stripe' === ( WC_Stripe_Helper::is_pre_30() ? $order->payment_method : $order->get_payment_method() ) ) {
246
			$charge   = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_transaction_id', true ) : $order->get_transaction_id();
247
			$captured = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_stripe_charge_captured', true ) : $order->get_meta( '_stripe_charge_captured', true );
248
249
			if ( $charge && 'no' === $captured ) {
250
				WC_Stripe_Helper::is_pre_30() ? update_post_meta( $order_id, '_stripe_charge_captured', 'yes' ) : $order->update_meta_data( '_stripe_charge_captured', 'yes' );
251
252
				// Store other data such as fees
253
				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 );
254
255
				if ( isset( $notification->data->object->balance_transaction ) ) {
256
					$this->update_fees( $order, $notification->data->object->balance_transaction );
257
				}
258
259
				if ( is_callable( array( $order, 'save' ) ) ) {
260
					$order->save();
261
				}
262
263
				/* translators: transaction id */
264
				$order->update_status( $order->needs_processing() ? 'processing' : 'completed', sprintf( __( 'Stripe charge complete (Charge ID: %s)', 'woocommerce-gateway-stripe' ), $notification->data->object->id ) );
265
266
				// Check and see if capture is partial.
267
				if ( $this->is_partial_capture( $notification ) ) {
268
					$order->set_total( $this->get_partial_amount_to_charge( $notification ) );
269
					$order->add_note( __( 'This charge was partially captured via Stripe Dashboard', 'woocommerce-gateway-stripe' ) );
270
					$order->save();
271
				}
272
			}
273
		}
274
	}
275
276
	/**
277
	 * Process webhook charge succeeded. This is used for payment methods
278
	 * that takes time to clear which is asynchronous. e.g. SEPA, SOFORT.
279
	 *
280
	 * @since 4.0.0
281
	 * @version 4.0.0
282
	 * @param object $notification
283
	 */
284
	public function process_webhook_charge_succeeded( $notification ) {
285
		// The following payment methods are synchronous so does not need to be handle via webhook.
286
		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 ) ) {
287
			return;
288
		}
289
290
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
291
292
		if ( ! $order ) {
293
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
294
			return;
295
		}
296
297
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
298
299
		if ( 'on-hold' !== $order->get_status() ) {
300
			return;
301
		}
302
303
		// Store other data such as fees
304
		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 );
305
306
		if ( isset( $notification->data->object->balance_transaction ) ) {
307
			$this->update_fees( $order, $notification->data->object->balance_transaction );
308
		}
309
310
		if ( is_callable( array( $order, 'save' ) ) ) {
311
			$order->save();
312
		}
313
314
		/* translators: transaction id */
315
		$order->update_status( $order->needs_processing() ? 'processing' : 'completed', sprintf( __( 'Stripe charge complete (Charge ID: %s)', 'woocommerce-gateway-stripe' ), $notification->data->object->id ) );
316
	}
317
318
	/**
319
	 * Process webhook charge failed. This is used for payment methods
320
	 * that takes time to clear which is asynchronous. e.g. SEPA, SOFORT.
321
	 *
322
	 * @since 4.0.0
323
	 * @version 4.0.0
324
	 * @param object $notification
325
	 */
326 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...
327
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
328
329
		if ( ! $order ) {
330
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
331
			return;
332
		}
333
334
		$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...
335
336
		if ( 'on-hold' !== $order->get_status() ) {
337
			return;
338
		}
339
340
		$order->update_status( 'failed', __( 'This payment failed to clear.', 'woocommerce-gateway-stripe' ) );
341
342
		do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
343
	}
344
345
	/**
346
	 * Process webhook source canceled. This is used for payment methods
347
	 * that redirects and awaits payments from customer.
348
	 *
349
	 * @since 4.0.0
350
	 * @version 4.0.0
351
	 * @param object $notification
352
	 */
353 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...
354
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
355
356
		if ( ! $order ) {
357
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
358
			return;
359
		}
360
361
		$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...
362
363
		if ( 'on-hold' !== $order->get_status() || 'cancelled' !== $order->get_status() ) {
364
			return;
365
		}
366
367
		$order->update_status( 'cancelled', __( 'This payment has cancelled.', 'woocommerce-gateway-stripe' ) );
368
369
		do_action( 'wc_gateway_stripe_process_webhook_payment_error', $order, $notification );
370
	}
371
372
	/**
373
	 * Process webhook refund.
374
	 * Note currently only support 1 time refund.
375
	 *
376
	 * @since 4.0.0
377
	 * @version 4.0.0
378
	 * @param object $notification
379
	 */
380
	public function process_webhook_refund( $notification ) {
381
		$order = WC_Stripe_Helper::get_order_by_charge_id( $notification->data->object->id );
382
383
		if ( ! $order ) {
384
			WC_Stripe_Logger::log( 'Could not find order via charge ID: ' . $notification->data->object->id );
385
			return;
386
		}
387
388
		$order_id = WC_Stripe_Helper::is_pre_30() ? $order->id : $order->get_id();
389
390
		if ( 'stripe' === ( WC_Stripe_Helper::is_pre_30() ? $order->payment_method : $order->get_payment_method() ) ) {
391
			$charge   = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_transaction_id', true ) : $order->get_transaction_id();
392
			$captured = WC_Stripe_Helper::is_pre_30() ? get_post_meta( $order_id, '_stripe_charge_captured', true ) : $order->get_meta( '_stripe_charge_captured', true );
393
394
			// Only refund captured charge.
395
			if ( $charge && 'yes' === $captured ) {
396
				// Create the refund.
397
				$refund = wc_create_refund( array(
398
					'order_id'       => $order_id,
399
					'amount'         => $this->get_refund_amount( $notification ),
400
					'reason'         => __( 'Refunded via Stripe Dashboard', 'woocommerce-gateway-stripe' ),
401
				) );
402
403
				if ( is_wp_error( $refund ) ) {
404
					WC_Stripe_Logger::log( $refund->get_error_message() );
405
				}
406
			}
407
		}
408
	}
409
410
	/**
411
	 * Checks if capture is partial.
412
	 *
413
	 * @since 4.0.0
414
	 * @version 4.0.0
415
	 * @param object $notification
416
	 */
417
	public function is_partial_capture( $notification ) {
418
		return 0 < $notification->data->object->amount_refunded;
419
	}
420
421
	/**
422
	 * Gets the amount refunded.
423
	 *
424
	 * @since 4.0.0
425
	 * @version 4.0.0
426
	 * @param object $notification
427
	 */
428
	public function get_refund_amount( $notification ) {
429
		if ( $this->is_partial_capture( $notification ) ) {
430
			$amount = $notification->data->object->amount_refunded / 100;
431
432
			if ( in_array( strtolower( $notification->data->object->currency ), WC_Stripe_Helper::no_decimal_currencies() ) ) {
433
				$amount = $notification->data->object->amount_refunded;
434
			}
435
436
			return $amount;
437
		}
438
439
		return false;
440
	}
441
442
	/**
443
	 * Gets the amount we actually charge.
444
	 *
445
	 * @since 4.0.0
446
	 * @version 4.0.0
447
	 * @param object $notification
448
	 */
449
	public function get_partial_amount_to_charge( $notification ) {
450
		if ( $this->is_partial_capture( $notification ) ) {
451
			$amount = ( $notification->data->object->amount - $notification->data->object->amount_refunded ) / 100;
452
453
			if ( in_array( strtolower( $notification->data->object->currency ), WC_Stripe_Helper::no_decimal_currencies() ) ) {
454
				$amount = ( $notification->data->object->amount - $notification->data->object->amount_refunded );
455
			}
456
457
			return $amount;
458
		}
459
460
		return false;
461
	}
462
463
	/**
464
	 * Processes the incoming webhook.
465
	 *
466
	 * @since 4.0.0
467
	 * @version 4.0.0
468
	 * @param string $request_body
469
	 */
470
	public function process_webhook( $request_body ) {
471
		$notification = json_decode( $request_body );
472
473
		switch ( $notification->type ) {
474
			case 'source.chargeable':
475
				$this->process_webhook_payment( $notification );
476
				break;
477
478
			case 'source.canceled':
479
				$this->process_webhook_source_canceled( $notification );
480
				break;
481
482
			case 'charge.succeeded':
483
				$this->process_webhook_charge_succeeded( $notification );
484
				break;
485
486
			case 'charge.failed':
487
				$this->process_webhook_charge_failed( $notification );
488
				break;
489
490
			case 'charge.captured':
491
				$this->process_webhook_capture( $notification );
492
				break;
493
494
			case 'charge.dispute.created':
495
				$this->process_webhook_dispute( $notification );
496
				break;
497
498
			case 'charge.refunded':
499
				$this->process_webhook_refund( $notification );
500
				break;
501
502
		}
503
	}
504
}
505
506
new WC_Stripe_Webhook_Handler();
507