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
← All changes | includes/stripe/admin/class-charitable-stripe-admin.php +1034 -60 1.7.0 → 1.8.13 View file →
@@ -43,26 +43,55 @@
43 43 /**
44 44 * Add a direct link to the Extensions settings page from the plugin row.
45 45 */
46 46 if ( class_exists( 'Charitable' ) ) {
47 - add_filter( 'plugin_action_links_' . plugin_basename( charitable()->get_path() ), [ $this, 'add_plugin_action_links' ] );
47 + add_filter( 'plugin_action_links_' . plugin_basename( charitable()->get_path() ), array( $this, 'add_plugin_action_links' ) );
48 48 }
49 49
50 50 /**
51 51 * Add settings to the Privacy tab.
52 52 */
53 - add_filter( 'charitable_settings_tab_fields_privacy', [ $this, 'add_stripe_privacy_settings' ] );
53 + add_filter( 'charitable_settings_tab_fields_privacy', array( $this, 'add_stripe_privacy_settings' ) );
54 54
55 55 /**
56 56 * When saving Stripe settings, check for webhook if secret key has changed (when you aren't using Stripe Connect AM)
57 57 */
58 - add_filter( 'charitable_save_settings', [ $this, 'save_stripe_settings' ], 10, 3 );
58 + add_filter( 'charitable_save_settings', array( $this, 'save_stripe_settings' ), 10, 3 );
59 59
60 60 /**
61 61 * When connecting Stripe Connect, check for webhook if secret key has changed.
62 62 */
63 - add_action( 'wpcharitable_stripe_account_connected', [ $this, 'update_webhook_upon_connection' ], 10, 1 );
63 + add_action( 'wpcharitable_stripe_account_connected', array( $this, 'update_webhook_upon_connection' ), 10, 1 );
64 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' ) );
65 94 }
66 95
67 96 /**
68 97 * Create and return the class object.
@@ -92,18 +121,18 @@
92 121 // $links[] = '<a href="' . admin_url( 'admin.php?page=charitable-settings&tab=gateways&group=gateways_stripe&default_gateway=true' ) . '">' . __( 'Settings', 'charitable-stripe' ) . '</a>';
93 122 } else {
94 123 $activate_url = esc_url(
95 124 add_query_arg(
96 - [
125 + array(
97 126 'charitable_action' => 'enable_gateway',
98 127 'gateway_id' => 'stripe',
99 128 '_nonce' => wp_create_nonce( 'gateway' ),
100 - ],
129 + ),
101 130 admin_url( 'admin.php?page=charitable-settings&tab=gateways' )
102 131 )
103 132 );
104 133
105 - $links[] = '<a href="' . $activate_url . '">' . __( 'Activate Stripe Gateway', 'charitable-stripe' ) . '</a>';
134 + $links[] = '<a href="' . $activate_url . '">' . __( 'Activate Stripe Gateway', 'charitable' ) . '</a>';
106 135 }
107 136
108 137 return $links;
109 138 }
@@ -117,9 +146,9 @@
117 146 * @return array
118 147 */
119 148 public function add_stripe_privacy_settings( $settings ) {
120 149 if ( array_key_exists( 'data_retention_fields', $settings ) ) {
121 - $settings['data_retention_fields']['options']['stripe'] = __( 'Stripe Data', 'charitable-stripe' );
150 + $settings['data_retention_fields']['options']['stripe'] = __( 'Stripe Data', 'charitable' );
122 151 }
123 152
124 153 return $settings;
125 154 }
@@ -135,13 +164,13 @@
135 164 * @return array
136 165 */
137 166 public function save_stripe_settings( $values, $new_values, $old_values ) {
138 167
139 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
140 - error_log( 'save_stripe_settings');
141 - error_log( print_r( $values, true ) );
142 - error_log( print_r( $new_values, true ) );
143 - error_log( print_r( $old_values, true ) );
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
144 173 }
145 174
146 175 /* Bail early if this is not the Stripe settings page. */
147 176 if ( ! array_key_exists( 'gateways_stripe', $values ) ) {
@@ -147,27 +176,32 @@
147 176 if ( ! array_key_exists( 'gateways_stripe', $values ) ) {
148 177 return $values;
149 178 }
150 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 +
151 185 /* Add webhooks unless we're on localhost. */
152 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
153 - error_log( 'save_settings');
186 + if ( charitable_is_debug() ) {
187 + error_log( 'save_stripe_settings midpoint'); // phpcs:ignore
154 188 }
155 189
156 190 // a reminder on the charitable_using_stripe_connect check:
157 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.
158 192 // the option is removed when, after the stripe connect is connected, the user clicks on the "disconnect" link is clicked in the settings.
159 - if ( charitable_stripe_should_setup_webhooks() && ! charitable_using_stripe_connect() ) {
193 + if ( function_exists( 'charitable_stripe_should_setup_webhooks' ) && charitable_stripe_should_setup_webhooks() && ! charitable_using_stripe_connect() ) {
160 194 if ( defined( 'CHARITABLE_FORCE_WEBHOOKS_WITHOUT_STRIPE_CONNECT' ) && CHARITABLE_FORCE_WEBHOOKS_WITHOUT_STRIPE_CONNECT ) {
161 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
162 - error_log( 'made it 2');
163 - error_log( print_r( $values, true ) );
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
164 198 }
165 199 $values = $this->setup_webhooks( $values, $new_values, $old_values );
166 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
167 - error_log( print_r( $values, true ) );
168 - error_log( print_r( $new_values, true ) );
169 - error_log( print_r( $old_values, true ) );
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
170 204 }
171 205 }
172 206 }
173 207
@@ -178,44 +212,54 @@
178 212 * When Stripe settings are saved, maybe run background processes to set hidden settings.
179 213 *
180 214 * @since 1.3.0
181 215 *
182 - * @param array $values The submitted values.
183 - * @param array $new_values The new settings.
184 - * @param array $old_values The previous settings.
185 - * @return array
216 + * @param array $account_data The account data.
217 + * @return void
186 218 */
187 219 public function update_webhook_upon_connection( $account_data ) {
188 220
189 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
190 - error_log( 'update_webhook_upon_connection 0');
221 + if ( charitable_is_debug() ) {
222 + // phpcs:disable
223 + error_log( 'update_webhook_upon_connection 0' );
191 224 error_log( print_r( charitable_stripe_should_setup_webhooks(), true ) );
192 225 error_log( print_r( charitable_using_stripe_connect(), true ) );
226 + // phpcs:enable
193 227 }
194 228
195 229 // a reminder on the charitable_using_stripe_connect check:
196 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.
197 231 // the option is removed when, after the stripe connect is connected, the user clicks on the "disconnect" link is clicked in the settings.
198 - if ( charitable_stripe_should_setup_webhooks() && charitable_using_stripe_connect() ) {
199 - // going to "simulate" a save settings so we can use re-use the setup_webhooks function
200 - $values = $new_values = $old_values = get_option( 'charitable_settings', array() );
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() ) {
201 236
202 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
203 - error_log( 'update_webhook_upon_connection 3');
237 + if ( charitable_is_debug() ) {
238 + // phpcs:disable
239 + error_log( 'update_webhook_upon_connection: refreshing webhooks for fresh signing secrets' );
204 240 error_log( print_r( $account_data, true ) );
205 - error_log( print_r( $values, true ) );
206 - error_log( print_r( $new_values, true ) );
207 - error_log( print_r( $old_values, true ) );
241 + // phpcs:enable
208 242 }
209 - $values = $this->setup_webhooks( $values, $new_values, $old_values );
210 243
211 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
212 - error_log( print_r( $values, true ) );
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 + }
213 260 }
214 261
215 - // update the settings
216 - update_option( 'charitable_settings', $values );
217 -
218 262 }
219 263 }
220 264
221 265 /**
@@ -235,12 +279,12 @@
235 279
236 280 $old_settings = $old_values['gateways_stripe'];
237 281 $new_settings = $values['gateways_stripe'];
238 282
239 - $setting_pairs = [
283 + $setting_pairs = array(
240 284 'test_secret_key' => true,
241 285 'live_secret_key' => false,
242 - ];
286 + );
243 287
244 288 foreach ( $setting_pairs as $setting_key => $test_mode ) {
245 289
246 290 $old = isset( $old_settings[ $setting_key ] ) ? trim( $old_settings[ $setting_key ] ) : false;
@@ -247,27 +291,30 @@
247 291 $new = trim( $new_settings[ $setting_key ] );
248 292
249 293 /* The secret key is unchanged and the upgrade is done, so no need to do anything. */
250 294 if ( $old == $new && $upgrade_done ) {
251 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
295 + if ( charitable_is_debug() ) {
296 + // phpcs:disable
252 297 error_log( 'key unchanged' );
253 298 error_log( 'old:' );
254 299 error_log( print_r( $old, true ) );
255 300 error_log( 'new:' );
256 301 error_log( print_r( $new, true ) );
302 + // phpcs:enable
257 303 }
258 304 }
259 305
260 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
306 + if ( charitable_is_debug() ) {
307 + // phpcs:disable
261 308 error_log( 'old:' );
262 - error_log( print_r( $old ) );
309 + error_log( print_r( $old, true ) );
263 310 error_log( 'new:' );
264 - error_log( print_r( $new ) );
311 + error_log( print_r( $new, true ) );
312 + // phpcs:enable
265 313 }
266 314
267 315 /* If the secret key has changed, deactivate the previously stored webhook. */
268 316 if ( $old != $new ) {
269 - error_log( 'old != new' );
270 317 $webhook_api = new Charitable_Stripe_Webhook_API( $test_mode, $old );
271 318 $webhook_api->deactivate_webhook();
272 319 }
273 320
@@ -279,49 +326,71 @@
279 326
280 327 /* Finally, if we're still here, add a webhook using the new secret key. */
281 328 $webhook_api = new Charitable_Stripe_Webhook_API( $test_mode, $new );
282 329
283 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
330 + if ( charitable_is_debug() ) {
331 + // phpcs:disable
284 332 error_log( 'webhook_api here' );
285 333 error_log( print_r( $webhook_api, true ) );
334 + // phpcs:enable
286 335 }
287 336
288 337 /* First, check if we have a webhook. */
289 338 $webhook = $webhook_api->get_webhook();
290 339
291 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
340 + if ( charitable_is_debug() ) {
341 + // phpcs:disable
292 342 error_log( 'webhook here' );
293 343 error_log( print_r( $webhook, true ) );
344 + // phpcs:enable
294 345 }
295 346
296 347 /* We don't have a webhook, so create one. */
297 348 if ( ! $webhook ) {
349 + // phpcs:disable
298 350 error_log( 'add webhook We do not have a webhook, so create one.' );
299 351 $webhook_id = $webhook_api->add_webhook();
300 - error_log( '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 + }
301 365 } else {
302 366 /* We have a webhook, but it needs to be updated. */
303 367 if ( $webhook_api->webhook_needs_update( $webhook ) ) {
304 368 $webhook_api->update_webhook();
305 - error_log( 'update webhook' );
306 369 }
307 370 $webhook_id = $webhook->id;
308 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
371 + if ( charitable_is_debug() ) {
372 + // phpcs:disable
309 373 error_log( print_r( $webhook_id, true ) );
374 + // phpcs:enable
310 375 }
311 376 }
312 377
313 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
378 + if ( charitable_is_debug() ) {
379 + // phpcs:disable
314 380 error_log( 'final testing data' );
315 381 error_log( print_r( $webhook_api->setting_key, true ) );
316 382 error_log( print_r( $webhook_id, true ) );
383 + // phpcs:enable
317 384 }
318 385
319 386 $values['gateways_stripe'][ $webhook_api->setting_key ] = $webhook_id;
320 387
321 - if ( defined( 'CHARITABLE_DEBUG' ) && CHARITABLE_DEBUG ) {
388 + if ( charitable_is_debug() ) {
389 + // phpcs:disable
322 390 error_log( 'values gateways_stripe updated' );
323 391 error_log( print_r( $values, true ) );
392 + // phpcs:enable
324 393 }
325 394 }
326 395
327 396 /* Mark the upgrade as done. */
@@ -326,20 +395,925 @@
326 395
327 396 /* Mark the upgrade as done. */
328 397 if ( ! $upgrade_done ) {
329 398 if ( ! is_array( $upgrade_log ) ) {
330 - $upgrade_log = [];
399 + $upgrade_log = array();
331 400 }
332 401
333 - $upgrade_log['stripe_update_hidden_settings'] = [
402 + $upgrade_log['stripe_update_hidden_settings'] = array(
334 403 'time' => time(),
335 404 'version' => charitable_stripe()->get_version(),
336 - ];
405 + );
337 406
338 407 update_option( 'charitable_stripe_upgrade_log', $upgrade_log );
339 408 }
340 409
341 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 + ) );
342 1316 }
343 1317 }
344 1318
345 1319 endif;