PluginProbe
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More / 6.35
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More v6.35
6.35 6.34 6.33.1 6.33 6.32.1 6.32 6.31 6.25 6.25.1 6.26 6.26.1 6.27 6.28 6.29 6.3 6.3.1 6.3.2 6.30 6.4 6.4.1 6.4.2 6.5 6.5.1 6.5.2 6.5.3 All 141 releases
formidable / stripe / controllers / FrmStrpLiteEventsController.php

FrmStrpLiteEventsController.php in Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More 6.35, at stripe/controllers/FrmStrpLiteEventsController.php

620 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 die( 'You are not allowed to call this page directly.' );
4 }
5
6 class FrmStrpLiteEventsController {
7
8 /**
9 * @var string
10 */
11 public static $events_to_skip_option_name = 'frm_strp_events_to_skip';
12
13 /**
14 * @var object|null
15 */
16 private $event;
17
18 /**
19 * @var object|null
20 */
21 private $invoice;
22
23 /**
24 * @var string|null
25 */
26 private $charge;
27
28 /**
29 * @var string|null
30 */
31 private $status;
32
33 /**
34 * @return void
35 */
36 private function set_payment_status() {
37 if ( $this->status === 'refunded' ) {
38 $this->charge = $this->invoice->id;
39 }
40
41 $frm_payment = new FrmTransLitePayment();
42 $payment = false;
43
44 if ( $this->charge ) {
45 $payment = $frm_payment->get_one_by( $this->charge, 'receipt_id' );
46 }
47
48 if ( ! $payment && $this->status === 'refunded' ) {
49 // If the refunded payment doesn't exist, stop here.
50 FrmTransLiteLog::log_message( 'Stripe Webhook Message', 'No action taken. The refunded payment does not exist' );
51 echo json_encode(
52 array(
53 'response' => 'no payment exists',
54 'success' => false,
55 )
56 );
57 return;
58 }
59
60 $run_triggers = false;
61
62 if ( ! $payment ) {
63 $payment = $this->prepare_from_invoice();
64 $run_triggers = true;
65 } elseif ( $payment->status !== $this->status ) {
66 if ( $this->should_skip_status_update_for_first_recurring_payment( $payment ) ) {
67 return;
68 }
69
70 $payment_values = (array) $payment;
71 $is_partial_refund = $this->is_partial_refund();
72
73 if ( $is_partial_refund ) {
74 $this->set_partial_refund( $payment_values );
75 $amount_refunded = number_format( $this->invoice->amount_refunded / 100, 2 );
76 // translators: %s: The amount of money that was refunded.
77 $note = sprintf( __( 'Payment partially refunded %s', 'formidable' ), $amount_refunded );
78 } else {
79 $payment_values['status'] = $this->status;
80 $payment->status = $this->status;
81 // translators: %s: The status of the payment.
82 $note = sprintf( __( 'Payment %s', 'formidable' ), $payment_values['status'] );
83 }
84
85 FrmTransLiteAppHelper::add_note_to_payment( $payment_values, $note );
86
87 // Read the status again right before the update, in case another request has already changed it.
88 $payment_status_still_does_not_match = $this->payment_status_still_does_not_match( $payment->id );
89 $updated = $frm_payment->update( $payment->id, $payment_values );
90
91 echo json_encode(
92 array(
93 'response' => 'Payment ' . $payment->id . ' was updated',
94 'success' => true,
95 )
96 );
97
98 if ( ! $is_partial_refund && $payment_status_still_does_not_match && $updated ) {
99 $run_triggers = true;
100 }
101 }//end if
102
103 if ( $run_triggers && $payment && $payment->action_id ) {
104 FrmTransLiteActionsController::trigger_payment_status_change(
105 array(
106 'status' => $this->status,
107 'payment' => $payment,
108 )
109 );
110 }
111 }
112
113 /**
114 * Double check that the payment status has not changed.
115 * This is to avoid running actions twice by mistake, since a Stripe Link
116 * return URL and a webhook event can both process the same payment.
117 *
118 * @since 6.35
119 *
120 * @param int $payment_id The id of the payment to check.
121 *
122 * @return bool
123 */
124 private function payment_status_still_does_not_match( $payment_id ) {
125 $frm_payment = new FrmTransLitePayment();
126 $payment = $frm_payment->get_one( $payment_id );
127
128 return $payment && $payment->status !== $this->status;
129 }
130
131 /**
132 * Skip updating the payment object for the first recurring payment.
133 * This is to prevent double notifications because the first recurring payment creates an invoice and that invoice triggers the payment events.
134 *
135 * @since 6.5, introduced in v2.07 of the Stripe add on.
136 *
137 * @param stdClass $payment
138 *
139 * @return bool
140 */
141 private function should_skip_status_update_for_first_recurring_payment( $payment ) {
142 if ( ! in_array( $this->event->type, array( 'payment_intent.succeeded', 'payment_intent.payment_failed' ), true ) ) {
143 return false;
144 }
145
146 if ( empty( $payment->sub_id ) ) {
147 // Only skip for subscriptions. This is because subscription events create an invoice, and the status change events trigger for the invoice as well.
148 return false;
149 }
150
151 return $this->is_first_payment( $payment );
152 }
153
154 /**
155 * Tell Stripe Connect API that the request came through by flushing early before processing.
156 * Flushing early allows the API to end the request earlier.
157 *
158 * @since 6.5, introduced in v2.07 of the Stripe add on.
159 *
160 * @return void
161 */
162 private function flush_response() {
163 ob_start();
164
165 // Get the size of the output.
166 $size = ob_get_length();
167
168 // Disable compression (in case content length is compressed).
169 header( 'Content-Encoding: none' );
170
171 // Set the content length of the response.
172 header( 'Content-Length: ' . $size );
173
174 // Close the connection.
175 header( 'Connection: close' );
176
177 // Flush all output.
178 ob_end_flush();
179 @ob_flush();
180 flush();
181 }
182
183 /**
184 * When a customer is deleted in Stripe, remove the link to a user.
185 *
186 * @since 6.5, introduced in v2.01 of the Stripe add on.
187 *
188 * @return void
189 */
190 private function reset_customer() {
191 global $wpdb;
192 $customer_id = $this->invoice->id;
193
194 if ( ! $customer_id ) {
195 return;
196 }
197 $wpdb->query(
198 $wpdb->prepare(
199 "DELETE FROM $wpdb->usermeta WHERE meta_value = %s AND meta_key LIKE %s",
200 $customer_id,
201 '_frmstrp_customer_id%'
202 )
203 );
204 }
205
206 /**
207 * @return void
208 */
209 private function maybe_subscription_canceled() {
210 // phpcs:ignore Universal.Operators.StrictComparisons
211 if ( $this->invoice->cancel_at_period_end == true ) {
212 $this->subscription_canceled( 'future_cancel' );
213 }
214 }
215
216 /**
217 * @param string $status
218 *
219 * @return bool
220 */
221 private function subscription_canceled( $status = 'canceled' ) {
222 $sub = $this->get_subscription( $this->invoice->id );
223
224 if ( ! $sub ) {
225 return false;
226 }
227
228 if ( $sub->status === $status ) {
229 FrmTransLiteLog::log_message( 'Stripe Webhook Message', 'No action taken since the subscription is already canceled.' );
230 echo json_encode(
231 array(
232 'response' => 'Already canceled',
233 'success' => true,
234 )
235 );
236 return false;
237 }
238
239 FrmTransLiteSubscriptionsController::change_subscription_status(
240 array(
241 'status' => $status,
242 'sub' => $sub,
243 )
244 );
245 return true;
246 }
247
248 /**
249 * @return false|object
250 */
251 private function prepare_from_invoice() {
252 if ( empty( $this->invoice->subscription ) ) {
253 // This isn't a subscription.
254 echo json_encode(
255 array(
256 'response' => 'Invoice missing',
257 'success' => false,
258 )
259 );
260 return false;
261 }
262
263 $sub = $this->get_subscription( $this->invoice->subscription );
264
265 if ( ! $sub ) {
266 return false;
267 }
268
269 $payment = $this->get_payment_for_sub( $sub->id );
270 $payment_values = (array) $payment;
271 $this->set_payment_values( $payment_values );
272
273 $frm_payment = new FrmTransLitePayment();
274
275 if ( $this->is_first_payment( $payment ) ) {
276 // The first payment for the subscription needs to be updated with the receipt id.
277 $frm_payment->update( $payment->id, $payment_values );
278 $payment_id = $payment->id;
279 } else {
280 $payment_values['test'] = $this->event->livemode ? 0 : 1;
281
282 // If this isn't the first, create a new payment.
283 $payment_id = $frm_payment->create( $payment_values );
284 }
285
286 $this->maybe_cancel_subscription( $sub );
287 $this->update_next_bill_date( $sub, $payment_values );
288 return $frm_payment->get_one( $payment_id );
289 }
290
291 /**
292 * Check if a subscription has reached its payment limit.
293 * If it has, the subscription will be cancelled by period end.
294 *
295 * @since 6.11
296 *
297 * @param object $sub
298 *
299 * @return void
300 */
301 private function maybe_cancel_subscription( $sub ) {
302 $action = FrmFormAction::get_single_action_type( $sub->action_id, 'payment' );
303
304 // @phpstan-ignore-next-line
305 if ( ! is_object( $action ) || empty( $action->post_content['payment_limit'] ) ) {
306 return;
307 }
308
309 $payment_limit = FrmStrpLiteSubscriptionHelper::prepare_payment_limit(
310 $action->post_content['payment_limit'],
311 // Form ID.
312 (int) $action->menu_order,
313 (int) $sub->item_id
314 );
315
316 if ( is_wp_error( $payment_limit ) ) {
317 FrmTransLiteLog::log_message( 'Invalid payment limit value', $payment_limit->get_error_message() );
318 return;
319 }
320
321 if ( $this->get_payments_count( $sub->id ) < $payment_limit ) {
322 return;
323 }
324
325 // Flag to cancel subscription at period end.
326 // In this case, we do not want to cancel immediately.
327 $hook = 'frm_stripe_cancel_subscription_at_period_end';
328 $filter = function () {
329 return true;
330 };
331
332 add_filter( $hook, $filter, 99 );
333
334 // There is no logged in user when a webhook event is processed, so the customer check has to be skipped here.
335 $cancelled = FrmStrpLiteAppHelper::call_stripe_helper_class( 'cancel_subscription_without_customer_check', $sub->sub_id );
336
337 if ( $cancelled ) {
338 FrmTransLiteSubscriptionsController::change_subscription_status(
339 array(
340 'status' => 'future_cancel',
341 'sub' => $sub,
342 )
343 );
344 } else {
345 FrmTransLiteLog::log_message( 'Stripe Webhook Message', 'Unable to cancel subscription ' . $sub->sub_id . ' after it reached its payment limit.' );
346 }
347
348 remove_filter( $hook, $filter, 99 );
349 }
350
351 /**
352 * Get the count of completed payments.
353 *
354 * @since 6.11
355 *
356 * @param string $sub_id Stripe subscriptino id prefixed with 'sub_'.
357 *
358 * @return int
359 */
360 private function get_payments_count( $sub_id ) {
361 $frm_payment = new FrmTransLitePayment();
362 $all_payments = $frm_payment->get_all_by( $sub_id, 'sub_id' );
363
364 return FrmTransLiteAppHelper::count_completed_payments( $all_payments );
365 }
366
367 /**
368 * @since 6.5, introduced in v2.07 of the Stripe add on.
369 *
370 * @param stdClass $payment
371 *
372 * @return bool
373 */
374 private function is_first_payment( $payment ) {
375 return ! $payment->receipt_id || str_starts_with( $payment->receipt_id, 'pi_' );
376 }
377
378 /**
379 * @param string $sub_id
380 *
381 * @return object|null
382 */
383 private function get_subscription( $sub_id ) {
384 $frm_sub = new FrmTransLiteSubscription();
385 $sub = $frm_sub->get_one_by( $sub_id, 'sub_id' );
386
387 if ( ! $sub ) {
388 // If this isn't an existing subscription, it must be a charge for another site/plugin.
389 FrmTransLiteLog::log_message( 'Stripe Webhook Message', 'No action taken since there is not a matching subscription for ' . $sub_id );
390 echo json_encode(
391 array(
392 'response' => 'Invoice missing',
393 'success' => false,
394 )
395 );
396 }
397
398 return $sub;
399 }
400
401 /**
402 * @param string $sub_id
403 *
404 * @return object|null
405 */
406 private function get_payment_for_sub( $sub_id ) {
407 $frm_payment = new FrmTransLitePayment();
408 return $frm_payment->get_one_by( $sub_id, 'sub_id' );
409 }
410
411 /**
412 * @param array $payment_values
413 *
414 * @return void
415 */
416 private function set_payment_values( &$payment_values ) {
417 $payment_values['begin_date'] = gmdate( 'Y-m-d' );
418 $payment_values['expire_date'] = '0000-00-00';
419
420 foreach ( $this->invoice->lines->data as $line ) {
421 $payment_values['amount'] = number_format( $line->amount / 100, 2, '.', '' );
422 $payment_values['begin_date'] = gmdate( 'Y-m-d', $line->period->start );
423 $payment_values['expire_date'] = gmdate( 'Y-m-d', $line->period->end );
424 }
425
426 $payment_values['receipt_id'] = $this->charge ? $this->charge : __( 'None', 'formidable' );
427 $payment_values['status'] = $this->status;
428 $payment_values['meta_value'] = array();
429 $payment_values['created_at'] = current_time( 'mysql', 1 );
430
431 FrmTransLiteAppHelper::add_note_to_payment( $payment_values );
432 }
433
434 /**
435 * @param object $sub
436 * @param array $payment
437 *
438 * @return void
439 */
440 private function update_next_bill_date( $sub, $payment ) {
441 $frm_sub = new FrmTransLiteSubscription();
442
443 if ( $payment['status'] === 'complete' ) {
444 $frm_sub->update( $sub->id, array( 'next_bill_date' => $payment['expire_date'] ) );
445 } elseif ( $payment['status'] === 'refunded' ) {
446 $frm_sub->update( $sub->id, array( 'next_bill_date' => $payment['begin_date'] ) );
447 }
448 }
449
450 /**
451 * @return bool
452 */
453 private function is_partial_refund() {
454 if ( $this->status !== 'refunded' ) {
455 return false;
456 }
457
458 $amount = $this->invoice->amount;
459 $amount_refunded = $this->invoice->amount_refunded;
460 return $amount !== $amount_refunded;
461 }
462
463 /**
464 * @param array $payment_values
465 *
466 * @return void
467 */
468 private function set_partial_refund( &$payment_values ) {
469 $payment_values['amount'] = $this->invoice->amount - $this->invoice->amount_refunded;
470 $payment_values['amount'] = number_format( $payment_values['amount'] / 100, 2 );
471 }
472
473 /**
474 * @return void
475 */
476 public function process_connect_events() {
477 $this->flush_response();
478
479 $unprocessed_event_ids = FrmStrpLiteConnectHelper::get_unprocessed_event_ids();
480
481 if ( $unprocessed_event_ids ) {
482 $this->process_event_ids( $unprocessed_event_ids );
483 }
484
485 wp_send_json_success();
486 }
487
488 /**
489 * @since 6.5, introduced in v2.07 of the Stripe add on.
490 *
491 * @param array<string> $event_ids
492 *
493 * @return void
494 */
495 private function process_event_ids( $event_ids ) {
496 foreach ( $event_ids as $event_id ) {
497 if ( $this->should_skip_event( $event_id ) ) {
498 continue;
499 }
500
501 set_transient( 'frm_last_process_' . $event_id, time(), 60 );
502
503 $this->event = FrmStrpLiteConnectHelper::get_event( $event_id );
504
505 if ( ! is_object( $this->event ) ) {
506 $this->count_failed_event( $event_id );
507 continue;
508 }
509
510 $this->handle_event();
511 $this->track_handled_event( $event_id );
512 FrmStrpLiteConnectHelper::process_event( $event_id );
513 }
514 }
515
516 /**
517 * @since 6.5, introduced in v2.07 of the Stripe add on.
518 *
519 * @param string $event_id
520 *
521 * @return bool True if the event should be skipped.
522 */
523 private function should_skip_event( $event_id ) {
524 if ( $this->last_attempt_to_process_event_is_too_recent( $event_id ) ) {
525 return true;
526 }
527
528 $option = get_option( self::$events_to_skip_option_name );
529
530 return is_array( $option ) && in_array( $event_id, $option, true );
531 }
532
533 /**
534 * @param string $event_id
535 *
536 * @return bool
537 */
538 private function last_attempt_to_process_event_is_too_recent( $event_id ) {
539 $last_process_attempt = get_transient( 'frm_last_process_' . $event_id );
540 return is_numeric( $last_process_attempt ) && $last_process_attempt > time() - 60;
541 }
542
543 /**
544 * @since 6.5, introduced in v2.07 of the Stripe add on.
545 *
546 * @param string $event_id
547 *
548 * @return void
549 */
550 private function count_failed_event( $event_id ) {
551 $transient_name = 'frm_failed_event_' . $event_id;
552 $transient = get_transient( $transient_name );
553 $failed_count = is_int( $transient ) ? $transient + 1 : 1;
554 $maximum_retries = 3;
555
556 if ( $failed_count >= $maximum_retries ) {
557 $this->track_handled_event( $event_id );
558 } else {
559 set_transient( $transient_name, $failed_count, 4 * DAY_IN_SECONDS );
560 }
561 }
562
563 /**
564 * Track an event to no longer process.
565 * This is called for successful events, and also for failed events after a number of retries.
566 *
567 * @since 6.5, introduced in v2.07 of the Stripe add on.
568 *
569 * @param string $event_id
570 *
571 * @return void
572 */
573 private function track_handled_event( $event_id ) {
574 $option = get_option( self::$events_to_skip_option_name );
575
576 if ( is_array( $option ) ) {
577 if ( count( $option ) > 1000 ) {
578 // Prevent the option from getting too big by removing the front item before adding the next.
579 array_shift( $option );
580 }
581 } else {
582 $option = array();
583 }
584
585 $option[] = $event_id;
586 update_option( self::$events_to_skip_option_name, $option, false );
587 }
588
589 /**
590 * @return void
591 */
592 private function handle_event() {
593 $this->invoice = $this->event->data->object;
594 $this->charge = $this->invoice->charge ?? false;
595
596 if ( ! $this->charge && $this->invoice->object === 'payment_intent' ) {
597 $this->charge = $this->invoice->id;
598 }
599
600 $events = array(
601 'payment_intent.succeeded' => 'complete',
602 'payment_intent.payment_failed' => 'failed',
603 'invoice.payment_succeeded' => 'complete',
604 'invoice.payment_failed' => 'failed',
605 'charge.refunded' => 'refunded',
606 );
607
608 if ( isset( $events[ $this->event->type ] ) ) {
609 $this->status = $events[ $this->event->type ];
610 $this->set_payment_status();
611 } elseif ( $this->event->type === 'customer.deleted' ) {
612 $this->reset_customer();
613 } elseif ( $this->event->type === 'customer.subscription.deleted' ) {
614 $this->subscription_canceled();
615 } elseif ( $this->event->type === 'customer.subscription.updated' ) {
616 $this->maybe_subscription_canceled();
617 }
618 }
619 }
620