PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.11.3
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.11.3
1.8.13.1 1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 All 212 releases
charitable / includes / stripe / gateway / class-charitable-stripe-webhook-processor.php

class-charitable-stripe-webhook-processor.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.11.3, at includes/stripe/gateway/class-charitable-stripe-webhook-processor.php

1,269 lines 43.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class responsible for processing webhooks.
4 *
5 * @package Charitable Stripe/Classes/Charitable_Stripe_Webhook_Processor
6 * @author Eric Daams
7 * @copyright Copyright (c) 2021-2022, WPCharitable
8 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
9 * @since 1.3.0
10 * @version 1.4.13
11 */
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 if ( ! class_exists( 'Charitable_Stripe_Webhook_Processor' ) ) :
19
20 /**
21 * Charitable_Stripe_Webhook_Processor
22 *
23 * @since 1.3.0
24 */
25 class Charitable_Stripe_Webhook_Processor {
26
27 /**
28 * Event object.
29 *
30 * @since 1.3.0
31 *
32 * @var \Stripe\Event
33 */
34 protected $event;
35
36 /**
37 * Gateway helper.
38 *
39 * @since 1.3.0
40 *
41 * @var Charitable_Gateway_Stripe_AM
42 */
43 protected $gateway;
44
45 /**
46 * Stripe Event object.
47 *
48 * @deprecated
49 *
50 * @since 1.3.0
51 * @since 1.4.0 Deprecated.
52 *
53 * @var \Stripe\Event
54 */
55 protected $stripe_event;
56
57 /**
58 * Create class object.
59 *
60 * @since 1.3.0
61 *
62 * @param \Stripe\Event $event Incoming event object.
63 *
64 */
65 public function __construct( \Stripe\Event $event ) {
66 $this->event = $event;
67 $this->gateway = new Charitable_Gateway_Stripe_AM();
68 }
69
70 /**
71 * Process an incoming Stripe IPN.
72 *
73 * @since 1.3.0
74 *
75 * @return void
76 */
77 public static function process() {
78
79 // phpcs:disable
80 if ( charitable_is_debug() ) {
81 error_log( 'Charitable_Stripe_Webhook_Processor PROCESS FUNCTION ' );
82 }
83 // phpcs:enable
84
85 /* Retrieve and validate the request's body with signature verification. */
86 $event = self::get_validated_incoming_event();
87
88 // phpcs:disable
89 if ( charitable_is_debug() ) {
90 error_log( print_r( $event, true ) );
91 }
92 // phpcs:enable
93
94 if ( ! $event ) {
95 status_header( 403 );
96 die( __( 'Invalid or unverified Stripe event.', 'charitable' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
97 }
98
99 // get_validated_incoming_event() now returns a verified \Stripe\Event object.
100 if ( ! ( $event instanceof \Stripe\Event ) ) {
101 status_header( 400 );
102 die( __( 'Unable to construct Stripe object with payload.', 'charitable' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
103 }
104
105 $processor = new Charitable_Stripe_Webhook_Processor( $event );
106 // phpcs:disable
107 if ( charitable_is_debug() ) {
108 error_log( 'Charitable_Stripe_Webhook_Processor PROCESS FUNCTION RUN' );
109 }
110 // phpcs:enable
111 $processor->run();
112 }
113
114 /**
115 * Run the processor.
116 *
117 * @since 1.3.0
118 * @since 1.8.9.5 Fixed fatal error when ErrorException is thrown by using type-safe exception handling.
119 *
120 * @return void
121 */
122 public function run() {
123 $this->set_stripe_api_key();
124
125 // phpcs:disable
126 if ( charitable_is_debug() ) {
127 error_log( sprintf( '[STRIPE_WEBHOOK] run() started - Event: %s | Type: %s', $this->event->id, $this->event->type ) );
128 }
129 // phpcs:enable
130
131 try {
132 status_header( 200 );
133
134 /* This is Stripe's test webhook, so just die with a success message. */
135 if ( 'evt_00000000000000' == $this->event->id ) {
136 die( __( 'Test webhook successfully received.', 'charitable' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
137 }
138
139 $this->run_event_processors();
140
141 // phpcs:disable
142 if ( charitable_is_debug() ) {
143 error_log( sprintf( '[STRIPE_WEBHOOK] run() completed successfully - Event: %s | Type: %s', $this->event->id, $this->event->type ) );
144 }
145 // phpcs:enable
146
147 die( __( 'Webhook processed.', 'charitable' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
148
149 } catch ( Exception $e ) {
150 // Handle Stripe API exceptions that have getJsonBody method
151 if ( method_exists( $e, 'getJsonBody' ) ) {
152 $body = $e->getJsonBody();
153 // phpcs:disable
154 if ( charitable_is_debug() ) {
155 error_log( sprintf( '[STRIPE_WEBHOOK] Stripe API Error - Event: %s | Message: %s', $this->event->id, $body['error']['message'] ) );
156 }
157 // phpcs:enable
158 } else {
159 // Handle generic PHP exceptions (ErrorException, etc.)
160 // phpcs:disable
161 if ( charitable_is_debug() ) {
162 error_log( sprintf( '[STRIPE_WEBHOOK] Exception caught - Event: %s | Type: %s | Exception: %s | File: %s:%d', $this->event->id, $this->event->type, get_class( $e ) . ': ' . $e->getMessage(), $e->getFile(), $e->getLine() ) );
163 }
164 // phpcs:enable
165 }
166
167 status_header( 500 );
168 die( __( 'Error while processing webhook.', 'charitable' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
169 }//end try
170 }
171
172 /**
173 * Set Stripe API key.
174 *
175 * @since 1.3.0
176 *
177 * @return boolean True if the API key is set. False otherwise.
178 */
179 public function set_stripe_api_key() {
180 $keys = $this->gateway->get_keys( false === $this->event->livemode );
181
182 if ( empty( $keys['secret_key'] ) ) {
183 return false;
184 }
185
186 return $this->gateway->setup_api( $keys['secret_key'] );
187 }
188
189 /**
190 * Get the account ID for the site.
191 *
192 * @since 1.3.0
193 *
194 * @return string|null Account ID if successfull. Null if the account couldn't be retrieved from Stripe.
195 */
196 public function get_site_account_id() {
197 $account_id = $this->gateway->get_value( 'account_id' );
198
199 if ( empty( $account_id ) ) {
200 try {
201 $this->set_stripe_api_key();
202
203 $account = \Stripe\Account::retrieve();
204 $account_id = $account->id;
205
206 /* Store the account id in the gateway settings. */
207 $options = get_option( 'charitable_settings' );
208 $options['gateways_stripe']['account_id'] = $account_id;
209
210 update_option( 'charitable_settings', $options );
211
212 } catch ( Exception $e ) {
213 $account_id = null;
214 }
215 }
216
217 return $account_id;
218 }
219
220 /**
221 * Check whether the current event is from a Connect webhook and is signed for the platform account.
222 *
223 * @since 1.3.0
224 *
225 * @return boolean
226 */
227 public function is_connect_webhook_for_site_account_id() {
228 // phpcs:disable
229 if ( charitable_is_debug() ) {
230 error_log( 'is_connect_webhook_for_site_account_id' );
231 error_log( print_r( $this->event->account, true ) );
232 error_log( print_r( $this->get_site_account_id(), true ) );
233 error_log( print_r( isset( $this->event->account ) && $this->get_site_account_id() == $this->event->account, true ) );
234 }
235 // phpcs:enable
236 return isset( $this->event->account ) && $this->get_site_account_id() == $this->event->account; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
237 }
238
239 /**
240 * Checks whether the current event is from a Connect webhook and
241 * is for a transaction taking place directly on a connected account.
242 *
243 * @since 1.4.0
244 *
245 * @return boolean
246 */
247 public function is_connect_webhook_for_connected_account() {
248 return isset( $this->event->account ) && $this->get_site_account_id() != $this->event->account; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
249 }
250
251 /**
252 * Get the options array to pass when retrieving the event from Stripe.
253 *
254 * @since 1.3.0
255 *
256 * @return array
257 */
258 public function get_options() {
259 if ( isset( $this->event->account ) ) {
260 return [
261 'stripe_account' => $this->event->account,
262 ];
263 }
264
265 return [];
266 }
267
268 /**
269 * Sets up any default event processors.
270 *
271 * @since 1.3.0
272 *
273 * @return void
274 */
275 public function run_event_processors() {
276 /**
277 * Default event processors.
278 *
279 * @since 1.3.0
280 *
281 * @param array $processors Array of Stripe event types and associated callback functions.
282 */
283 $default_processors = apply_filters(
284 'charitable_stripe_default_event_processors',
285 [
286 'charge.refunded' => [ $this, 'process_refund' ],
287 'invoice.created' => [ $this, 'process_invoice_created' ],
288 'invoice.payment_failed' => [ $this, 'process_invoice_payment_failed' ],
289 'invoice.payment_succeeded' => [ $this, 'process_invoice_payment_succeeded' ],
290 'customer.subscription.updated' => [ $this, 'process_customer_subscription_updated' ],
291 'customer.subscription.deleted' => [ $this, 'process_customer_subscription_deleted' ],
292 'payment_intent.payment_failed' => [ $this, 'process_payment_intent_payment_failed' ],
293 'payment_intent.succeeded' => [ $this, 'process_payment_intent_succeeded' ],
294 'checkout.session.completed' => [ $this, 'process_checkout_session_completed' ],
295 ]
296 );
297
298 // phpcs:disable
299 if ( charitable_is_debug() ) {
300 error_log( 'run_event_processors' );
301 error_log( 'default_processors' );
302 error_log( print_r( $default_processors, true ) );
303 error_log( '$this->event->type' );
304 error_log( print_r( $this->event->type, true ) );
305 }
306 // phpcs:enable
307
308 /* Check if this event can be handled by one of our built-in event processors. */
309 if ( array_key_exists( $this->event->type, $default_processors ) ) {
310
311 // phpcs:disable
312 if ( charitable_is_debug() ) {
313 error_log( 'array_key_exists' );
314 }
315 // phpcs:enable
316
317 /**
318 * Double-check that this isn't a Connect webhook for the site account.
319 *
320 * We want to skip processing for those because there will be a duplicate
321 * standard webhook coming in as well, for the same event.
322 *
323 * If you still want to do something with the Connect webhook, you can use
324 * the `charitable_stripe_ipn_event` hook below.
325 */
326 if ( ! $this->is_connect_webhook_for_site_account_id() ) {
327
328 // phpcs:disable
329 if ( charitable_is_debug() ) {
330 error_log( '! $this->is_connect_webhook_for_site_account_id()' );
331 }
332 // phpcs:enable
333
334 $processor = $default_processors[ $this->event->type ];
335 if ( is_callable( $processor ) ) {
336 $message = $processor( $this->event );
337 } else {
338 $message = 'Invalid processor';
339 }
340
341 /* Kill processing with a message returned by the event processor. */
342 die( $message ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
343 }
344 }
345
346 /**
347 * Fire an action hook to process the event.
348 *
349 * Note that this will only fire for webhooks that have not already been processed by one
350 * of the default webhook handlers above.
351 *
352 * @since 1.0.0
353 *
354 * @param string $event_type Type of event.
355 * @param \Stripe\Event $event Stripe event object.
356 */
357 // phpcs:disable
358 if ( charitable_is_debug() ) {
359 error_log( 'before charitable_stripe_ipn_event' );
360 }
361 // phpcs:enable
362 do_action( 'charitable_stripe_ipn_event', $this->event->type, $this->event );
363 // phpcs:disable
364 if ( charitable_is_debug() ) {
365 error_log( 'after charitable_stripe_ipn_event' );
366 }
367 // phpcs:enable
368 }
369
370 /**
371 * Process a refund initiated via the Stripe dashboard.
372 *
373 * @see https://stripe.com/docs/api#events
374 *
375 * @since 1.3.0
376 *
377 * @param object $event The Stripe event object.
378 * @return string Response message
379 */
380 public function process_refund( $event ) {
381 $charge = $event->data->object;
382
383 /**
384 * If we're missing a donation ID, stop processing.
385 * This probably isn't a Charitable payment.
386 */
387 if ( ! isset( $charge->metadata->donation_id ) ) {
388 return __( 'Donation Webhook: Missing donation ID', 'charitable' );
389 }
390
391 $donation_id = $charge->metadata->donation_id;
392 $refund = $charge->refunds->data[0];
393 $refund_amount = $refund->amount;
394
395 if ( ! Charitable_Stripe_Gateway_Processor::is_zero_decimal_currency( $refund->currency ) ) {
396 $refund_amount = $refund_amount / 100;
397 }
398
399 if ( Charitable::DONATION_POST_TYPE !== get_post_type( $donation_id ) ) {
400 return __( 'Donation Webhook: Refund donation ID not valid', 'charitable' );
401 }
402
403 $donation = new Charitable_Donation( $donation_id );
404
405 /**
406 * Ensure that the gateway transaction ID matches the charge ID, to avoid refunding a
407 * donation originally made on a different site.
408 *
409 * @see https://bitbucket.org/wpcharitable/charitable-stripe/issues/54/webhooks-distinguish-between-webhooks-for
410 */
411 if ( $donation->get_gateway_transaction_id() != $charge->id ) {
412 return __( 'Donation Webhook: Charge ID does not match donation reference on this site', 'charitable' );
413 }
414
415 $donation->process_refund( $refund_amount, __( 'Donation refunded from the Stripe dashboard.', 'charitable' ) );
416
417 return __( 'Donation Webhook: Refund processed', 'charitable' );
418 }
419
420 /**
421 * Process the payment_intent.payment_failed webhook.
422 *
423 * @since 1.4.0
424 *
425 * @param object $event The Stripe event object.
426 * @return string Response message.
427 */
428 public function process_payment_intent_payment_failed( $event ) {
429 $payment_intent = $event->data->object;
430
431 /* Process a failed payment intent for a subscription payment. */
432 if ( ! is_null( $payment_intent->invoice ) ) {
433 return $this->process_payment_intent_payment_failed_for_subscription( $event );
434 }
435
436 if ( ! isset( $payment_intent->metadata->donation_id ) ) {
437 return __( 'Donation Webhook: Missing donation ID', 'charitable' );
438 }
439
440 $donation_id = $payment_intent->metadata->donation_id;
441
442 if ( Charitable::DONATION_POST_TYPE !== get_post_type( $donation_id ) ) {
443 return __( 'Donation Webhook: Donation ID not valid', 'charitable' );
444 }
445
446 $donation = new Charitable_Donation( $donation_id );
447
448 /**
449 * Ensure that the payment intent matches the one we have on record for this
450 * donation, to make sure this is the correct donation.
451 *
452 * @see https://github.com/Charitable/Charitable-Stripe/issues/54/
453 */
454 if ( get_post_meta( $donation_id, '_stripe_payment_intent', true ) != $payment_intent->id ) {
455 return __( 'Donation Webhook: Payment Intent does not match donation reference on this site', 'charitable' );
456 }
457
458 /* Log the payment error along with the error code. */
459 $donation->log()->add(
460 sprintf(
461 '%1$s Error code: <a href="%2$s" target="_blank">%3$s</a>',
462 $payment_intent->last_payment_error->message,
463 $payment_intent->last_payment_error->doc_url,
464 $payment_intent->last_payment_error->code
465 )
466 );
467
468 /* Record the number of payment failures. */
469 $this->update_payment_failure_count( $donation, $payment_intent->id );
470
471 /* Mark the donation as Failed. */
472 $donation->update_status( 'charitable-failed' );
473
474 return __( 'Donation Webhook: Donation marked as Failed', 'charitable' );
475 }
476
477 /**
478 * Process a payment intent payment failure for a subscription payment.
479 *
480 * @since 1.4.0
481 *
482 * @param object $event The Stripe event object.
483 * @return string
484 */
485 public function process_payment_intent_payment_failed_for_subscription( $event ) {
486 if ( ! $this->is_recurring_installed() ) {
487 return __( 'Subscription Webhook: Unable to process without Charitable Recurring extension.', 'charitable' );
488 }
489
490 $payment_intent = $event->data->object;
491
492 try {
493 $gateway = new Charitable_Gateway_Stripe_AM;
494 $gateway->setup_api();
495
496 /* Get the invoice, so we can get the subscription id from that. */
497 $invoice = \Stripe\Invoice::retrieve( $payment_intent->invoice, $this->get_options() );
498 } catch ( Exception $e ) {
499 return __( 'Donation Webhook: Unable to retrieve invoice for failed payment intent.', 'charitable' );
500 }
501
502 $subscription = charitable_recurring_get_subscription_by_gateway_id( $invoice->subscription, 'stripe' );
503
504 if ( ! $subscription || ! is_a( $subscription, 'Charitable_Recurring_Donation' ) ) {
505 return __( 'Donation Webhook: No matching subscription found for invoice with failed payment intent.', 'charitable' );
506 }
507
508 $subscription_log = new Charitable_Stripe_Recurring_Donation_Log( $subscription );
509 $first_donation = $subscription->get_first_donation_id();
510
511 /* Make sure this is not for the first donation. */
512 if ( 'charitable-pending' != get_post_status( $first_donation ) ) {
513 $subscription_log->log_failed_renewal_invoice( $invoice->id, $invoice->payment_intent );
514
515 /* Mark the subscription as cancelled. */
516 if ( 'canceled' == $payment_intent->status ) {
517 $subscription->update_status( 'charitable-cancelled' );
518 return __( 'Donation Webhook: Recurring donation for payment intent marked as cancelled.', 'charitable' );
519 } else {
520 $subscription->update_status( 'charitable-cancel' );
521 return __( 'Donation Webhook: Recurring donation for payment intent marked as pending cancellation.', 'charitable' );
522 }
523 }
524
525 /* This was the first donation, so mark the recurring donation as failed. */
526 $subscription->set_to_failed(
527 $subscription_log->get_failed_invoice_log_message( $invoice->id, $invoice->payment_intent )
528 );
529
530 /* Log the payment error along with the error code. */
531 $donation = new Charitable_Donation( $first_donation );
532 $donation->log()->add(
533 sprintf(
534 '%1$s Error code: <a href="%2$s" target="_blank">%3$s</a>',
535 $payment_intent->last_payment_error->message,
536 $payment_intent->last_payment_error->doc_url,
537 $payment_intent->last_payment_error->code
538 )
539 );
540
541 /* Record the number of payment failures. */
542 $this->update_payment_failure_count( $donation, $invoice->payment_intent );
543
544 /* Mark the donation as Failed. */
545 $donation->update_status( 'charitable-failed' );
546
547 return __( 'Donation Webhook: Recurring donation and initial payment for payment intent marked as failed', 'charitable' );
548 }
549
550 /**
551 * Process the payment_intent.succeeded webhook.
552 *
553 * @since 1.4.0
554 *
555 * @param object $event The Stripe event object.
556 * @return string Response message.
557 */
558 public function process_payment_intent_succeeded( $event ) {
559
560 // phpcs:disable
561 if ( charitable_is_debug() ) {
562 error_log( 'process_payment_intent_succeeded' );
563 }
564 // phpcs:enable
565
566 $payment_intent = $event->data->object;
567
568 // phpcs:disable
569 if ( charitable_is_debug() ) {
570 error_log( print_r( $payment_intent, true ) );
571 }
572 // phpcs:enable
573
574 if ( ! isset( $payment_intent->metadata->donation_id ) ) {
575 return __( 'Donation Webhook: Missing donation ID', 'charitable' );
576 }
577
578 $donation_id = $payment_intent->metadata->donation_id;
579
580 // phpcs:disable
581 if ( charitable_is_debug() ) {
582 error_log( print_r( $donation_id, true ) );
583 }
584 // phpcs:enable
585
586 if ( Charitable::DONATION_POST_TYPE !== get_post_type( $donation_id ) ) {
587 return __( 'Donation Webhook: Donation ID not valid', 'charitable' );
588 }
589
590 /**
591 * Ensure that the payment intent matches the one we have on record for this
592 * donation, to make sure this is the correct donation.
593 *
594 * @see https://bitbucket.org/wpcharitable/charitable-stripe/issues/54/webhooks-distinguish-between-webhooks-for
595 */
596 if ( get_post_meta( $donation_id, '_stripe_payment_intent', true ) != $payment_intent->id ) {
597 return __( 'Donation Webhook: Payment Intent does not match donation reference on this site', 'charitable' );
598 }
599
600 $donation = new Charitable_Donation( $donation_id );
601
602 // phpcs:disable
603 if ( charitable_is_debug() ) {
604 error_log( print_r( $donation, true ) );
605 }
606 // phpcs:enable
607
608 /* Update the donation log. */
609 $log = new Charitable_Stripe_Donation_Log( $donation );
610
611 if ( $this->is_connect_webhook_for_connected_account() ) {
612 // phpcs:disable
613 if ( charitable_is_debug() ) {
614 error_log( 'is_connect_webhook_for_connected_account' );
615 }
616 // phpcs:enable
617 $log->log_connected_account( $this->event->account );
618 $log->log_connect_details( $payment_intent );
619 } else {
620 // phpcs:disable
621 if ( charitable_is_debug() ) {
622 error_log( 'NOT is_connect_webhook_for_connected_account' );
623 error_log( print_r( $payment_intent->charges->data[0]->id, true ) );
624 }
625 // phpcs:enable
626 $log->log_charge( $payment_intent->charges->data[0]->id );
627
628 /**
629 * If this was a payment on the platform but with funds going to a
630 * connected account, log the relevant details.
631 */
632 if ( ! is_null( $payment_intent->application_fee_amount ) ) {
633 $log->log_connect_details( $payment_intent );
634 }
635 }
636
637 // phpcs:disable
638 if ( charitable_is_debug() ) {
639 error_log( 'made it to charitable-completed' );
640 }
641 // phpcs:enable
642 /* Finally, update the donation status. */
643 $donation->update_status( 'charitable-completed' );
644
645 return __( 'Donation Webhook: Donation marked as Paid', 'charitable' );
646 }
647
648 /**
649 * Process the checkout.session.completed webhook.
650 *
651 * When a session is completed, a `payment_intent.succeeded` or
652 * `payment_intent.payment_failed` event is also fired, so we
653 * update the status of the donation when that is received.
654 *
655 * However, since the payment intent is not logged when the
656 * donation is initially processed for Checkout, we record
657 * the payment intent in the Donation log here.
658 *
659 * @since 1.4.0
660 *
661 * @param object $event The Stripe event object.
662 * @return string Response message.
663 */
664 public function process_checkout_session_completed( $event ) {
665 $session = $event->data->object;
666 $donation_id = $session->client_reference_id;
667
668 /**
669 * Ensure that the session id matches the session id we recorded for this donation.
670 *
671 * @see https://bitbucket.org/wpcharitable/charitable-stripe/issues/54/webhooks-distinguish-between-webhooks-for
672 */
673 if ( $session->id != get_post_meta( $donation_id, '_stripe_session_id', true ) ) {
674 return __( 'Donation Webhook: Session id does not match donation reference on this site', 'charitable' );
675 }
676
677 /* Ensure the post type is correct. */
678 if ( Charitable::DONATION_POST_TYPE !== get_post_type( $donation_id ) ) {
679 return __( 'Donation Webhook: Donation ID not valid', 'charitable' );
680 }
681
682 /* Process subscriptions separately. */
683 if ( 'subscription' === $session->mode ) {
684 return $this->process_checkout_session_completed_for_subscription( $event );
685 }
686
687 /* If this is not a subscription, we need a payment intent. */
688 if ( is_null( $session->payment_intent ) ) {
689 return __( 'Donation Webhook: Missing payment intent', 'charitable' );
690 }
691
692 /* Mark the donation as complete. */
693 $donation = new Charitable_Donation( $donation_id );
694 $donation->update_status( 'charitable-completed' );
695
696 /* Log the Payment Intent to the session. */
697 $log = new Charitable_Stripe_Donation_Log( $donation );
698
699 if ( $this->is_connect_webhook_for_connected_account() ) {
700 $log->log_connected_account( $this->event->account );
701 $log->log_connect_details_with_payment_intent_id( $session->payment_intent, $this->get_options() );
702 } else {
703 $log->log_payment_intent( $session->payment_intent );
704 }
705
706 return __( 'Session Webhook: Donation updated with Payment Intent data', 'charitable' );
707 }
708
709 /**
710 * Process a checkout.session.completed event for a subscription.
711 *
712 * @since 1.4.3
713 *
714 * @param object $event The Stripe event object.
715 * @return string
716 */
717 public function process_checkout_session_completed_for_subscription( $event ) {
718 $session = $event->data->object;
719 $donation_id = $session->client_reference_id;
720
721 /* Make sure we have a subscription. */
722 if ( is_null( $session->subscription ) ) {
723 return __( 'Session Webhook: Missing subscription', 'charitable' );
724 }
725
726 /* Mark the donation as complete. */
727 $donation = charitable_get_donation( $donation_id );
728 $subscription = $donation->get_donation_plan();
729
730 /* Make sure a valid subscription exists. */
731 if ( ! $subscription ) {
732 return __( 'Session Webhook: Invalid subscription', 'charitable' );
733 }
734
735 /* Log the subscription id. */
736 $log = new Charitable_Stripe_Recurring_Donation_Log( $subscription );
737 $log->log_subscription( $session->subscription );
738
739 /* If the subscription should end after a certain amount of time, set that. */
740 if ( method_exists( $subscription, 'get_donation_length' ) ) {
741 $length = (int) $subscription->get_donation_length();
742
743 if ( $length ) {
744 $cancel_at = charitable_recurring_calculate_future_date(
745 $length,
746 $subscription->get_donation_period(),
747 date( 'Y-m-d 00:00:00' ), // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
748 'U'
749 );
750
751 /* Set the cancel_at in the subscription. */
752 try {
753 $stripe_sub = \Stripe\Subscription::retrieve( $session->subscription, $this->get_options() );
754 $stripe_sub->cancel_at = $cancel_at - HOUR_IN_SECONDS;
755 $stripe_sub->save();
756 } catch ( Exception $e ) {
757 $subscription->update_donation_log( __( 'Unable to set cancel time for subscription.', 'charitable' ) );
758 }
759 }
760 }
761
762 return __( 'Session Webhook: Donation and subscription updated with session data', 'charitable' );
763 }
764
765 /**
766 * Process the invoice.created webhook.
767 *
768 * @since 1.3.0
769 *
770 * @param object $event The Stripe event object.
771 * @return string Response message
772 */
773 public function process_invoice_created( $event ) {
774 if ( ! $this->is_recurring_installed() ) {
775 return __( 'Subscription Webhook: Unable to process without Charitable Recurring extension.', 'charitable' );
776 }
777
778 $invoice = $event->data->object;
779 $subscription = $this->get_subscription_for_webhook_object( $invoice );
780
781 if ( ! $subscription || ! is_a( $subscription, 'Charitable_Recurring_Donation' ) ) {
782 return __( 'Subscription Webhook: Missing subscription', 'charitable' );
783 }
784
785 /* Record the invoice in the subscription. */
786 if ( ! $this->is_connect_webhook_for_connected_account() ) {
787 $log = new Charitable_Stripe_Recurring_Donation_Log( $subscription );
788 $log->log_new_invoice( $invoice->id );
789 }
790
791 return __( 'Subscription Webhook: Invoice created', 'charitable' );
792 }
793
794 /**
795 * Process the invoice.payment_failed webhook.
796 *
797 * @since 1.3.0
798 *
799 * @param object $event The Stripe event object.
800 * @return string Response message
801 */
802 public function process_invoice_payment_failed( $event ) {
803 if ( ! $this->is_recurring_installed() ) {
804 return __( 'Subscription Webhook: Unable to process without Charitable Recurring extension.', 'charitable' );
805 }
806
807 $invoice = $event->data->object;
808
809 if ( ! in_array( $invoice->status, [ 'void', 'uncollectible' ] ) ) {
810 // translators: %s is the invoice status.
811 return sprintf( __( 'Subscription Webhook: Not processing invoice with a status of %s.', 'charitable' ), $invoice->status );
812 }
813
814 $subscription = $this->get_subscription_for_webhook_object( $invoice );
815
816 if ( empty( $subscription ) || ! is_a( $subscription, 'Charitable_Recurring_Donation' ) ) {
817 return __( 'Subscription Webhook: Missing subscription', 'charitable' );
818 }
819
820 $subscription_log = new Charitable_Stripe_Recurring_Donation_Log( $subscription );
821 $subscription->set_to_failed(
822 $subscription_log->get_failed_invoice_log_message( $invoice->id, $invoice->payment_intent )
823 );
824
825 return __( 'Subscription Webhook: Invoice payment failed', 'charitable' );
826 }
827
828 /**
829 * Process the invoice.payment_succeeded webhook.
830 *
831 * @since 1.3.0
832 *
833 * @param object $event The Stripe event object.
834 * @return string Response message
835 */
836 public function process_invoice_payment_succeeded( $event ) {
837 if ( ! $this->is_recurring_installed() ) {
838 return __( 'Subscription Webhook: Unable to process without Charitable Recurring extension.', 'charitable' );
839 }
840
841 $invoice = $event->data->object;
842 $subscription = $this->get_subscription_for_webhook_object( $invoice );
843
844 if ( charitable_is_debug() ) {
845 error_log( sprintf( '[STRIPE_WEBHOOK] invoice.payment_succeeded - Invoice: %s | Charge: %s | PaymentIntent: %s | Stripe Sub: %s', $invoice->id, $invoice->charge ?? 'null', $invoice->payment_intent ?? 'null', $invoice->subscription ?? 'null' ) ); // phpcs:ignore
846 }
847
848 if ( empty( $subscription ) || ! is_a( $subscription, 'Charitable_Recurring_Donation' ) ) {
849 if ( charitable_is_debug() ) {
850 error_log( sprintf( '[STRIPE_WEBHOOK] No matching Charitable subscription found for Stripe sub: %s', $invoice->subscription ?? 'null' ) ); // phpcs:ignore
851 }
852 return __( 'Subscription Webhook: Missing subscription', 'charitable' );
853 }
854
855 $subscription_status = $subscription->get_status();
856
857 if ( charitable_is_debug() ) {
858 error_log( sprintf( '[STRIPE_WEBHOOK] Matched Charitable subscription #%d | Status: %s | Gateway Sub ID: %s', $subscription->get_donation_id(), $subscription_status, $subscription->get_gateway_subscription_id() ) ); // phpcs:ignore
859 }
860
861 /* Do not process payments for cancelled subscriptions. */
862 if ( 'charitable-cancelled' === $subscription_status ) {
863 if ( charitable_is_debug() ) {
864 error_log( sprintf( '[STRIPE_WEBHOOK] BLOCKED: Cancelled subscription #%d received invoice.payment_succeeded (Stripe: %s) - skipping to prevent reactivation', $subscription->get_donation_id(), $invoice->subscription ) ); // phpcs:ignore
865 }
866 return __( 'Subscription Webhook: Subscription is cancelled, skipping payment processing', 'charitable' );
867 }
868
869 /* The first donation is pending, which means this is the payment for that webhook. */
870 $first_donation = $subscription->get_first_donation_id();
871 $first_donation_status = get_post_status( $first_donation );
872
873 if ( 'charitable-pending' == $first_donation_status ) { // phpcs:ignore
874 $donation_id = $first_donation;
875 $donation = charitable_get_donation( $donation_id );
876
877 if ( charitable_is_debug() ) {
878 error_log( sprintf( '[STRIPE_WEBHOOK] Processing as FIRST payment - Donation #%d (status: %s)', $donation_id, $first_donation_status ) ); // phpcs:ignore
879 }
880 } else {
881 /* Check whether we've already added this renewal using the charge ID. */
882 $existing_donation = charitable_get_donation_by_transaction_id( $invoice->charge );
883
884 if ( $existing_donation ) {
885 if ( charitable_is_debug() ) {
886 error_log( sprintf( '[STRIPE_WEBHOOK] DUPLICATE BLOCKED: Charge %s already exists as donation #%d - idempotency check passed', $invoice->charge, $existing_donation ) ); // phpcs:ignore
887 }
888 return __( 'Subscription Webhook: Renewal has already been added', 'charitable' );
889 }
890
891 if ( charitable_is_debug() ) {
892 error_log( sprintf( '[STRIPE_WEBHOOK] Creating RENEWAL donation for subscription #%d (Charge: %s not found in existing donations)', $subscription->get_donation_id(), $invoice->charge ) ); // phpcs:ignore
893 }
894
895 /* Transient lock to prevent duplicate renewals from near-simultaneous webhooks. */
896 $lock_key = 'charitable_renewal_' . $invoice->charge;
897 if ( get_transient( $lock_key ) ) {
898 return __( 'Subscription Webhook: Renewal is already being processed', 'charitable' );
899 }
900 set_transient( $lock_key, true, 300 );
901
902 $donation_id = $subscription->create_renewal_donation( array( 'status' => 'charitable-completed' ) );
903 $donation = charitable_get_donation( $donation_id );
904
905 if ( charitable_is_debug() ) {
906 if ( is_wp_error( $donation_id ) ) {
907 error_log( sprintf( '[STRIPE_WEBHOOK] ERROR: Renewal creation failed - %s', $donation_id->get_error_message() ) ); // phpcs:ignore
908 } else {
909 error_log( sprintf( '[STRIPE_WEBHOOK] Renewal donation #%d created successfully', $donation_id ) ); // phpcs:ignore
910 }
911 }
912 }
913
914 /* Update the log. */
915 $log = new Charitable_Stripe_Donation_Log( $donation );
916
917 if ( $this->is_connect_webhook_for_connected_account() ) {
918 $log->log_connected_account( $this->event->account );
919 $log->log_connect_details_with_payment_intent_id( $invoice->payment_intent, $this->get_options() );
920 } else {
921 $log->log_payment_intent( $invoice->payment_intent );
922 $log->log_charge( $invoice->charge );
923 }
924
925 /* Mark the first payment as complete. */
926 if ( $first_donation === $donation_id ) {
927 $donation->update_status( 'charitable-completed' );
928 }
929
930 /* Mark subscription as active or completed. */
931 $subscription->renew();
932
933 /* Store the donation_id in the charge's metadata to support refunds. */
934 try {
935 $charge = \Stripe\Charge::retrieve( $invoice->charge, $this->get_options() );
936 $charge->metadata = charitable_stripe_get_donation_metadata( $donation );
937 $charge->save();
938 } catch ( Exception $e ) {
939 $donation->update_donation_log( __( 'Unable to save donation ID to Stripe charge metadata.', 'charitable' ) );
940 if ( charitable_is_debug() ) {
941 error_log( sprintf( '[STRIPE_WEBHOOK] Warning: Could not save metadata to Stripe charge %s - %s', $invoice->charge, $e->getMessage() ) ); // phpcs:ignore
942 }
943 }
944
945 if ( charitable_is_debug() ) {
946 error_log( sprintf( '[STRIPE_WEBHOOK] SUCCESS: invoice.payment_succeeded fully processed - Donation #%d | Subscription #%d | Charge: %s', $donation_id, $subscription->get_donation_id(), $invoice->charge ) ); // phpcs:ignore
947 }
948
949 return __( 'Subscription Webhook: Payment complete', 'charitable' );
950 }
951
952 /**
953 * Process the customer.subscription.updated webhook.
954 *
955 * @since 1.3.0
956 *
957 * @param object $event The Stripe event object.
958 * @return string Response message
959 */
960 public function process_customer_subscription_updated( $event ) {
961 if ( ! $this->is_recurring_installed() ) {
962 return __( 'Subscription Webhook: Unable to process without Charitable Recurring extension.', 'charitable' );
963 }
964
965 $object = $event->data->object;
966 $subscription = $this->get_subscription_for_webhook_object( $object );
967
968 if ( empty( $subscription ) ) {
969 if ( charitable_is_debug() ) {
970 error_log( sprintf( '[STRIPE_WEBHOOK] customer.subscription.updated - No matching Charitable subscription for Stripe sub: %s', $object->id ?? 'null' ) ); // phpcs:ignore
971 }
972 return __( 'Subscription Webhook: Missing subscription', 'charitable' );
973 }
974
975 $stripe_status = $this->get_subscription_status( $object->status );
976 $current_status = $subscription->get_status();
977
978 if ( charitable_is_debug() ) {
979 error_log( sprintf( '[STRIPE_WEBHOOK] customer.subscription.updated - Subscription #%d | Stripe status: %s → Charitable status: %s | Current Charitable status: %s', $subscription->get_donation_id(), $object->status, $stripe_status, $current_status ) ); // phpcs:ignore
980 }
981
982 /* Do not allow Stripe to reactivate a subscription that was cancelled in Charitable. */
983 if ( 'charitable-cancelled' === $current_status && 'charitable-cancelled' !== $stripe_status ) {
984 if ( charitable_is_debug() ) {
985 error_log( sprintf( '[STRIPE_WEBHOOK] BLOCKED: Stripe tried to change cancelled subscription #%d to %s (Stripe status: %s) - preserving cancelled status', $subscription->get_donation_id(), $stripe_status, $object->status ) ); // phpcs:ignore
986 }
987 return __( 'Subscription Webhook: Subscription is cancelled in Charitable, ignoring Stripe status update', 'charitable' );
988 }
989
990 if ( $stripe_status != $current_status && 'charitable-completed' != $current_status ) { // phpcs:ignore
991 if ( charitable_is_debug() ) {
992 error_log( sprintf( '[STRIPE_WEBHOOK] Updating subscription #%d status: %s → %s', $subscription->get_donation_id(), $current_status, $stripe_status ) ); // phpcs:ignore
993 }
994 $subscription->update_status( $stripe_status );
995 }
996
997 return __( 'Subscription Webhook: Recurring donation updated', 'charitable' );
998 }
999
1000 /**
1001 * Process the customer.subscription.deleted webhook.
1002 *
1003 * @since 1.3.0
1004 *
1005 * @param object $event The Stripe event object.
1006 * @return string Response message
1007 */
1008 public function process_customer_subscription_deleted( $event ) {
1009 if ( ! $this->is_recurring_installed() ) {
1010 return __( 'Subscription Webhook: Unable to process without Charitable Recurring extension.', 'charitable' );
1011 }
1012
1013 $object = $event->data->object;
1014 $subscription = $this->get_subscription_for_webhook_object( $object );
1015
1016 if ( empty( $subscription ) ) {
1017 if ( charitable_is_debug() ) {
1018 error_log( sprintf( '[STRIPE_WEBHOOK] customer.subscription.deleted - No matching Charitable subscription for Stripe sub: %s', $object->id ?? 'null' ) ); // phpcs:ignore
1019 }
1020 return __( 'Subscription Webhook: Missing subscription', 'charitable' );
1021 }
1022
1023 $current_status = $subscription->get_status();
1024
1025 if ( charitable_is_debug() ) {
1026 error_log( sprintf( '[STRIPE_WEBHOOK] customer.subscription.deleted - Subscription #%d | Current status: %s', $subscription->get_donation_id(), $current_status ) ); // phpcs:ignore
1027 }
1028
1029 if ( 'charitable-completed' != $current_status ) { // phpcs:ignore
1030 $subscription->update_status( 'charitable-cancelled' );
1031 if ( charitable_is_debug() ) {
1032 error_log( sprintf( '[STRIPE_WEBHOOK] Subscription #%d cancelled (was: %s)', $subscription->get_donation_id(), $current_status ) ); // phpcs:ignore
1033 }
1034 }
1035
1036 return __( 'Subscription Webhook: Recurring donation cancelled', 'charitable' );
1037 }
1038
1039 /**
1040 * Return a recurring donation object for a particular invoice, or false if
1041 * none is found.
1042 *
1043 * @since 1.4.0
1044 *
1045 * @param object $object The invoice or subscription object received from Stripe.
1046 * @return Charitable_Recurring_Donation|false
1047 */
1048 private function get_subscription_for_webhook_object( $object ) {
1049 $subscription_id = 'subscription' == $object->object ? $object->id : $object->subscription;
1050
1051 return charitable_recurring_get_subscription_by_gateway_id( $subscription_id, 'stripe' );
1052 }
1053
1054 /**
1055 * Given a Stripe subscription status, return the corresponding Charitable status.
1056 *
1057 * @since 1.4.0
1058 *
1059 * @param string $status Stripe subscription status.
1060 * @return string
1061 */
1062 public function get_subscription_status( $status ) {
1063 switch( $status ) {
1064 case 'incomplete':
1065 case 'trialing':
1066 return 'charitable-pending';
1067
1068 case 'active':
1069 return 'charitable-active';
1070
1071 case 'past_due':
1072 return 'charitable-cancel';
1073
1074 case 'canceled':
1075 case 'unpaid':
1076 case 'incomplete_expired':
1077 return 'charitable-cancelled';
1078 }
1079 }
1080
1081 /**
1082 * When payment failures for a particular payment intent, update the failure count.
1083 *
1084 * After three failures, cancel the payment intent.
1085 *
1086 * @since 1.4.9
1087 *
1088 * @param Charitable_Abstract_Donation $donation The donation to be updated.
1089 * @param string $payment_intent The payment intent id.
1090 * @return void
1091 */
1092 public function update_payment_failure_count( Charitable_Abstract_Donation $donation, $payment_intent ) {
1093 $failure_count = (int) get_post_meta( $donation->ID, '_stripe_payment_intent_failure_count', true );
1094 $failure_count += 1;
1095
1096 /* Update the failure count. */
1097 update_post_meta( $donation->ID, '_stripe_payment_intent_failure_count', $failure_count );
1098
1099 /**
1100 * Filter the threshold number of failures after which a payment intent
1101 * should be cancelled.
1102 *
1103 * @since 1.4.9
1104 *
1105 * @param int $threshold The threshold number.
1106 */
1107 $threshold = apply_filters( 'charitable_stripe_payment_failure_cancellation_threshold', 3 );
1108
1109 /* The threshold has been reached, so cancel the payment intent. */
1110 if ( $threshold <= $failure_count ) {
1111 $intent = new Charitable_Stripe_Payment_Intent( $payment_intent );
1112 $intent->cancel();
1113
1114 /* Add a log message. */
1115 $donation->log()->add(
1116 sprintf(
1117 /* translators: %d: threshold */
1118 __( 'The payment intent has been cancelled after %d failed payment attempts.', 'charitable' ),
1119 $threshold
1120 )
1121 );
1122 }
1123 }
1124
1125 /**
1126 * Check whether Recurring Donations is active.
1127 *
1128 * @since 1.4.0
1129 *
1130 * @return boolean
1131 */
1132 private function is_recurring_installed() {
1133 return class_exists( 'Charitable_Recurring' );
1134 }
1135
1136 /**
1137 * For an IPN request, get the validated incoming event object.
1138 *
1139 * Verifies the Stripe webhook signature to prevent forged events.
1140 *
1141 * @since 1.3.0
1142 * @since 1.4.0 Returns an array instead of an object.
1143 * @since 1.8.9.8 Verifies Stripe-Signature header using webhook signing secret.
1144 *
1145 * @return false|\Stripe\Event If valid, returns a verified Stripe Event object. Otherwise false.
1146 */
1147 private static function get_validated_incoming_event() {
1148 $body = @file_get_contents( 'php://input' );
1149
1150 if ( empty( $body ) ) {
1151 return false;
1152 }
1153
1154 $sig_header = isset( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ) : '';
1155 $signing_secret = self::get_webhook_signing_secret();
1156
1157 // If we have both a signature header and a signing secret, verify cryptographically.
1158 if ( ! empty( $sig_header ) && ! empty( $signing_secret ) ) {
1159 try {
1160 $event = \Stripe\Webhook::constructEvent( $body, $sig_header, $signing_secret );
1161 return $event;
1162 } catch ( \Stripe\Error\SignatureVerification $e ) {
1163 if ( charitable_is_debug() ) {
1164 error_log( 'Charitable Stripe webhook signature verification failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1165 }
1166 self::record_verification_failure();
1167 return false;
1168 } catch ( \UnexpectedValueException $e ) {
1169 if ( charitable_is_debug() ) {
1170 error_log( 'Charitable Stripe webhook invalid payload: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1171 }
1172 self::record_verification_failure();
1173 return false;
1174 } catch ( \Exception $e ) {
1175 // Catches \Stripe\Exception\SignatureVerificationException from newer Stripe SDK versions
1176 // in addition to the legacy \Stripe\Error\SignatureVerification namespace above.
1177 if ( charitable_is_debug() ) {
1178 error_log( 'Charitable Stripe webhook signature verification failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1179 }
1180 self::record_verification_failure();
1181 return false;
1182 }
1183 }
1184
1185 // Fallback: if no signing secret is stored yet (e.g., legacy installs before upgrade),
1186 // verify the event by re-fetching it from the Stripe API using the event ID.
1187 $event_data = json_decode( $body, true );
1188
1189 if ( ! is_array( $event_data ) || ! array_key_exists( 'id', $event_data ) ) {
1190 self::record_verification_failure();
1191 return false;
1192 }
1193
1194 // Do not accept events with obviously forged IDs.
1195 if ( 0 !== strpos( $event_data['id'], 'evt_' ) ) {
1196 self::record_verification_failure();
1197 return false;
1198 }
1199
1200 try {
1201 $gateway = new Charitable_Gateway_Stripe_AM();
1202 $gateway->setup_api();
1203
1204 // Re-retrieve the event from Stripe API to confirm authenticity.
1205 $event = \Stripe\Event::retrieve( $event_data['id'] );
1206 return $event;
1207 } catch ( \Exception $e ) {
1208 if ( charitable_is_debug() ) {
1209 error_log( 'Charitable Stripe webhook event verification via API failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1210 }
1211 self::record_verification_failure();
1212 return false;
1213 }
1214 }
1215
1216 /**
1217 * Retrieve the webhook signing secret for the current mode.
1218 *
1219 * Checks all possible signing secret option keys (live/test, connect/direct)
1220 * and returns the first non-empty one found.
1221 *
1222 * @since 1.8.9.8
1223 *
1224 * @return string|false The signing secret, or false if not found.
1225 */
1226 private static function get_webhook_signing_secret() {
1227 // Allow override via constant (useful for Stripe CLI testing).
1228 if ( defined( 'CHARITABLE_WEBHOOK_SIGNING_SECRET' ) && CHARITABLE_WEBHOOK_SIGNING_SECRET ) {
1229 return CHARITABLE_WEBHOOK_SIGNING_SECRET;
1230 }
1231
1232 $test_mode = charitable_get_option( 'test_mode', false );
1233
1234 // Check the signing secret keys in priority order based on current mode.
1235 if ( $test_mode ) {
1236 $keys = array( 'test_connect_webhook_signing_secret', 'test_webhook_signing_secret' );
1237 } else {
1238 $keys = array( 'live_connect_webhook_signing_secret', 'live_webhook_signing_secret' );
1239 }
1240
1241 foreach ( $keys as $key ) {
1242 $secret = charitable_get_option( array( 'gateways_stripe', $key ) );
1243 if ( ! empty( $secret ) ) {
1244 return $secret;
1245 }
1246 }
1247
1248 return false;
1249 }
1250
1251 /**
1252 * Record a webhook verification failure for admin alerting.
1253 *
1254 * Increments a transient counter that expires after 24 hours.
1255 * When the count exceeds the threshold, an admin notice is displayed.
1256 *
1257 * @since 1.8.9.8
1258 *
1259 * @return void
1260 */
1261 private static function record_verification_failure() {
1262 $transient_key = 'charitable_stripe_webhook_verification_failures';
1263 $count = (int) get_transient( $transient_key );
1264 set_transient( $transient_key, $count + 1, DAY_IN_SECONDS );
1265 }
1266 }
1267
1268 endif;
1269