PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
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 1.7.0.9 All 211 releases
charitable / includes / stripe / admin / class-charitable-stripe-admin.php

class-charitable-stripe-admin.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.13, at includes/stripe/admin/class-charitable-stripe-admin.php

1,320 lines 51.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The class responsible for adding & saving extra settings in the Charitable admin.
4 *
5 * @package Charitable Stripe/Classes/Charitable_Stripe_Admin
6 * @author David Bisset
7 * @copyright Copyright (c) 2021-2022, WP Charitable LLC
8 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
9 * @since 1.1.0
10 * @version 1.3.0
11 */
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 if ( ! class_exists( 'Charitable_Stripe_Admin' ) ) :
19
20 /**
21 * Charitable_Stripe_Admin
22 *
23 * @since 1.1.0
24 */
25 class Charitable_Stripe_Admin {
26
27 /**
28 * Single instance of this class.
29 *
30 * @since 1.1.0
31 *
32 * @var Charitable_Stripe_Admin
33 */
34 private static $instance = null;
35
36 /**
37 * Create class object. Private constructor.
38 *
39 * @since 1.1.0
40 */
41 public function __construct() {
42
43 /**
44 * Add a direct link to the Extensions settings page from the plugin row.
45 */
46 if ( class_exists( 'Charitable' ) ) {
47 add_filter( 'plugin_action_links_' . plugin_basename( charitable()->get_path() ), array( $this, 'add_plugin_action_links' ) );
48 }
49
50 /**
51 * Add settings to the Privacy tab.
52 */
53 add_filter( 'charitable_settings_tab_fields_privacy', array( $this, 'add_stripe_privacy_settings' ) );
54
55 /**
56 * When saving Stripe settings, check for webhook if secret key has changed (when you aren't using Stripe Connect AM)
57 */
58 add_filter( 'charitable_save_settings', array( $this, 'save_stripe_settings' ), 10, 3 );
59
60 /**
61 * When connecting Stripe Connect, check for webhook if secret key has changed.
62 */
63 add_action( 'wpcharitable_stripe_account_connected', array( $this, 'update_webhook_upon_connection' ), 10, 1 );
64
65 /**
66 * Run webhook signing secret migration on admin_init.
67 */
68 add_action( 'admin_init', array( $this, 'maybe_migrate_webhook_signing_secrets' ) );
69
70 /**
71 * Add webhook security status to Stripe gateway settings.
72 */
73 add_filter( 'charitable_settings_fields_gateways_gateway_stripe', array( $this, 'add_webhook_security_settings' ), 20 );
74
75 /**
76 * Display admin notice if webhook signature verification failures are detected.
77 */
78 add_action( 'admin_notices', array( $this, 'maybe_show_webhook_failure_notice' ) );
79
80 /**
81 * AJAX handler for refreshing webhook signing secret.
82 */
83 add_action( 'wp_ajax_charitable_refresh_webhook_signing_secret', array( $this, 'ajax_refresh_webhook_signing_secret' ) );
84
85 /**
86 * Add "Sync Pending Donations" UI to Stripe gateway settings.
87 */
88 add_filter( 'charitable_settings_fields_gateways_gateway_stripe', array( $this, 'add_sync_pending_donations_settings' ), 25 );
89
90 /**
91 * AJAX handler for syncing pending Stripe donations.
92 */
93 add_action( 'wp_ajax_charitable_sync_pending_stripe_donations', array( $this, 'ajax_sync_pending_stripe_donations' ) );
94 }
95
96 /**
97 * Create and return the class object.
98 *
99 * @since 1.1.0
100 *
101 * @return Charitable_Stripe_Admin
102 */
103 public static function get_instance() {
104 if ( is_null( self::$instance ) ) {
105 self::$instance = new Charitable_Stripe_Admin();
106 }
107
108 return self::$instance;
109 }
110
111 /**
112 * Add links to activate
113 *
114 * @since 1.1.0
115 *
116 * @param string[] $links Plugin action links.
117 * @return string[]
118 */
119 public function add_plugin_action_links( $links ) {
120 if ( Charitable_Gateways::get_instance()->is_active_gateway( 'stripe' ) ) {
121 // $links[] = '<a href="' . admin_url( 'admin.php?page=charitable-settings&tab=gateways&group=gateways_stripe&default_gateway=true' ) . '">' . __( 'Settings', 'charitable-stripe' ) . '</a>';
122 } else {
123 $activate_url = esc_url(
124 add_query_arg(
125 array(
126 'charitable_action' => 'enable_gateway',
127 'gateway_id' => 'stripe',
128 '_nonce' => wp_create_nonce( 'gateway' ),
129 ),
130 admin_url( 'admin.php?page=charitable-settings&tab=gateways' )
131 )
132 );
133
134 $links[] = '<a href="' . $activate_url . '">' . __( 'Activate Stripe Gateway', 'charitable' ) . '</a>';
135 }
136
137 return $links;
138 }
139
140 /**
141 * Add extra settings to the Privacy tab.
142 *
143 * @since 1.3.0
144 *
145 * @param array $settings The privacy settings.
146 * @return array
147 */
148 public function add_stripe_privacy_settings( $settings ) {
149 if ( array_key_exists( 'data_retention_fields', $settings ) ) {
150 $settings['data_retention_fields']['options']['stripe'] = __( 'Stripe Data', 'charitable' );
151 }
152
153 return $settings;
154 }
155
156 /**
157 * When Stripe settings are saved, maybe run background processes to set hidden settings.
158 *
159 * @since 1.3.0
160 *
161 * @param array $values The submitted values.
162 * @param array $new_values The new settings.
163 * @param array $old_values The previous settings.
164 * @return array
165 */
166 public function save_stripe_settings( $values, $new_values, $old_values ) {
167
168 if ( charitable_is_debug() ) {
169 error_log( 'save_stripe_settings'); // phpcs:ignore
170 error_log( print_r( $values, true ) ); // phpcs:ignore
171 error_log( print_r( $new_values, true ) ); // phpcs:ignore
172 error_log( print_r( $old_values, true ) ); // phpcs:ignore
173 }
174
175 /* Bail early if this is not the Stripe settings page. */
176 if ( ! array_key_exists( 'gateways_stripe', $values ) ) {
177 return $values;
178 }
179
180 /* Bail early if Stripe is not an active gateway */
181 if ( isset( $values['active_gateways'] ) && ! array_key_exists( 'gateways_stripe', $values['active_gateways'] ) ) {
182 return $values;
183 }
184
185 /* Add webhooks unless we're on localhost. */
186 if ( charitable_is_debug() ) {
187 error_log( 'save_stripe_settings midpoint'); // phpcs:ignore
188 }
189
190 // a reminder on the charitable_using_stripe_connect check:
191 // the option gets written when the stripe connect in the core plugin (starting in v1.7.0) is connected in gateway settings in the admin.
192 // the option is removed when, after the stripe connect is connected, the user clicks on the "disconnect" link is clicked in the settings.
193 if ( function_exists( 'charitable_stripe_should_setup_webhooks' ) && charitable_stripe_should_setup_webhooks() && ! charitable_using_stripe_connect() ) {
194 if ( defined( 'CHARITABLE_FORCE_WEBHOOKS_WITHOUT_STRIPE_CONNECT' ) && CHARITABLE_FORCE_WEBHOOKS_WITHOUT_STRIPE_CONNECT ) {
195 if ( charitable_is_debug() ) {
196 error_log( 'charitable_stripe_should_setup_webhooks exists'); // phpcs:ignore
197 error_log( print_r( $values, true ) ); // phpcs:ignore
198 }
199 $values = $this->setup_webhooks( $values, $new_values, $old_values );
200 if ( charitable_is_debug() ) {
201 error_log( print_r( $values, true ) ); // phpcs:ignore
202 error_log( print_r( $new_values, true ) ); // phpcs:ignore
203 error_log( print_r( $old_values, true ) ); // phpcs:ignore
204 }
205 }
206 }
207
208 return $values;
209 }
210
211 /**
212 * When Stripe settings are saved, maybe run background processes to set hidden settings.
213 *
214 * @since 1.3.0
215 *
216 * @param array $account_data The account data.
217 * @return void
218 */
219 public function update_webhook_upon_connection( $account_data ) {
220
221 if ( charitable_is_debug() ) {
222 // phpcs:disable
223 error_log( 'update_webhook_upon_connection 0' );
224 error_log( print_r( charitable_stripe_should_setup_webhooks(), true ) );
225 error_log( print_r( charitable_using_stripe_connect(), true ) );
226 // phpcs:enable
227 }
228
229 // a reminder on the charitable_using_stripe_connect check:
230 // the option gets written when the stripe connect in the core plugin (starting in v1.7.0) is connected in gateway settings in the admin.
231 // the option is removed when, after the stripe connect is connected, the user clicks on the "disconnect" link is clicked in the settings.
232 // The wpcharitable_stripe_account_connected action itself guarantees Stripe Connect
233 // was just established — charitable_using_stripe_connect() is intentionally omitted
234 // here because its option may not yet be written when this action fires.
235 if ( function_exists( 'charitable_stripe_should_setup_webhooks' ) && charitable_stripe_should_setup_webhooks() ) {
236
237 if ( charitable_is_debug() ) {
238 // phpcs:disable
239 error_log( 'update_webhook_upon_connection: refreshing webhooks for fresh signing secrets' );
240 error_log( print_r( $account_data, true ) );
241 // phpcs:enable
242 }
243
244 // On every Stripe Connect (or reconnect), force fresh webhook creation to guarantee
245 // a correct signing secret is stored. refresh_webhook_signing_secret() deletes any
246 // existing webhook on Stripe, creates a new one, and saves both the webhook ID and
247 // signing secret directly to the DB — avoiding any stale-snapshot overwrite risk.
248 // This supersedes the old setup_webhooks() approach for this specific code path.
249 $stripe_settings = charitable_get_option( 'gateways_stripe', array() );
250 $mode_pairs = array(
251 true => 'test_secret_key',
252 false => 'live_secret_key',
253 );
254
255 foreach ( $mode_pairs as $test_mode => $key ) {
256 if ( ! empty( $stripe_settings[ $key ] ) ) {
257 $webhook_api = new Charitable_Stripe_Webhook_API( $test_mode, $stripe_settings[ $key ] );
258 $webhook_api->refresh_webhook_signing_secret();
259 }
260 }
261
262 }
263 }
264
265 /**
266 * Set up webhooks after settings are saved.
267 *
268 * @since 1.4.0
269 *
270 * @param array $values The submitted values.
271 * @param array $new_values The new settings.
272 * @param array $old_values The previous settings.
273 * @return array
274 */
275 private function setup_webhooks( $values, $new_values, $old_values ) {
276 /* Check whether the stripe_update_hidden_settings upgrade has been completed. */
277 $upgrade_log = get_option( 'charitable_stripe_upgrade_log' );
278 $upgrade_done = is_array( $upgrade_log ) && array_key_exists( 'stripe_update_hidden_settings', $upgrade_log );
279
280 $old_settings = $old_values['gateways_stripe'];
281 $new_settings = $values['gateways_stripe'];
282
283 $setting_pairs = array(
284 'test_secret_key' => true,
285 'live_secret_key' => false,
286 );
287
288 foreach ( $setting_pairs as $setting_key => $test_mode ) {
289
290 $old = isset( $old_settings[ $setting_key ] ) ? trim( $old_settings[ $setting_key ] ) : false;
291 $new = trim( $new_settings[ $setting_key ] );
292
293 /* The secret key is unchanged and the upgrade is done, so no need to do anything. */
294 if ( $old == $new && $upgrade_done ) {
295 if ( charitable_is_debug() ) {
296 // phpcs:disable
297 error_log( 'key unchanged' );
298 error_log( 'old:' );
299 error_log( print_r( $old, true ) );
300 error_log( 'new:' );
301 error_log( print_r( $new, true ) );
302 // phpcs:enable
303 }
304 }
305
306 if ( charitable_is_debug() ) {
307 // phpcs:disable
308 error_log( 'old:' );
309 error_log( print_r( $old, true ) );
310 error_log( 'new:' );
311 error_log( print_r( $new, true ) );
312 // phpcs:enable
313 }
314
315 /* If the secret key has changed, deactivate the previously stored webhook. */
316 if ( $old != $new ) {
317 $webhook_api = new Charitable_Stripe_Webhook_API( $test_mode, $old );
318 $webhook_api->deactivate_webhook();
319 }
320
321 /* If the new secret key is blank, set webhook_id to false. */
322 if ( '' == $new && isset( $webhook_api->setting_key ) ) {
323 $values['gateways_stripe'][ $webhook_api->setting_key ] = false;
324 continue;
325 }
326
327 /* Finally, if we're still here, add a webhook using the new secret key. */
328 $webhook_api = new Charitable_Stripe_Webhook_API( $test_mode, $new );
329
330 if ( charitable_is_debug() ) {
331 // phpcs:disable
332 error_log( 'webhook_api here' );
333 error_log( print_r( $webhook_api, true ) );
334 // phpcs:enable
335 }
336
337 /* First, check if we have a webhook. */
338 $webhook = $webhook_api->get_webhook();
339
340 if ( charitable_is_debug() ) {
341 // phpcs:disable
342 error_log( 'webhook here' );
343 error_log( print_r( $webhook, true ) );
344 // phpcs:enable
345 }
346
347 /* We don't have a webhook, so create one. */
348 if ( ! $webhook ) {
349 // phpcs:disable
350 error_log( 'add webhook We do not have a webhook, so create one.' );
351 $webhook_id = $webhook_api->add_webhook();
352 if ( charitable_is_debug( 'stripe' ) ) {
353 error_log( 'add webhook' );
354 }
355 // phpcs:enable
356
357 /* add_webhook() saves the signing secret directly to the DB. Sync it back
358 * into $values now so a subsequent update_option() call with the stale
359 * snapshot does not overwrite the newly-saved secret. */
360 $signing_secret_key = $test_mode ? 'test_webhook_signing_secret' : 'live_webhook_signing_secret';
361 $fresh_settings = get_option( 'charitable_settings', array() );
362 if ( ! empty( $fresh_settings['gateways_stripe'][ $signing_secret_key ] ) ) {
363 $values['gateways_stripe'][ $signing_secret_key ] = $fresh_settings['gateways_stripe'][ $signing_secret_key ];
364 }
365 } else {
366 /* We have a webhook, but it needs to be updated. */
367 if ( $webhook_api->webhook_needs_update( $webhook ) ) {
368 $webhook_api->update_webhook();
369 }
370 $webhook_id = $webhook->id;
371 if ( charitable_is_debug() ) {
372 // phpcs:disable
373 error_log( print_r( $webhook_id, true ) );
374 // phpcs:enable
375 }
376 }
377
378 if ( charitable_is_debug() ) {
379 // phpcs:disable
380 error_log( 'final testing data' );
381 error_log( print_r( $webhook_api->setting_key, true ) );
382 error_log( print_r( $webhook_id, true ) );
383 // phpcs:enable
384 }
385
386 $values['gateways_stripe'][ $webhook_api->setting_key ] = $webhook_id;
387
388 if ( charitable_is_debug() ) {
389 // phpcs:disable
390 error_log( 'values gateways_stripe updated' );
391 error_log( print_r( $values, true ) );
392 // phpcs:enable
393 }
394 }
395
396 /* Mark the upgrade as done. */
397 if ( ! $upgrade_done ) {
398 if ( ! is_array( $upgrade_log ) ) {
399 $upgrade_log = array();
400 }
401
402 $upgrade_log['stripe_update_hidden_settings'] = array(
403 'time' => time(),
404 'version' => charitable_stripe()->get_version(),
405 );
406
407 update_option( 'charitable_stripe_upgrade_log', $upgrade_log );
408 }
409
410 return $values;
411 }
412
413 /**
414 * Migration: Fetch and store webhook signing secrets for existing webhooks.
415 *
416 * Runs once on upgrade to 1.8.9.8. Re-creates existing webhooks to obtain
417 * and store the signing secret for cryptographic signature verification.
418 *
419 * @since 1.8.9.8
420 *
421 * @return void
422 */
423 public function maybe_migrate_webhook_signing_secrets() {
424 // Never run during AJAX requests — this migration makes live Stripe API calls
425 // and must only run during a real admin page load.
426 if ( wp_doing_ajax() ) {
427 return;
428 }
429
430 if ( ! current_user_can( 'manage_options' ) ) {
431 return;
432 }
433
434 // Stripe webhook classes are only loaded when Stripe is an active gateway.
435 if ( ! class_exists( 'Charitable_Stripe_Webhook_API' ) ) {
436 return;
437 }
438
439 // Charitable_Gateways must be available before calling is_active_gateway().
440 if ( ! class_exists( 'Charitable_Gateways' ) ) {
441 return;
442 }
443
444 $upgrade_log = get_option( 'charitable_stripe_upgrade_log', array() );
445 $upgrade_done = is_array( $upgrade_log ) && array_key_exists( 'stripe_webhook_signing_secrets', $upgrade_log );
446
447 if ( $upgrade_done ) {
448 // Retry if migration previously failed.
449 $needs_retry = (bool) get_transient( 'charitable_stripe_signing_secret_migration_failed' );
450
451 if ( $needs_retry ) {
452 // Throttle retries — don't hammer the Stripe API on every page load.
453 if ( get_transient( 'charitable_stripe_migration_retry_cooldown' ) ) {
454 return;
455 }
456 }
457
458 if ( ! $needs_retry ) {
459 // Also retry if the current mode has API keys but no signing secret for the
460 // direct webhook. Connect webhook failures are non-blocking.
461 $test_mode = charitable_get_option( 'test_mode', false );
462 $check = new Charitable_Stripe_Webhook_API( $test_mode, null, false );
463 if ( $check->has_api_key() && ! $check->has_signing_secret() ) {
464 $needs_retry = true;
465 }
466 }
467
468 if ( ! $needs_retry ) {
469 return;
470 }
471
472 unset( $upgrade_log['stripe_webhook_signing_secrets'] );
473 update_option( 'charitable_stripe_upgrade_log', $upgrade_log );
474 }
475
476 // Check if Stripe gateway is active.
477 if ( ! Charitable_Gateways::get_instance()->is_active_gateway( 'stripe' ) ) {
478 // Mark as done — no Stripe gateway active, nothing to migrate.
479 $this->mark_signing_secret_migration_done( $upgrade_log );
480 return;
481 }
482
483 // Attempt to refresh signing secrets for all webhook configurations.
484 $configurations = array(
485 array( 'test_mode' => true, 'connect' => false ),
486 array( 'test_mode' => true, 'connect' => true ),
487 array( 'test_mode' => false, 'connect' => false ),
488 array( 'test_mode' => false, 'connect' => true ),
489 );
490
491 $any_refreshed = false;
492 $any_failed = false;
493 $any_connect_failed = false;
494
495 foreach ( $configurations as $config ) {
496 $webhook_api = new Charitable_Stripe_Webhook_API( $config['test_mode'], null, $config['connect'] );
497
498 // Skip if we already have a signing secret for this configuration.
499 if ( $webhook_api->has_signing_secret() ) {
500 continue;
501 }
502
503 // Skip if no API keys are configured for this mode — nothing to connect to.
504 if ( ! $webhook_api->has_api_key() ) {
505 continue;
506 }
507
508 $result = $webhook_api->refresh_webhook_signing_secret();
509
510 if ( $result ) {
511 $any_refreshed = true;
512 } elseif ( $config['connect'] ) {
513 // Connect webhook failures are non-blocking — not all sites use Stripe Connect
514 // as a platform. Don't count these as migration failures.
515 $any_connect_failed = true;
516 } else {
517 $any_failed = true;
518 }
519 }
520
521 // Only set failure transient for direct webhook failures, not connect webhook failures.
522 if ( $any_failed ) {
523 // Set a cooldown so failed migrations don't retry on every page load.
524 set_transient( 'charitable_stripe_migration_retry_cooldown', true, HOUR_IN_SECONDS );
525 // Store a flag so the admin warning can reference it.
526 set_transient( 'charitable_stripe_signing_secret_migration_failed', true, DAY_IN_SECONDS * 30 );
527 } else {
528 delete_transient( 'charitable_stripe_migration_retry_cooldown' );
529 // No direct failures — clear any stale failure transient so the notice goes away.
530 delete_transient( 'charitable_stripe_signing_secret_migration_failed' );
531 }
532
533 $this->mark_signing_secret_migration_done( $upgrade_log );
534 }
535
536 /**
537 * Mark the signing secret migration as completed.
538 *
539 * @since 1.8.9.8
540 *
541 * @param array $upgrade_log The current upgrade log.
542 * @return void
543 */
544 private function mark_signing_secret_migration_done( $upgrade_log ) {
545 if ( ! is_array( $upgrade_log ) ) {
546 $upgrade_log = array();
547 }
548
549 $upgrade_log['stripe_webhook_signing_secrets'] = array(
550 'time' => time(),
551 'version' => charitable()->get_version(),
552 );
553
554 update_option( 'charitable_stripe_upgrade_log', $upgrade_log );
555 }
556
557 /**
558 * Add webhook security status fields to the Stripe settings page.
559 *
560 * Shows an inline warning if no signing secret is stored, and a button
561 * to manually refresh the signing secret.
562 *
563 * @since 1.8.9.8
564 *
565 * @param array $settings The current Stripe settings fields.
566 * @return array
567 */
568 public function add_webhook_security_settings( $settings ) {
569 $test_mode = charitable_get_option( 'test_mode', false );
570 $has_secret = false;
571
572 // Check if a signing secret exists for the current mode.
573 $secret_keys = $test_mode
574 ? array( 'test_connect_webhook_signing_secret', 'test_webhook_signing_secret' )
575 : array( 'live_connect_webhook_signing_secret', 'live_webhook_signing_secret' );
576
577 foreach ( $secret_keys as $key ) {
578 if ( ! empty( charitable_get_option( array( 'gateways_stripe', $key ) ) ) ) {
579 $has_secret = true;
580 break;
581 }
582 }
583
584 // Only show if Stripe is connected.
585 $gateway = new Charitable_Gateway_Stripe_AM();
586 if ( ! $gateway->maybe_stripe_connected() ) {
587 return $settings;
588 }
589
590 $nonce = wp_create_nonce( 'charitable_refresh_webhook_signing_secret' );
591 $ajax_url = admin_url( 'admin-ajax.php' );
592
593 if ( ! $has_secret ) {
594 $settings['webhook_security_warning'] = array(
595 'type' => 'content',
596 'title' => __( 'Webhook Security', 'charitable' ),
597 'priority' => 50,
598 'content' => '<div class="charitable-inline-notice warning">'
599 . '<p><strong>' . esc_html__( 'Webhook signature verification is not configured.', 'charitable' ) . '</strong></p>'
600 . '<p>' . esc_html__( 'Your Stripe webhooks are currently verified using a fallback method. For stronger security, click the button below to enable cryptographic signature verification.', 'charitable' ) . '</p>'
601 . '<p><button type="button" class="button" id="charitable-refresh-webhook-secret" data-nonce="' . esc_attr( $nonce ) . '" data-ajax-url="' . esc_url( $ajax_url ) . '">'
602 . esc_html__( 'Enable Webhook Signature Verification', 'charitable' )
603 . '</button> <span class="spinner" style="float:none;"></span></p>'
604 . '<p class="charitable-webhook-secret-result" style="display:none;"></p>'
605 . '</div>',
606 );
607 } else {
608 $settings['webhook_security_status'] = array(
609 'type' => 'content',
610 'title' => __( 'Webhook Security', 'charitable' ),
611 'priority' => 50,
612 'content' => '<div class="charitable-inline-notice info">'
613 . '<p>' . esc_html__( 'Webhook signature verification is active. Incoming Stripe webhooks are cryptographically verified.', 'charitable' ) . '</p>'
614 . '<p><button type="button" class="button button-link" id="charitable-refresh-webhook-secret" data-nonce="' . esc_attr( $nonce ) . '" data-ajax-url="' . esc_url( $ajax_url ) . '">'
615 . esc_html__( 'Refresh Signing Secret', 'charitable' )
616 . '</button> <span class="spinner" style="float:none;"></span></p>'
617 . '<p class="charitable-webhook-secret-result" style="display:none;"></p>'
618 . '</div>',
619 );
620 }
621
622 // Add inline JS for the refresh button.
623 $settings['webhook_security_script'] = array(
624 'type' => 'content',
625 'title' => '',
626 'priority' => 51,
627 'content' => '<script>
628 jQuery(function($) {
629 $("#charitable-refresh-webhook-secret").on("click", function(e) {
630 e.preventDefault();
631 var $btn = $(this),
632 $spinner = $btn.next(".spinner"),
633 $result = $btn.closest("div").find(".charitable-webhook-secret-result");
634
635 $btn.prop("disabled", true);
636 $spinner.addClass("is-active");
637 $result.hide();
638
639 $.post($btn.data("ajax-url"), {
640 action: "charitable_refresh_webhook_signing_secret",
641 _wpnonce: $btn.data("nonce")
642 }, function(response) {
643 $spinner.removeClass("is-active");
644 $btn.prop("disabled", false);
645 $result.show();
646 if (response.success) {
647 $result.empty().append($("<span>").text(response.data.message).css("color", "green"));
648 if (response.data.reload) {
649 setTimeout(function() { location.reload(); }, 1500);
650 }
651 } else {
652 $result.empty().append($("<span>").text(response.data.message).css("color", "red"));
653 }
654 }).fail(function() {
655 $spinner.removeClass("is-active");
656 $btn.prop("disabled", false);
657 $result.show().empty().append($("<span>").text("' . esc_js( __( 'Request failed. Please try again.', 'charitable' ) ) . '").css("color", "red"));
658 });
659 });
660 });
661 </script>',
662 );
663
664 return $settings;
665 }
666
667 /**
668 * Display admin notice when repeated webhook signature verification failures are detected.
669 *
670 * @since 1.8.9.8
671 *
672 * @return void
673 */
674 public function maybe_show_webhook_failure_notice() {
675 if ( ! current_user_can( 'manage_charitable_settings' ) ) {
676 return;
677 }
678
679 $failure_count = (int) get_transient( 'charitable_stripe_webhook_verification_failures' );
680
681 if ( $failure_count >= 5 ) {
682 $settings_url = admin_url( 'admin.php?page=charitable-settings&tab=gateways&group=gateways_stripe' );
683 ?>
684 <div class="notice notice-warning">
685 <p>
686 <strong><?php esc_html_e( 'Charitable: Stripe Webhook Notice', 'charitable' ); ?></strong>
687 </p>
688 <p>
689 <?php
690 printf(
691 /* translators: %1$d: number of failures, %2$s: opening Stripe settings link tag, %3$s: closing link tag, %4$s: opening documentation link tag, %5$s: closing link tag. */
692 esc_html__( '%1$d webhook events failed signature verification in the last 24 hours. This often happens after reconnecting Stripe or refreshing your account. If the issue persists, verify your signing secret in your %2$sStripe settings%3$s. %4$sLearn more%5$s.', 'charitable' ),
693 $failure_count,
694 '<a href="' . esc_url( $settings_url ) . '">',
695 '</a>',
696 '<a href="https://www.wpcharitable.com/documentation/stripe-webhook-setup-and-troubleshooting-in-charitable/" target="_blank">',
697 '</a>'
698 );
699 ?>
700 </p>
701 </div>
702 <?php
703 }
704
705 // Also show migration failure notice.
706 if ( get_transient( 'charitable_stripe_signing_secret_migration_failed' ) ) {
707 // Before showing the notice, verify that a signing secret is genuinely missing.
708 // Use the same check as add_webhook_security_settings() so the notice is always
709 // consistent with the "active" status widget on the settings page: if ANY secret
710 // (connect or direct) is stored, the UI shows "active" and the failure notice
711 // must not appear simultaneously.
712 $show_notice = true;
713 $test_mode = charitable_get_option( 'test_mode', false );
714
715 $secret_keys = $test_mode
716 ? array( 'test_connect_webhook_signing_secret', 'test_webhook_signing_secret' )
717 : array( 'live_connect_webhook_signing_secret', 'live_webhook_signing_secret' );
718
719 foreach ( $secret_keys as $key ) {
720 if ( ! empty( charitable_get_option( array( 'gateways_stripe', $key ) ) ) ) {
721 delete_transient( 'charitable_stripe_signing_secret_migration_failed' );
722 $show_notice = false;
723 break;
724 }
725 }
726
727 if ( $show_notice ) {
728 $settings_url = admin_url( 'admin.php?page=charitable-settings&tab=gateways&group=gateways_stripe' );
729 ?>
730 <div class="notice notice-warning is-dismissible">
731 <p>
732 <strong><?php esc_html_e( 'Charitable: Stripe Webhook Security Setup', 'charitable' ); ?></strong>
733 </p>
734 <p>
735 <?php
736 printf(
737 /* translators: %1$s: opening link tag, %2$s: closing link tag. */
738 esc_html__( 'Charitable was unable to automatically configure webhook signature verification for Stripe. Please visit your %1$sStripe settings%2$s and click "Enable Webhook Signature Verification" to secure your webhook endpoint.', 'charitable' ),
739 '<a href="' . esc_url( $settings_url ) . '">',
740 '</a>'
741 );
742 ?>
743 </p>
744 </div>
745 <?php
746 }
747 }
748 }
749
750 /**
751 * AJAX handler for refreshing the webhook signing secret.
752 *
753 * @since 1.8.9.8
754 *
755 * @return void
756 */
757 public function ajax_refresh_webhook_signing_secret() {
758 check_ajax_referer( 'charitable_refresh_webhook_signing_secret' );
759
760 if ( ! current_user_can( 'manage_charitable_settings' ) ) {
761 wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'charitable' ) ) );
762 }
763
764 $test_mode = charitable_get_option( 'test_mode', false );
765
766 // Webhooks are always registered as direct (non-connect-application) webhooks
767 // by setup_webhooks(). Never pass connect_application=true here, regardless of
768 // whether the site authenticated via Stripe Connect.
769 $webhook_api = new Charitable_Stripe_Webhook_API( $test_mode, null, false );
770 $result = $webhook_api->refresh_webhook_signing_secret();
771
772 if ( $result ) {
773 // Clear the migration failure transient if it exists.
774 delete_transient( 'charitable_stripe_signing_secret_migration_failed' );
775 // Clear the failure counter — signing secret is fresh, past failures are no longer relevant.
776 delete_transient( 'charitable_stripe_webhook_verification_failures' );
777
778 wp_send_json_success(
779 array(
780 'message' => __( 'Webhook signature verification has been enabled successfully. The page will reload.', 'charitable' ),
781 'reload' => true,
782 )
783 );
784 } else {
785 wp_send_json_error(
786 array(
787 'message' => __( 'Unable to configure webhook signature verification. Please ensure your Stripe API keys are valid and try again, or disconnect and reconnect your Stripe account.', 'charitable' ),
788 )
789 );
790 }
791 }
792
793 /**
794 * Add "Sync Pending Donations" UI block to the Stripe gateway settings page.
795 *
796 * Renders a dry-run/live button that checks Stripe for the status of pending
797 * donations made in the last 30 days and marks them completed when Stripe
798 * shows payment succeeded. Processes donations in batches of 5 to avoid
799 * proxy timeouts on sites with many pending donations.
800 *
801 * @since 1.8.10.2
802 * @version 1.8.10.3
803 *
804 * @param array $settings The current Stripe settings fields.
805 * @return array
806 */
807 public function add_sync_pending_donations_settings( $settings ) {
808 // Only show if Stripe is connected.
809 $gateway = new Charitable_Gateway_Stripe_AM();
810 if ( ! $gateway->maybe_stripe_connected() ) {
811 return $settings;
812 }
813
814 $nonce = wp_create_nonce( 'charitable_sync_pending_stripe_donations' );
815 $ajax_url = admin_url( 'admin-ajax.php' );
816
817 $settings['sync_pending_donations'] = array(
818 'type' => 'content',
819 'title' => __( 'Sync Pending Donations', 'charitable' ),
820 'priority' => 55,
821 'content' => '<div class="charitable-inline-notice info">'
822 . '<p>' . esc_html__( 'Use this tool if donations are stuck in Pending due to a webhook issue. It checks Stripe for the last 30 days and marks any confirmed payments as completed.', 'charitable' ) . '</p>'
823 . '<p><label><input type="checkbox" id="charitable-sync-dry-run" checked> '
824 . esc_html__( 'Dry Run (preview only, no changes made)', 'charitable' ) . '</label></p>'
825 . '<p><label><input type="checkbox" id="charitable-sync-send-emails"> '
826 . esc_html__( 'Send receipt emails for completed donations', 'charitable' ) . '</label></p>'
827 . '<p><button type="button" class="button" id="charitable-sync-pending-donations" data-nonce="' . esc_attr( $nonce ) . '" data-ajax-url="' . esc_url( $ajax_url ) . '">'
828 . esc_html__( 'Sync Pending Donations', 'charitable' )
829 . '</button> <span class="charitable-sync-spinner spinner" style="float:none;"></span></p>'
830 . '<p class="charitable-sync-result" style="display:none;"></p>'
831 . '</div>',
832 );
833
834 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
835 $settings['sync_pending_donations_script'] = array(
836 'type' => 'content',
837 'title' => '',
838 'priority' => 56,
839 'content' => '<script>
840 jQuery(function($) {
841 $("#charitable-sync-pending-donations").on("click", function(e) {
842 e.preventDefault();
843 var $btn = $(this),
844 $spinner = $btn.siblings(".charitable-sync-spinner"),
845 $result = $btn.closest("div").find(".charitable-sync-result"),
846 dry_run = $("#charitable-sync-dry-run").is(":checked") ? 1 : 0,
847 send_emails = $("#charitable-sync-send-emails").is(":checked") ? 1 : 0,
848 ajaxUrl = $btn.data("ajax-url"),
849 nonce = $btn.data("nonce");
850
851 $btn.prop("disabled", true);
852 $spinner.addClass("is-active");
853 $result.hide();
854
855 function runBatch(ids_to_process, remaining_ids, total_found, acc) {
856 var data = {
857 action: "charitable_sync_pending_stripe_donations",
858 _wpnonce: nonce,
859 dry_run: dry_run,
860 send_emails: send_emails,
861 ids_to_process: ids_to_process,
862 remaining_ids: remaining_ids,
863 total_found: total_found
864 };
865 if (dry_run) {
866 data.acc_would_update = acc.would_update;
867 data.acc_total_amount = acc.total_amount;
868 data.acc_earliest_ts = acc.earliest_ts;
869 data.acc_latest_ts = acc.latest_ts;
870 } else {
871 data.acc_updated = acc.updated;
872 data.acc_skipped = acc.skipped;
873 data.acc_errors = acc.errors;
874 }
875 $.post(ajaxUrl, data, function(response) {
876 if (!response.success) {
877 $spinner.removeClass("is-active");
878 $btn.prop("disabled", false);
879 $result.show().html("<span style=\"color:red;\">" + response.data.message + "</span>");
880 return;
881 }
882 var d = response.data;
883 if (d.has_more) {
884 var next_acc;
885 if (dry_run) {
886 var bE = d.batch_earliest_ts || 0, bL = d.batch_latest_ts || 0;
887 next_acc = {
888 would_update: acc.would_update + (d.batch_would_update || 0),
889 total_amount: acc.total_amount + (d.batch_total_amount || 0),
890 earliest_ts: (acc.earliest_ts === 0) ? bE : (bE === 0 ? acc.earliest_ts : Math.min(acc.earliest_ts, bE)),
891 latest_ts: Math.max(acc.latest_ts, bL)
892 };
893 } else {
894 next_acc = {
895 updated: acc.updated + (d.updated || 0),
896 skipped: acc.skipped + (d.skipped || 0),
897 errors: acc.errors + (d.errors || 0)
898 };
899 }
900 $result.show().html("<span style=\"color:#666;\">" + d.message + "</span>");
901 runBatch(d.next_ids, d.next_remaining, d.total_found, next_acc);
902 } else {
903 $spinner.removeClass("is-active");
904 $btn.prop("disabled", false);
905 $result.show().html("<span style=\"color:green;\">" + d.message + "</span>");
906 }
907 }).fail(function() {
908 $spinner.removeClass("is-active");
909 $btn.prop("disabled", false);
910 $result.show().html("<span style=\"color:red;\">Request failed. Please try again.</span>");
911 });
912 }
913
914 var initialAcc = dry_run
915 ? { would_update: 0, total_amount: 0, earliest_ts: 0, latest_ts: 0 }
916 : { updated: 0, skipped: 0, errors: 0 };
917 runBatch([], [], 0, initialAcc);
918 });
919 });
920 </script>',
921 );
922 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
923
924 return $settings;
925 }
926
927 /**
928 * AJAX handler: check recent pending Stripe donations against the Stripe API
929 * and mark as completed any where the PaymentIntent status is "succeeded".
930 *
931 * Supports dry-run mode (preview with no DB writes), suppressing receipt
932 * emails, and batched processing (5 donations per request) to avoid proxy
933 * timeouts on sites with many pending donations.
934 *
935 * @since 1.8.10.2
936 * @version 1.8.10.3
937 *
938 * @return void
939 */
940 public function ajax_sync_pending_stripe_donations() {
941 check_ajax_referer( 'charitable_sync_pending_stripe_donations' );
942
943 if ( charitable_is_debug() ) {
944 error_log( '[charitable_sync_pending_stripe_donations] AJAX handler fired.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
945 }
946
947 if ( ! current_user_can( 'manage_charitable_settings' ) ) {
948 if ( charitable_is_debug() ) {
949 error_log( '[charitable_sync_pending_stripe_donations] Permission check failed.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
950 }
951 wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'charitable' ) ) );
952 return;
953 }
954
955 $dry_run = ! empty( $_POST['dry_run'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
956 $send_emails = ! empty( $_POST['send_emails'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
957
958 // Set up the Stripe API with the current mode's secret key.
959 $gateway = new Charitable_Gateway_Stripe_AM();
960 if ( ! $gateway->setup_api() ) {
961 if ( charitable_is_debug() ) {
962 error_log( '[charitable_sync_pending_stripe_donations] setup_api() failed — no valid API key.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
963 }
964 wp_send_json_error( array( 'message' => __( 'Unable to connect to Stripe. Please check that your API keys are configured correctly.', 'charitable' ) ) );
965 return;
966 }
967
968 // Cap each individual Stripe API call at 10 seconds so a slow or unresponsive
969 // Stripe endpoint cannot cause the entire AJAX batch request to time out.
970 // The SDK default is 80 seconds — far too long for a synchronous admin request.
971 \Stripe\HttpClient\CurlClient::instance()->setTimeout( 10 );
972
973 /**
974 * Filter the number of days back to look for pending Stripe donations.
975 *
976 * @since 1.8.10.2
977 *
978 * @param int $days Number of days. Default 30.
979 */
980 $days = (int) apply_filters( 'charitable_sync_pending_stripe_donations_days', 30 );
981 $days = max( 1, $days );
982
983 // Batch size: number of Stripe API calls per AJAX request.
984 $batch_size = 5;
985
986 /**
987 * Filter the maximum number of pending Stripe donations to check per sync run.
988 *
989 * @since 1.8.10.2
990 *
991 * @param int $limit Maximum number. Default 50.
992 */
993 $sync_limit = (int) apply_filters( 'charitable_sync_pending_stripe_donations_limit', 50 );
994 $sync_limit = max( 1, $sync_limit );
995
996 // -------------------------------------------------------------------------
997 // Determine the IDs to process this batch.
998 // On the first call ids_to_process is empty — query the DB.
999 // On subsequent calls JS sends us the exact IDs to process.
1000 // -------------------------------------------------------------------------
1001 if ( ! empty( $_POST['ids_to_process'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1002 $ids_to_process = array_map( 'absint', (array) $_POST['ids_to_process'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1003 $remaining_ids = ! empty( $_POST['remaining_ids'] ) ? array_map( 'absint', (array) $_POST['remaining_ids'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1004 $total_found = max( count( $ids_to_process ) + count( $remaining_ids ), absint( $_POST['total_found'] ?? 0 ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1005 } else {
1006 if ( charitable_is_debug() ) {
1007 error_log( '[charitable_sync_pending_stripe_donations] Stripe API initialized. Mode: ' . ( $dry_run ? 'DRY RUN' : 'LIVE' ) . '. Querying pending donations (last ' . $days . ' days).' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1008 }
1009
1010 $query = new WP_Query(
1011 array(
1012 'post_type' => Charitable::DONATION_POST_TYPE,
1013 'post_status' => 'charitable-pending',
1014 'posts_per_page' => $sync_limit,
1015 'fields' => 'ids',
1016 'no_found_rows' => true,
1017 'date_query' => array(
1018 array(
1019 'after' => $days . ' days ago',
1020 'inclusive' => true,
1021 ),
1022 ),
1023 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1024 array(
1025 'key' => '_stripe_payment_intent',
1026 'value' => '',
1027 'compare' => '!=',
1028 ),
1029 ),
1030 )
1031 );
1032
1033 $all_ids = $query->posts;
1034
1035 if ( charitable_is_debug() ) {
1036 error_log( '[charitable_sync_pending_stripe_donations] Query found ' . count( $all_ids ) . ' pending donation(s): ' . implode( ', ', $all_ids ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1037 }
1038
1039 if ( empty( $all_ids ) ) {
1040 wp_send_json_success( array(
1041 'message' => sprintf(
1042 /* translators: %d: number of days */
1043 __( 'No pending Stripe donations found in the last %d days.', 'charitable' ),
1044 $days
1045 ),
1046 'has_more' => false,
1047 ) );
1048 return;
1049 }
1050
1051 $ids_to_process = array_slice( $all_ids, 0, $batch_size );
1052 $remaining_ids = array_slice( $all_ids, $batch_size );
1053 $total_found = count( $all_ids );
1054 }
1055
1056 $has_more = ! empty( $remaining_ids );
1057 $next_ids = array_slice( $remaining_ids, 0, $batch_size );
1058 $next_remaining = array_slice( $remaining_ids, $batch_size );
1059
1060 // Number of donations we will have processed after this batch completes.
1061 $processed_so_far = $total_found - count( $remaining_ids );
1062
1063 // -------------------------------------------------------------------------
1064 // Accumulated stats from previous batches (sent back by JS each call).
1065 // -------------------------------------------------------------------------
1066 if ( $dry_run ) {
1067 $acc_would_update = absint( $_POST['acc_would_update'] ?? 0 ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1068 $acc_total_amount = max( 0.0, (float) ( $_POST['acc_total_amount'] ?? 0 ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1069 $acc_earliest_ts = absint( $_POST['acc_earliest_ts'] ?? 0 ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1070 $acc_latest_ts = absint( $_POST['acc_latest_ts'] ?? 0 ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1071 } else {
1072 $acc_updated = absint( $_POST['acc_updated'] ?? 0 ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1073 $acc_skipped = absint( $_POST['acc_skipped'] ?? 0 ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1074 $acc_errors = absint( $_POST['acc_errors'] ?? 0 ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1075 }
1076
1077 // =========================================================================
1078 // DRY RUN — check Stripe, accumulate stats, no DB writes, no log entries.
1079 // =========================================================================
1080 if ( $dry_run ) {
1081 $batch_would_update = 0;
1082 $batch_total_amount = 0.0;
1083 $batch_earliest_ts = 0;
1084 $batch_latest_ts = 0;
1085
1086 foreach ( $ids_to_process as $donation_id ) {
1087 $intent_id = get_post_meta( $donation_id, '_stripe_payment_intent', true );
1088 if ( empty( $intent_id ) ) {
1089 continue;
1090 }
1091
1092 $options = array();
1093 $account_id = get_post_meta( $donation_id, '_stripe_account_id', true );
1094 if ( ! empty( $account_id ) ) {
1095 $options['stripe_account'] = $account_id;
1096 }
1097
1098 try {
1099 $intent = \Stripe\PaymentIntent::retrieve( $intent_id, $options );
1100
1101 if ( charitable_is_debug() ) {
1102 error_log( '[charitable_sync_pending_stripe_donations] Dry run — Donation #' . $donation_id . ' PaymentIntent status: ' . $intent->status ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1103 }
1104
1105 if ( 'succeeded' === $intent->status ) {
1106 $batch_would_update++;
1107 $donation = charitable_get_donation( $donation_id );
1108 if ( $donation ) {
1109 $batch_total_amount += (float) $donation->get_total_donation_amount();
1110 }
1111 $timestamp = strtotime( (string) get_post_field( 'post_date', $donation_id ) );
1112 if ( false !== $timestamp ) {
1113 if ( 0 === $batch_earliest_ts || $timestamp < $batch_earliest_ts ) {
1114 $batch_earliest_ts = $timestamp;
1115 }
1116 if ( $timestamp > $batch_latest_ts ) {
1117 $batch_latest_ts = $timestamp;
1118 }
1119 }
1120 }
1121 } catch ( \Exception $e ) {
1122 if ( charitable_is_debug() ) {
1123 error_log( '[charitable_sync_pending_stripe_donations] Dry run — Donation #' . $donation_id . ' Stripe API error: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1124 }
1125 }
1126 }
1127
1128 $total_would_update = $acc_would_update + $batch_would_update;
1129 $total_amount = $acc_total_amount + $batch_total_amount;
1130 $earliest_ts = ( 0 === $acc_earliest_ts ) ? $batch_earliest_ts : ( ( 0 === $batch_earliest_ts ) ? $acc_earliest_ts : min( $acc_earliest_ts, $batch_earliest_ts ) );
1131 $latest_ts = max( $acc_latest_ts, $batch_latest_ts );
1132
1133 if ( $has_more ) {
1134 $message = sprintf(
1135 /* translators: 1: checked count, 2: total count */
1136 __( 'Dry Run: Checking donations... (%1$d of %2$d checked)', 'charitable' ),
1137 $processed_so_far,
1138 $total_found
1139 );
1140 } elseif ( 0 === $total_would_update ) {
1141 $message = __( 'Dry Run: No pending donations were found with a confirmed payment in Stripe. No changes were made.', 'charitable' );
1142 } else {
1143 $message = sprintf(
1144 /* translators: 1: count, 2: formatted total, 3: earliest date, 4: latest date */
1145 __( 'Dry Run: Found %1$d donation(s) that would be updated, totaling %2$s. Date range: %3$s – %4$s. No changes were made. Uncheck "Dry Run" and click again to process.', 'charitable' ),
1146 $total_would_update,
1147 charitable_format_money( $total_amount ),
1148 date_i18n( 'M j', $earliest_ts ),
1149 date_i18n( 'M j', $latest_ts )
1150 );
1151 }
1152
1153 wp_send_json_success( array(
1154 'message' => $message,
1155 'has_more' => $has_more,
1156 'next_ids' => $next_ids,
1157 'next_remaining' => $next_remaining,
1158 'total_found' => $total_found,
1159 'batch_would_update' => $batch_would_update,
1160 'batch_total_amount' => $batch_total_amount,
1161 'batch_earliest_ts' => $batch_earliest_ts,
1162 'batch_latest_ts' => $batch_latest_ts,
1163 ) );
1164 return;
1165 }
1166
1167 // =========================================================================
1168 // LIVE RUN — update statuses and write donation log entries.
1169 // =========================================================================
1170 if ( ! $send_emails ) {
1171 remove_action( 'charitable-completed_donation', array( 'Charitable_Email_Donation_Receipt', 'send_with_donation_id' ) );
1172 remove_action( 'charitable-completed_donation', array( 'Charitable_Email_New_Donation', 'send_with_donation_id' ) );
1173 }
1174
1175 $updated = 0;
1176 $skipped = 0;
1177 $errors = 0;
1178
1179 foreach ( $ids_to_process as $donation_id ) {
1180 $intent_id = get_post_meta( $donation_id, '_stripe_payment_intent', true );
1181
1182 if ( empty( $intent_id ) ) {
1183 if ( charitable_is_debug() ) {
1184 error_log( '[charitable_sync_pending_stripe_donations] Donation #' . $donation_id . ' — no PaymentIntent meta, skipping.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1185 }
1186 $donation = charitable_get_donation( $donation_id );
1187 if ( $donation ) {
1188 $donation->log()->add( __( 'Stripe Sync: No PaymentIntent ID found — skipped.', 'charitable' ) );
1189 }
1190 $skipped++;
1191 continue;
1192 }
1193
1194 $options = array();
1195 $account_id = get_post_meta( $donation_id, '_stripe_account_id', true );
1196 if ( ! empty( $account_id ) ) {
1197 $options['stripe_account'] = $account_id;
1198 }
1199
1200 if ( charitable_is_debug() ) {
1201 error_log( '[charitable_sync_pending_stripe_donations] Donation #' . $donation_id . ' — retrieving PaymentIntent ' . $intent_id . ( ! empty( $account_id ) ? ' (Connect account: ' . $account_id . ')' : '' ) . '.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1202 }
1203
1204 try {
1205 $intent = \Stripe\PaymentIntent::retrieve( $intent_id, $options );
1206
1207 if ( charitable_is_debug() ) {
1208 error_log( '[charitable_sync_pending_stripe_donations] Donation #' . $donation_id . ' — PaymentIntent status: ' . $intent->status . '.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1209 }
1210
1211 if ( 'succeeded' === $intent->status ) {
1212 $donation = charitable_get_donation( $donation_id );
1213 if ( ! $donation ) {
1214 $errors++;
1215 continue;
1216 }
1217 // Guard against double-processing if a webhook completed this donation
1218 // between the first-call query and this batch.
1219 if ( 'charitable-completed' === $donation->get_status() ) {
1220 $skipped++;
1221 if ( charitable_is_debug() ) {
1222 error_log( '[charitable_sync_pending_stripe_donations] Donation #' . $donation_id . ' — already completed (webhook beat us to it), skipping.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1223 }
1224 continue;
1225 }
1226 $donation->update_status( 'charitable-completed' );
1227 $donation->log()->add(
1228 sprintf(
1229 /* translators: %s: Stripe PaymentIntent ID. */
1230 __( 'Stripe Sync: PaymentIntent %s confirmed (succeeded). Status updated to Paid.', 'charitable' ),
1231 $intent_id
1232 )
1233 );
1234 $updated++;
1235 if ( charitable_is_debug() ) {
1236 error_log( '[charitable_sync_pending_stripe_donations] Donation #' . $donation_id . ' — marked completed.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1237 }
1238 } else {
1239 $donation = charitable_get_donation( $donation_id );
1240 if ( $donation ) {
1241 $donation->log()->add(
1242 sprintf(
1243 /* translators: 1: Stripe PaymentIntent ID, 2: Stripe status string. */
1244 __( "Stripe Sync: PaymentIntent %1\$s status '%2\$s' — payment not confirmed. Status remains Pending.", 'charitable' ),
1245 $intent_id,
1246 $intent->status
1247 )
1248 );
1249 }
1250 $skipped++;
1251 if ( charitable_is_debug() ) {
1252 error_log( '[charitable_sync_pending_stripe_donations] Donation #' . $donation_id . ' — skipped (Stripe status: ' . $intent->status . ').' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1253 }
1254 }
1255 } catch ( \Exception $e ) {
1256 $donation = charitable_get_donation( $donation_id );
1257 if ( $donation ) {
1258 $donation->log()->add(
1259 sprintf(
1260 /* translators: 1: Stripe PaymentIntent ID, 2: error message. */
1261 __( 'Stripe Sync: Error retrieving PaymentIntent %1$s — %2$s', 'charitable' ),
1262 $intent_id,
1263 $e->getMessage()
1264 )
1265 );
1266 }
1267 $errors++;
1268 if ( charitable_is_debug() ) {
1269 error_log( '[charitable_sync_pending_stripe_donations] Donation #' . $donation_id . ' — Stripe API error: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1270 }
1271 }
1272 }
1273
1274 if ( ! $send_emails ) {
1275 add_action( 'charitable-completed_donation', array( 'Charitable_Email_Donation_Receipt', 'send_with_donation_id' ) );
1276 add_action( 'charitable-completed_donation', array( 'Charitable_Email_New_Donation', 'send_with_donation_id' ) );
1277 }
1278
1279 $total_updated = $acc_updated + $updated;
1280 $total_skipped = $acc_skipped + $skipped;
1281 $total_errors = $acc_errors + $errors;
1282
1283 if ( $has_more ) {
1284 if ( charitable_is_debug() ) {
1285 error_log( '[charitable_sync_pending_stripe_donations] Batch done. Updated: ' . $updated . ', Skipped: ' . $skipped . ', Errors: ' . $errors . '. Running totals — Updated: ' . $total_updated . ', Skipped: ' . $total_skipped . ', Errors: ' . $total_errors . '.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1286 }
1287 $message = sprintf(
1288 /* translators: 1: processed count, 2: total count */
1289 __( 'Syncing... %1$d of %2$d donations processed.', 'charitable' ),
1290 $processed_so_far,
1291 $total_found
1292 );
1293 } else {
1294 if ( charitable_is_debug() ) {
1295 error_log( '[charitable_sync_pending_stripe_donations] Done. Updated: ' . $total_updated . ', Skipped: ' . $total_skipped . ', Errors: ' . $total_errors . '.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1296 }
1297 $message = sprintf(
1298 /* translators: 1: updated count, 2: skipped count, 3: error count */
1299 __( '%1$d donation(s) marked completed. %2$d still pending in Stripe. %3$d error(s).', 'charitable' ),
1300 $total_updated,
1301 $total_skipped,
1302 $total_errors
1303 );
1304 }
1305
1306 wp_send_json_success( array(
1307 'message' => $message,
1308 'has_more' => $has_more,
1309 'next_ids' => $next_ids,
1310 'next_remaining' => $next_remaining,
1311 'total_found' => $total_found,
1312 'updated' => $updated,
1313 'skipped' => $skipped,
1314 'errors' => $errors,
1315 ) );
1316 }
1317 }
1318
1319 endif;
1320