PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.12.2
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.12.2
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-api.php

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

649 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Get, update and deactivate webhooks.
4 *
5 * @package Charitable Stripe/Classes/Charitable_Stripe_Webhook_API
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.3.0
11 */
12
13 // phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_error_log,WordPress.PHP.DevelopmentFunctions.error_log_print_r,WordPress.PHP.DevelopmentFunctions.error_log_var_export
14
15 // Exit if accessed directly.
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit;
18 }
19
20 if ( ! class_exists( 'Charitable_Stripe_Webhook_API' ) ) :
21
22 /**
23 * Charitable_Stripe_Webhook_API
24 *
25 * @since 1.3.0
26 */
27 class Charitable_Stripe_Webhook_API {
28
29 /**
30 * Whether we're in test mode.
31 *
32 * @since 1.3.0
33 *
34 * @var boolean
35 */
36 public $test_mode;
37
38 /**
39 * Secret key.
40 *
41 * @since 1.3.0
42 *
43 * @var string
44 */
45 private $secret_key;
46
47 /**
48 * The webhook setting key, based on whether we're in test mode
49 * and whether this is for the Connect webhook.
50 *
51 * @since 1.3.0
52 *
53 * @var string
54 */
55 private $setting_key;
56
57 /**
58 * Whether this is for the Connect webhook.
59 *
60 * @since 1.3.0
61 *
62 * @var boolean
63 */
64 private $connect_application;
65
66 /**
67 * Gateway helper.
68 *
69 * @since 1.3.0
70 *
71 * @var Charitable_Gateway_Stripe_AM
72 */
73 private $gateway;
74
75 /**
76 * Create class object.
77 *
78 * @since 1.3.0
79 *
80 * @param boolean|null $test_mode Whether to use test mode or not. If left as
81 * null, the site test mode setting will be used.
82 * @param boolean|null $secret_key The api login id to use. If left as null, the
83 * stored setting will be used.
84 * @param boolean $connect_application Whether we want the Connect webhook.
85 */
86 public function __construct( $test_mode = null, $secret_key = null, $connect_application = false ) {
87 $this->test_mode = is_null( $test_mode ) ? charitable_get_option( 'test_mode', false ) : $test_mode;
88 $this->connect_application = $connect_application;
89 $this->secret_key = is_null( $secret_key ) ? $this->parse_secret_key() : $secret_key;
90 $this->setting_key = $this->parse_setting_key();
91 $this->gateway = new Charitable_Gateway_Stripe_AM();
92 }
93
94 /**
95 * Returns class properties.
96 *
97 * @since 1.3.0
98 *
99 * @param string $prop The property to return.
100 * @return mixed
101 */
102 public function __get( $prop ) {
103 return isset( $this->$prop ) ? $this->$prop : null;
104 }
105
106 /**
107 * Return the set of webhook event types we need to subscribe to.
108 *
109 * @since 1.3.0
110 *
111 * @return array
112 */
113 public function get_webhook_events() {
114 /**
115 * Filter the events that the webhook will be notified about.
116 *
117 * @since 1.3.0
118 *
119 * @param array $events The events that the webhook will be notified about.
120 */
121 return apply_filters(
122 'charitable_stripe_webhook_events',
123 [
124 'charge.refunded',
125 'invoice.created',
126 'invoice.payment_failed',
127 'invoice.payment_succeeded',
128 'invoice.payment_action_required',
129 'customer.subscription.updated',
130 'customer.subscription.deleted',
131 'payment_intent.payment_failed',
132 'payment_intent.succeeded',
133 'checkout.session.completed',
134 ],
135 $this->connect_application
136 );
137 }
138
139 /**
140 * Return the webhook listener endpoint.
141 *
142 * @since 1.3.0
143 *
144 * @return string
145 */
146 public function get_webhook_listener() {
147 return charitable_get_ipn_url( Charitable_Gateway_Stripe_AM::ID );
148 }
149
150 /**
151 * Add a new webhook.
152 *
153 * @since 1.3.0
154 *
155 * @return string The webhook id.
156 */
157 public function add_webhook( $idempotency_key = '' ) {
158 /**
159 * First check whether the webhook has already been added.
160 */
161 $webhook = $this->get_webhook();
162
163 if ( charitable_is_debug() ) {
164 error_log( 'add_webook' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
165 error_log( print_r( $webhook, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log,WordPress.PHP.DevelopmentFunctions.error_log_print_r
166 }
167
168 if ( false === $webhook ) {
169 try {
170
171 if ( charitable_is_debug() ) {
172 error_log( 'add_webook false' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
173 error_log( print_r( $this->secret_key, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log,WordPress.PHP.DevelopmentFunctions.error_log_print_r
174 }
175
176 $this->gateway->setup_api( $this->secret_key );
177
178 if ( charitable_is_debug() ) {
179 error_log( 'add_webook after setup_api' );
180 }
181
182 $request_opts = ! empty( $idempotency_key ) ? [ 'idempotency_key' => $idempotency_key ] : [];
183
184 $webhook = \Stripe\WebhookEndpoint::create(
185 [
186 'url' => $this->get_webhook_listener(),
187 'enabled_events' => $this->get_webhook_events(),
188 'api_version' => Charitable_Gateway_Stripe_AM::STRIPE_API_VERSION,
189 'connect' => $this->connect_application,
190 ],
191 $request_opts
192 );
193
194 if ( charitable_is_debug() ) {
195 error_log( 'add_webook after WebhookEndpoint' );
196 error_log( print_r( $webhook, true ) );
197 }
198 // Override the signing secret, for testing purposes (perhaps with the Stripe API CLI).
199 if ( defined( 'CHARITABLE_WEBHOOK_SIGNING_SECRET' ) && CHARITABLE_WEBHOOK_SIGNING_SECRET ) {
200 $webhook->secret = CHARITABLE_WEBHOOK_SIGNING_SECRET;
201 }
202 if ( charitable_is_debug() ) {
203 error_log( 'add_webook after WebhookEndpoint UPDATED FOR CLI' );
204 error_log( print_r( $webhook, true ) );
205 }
206
207 // Store the webhook signing secret for signature verification.
208 if ( ! empty( $webhook->secret ) ) {
209 $this->save_webhook_signing_secret( $webhook->secret );
210 }
211
212 } catch ( Exception $e ) {
213 if ( charitable_is_debug() ) {
214 error_log(
215 sprintf(
216 // translators: %s is the error message.
217 __( 'Error creating Stripe webhook: %s', 'charitable' ),
218 $e->getMessage()
219 )
220 );
221 }
222
223 if ( charitable_is_debug() ) {
224 error_log( 'add_webhook ERROR' );
225 error_log( print_r( $e->getMessage(), true ) );
226 }
227
228 return 'invalid_request';
229 }
230 }
231
232 if ( charitable_is_debug() ) {
233 error_log( 'add_webook returning $webhook->id' );
234 error_log( print_r( $webhook->id, true ) );
235 }
236
237 return $webhook->id;
238 }
239
240 /**
241 * Returns the WebhookEndpoint object, or false if one doesn't exist yet.
242 *
243 * @since 1.3.0
244 *
245 * @return false|\Stripe\WebhookEndpoint
246 */
247 public function get_webhook() {
248 $webhook_id = charitable_get_option( [ 'gateways_stripe', $this->setting_key ], '' );
249
250 if ( ! $webhook_id ) {
251 if ( charitable_is_debug() ) {
252 error_log( 'get_webhook ! $webhook_id' );
253 }
254 return $this->has_webhook();
255 }
256
257 try {
258 if ( charitable_is_debug() ) {
259 error_log( 'get_webhook try setup_api using secret key ' . $this->secret_key );
260 }
261 $this->gateway->setup_api( $this->secret_key );
262
263 return \Stripe\WebhookEndpoint::retrieve( $webhook_id );
264 } catch ( Exception $e ) {
265 if ( charitable_is_debug() ) {
266 error_log( 'get_webhook try setup_api catch' );
267 }
268 return false;
269 }
270 }
271
272 /**
273 * Checks whether a matching webhook already exists within Stripe.
274 *
275 * @since 1.3.0
276 *
277 * @return false|\Stripe\WebhookEndpoint If a webhook exists, returns it.
278 * Otherwise, returns false.
279 */
280 public function has_webhook() {
281 try {
282 $this->gateway->setup_api( $this->secret_key );
283
284 $endpoints = \Stripe\WebhookEndpoint::all( [ 'limit' => 100 ] );
285
286 $endpoint_urls = $this->get_possible_endpoint_urls();
287
288 if ( charitable_is_debug() ) {
289 error_log( 'has_webhook endpoint_urls' );
290 error_log( print_r( $endpoint_urls, true ) );
291 error_log( print_r( $endpoints, true ) );
292 }
293
294 foreach ( $endpoints->data as $webhook ) {
295 if ( ! in_array( $webhook->url, $endpoint_urls ) ) {
296 continue;
297 }
298
299 // Skip disabled webhooks — a disabled endpoint cannot receive events and
300 // should not prevent creation of a new, active one.
301 if ( isset( $webhook->status ) && 'enabled' !== $webhook->status ) {
302 continue;
303 }
304
305 /**
306 * If we're looking for a Connect application webhook, check that the application
307 * property is not null. Otherwise, make sure it is null.
308 */
309 if ( $this->connect_application ? is_null( $webhook->application ) : ! is_null( $webhook->application ) ) {
310 continue;
311 }
312
313 return $webhook;
314 }
315 } catch ( Exception $e ) {
316 if ( charitable_is_debug() ) {
317 error_log( var_export( $e, true ) );
318 }
319
320 return false;
321 }
322
323 return false;
324 }
325
326 /**
327 * Checks if a webhook needs an update.
328 *
329 * @since 1.3.0
330 *
331 * @param \Stripe\WebhookEndpoint $webhook The webhook endpoint object.
332 * @return boolean
333 */
334 public function webhook_needs_update( $webhook ) {
335 /* The webhook is not enabled, so it needs an update. */
336 if ( 'enabled' != $webhook->status ) {
337 return true;
338 }
339
340 /* The webhook is not sending some events we need, so we need to update it. */
341 if ( ! in_array( '*', $webhook->enabled_events ) && ! empty( array_diff( $this->get_webhook_events(), $webhook->enabled_events ) ) ) {
342 return true;
343 }
344
345 return false;
346 }
347
348 /**
349 * Update the webhook.
350 *
351 * @since 1.3.0
352 *
353 * @return boolean
354 */
355 public function update_webhook() {
356 $webhook = $this->get_webhook();
357
358 if ( ! $webhook ) {
359 return false;
360 }
361
362 try {
363 $this->gateway->setup_api( $this->secret_key );
364
365 $webhook = \Stripe\WebhookEndpoint::retrieve( $webhook->id );
366 $webhook->disabled = false;
367 $webhook->enabled_events = $this->get_webhook_events();
368 $webhook->save();
369
370 return true;
371 } catch ( Exception $e ) {
372 return false;
373 }
374 }
375
376 /**
377 * Deactivate the webhook.
378 *
379 * @since 1.3.0
380 *
381 * @return boolean
382 */
383 public function deactivate_webhook() {
384 $webhook = $this->get_webhook();
385
386 if ( ! $webhook ) {
387 return false;
388 }
389
390 try {
391 $this->gateway->setup_api( $this->secret_key );
392
393 $webhook = \Stripe\WebhookEndpoint::retrieve( $webhook->id );
394 $webhook->disabled = true;
395 $webhook->save();
396 } catch ( Exception $e ) {
397 return false;
398 }
399 }
400
401 /**
402 * Returns the setting key to use based on whether it's test mode and whether it's for the Connect webhook.
403 *
404 * @since 1.3.0
405 *
406 * @return string
407 */
408 private function parse_setting_key() {
409 if ( $this->test_mode ) {
410 return $this->connect_application ? 'test_connect_webhook_id' : 'test_webhook_id';
411 }
412
413 return $this->connect_application ? 'live_connect_webhook_id' : 'live_webhook_id';
414 }
415
416 /**
417 * Returns the option key for the webhook signing secret.
418 *
419 * @since 1.8.9.8
420 *
421 * @return string
422 */
423 private function get_signing_secret_key() {
424 if ( $this->test_mode ) {
425 return $this->connect_application ? 'test_connect_webhook_signing_secret' : 'test_webhook_signing_secret';
426 }
427
428 return $this->connect_application ? 'live_connect_webhook_signing_secret' : 'live_webhook_signing_secret';
429 }
430
431 /**
432 * Fetch the signing secret from an existing Stripe webhook and store it locally.
433 *
434 * Note: Stripe only returns the full signing secret at webhook creation time.
435 * For existing webhooks, we must re-create the webhook to get a new secret.
436 *
437 * @since 1.8.9.8
438 *
439 * @return bool True if the signing secret was stored, false otherwise.
440 */
441 /**
442 * Delete all Stripe webhook endpoints for this site's listener URL.
443 *
444 * Clears any duplicate endpoints that may have accumulated from repeated
445 * disconnect/reconnect cycles or multiple clicks of the signature verification
446 * button. Also clears the stored webhook ID so add_webhook() starts fresh.
447 *
448 * @since 1.8.10.4
449 *
450 * @return void
451 */
452 private function delete_all_webhooks_by_url() {
453 try {
454 $this->gateway->setup_api( $this->secret_key );
455
456 $endpoints = \Stripe\WebhookEndpoint::all( [ 'limit' => 100 ] );
457 $endpoint_urls = $this->get_possible_endpoint_urls();
458
459 foreach ( $endpoints->data as $webhook ) {
460 if ( ! in_array( $webhook->url, $endpoint_urls ) ) {
461 continue;
462 }
463
464 // Only delete webhooks of our type (direct vs. Connect application).
465 if ( $this->connect_application ? is_null( $webhook->application ) : ! is_null( $webhook->application ) ) {
466 continue;
467 }
468
469 try {
470 $webhook->delete();
471
472 if ( charitable_is_debug() ) {
473 error_log( 'Charitable Stripe: deleted webhook endpoint ' . $webhook->id . ' during refresh.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
474 }
475 } catch ( \Exception $e ) {
476 if ( charitable_is_debug() ) {
477 error_log( 'Charitable Stripe: could not delete webhook ' . $webhook->id . ': ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
478 }
479 }
480 }
481 } catch ( \Exception $e ) {
482 if ( charitable_is_debug() ) {
483 error_log( 'Charitable Stripe: delete_all_webhooks_by_url failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
484 }
485 }
486
487 // Clear the stored webhook ID and signing secret so that add_webhook()
488 // creates a fresh endpoint and has_signing_secret() accurately reflects
489 // whether a new secret was returned (vs. an idempotency-cached response
490 // that omits the secret for an already-deleted endpoint).
491 $settings = get_option( 'charitable_settings', array() );
492 $changed = false;
493
494 if ( isset( $settings['gateways_stripe'][ $this->setting_key ] ) ) {
495 unset( $settings['gateways_stripe'][ $this->setting_key ] );
496 $changed = true;
497 }
498
499 $signing_secret_key = $this->get_signing_secret_key();
500 if ( isset( $settings['gateways_stripe'][ $signing_secret_key ] ) ) {
501 unset( $settings['gateways_stripe'][ $signing_secret_key ] );
502 $changed = true;
503 }
504
505 if ( $changed ) {
506 update_option( 'charitable_settings', $settings );
507 }
508 }
509
510 public function refresh_webhook_signing_secret() {
511 try {
512 $this->gateway->setup_api( $this->secret_key );
513
514 // Delete ALL webhook endpoints for our listener URL, not just the stored one.
515 // Multiple endpoints accumulate when disconnect/reconnect or this button is clicked
516 // repeatedly. Each has a different signing secret; keeping duplicates causes
517 // SignatureVerificationException 500s because Charitable can only store one secret.
518 $this->delete_all_webhooks_by_url();
519
520 // Generate a time-windowed idempotency key (30-second window) so that
521 // concurrent or rapidly-retried requests — e.g. a double-click or a
522 // network glitch where Stripe received the request but the response
523 // never made it back — map to a single endpoint creation at Stripe.
524 // After 30 seconds the window rolls over, so intentional re-runs always
525 // get a fresh key and create a new endpoint normally.
526 $idempotency_key = 'charitable_wh_create_' . md5(
527 home_url() . '|'
528 . ( $this->test_mode ? 'test' : 'live' ) . '|'
529 . ( $this->connect_application ? 'connect' : 'direct' ) . '|'
530 . (string) floor( time() / 30 )
531 );
532
533 $new_webhook_id = $this->add_webhook( $idempotency_key );
534
535 if ( 'invalid_request' === $new_webhook_id || empty( $new_webhook_id ) ) {
536 return false;
537 }
538
539 // Guard against a Stripe idempotency cache hit: when the same key is
540 // reused within the 30-second window, Stripe returns the cached creation
541 // response but omits ->secret (only returned on true first creation).
542 // delete_all_webhooks_by_url() already cleared the old secret, so if
543 // has_signing_secret() is still false here it means no new secret was
544 // stored — the webhook ID would be stale/deleted. Return false so the
545 // caller sees a failure rather than persisting a broken state.
546 if ( ! $this->has_signing_secret() ) {
547 return false;
548 }
549
550 // add_webhook() already stores the signing secret and returns the new ID.
551 // Update the stored webhook ID.
552 $settings = get_option( 'charitable_settings', array() );
553
554 if ( ! isset( $settings['gateways_stripe'] ) ) {
555 $settings['gateways_stripe'] = array();
556 }
557
558 $settings['gateways_stripe'][ $this->setting_key ] = $new_webhook_id;
559 update_option( 'charitable_settings', $settings );
560
561 return true;
562
563 } catch ( \Exception $e ) {
564 if ( charitable_is_debug() ) {
565 error_log( 'Charitable: Failed to refresh webhook signing secret: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
566 }
567 return false;
568 }
569 }
570
571 /**
572 * Check if an API key is configured for this webhook's mode.
573 *
574 * Uses an internal check to avoid __get() returning null for private
575 * properties in some PHP 8.x multisite contexts.
576 *
577 * @since 1.8.10.1
578 *
579 * @return bool
580 */
581 public function has_api_key() {
582 return ! empty( $this->secret_key );
583 }
584
585 /**
586 * Check if a webhook signing secret is stored for this webhook.
587 *
588 * @since 1.8.9.8
589 *
590 * @return bool
591 */
592 public function has_signing_secret() {
593 $secret = charitable_get_option( array( 'gateways_stripe', $this->get_signing_secret_key() ) );
594 return ! empty( $secret );
595 }
596
597 /**
598 * Save the webhook signing secret to Charitable options.
599 *
600 * @since 1.8.9.8
601 *
602 * @param string $secret The webhook signing secret from Stripe.
603 * @return void
604 */
605 public function save_webhook_signing_secret( $secret ) {
606 $settings = get_option( 'charitable_settings', array() );
607
608 if ( ! isset( $settings['gateways_stripe'] ) ) {
609 $settings['gateways_stripe'] = array();
610 }
611
612 $settings['gateways_stripe'][ $this->get_signing_secret_key() ] = $secret;
613
614 update_option( 'charitable_settings', $settings );
615 }
616
617 /**
618 * Return the secret key to use based on whether it's test mode.
619 *
620 * @since 1.3.0
621 *
622 * @return string
623 */
624 private function parse_secret_key() {
625 $setting = $this->test_mode ? 'test_secret_key' : 'live_secret_key';
626
627 return charitable_get_option( [ 'gateways_stripe', $setting ] );
628 }
629
630 /**
631 * Return all possible webhook URLs.
632 *
633 * @since 1.3.0
634 *
635 * @return string[]
636 */
637 private function get_possible_endpoint_urls() {
638 $home_url = home_url();
639
640 return [
641 sprintf( '%s/charitable-listener/%s', untrailingslashit( $home_url ), Charitable_Gateway_Stripe_AM::ID ),
642 esc_url_raw( add_query_arg( [ 'charitable-listener' => Charitable_Gateway_Stripe_AM::ID ], trailingslashit( $home_url ) ) ),
643 esc_url_raw( add_query_arg( [ 'charitable-listener' => Charitable_Gateway_Stripe_AM::ID ], untrailingslashit( $home_url ) ) ),
644 ];
645 }
646 }
647
648 endif;
649