PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.4.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.4.0
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / payments / stripe / stripe-settings.php

stripe-settings.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.4.0, at inc/payments/stripe/stripe-settings.php

1,409 lines 44.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Stripe Settings - REST API and configuration management
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Payments\Stripe;
9
10 // Exit if accessed directly.
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 use SureDonation\Inc\Payments\Payment_Helper;
16 use SureDonation\Inc\Traits\Get_Instance;
17 use WP_Error;
18 use WP_REST_Request;
19 use WP_REST_Response;
20 use WP_REST_Server;
21
22 /**
23 * Stripe_Settings class
24 * Manages Stripe configuration and REST API endpoints
25 *
26 * @since 0.0.1
27 */
28 class Stripe_Settings {
29 use Get_Instance;
30
31 /**
32 * Cron hook that reconciles webhook events away from the request path.
33 *
34 * @since 1.4.0
35 */
36 public const WEBHOOK_SYNC_HOOK = 'suredonation_sync_stripe_webhook_events';
37
38 /**
39 * Option holding the fingerprint of the last successfully synced event list.
40 *
41 * @since 1.4.0
42 */
43 private const WEBHOOK_SYNC_OPTION = 'suredonation_stripe_webhook_events_synced';
44
45 /**
46 * Transient that backs off retries after a failed sync.
47 *
48 * @since 1.4.0
49 */
50 private const WEBHOOK_SYNC_BACKOFF = 'suredonation_stripe_webhook_sync_backoff';
51
52 /**
53 * Constructor
54 *
55 * @since 0.0.1
56 */
57 public function __construct() {
58 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
59 add_action( 'admin_init', [ $this, 'intercept_stripe_callback' ] );
60 add_action( 'admin_init', [ $this, 'maybe_sync_webhook_events' ] );
61 add_action( self::WEBHOOK_SYNC_HOOK, [ $this, 'run_webhook_event_sync' ] );
62 add_filter( 'suredonation_stripe_account_usage_blockers', [ $this, 'add_form_usage_blocker' ], 10, 2 );
63 }
64
65 /**
66 * Register REST API routes
67 *
68 * @return void
69 * @since 0.0.1
70 */
71 public function register_routes() {
72 // Get Stripe settings.
73 register_rest_route(
74 'suredonation/v1',
75 '/payments/stripe/settings',
76 [
77 'methods' => WP_REST_Server::READABLE,
78 'callback' => [ $this, 'get_settings' ],
79 'permission_callback' => [ $this, 'check_permissions' ],
80 ]
81 );
82
83 // Update Stripe settings.
84 register_rest_route(
85 'suredonation/v1',
86 '/payments/stripe/settings',
87 [
88 'methods' => WP_REST_Server::EDITABLE,
89 'callback' => [ $this, 'update_settings' ],
90 'permission_callback' => [ $this, 'check_permissions' ],
91 ]
92 );
93
94 // Get Stripe Connect URL.
95 register_rest_route(
96 'suredonation/v1',
97 '/payments/stripe/connect-url',
98 [
99 'methods' => WP_REST_Server::READABLE,
100 'callback' => [ $this, 'get_connect_url' ],
101 'permission_callback' => [ $this, 'check_permissions' ],
102 ]
103 );
104
105 // List connected accounts (sanitized).
106 register_rest_route(
107 'suredonation/v1',
108 '/payments/stripe/accounts',
109 [
110 'methods' => WP_REST_Server::READABLE,
111 'callback' => [ $this, 'get_accounts' ],
112 'permission_callback' => [ $this, 'check_permissions' ],
113 ]
114 );
115
116 // Set the default account.
117 register_rest_route(
118 'suredonation/v1',
119 '/payments/stripe/accounts/default',
120 [
121 'methods' => WP_REST_Server::CREATABLE,
122 'callback' => [ $this, 'set_default_account' ],
123 'permission_callback' => [ $this, 'check_permissions' ],
124 'args' => [
125 'account_id' => [
126 'required' => true,
127 'type' => 'string',
128 'sanitize_callback' => 'sanitize_text_field',
129 ],
130 ],
131 ]
132 );
133
134 // Disconnect Stripe (a specific account, or the default when omitted).
135 register_rest_route(
136 'suredonation/v1',
137 '/payments/stripe/disconnect',
138 [
139 'methods' => WP_REST_Server::CREATABLE,
140 'callback' => [ $this, 'disconnect_stripe' ],
141 'permission_callback' => [ $this, 'check_permissions' ],
142 'args' => [
143 'account_id' => [
144 'required' => false,
145 'type' => 'string',
146 'sanitize_callback' => 'sanitize_text_field',
147 ],
148 ],
149 ]
150 );
151
152 // Create webhook.
153 register_rest_route(
154 'suredonation/v1',
155 '/payments/stripe/webhook/create',
156 [
157 'methods' => WP_REST_Server::CREATABLE,
158 'callback' => [ $this, 'create_webhook' ],
159 'permission_callback' => [ $this, 'check_permissions' ],
160 'args' => [
161 // No default: an omitted mode resolves to the site's current
162 // payment mode in the callback. Defaulting to 'all' made a
163 // caller working in test mode fail on the live account.
164 'mode' => [
165 'required' => false,
166 'type' => 'string',
167 'sanitize_callback' => 'sanitize_text_field',
168 'validate_callback' => static function ( $param ) {
169 if ( ! in_array( $param, [ 'all', 'test', 'live' ], true ) ) {
170 return new \WP_Error(
171 'invalid_mode',
172 sprintf(
173 /* translators: %s: provided mode value */
174 __( 'Invalid mode "%s". Must be "all", "test" or "live".', 'suredonation' ),
175 $param
176 )
177 );
178 }
179 return true;
180 },
181 ],
182 'account_id' => [
183 'required' => false,
184 'type' => 'string',
185 'sanitize_callback' => 'sanitize_text_field',
186 ],
187 ],
188 ]
189 );
190
191 // Delete webhook.
192 register_rest_route(
193 'suredonation/v1',
194 '/payments/stripe/webhook/delete',
195 [
196 'methods' => WP_REST_Server::DELETABLE,
197 'callback' => [ $this, 'delete_webhook' ],
198 'permission_callback' => [ $this, 'check_permissions' ],
199 'args' => [
200 'mode' => [
201 'required' => true,
202 'validate_callback' => static function ( $param ) {
203 return in_array( $param, [ 'test', 'live' ], true );
204 },
205 ],
206 'account_id' => [
207 'required' => false,
208 'type' => 'string',
209 'sanitize_callback' => 'sanitize_text_field',
210 ],
211 ],
212 ]
213 );
214 }
215
216 /**
217 * Get Stripe settings
218 *
219 * @param WP_REST_Request $request Request object.
220 * @return WP_REST_Response Response object.
221 * @since 0.0.1
222 */
223 public function get_settings( $request ) {
224 unset( $request ); // Unused parameter.
225
226 $stripe_settings = Stripe_Helper::get_all_stripe_settings();
227 $global_settings = Payment_Helper::get_all_payment_settings();
228
229 // Remove sensitive data from response.
230 $safe_settings = $stripe_settings;
231 unset( $safe_settings['stripe_live_secret_key'] );
232 unset( $safe_settings['stripe_test_secret_key'] );
233 unset( $safe_settings['webhook_test_secret'] );
234 unset( $safe_settings['webhook_live_secret'] );
235
236 // Never expose the raw accounts map (it holds secret keys + webhook secrets);
237 // replace it with the sanitized, publishable-only list.
238 unset( $safe_settings['accounts'] );
239 $safe_settings['accounts'] = Stripe_Helper::get_public_accounts();
240 $safe_settings['default_account_id'] = Stripe_Helper::get_default_account_id();
241
242 // Add global settings (currency, payment_mode, fee_recovery).
243 $safe_settings['currency'] = $global_settings['currency'] ?? 'USD';
244 $safe_settings['currency_symbol'] = Payment_Helper::get_currency_symbol( is_string( $safe_settings['currency'] ) ? $safe_settings['currency'] : 'USD' );
245 $safe_settings['payment_mode'] = $global_settings['payment_mode'] ?? 'test';
246 $safe_settings['currency_sign_position'] = Payment_Helper::get_currency_sign_position();
247 $safe_settings['fee_recovery'] = Payment_Helper::get_fee_recovery_settings();
248
249 // Include gateway list so the settings UI knows which gateways exist.
250 $safe_settings['gateways'] = array_map(
251 static function ( $gw ) {
252 return [
253 'label' => $gw['label'],
254 'supports_recurring' => $gw['supports_recurring'] ?? false,
255 ];
256 },
257 Payment_Helper::get_supported_gateways()
258 );
259
260 return new WP_REST_Response(
261 [
262 'success' => true,
263 'settings' => $safe_settings,
264 ],
265 200
266 );
267 }
268
269 /**
270 * Update Stripe settings
271 *
272 * @param WP_REST_Request $request Request object.
273 * @return WP_REST_Response|WP_Error Response object.
274 * @since 0.0.1
275 */
276 public function update_settings( $request ) {
277 $settings = $request->get_json_params();
278
279 if ( empty( $settings ) ) {
280 return new WP_Error(
281 'invalid_settings',
282 __( 'Invalid settings provided', 'suredonation' ),
283 [ 'status' => 400 ]
284 );
285 }
286
287 // Handle global settings (currency, payment_mode, currency_sign_position, fee_recovery) separately.
288 $global_updated = true;
289 if ( isset( $settings['currency'] ) || isset( $settings['payment_mode'] ) || isset( $settings['currency_sign_position'] ) || isset( $settings['fee_recovery'] ) ) {
290 $global_settings = Payment_Helper::get_all_payment_settings();
291
292 if ( isset( $settings['currency'] ) ) {
293 $global_settings['currency'] = sanitize_text_field( $settings['currency'] );
294 unset( $settings['currency'] );
295 }
296
297 if ( isset( $settings['payment_mode'] ) ) {
298 $mode = sanitize_text_field( $settings['payment_mode'] );
299 if ( in_array( $mode, [ 'test', 'live' ], true ) ) {
300 $global_settings['payment_mode'] = $mode;
301 }
302 unset( $settings['payment_mode'] );
303 }
304
305 if ( isset( $settings['currency_sign_position'] ) ) {
306 $position = sanitize_text_field( $settings['currency_sign_position'] );
307 if ( in_array( $position, Payment_Helper::ALLOWED_SIGN_POSITIONS, true ) ) {
308 $global_settings['currency_sign_position'] = $position;
309 }
310 unset( $settings['currency_sign_position'] );
311 }
312
313 if ( isset( $settings['fee_recovery'] ) && is_array( $settings['fee_recovery'] ) ) {
314 $fee_recovery = $settings['fee_recovery'];
315 $fee_percentage = max( 0, min( 99.99, floatval( $fee_recovery['fee_percentage'] ?? 2.9 ) ) );
316 $fee_fixed = max( 0, floatval( $fee_recovery['fee_fixed'] ?? 0.30 ) );
317 $fee_mode = isset( $fee_recovery['fee_mode'] ) && in_array( $fee_recovery['fee_mode'], [ 'all_gateways', 'per_gateway' ], true )
318 ? $fee_recovery['fee_mode'] : 'all_gateways';
319
320 $sanitized_fee = [
321 'fee_percentage' => $fee_percentage,
322 'fee_fixed' => $fee_fixed,
323 'fee_mode' => $fee_mode,
324 ];
325
326 // Sanitize per-gateway settings — only allow registered gateway keys.
327 $allowed_gateways = array_keys( Payment_Helper::get_supported_gateways() );
328 if ( isset( $fee_recovery['gateways'] ) && is_array( $fee_recovery['gateways'] ) ) {
329 $gateways = [];
330 foreach ( $fee_recovery['gateways'] as $gw_key => $gw_val ) {
331 $gw_key = sanitize_text_field( $gw_key );
332 if ( ! in_array( $gw_key, $allowed_gateways, true ) ) {
333 continue;
334 }
335 if ( is_array( $gw_val ) ) {
336 $gateways[ $gw_key ] = [
337 'fee_percentage' => max( 0, min( 99.99, floatval( $gw_val['fee_percentage'] ?? 0 ) ) ),
338 'fee_fixed' => max( 0, floatval( $gw_val['fee_fixed'] ?? 0 ) ),
339 'enabled' => ! empty( $gw_val['enabled'] ),
340 ];
341 }
342 }
343 $sanitized_fee['gateways'] = $gateways;
344 }
345
346 $global_settings['fee_recovery'] = $sanitized_fee;
347 unset( $settings['fee_recovery'] );
348 }
349
350 $global_updated = Payment_Helper::update_all_payment_settings( $global_settings );
351 }
352
353 // Sanitize and update Stripe-specific settings.
354 $stripe_updated = true;
355 if ( ! empty( $settings ) ) {
356 $sanitized_settings = $this->sanitize_settings( $settings );
357
358 // Preserve stored secret keys. The GET response intentionally strips
359 // these (see get_settings()), so a Save that originates from the
360 // hydrated client state — e.g. changing Currency/Payment Mode on the
361 // General tab — would otherwise drop them when update_gateway_settings()
362 // full-replaces the gateway entry, silently breaking live charging and
363 // webhook verification. Only restore a secret when it is absent from
364 // the request, so an explicit update still overwrites it.
365 $existing_stripe = Stripe_Helper::get_all_stripe_settings();
366 $secret_keys = [
367 'stripe_live_secret_key',
368 'stripe_test_secret_key',
369 'webhook_test_secret',
370 'webhook_live_secret',
371 ];
372 foreach ( $secret_keys as $secret_key ) {
373 if ( ! isset( $sanitized_settings[ $secret_key ] ) && ! empty( $existing_stripe[ $secret_key ] ) ) {
374 $sanitized_settings[ $secret_key ] = $existing_stripe[ $secret_key ];
375 }
376 }
377
378 // Preserve the multi-account map + default pointer. They are managed by
379 // the connect/disconnect/default flows — never by this endpoint — and the
380 // full-replace above would otherwise drop them.
381 foreach ( [ 'accounts', 'default_account_id' ] as $preserved_key ) {
382 if ( ! isset( $sanitized_settings[ $preserved_key ] ) && isset( $existing_stripe[ $preserved_key ] ) ) {
383 $sanitized_settings[ $preserved_key ] = $existing_stripe[ $preserved_key ];
384 }
385 }
386
387 $stripe_updated = Stripe_Helper::update_all_stripe_settings( $sanitized_settings );
388 }
389
390 if ( ! $global_updated && ! $stripe_updated ) {
391 return new WP_Error(
392 'update_failed',
393 __( 'Failed to update settings', 'suredonation' ),
394 [ 'status' => 500 ]
395 );
396 }
397
398 return new WP_REST_Response(
399 [
400 'success' => true,
401 'message' => __( 'Settings updated successfully', 'suredonation' ),
402 ],
403 200
404 );
405 }
406
407 /**
408 * Get Stripe Connect URL
409 *
410 * @param WP_REST_Request $request Request object.
411 * @return WP_REST_Response Response object.
412 * @since 0.0.1
413 */
414 public function get_connect_url( $request ) {
415 unset( $request ); // Unused parameter.
416
417 $connect_url = Stripe_Helper::get_stripe_connect_url();
418
419 return new WP_REST_Response(
420 [
421 'success' => true,
422 'connect_url' => $connect_url,
423 ],
424 200
425 );
426 }
427
428 /**
429 * Disconnect Stripe account
430 *
431 * @param WP_REST_Request $request Request object.
432 * @return WP_REST_Response|WP_Error Response object.
433 * @since 0.0.1
434 */
435 public function disconnect_stripe( $request ) {
436 $account_id = $request->get_param( 'account_id' );
437 $account_id = is_string( $account_id ) ? sanitize_text_field( $account_id ) : '';
438
439 if ( '' === $account_id ) {
440 $account_id = Stripe_Helper::get_default_account_id();
441 }
442
443 if ( '' === $account_id || empty( Stripe_Helper::get_account( $account_id ) ) ) {
444 return new WP_Error(
445 'account_not_found',
446 __( 'Stripe account not found.', 'suredonation' ),
447 [ 'status' => 404 ]
448 );
449 }
450
451 // Guard rail: refuse to disconnect an account that is still in use.
452 $blockers = self::get_account_usage_blockers( $account_id );
453 if ( ! empty( $blockers ) ) {
454 return new WP_Error(
455 'account_in_use',
456 __( 'This Stripe account is still in use and cannot be disconnected.', 'suredonation' ),
457 [
458 'status' => 409,
459 'blockers' => array_values( $blockers ),
460 ]
461 );
462 }
463
464 // Delete this account's webhooks first, then remove the account.
465 $this->delete_webhook_for_mode( 'test', $account_id );
466 $this->delete_webhook_for_mode( 'live', $account_id );
467 Stripe_Helper::remove_account( $account_id );
468
469 return new WP_REST_Response(
470 [
471 'success' => true,
472 'message' => __( 'Stripe account disconnected successfully', 'suredonation' ),
473 ],
474 200
475 );
476 }
477
478 /**
479 * Set the default Stripe account.
480 *
481 * @param WP_REST_Request $request Request object.
482 * @return WP_REST_Response|WP_Error Response object.
483 * @since 1.3.0
484 */
485 public function set_default_account( $request ) {
486 $account_id = $request->get_param( 'account_id' );
487 $account_id = is_string( $account_id ) ? sanitize_text_field( $account_id ) : '';
488
489 if ( '' === $account_id || ! Stripe_Helper::set_default_account( $account_id ) ) {
490 return new WP_Error(
491 'account_not_found',
492 __( 'Stripe account not found.', 'suredonation' ),
493 [ 'status' => 404 ]
494 );
495 }
496
497 return new WP_REST_Response(
498 [
499 'success' => true,
500 'message' => __( 'Default Stripe account updated.', 'suredonation' ),
501 'default_account_id' => $account_id,
502 ],
503 200
504 );
505 }
506
507 /**
508 * Get the sanitized list of connected Stripe accounts (no secret material).
509 *
510 * @param WP_REST_Request $request Request object.
511 * @return WP_REST_Response Response object.
512 * @since 1.3.0
513 */
514 public function get_accounts( $request ) {
515 unset( $request ); // Unused parameter.
516
517 return new WP_REST_Response(
518 [
519 'success' => true,
520 'accounts' => Stripe_Helper::get_public_accounts(),
521 ],
522 200
523 );
524 }
525
526 /**
527 * Collect reasons an account cannot be disconnected.
528 *
529 * Extensible via the `suredonation_stripe_account_usage_blockers` filter —
530 * this class registers a blocker for donation forms assigned to the account,
531 * and Pro adds one for active subscriptions. Each blocker is a human-readable
532 * string.
533 *
534 * @param string $account_id Account id being disconnected.
535 * @return array<int, string> List of blocker messages (empty = safe to disconnect).
536 * @since 1.3.0
537 */
538 public static function get_account_usage_blockers( $account_id ) {
539 /**
540 * Filter the list of reasons a Stripe account cannot be disconnected.
541 *
542 * @param array<int, string> $blockers Blocker messages.
543 * @param string $account_id Account id being disconnected.
544 */
545 $blockers = apply_filters( 'suredonation_stripe_account_usage_blockers', [], $account_id );
546 return is_array( $blockers ) ? $blockers : [];
547 }
548
549 /**
550 * Block disconnecting an account that donation forms are still assigned to.
551 *
552 * @param array<int, string> $blockers Existing blocker messages.
553 * @param string $account_id Account id being disconnected.
554 * @return array<int, string> Blocker messages.
555 * @since 1.3.0
556 */
557 public function add_form_usage_blocker( $blockers, $account_id ) {
558 if ( ! is_array( $blockers ) ) {
559 $blockers = [];
560 }
561
562 if ( empty( $account_id ) || ! is_string( $account_id ) ) {
563 return $blockers;
564 }
565
566 $query = new \WP_Query(
567 [
568 'post_type' => \SureDonation\Inc\Post_Types\Donation_Form::POST_TYPE,
569 'post_status' => 'any',
570 'fields' => 'ids',
571 'posts_per_page' => 1,
572 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Admin-only disconnect guard; a meta lookup is required and infrequent.
573 'meta_key' => Stripe_Helper::FORM_ACCOUNT_META_KEY,
574 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Admin-only disconnect guard; a meta lookup is required and infrequent.
575 'meta_value' => $account_id,
576 ]
577 );
578
579 $count = (int) $query->found_posts;
580
581 if ( $count > 0 ) {
582 $blockers[] = sprintf(
583 /* translators: %s: number of donation forms */
584 _n(
585 '%s donation form is assigned to this account.',
586 '%s donation forms are assigned to this account.',
587 $count,
588 'suredonation'
589 ),
590 number_format_i18n( $count )
591 );
592 }
593
594 return $blockers;
595 }
596
597 /**
598 * Create webhook for specified mode
599 *
600 * @param WP_REST_Request $request Request object.
601 * @return WP_REST_Response|WP_Error Response object.
602 * @since 0.0.1
603 */
604 public function create_webhook( $request ) {
605 $mode = $request->get_param( 'mode' );
606 $account_id = $request->get_param( 'account_id' );
607 $account_id = is_string( $account_id ) ? sanitize_text_field( $account_id ) : null;
608
609 // An omitted mode targets the mode the site is currently in, so a caller
610 // working in test never reaches the live account. 'all' remains available
611 // for programmatic setup, but is never implied.
612 if ( empty( $mode ) ) {
613 $mode = Payment_Helper::get_payment_mode();
614 $mode = in_array( $mode, [ 'test', 'live' ], true ) ? $mode : 'test';
615 }
616
617 if ( 'all' === $mode ) {
618 $result = $this->setup_stripe_webhooks( $account_id );
619
620 if ( empty( $result['success'] ) ) {
621 $message = isset( $result['message'] ) && is_string( $result['message'] ) && '' !== $result['message']
622 ? $result['message']
623 : __( 'Failed to create webhook.', 'suredonation' );
624 return new WP_Error( 'webhook_create_failed', $message );
625 }
626
627 $partial = ! empty( $result['errors'] );
628 $failures = isset( $result['message'] ) && is_string( $result['message'] ) ? $result['message'] : '';
629
630 return new WP_REST_Response(
631 [
632 'success' => true,
633 'partial' => $partial,
634 'message' => $partial
635 ? sprintf(
636 /* translators: %s: per-mode failure reasons, already prefixed with the mode name. */
637 __( 'Webhook created, but some modes failed. %s', 'suredonation' ),
638 $failures
639 )
640 : __( 'Webhook created successfully.', 'suredonation' ),
641 'data' => $result,
642 ],
643 200
644 );
645 }
646
647 $result = $this->create_webhook_for_mode( $mode, $account_id );
648
649 if ( is_wp_error( $result ) ) {
650 return $result;
651 }
652
653 return new WP_REST_Response(
654 [
655 'success' => true,
656 'message' => sprintf(
657 // translators: %s is the mode (test or live).
658 __( 'Webhook created successfully for %s mode', 'suredonation' ),
659 $mode
660 ),
661 'data' => [
662 'id' => is_string( $result['id'] ?? null ) ? $result['id'] : '',
663 'url' => is_string( $result['url'] ?? null ) ? $result['url'] : '',
664 'status' => is_string( $result['status'] ?? null ) ? $result['status'] : '',
665 ],
666 ],
667 200
668 );
669 }
670
671 /**
672 * Delete webhook for specified mode
673 *
674 * @param WP_REST_Request $request Request object.
675 * @return WP_REST_Response Response object.
676 * @since 0.0.1
677 */
678 public function delete_webhook( $request ) {
679 $mode = $request->get_param( 'mode' );
680 $account_id = $request->get_param( 'account_id' );
681 $account_id = is_string( $account_id ) ? sanitize_text_field( $account_id ) : null;
682
683 $this->delete_webhook_for_mode( $mode, $account_id );
684
685 return new WP_REST_Response(
686 [
687 'success' => true,
688 'message' => sprintf(
689 // translators: %s is the mode (test or live).
690 __( 'Webhook deleted successfully for %s mode', 'suredonation' ),
691 $mode
692 ),
693 ],
694 200
695 );
696 }
697
698 /**
699 * Intercept Stripe OAuth callback
700 *
701 * This function validates the OAuth callback from Stripe Connect by:
702 * 1. Verifying user has admin capabilities
703 * 2. Checking for the required page parameter for the plugin
704 * 3. Validating the nonce using wp_verify_nonce()
705 * 4. Comparing the nonce with the stored transient for additional security
706 *
707 * @return void
708 * @since 0.0.1
709 */
710 public function intercept_stripe_callback() {
711 // Check if user has permission to connect Stripe.
712 if ( ! current_user_can( 'manage_options' ) ) {
713 return;
714 }
715
716 // Check if this is a Stripe callback page.
717 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified the nonce below.
718 if ( ! isset( $_GET['page'] ) || 'suredonation' !== sanitize_text_field( wp_unslash( $_GET['page'] ) ) ) {
719 return;
720 }
721
722 // Get and sanitize the nonce from URL.
723 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verifying the custom nonce here.
724 $nonce = isset( $_GET['suredonation_stripe_connect_nonce'] )
725 ? sanitize_text_field( wp_unslash( $_GET['suredonation_stripe_connect_nonce'] ) )
726 : '';
727
728 // Check if nonce parameter exists.
729 if ( empty( $nonce ) ) {
730 return;
731 }
732
733 // Verify the nonce using WordPress's built-in verification.
734 if ( ! wp_verify_nonce( $nonce, 'stripe-connect' ) ) {
735 wp_die(
736 esc_html__( 'Security verification failed. Invalid nonce.', 'suredonation' ),
737 esc_html__( 'Stripe Connect Error', 'suredonation' ),
738 [ 'response' => 403 ]
739 );
740 }
741
742 // Additional verification: Compare with stored transient.
743 $saved_nonce = get_transient( 'suredonation_stripe_connect_nonce_' . get_current_user_id() );
744
745 if ( $nonce !== $saved_nonce ) {
746 wp_die(
747 esc_html__( 'Security verification failed. OAuth session expired or nonce mismatch.', 'suredonation' ),
748 esc_html__( 'Stripe Connect Error', 'suredonation' ),
749 [ 'response' => 403 ]
750 );
751 }
752
753 // Handle the callback.
754 $this->handle_stripe_callback();
755 }
756
757 /**
758 * Get Stripe account name for a connected account.
759 *
760 * @param string|null $account_id Optional account id; the default account is used when empty.
761 * @return string Account name or empty string if not found.
762 * @since 0.0.1
763 */
764 public function get_account_name( $account_id = null ) {
765 if ( empty( $account_id ) ) {
766 $account_id = Stripe_Helper::get_default_account_id();
767 }
768
769 if ( empty( $account_id ) || ! is_string( $account_id ) ) {
770 return '';
771 }
772
773 // Call Stripe API to get account information (using this account's key).
774 $api_response = Stripe_Helper::stripe_api_request( 'accounts/' . $account_id, 'GET', [], [ 'account_id' => $account_id ] );
775
776 // Check for API error.
777 if ( is_wp_error( $api_response ) ) {
778 return '';
779 }
780
781 // API response is the account object directly, not wrapped in 'data'.
782 $get_data = is_array( $api_response ) ? $api_response : [];
783
784 // Return business name or display name.
785 $business_profile = isset( $get_data['business_profile'] ) && is_array( $get_data['business_profile'] ) ? $get_data['business_profile'] : [];
786 if ( isset( $business_profile['name'] ) && is_string( $business_profile['name'] ) ) {
787 return sanitize_text_field( $business_profile['name'] );
788 }
789
790 $settings = isset( $get_data['settings'] ) && is_array( $get_data['settings'] ) ? $get_data['settings'] : [];
791 $dashboard = isset( $settings['dashboard'] ) && is_array( $settings['dashboard'] ) ? $settings['dashboard'] : [];
792 if ( isset( $dashboard['display_name'] ) && is_string( $dashboard['display_name'] ) ) {
793 return sanitize_text_field( $dashboard['display_name'] );
794 }
795
796 return '';
797 }
798
799 /**
800 * Check if user has permission
801 *
802 * @return bool True if user has permission.
803 * @since 0.0.1
804 */
805 public function check_permissions() {
806 return current_user_can( 'manage_options' );
807 }
808
809 /**
810 * Stripe events the webhook endpoint subscribes to.
811 *
812 * Handlers are useless unless the event is subscribed here, so anything
813 * adding a handler has to be able to add its event. Pro's recurring
814 * payment-failure handler was unreachable on every site because
815 * `invoice.payment_failed` was missing from this list and there was no way
816 * for Pro to add it.
817 *
818 * @return array<int, string> Stripe event names.
819 * @since 1.4.0
820 */
821 public static function get_webhook_events() {
822 $events = [
823 'charge.succeeded',
824 'charge.failed',
825 'charge.refunded',
826 'charge.refund.updated',
827 'charge.dispute.created',
828 'charge.dispute.closed',
829 'invoice.payment_succeeded',
830 'invoice.payment_failed',
831 'customer.subscription.created',
832 'customer.subscription.updated',
833 'customer.subscription.deleted',
834 'payment_intent.succeeded',
835 'payment_intent.payment_failed',
836 'payment_intent.canceled',
837 ];
838
839 /**
840 * Filter the Stripe events the webhook endpoint subscribes to.
841 *
842 * @param array<int, string> $events Stripe event names.
843 * @since 1.4.0
844 */
845 $events = apply_filters( 'suredonation_stripe_webhook_events', $events );
846
847 if ( ! is_array( $events ) ) {
848 return [];
849 }
850
851 return array_values( array_unique( array_filter( $events, 'is_string' ) ) );
852 }
853
854 /**
855 * Reconcile webhook events for every connected account, once per event list.
856 *
857 * Keyed on a hash of the event list rather than a plugin version, so it runs
858 * when the events actually change — including when Pro is activated and adds
859 * its own — and stays quiet otherwise. Each pass is a couple of Stripe calls
860 * per connected mode, on an admin request only.
861 *
862 * @return void
863 * @since 1.4.0
864 */
865 public function maybe_sync_webhook_events() {
866 // Context first. admin_init also fires on admin-ajax.php, before the
867 // nopriv dispatch, and the whole donation flow runs through nopriv ajax
868 // actions — so a donor's payment request reaches this method. Checking
869 // the signature first meant that request still paid for an option read
870 // and a hash of the event list before being turned away here, which is
871 // the cost this guard exists to avoid.
872 if ( wp_doing_ajax() || wp_doing_cron() || ! current_user_can( 'manage_options' ) ) {
873 return;
874 }
875
876 if ( get_option( self::WEBHOOK_SYNC_OPTION ) === self::get_webhook_events_signature() ) {
877 return;
878 }
879
880 // While the backoff is held the last run failed, and rescheduling on each
881 // pageview would queue an event that immediately returns — busy-waiting
882 // through cron until the transient expires.
883 if ( get_transient( self::WEBHOOK_SYNC_BACKOFF ) ) {
884 return;
885 }
886
887 if ( ! wp_next_scheduled( self::WEBHOOK_SYNC_HOOK ) ) {
888 wp_schedule_single_event( time(), self::WEBHOOK_SYNC_HOOK );
889 }
890 }
891
892 /**
893 * Reconcile webhook events for every connected account.
894 *
895 * Runs on cron. The signature is recorded only when nothing failed, so a
896 * transient Stripe error is retried rather than being remembered as done —
897 * which would leave that site permanently without the events it is missing.
898 * A short backoff keeps a persistent failure from retrying on every run.
899 *
900 * @return void
901 * @since 1.4.0
902 */
903 public function run_webhook_event_sync() {
904 if ( get_transient( self::WEBHOOK_SYNC_BACKOFF ) ) {
905 return;
906 }
907
908 $failed = false;
909
910 foreach ( Stripe_Helper::get_all_accounts() as $account_id => $account ) {
911 if ( ! is_array( $account ) ) {
912 continue;
913 }
914 foreach ( [ 'test', 'live' ] as $mode ) {
915 if ( is_wp_error( $this->sync_webhook_events( $mode, (string) $account_id ) ) ) {
916 $failed = true;
917 }
918 }
919 }
920
921 if ( $failed ) {
922 set_transient( self::WEBHOOK_SYNC_BACKOFF, true, HOUR_IN_SECONDS );
923 return;
924 }
925
926 update_option( self::WEBHOOK_SYNC_OPTION, self::get_webhook_events_signature(), false );
927 }
928
929 /**
930 * Fingerprint of the current event list.
931 *
932 * Sorted before hashing so a filter that returns the same events in a
933 * different order does not look like a change and re-trigger the sync.
934 *
935 * @return string Signature.
936 * @since 1.4.0
937 */
938 private static function get_webhook_events_signature() {
939 $events = self::get_webhook_events();
940 sort( $events );
941
942 return md5( (string) wp_json_encode( $events ) );
943 }
944
945 /**
946 * Bring an existing webhook endpoint's event list up to date.
947 *
948 * The event list is otherwise only applied when the endpoint is created, so
949 * a site that connected Stripe before an event was added never receives it.
950 * Reconciling on read means those sites pick up new events without having to
951 * disconnect and reconnect, which would invalidate the stored signing secret.
952 *
953 * Returns a WP_Error only for a genuine API failure. Every other outcome —
954 * no account, no endpoint yet, an endpoint already current, or one
955 * subscribed to everything — is a legitimate no-op and must not be treated
956 * as a failure, or one such account would block the whole run from being
957 * recorded as done.
958 *
959 * @param string $mode Payment mode.
960 * @param string|null $account_id Account id; the default account is used when empty.
961 * @return bool|WP_Error True when updated, false when no action was needed, WP_Error on API failure.
962 * @since 1.4.0
963 */
964 public function sync_webhook_events( $mode, $account_id = null ) {
965 if ( empty( $account_id ) ) {
966 $account_id = Stripe_Helper::get_default_account_id();
967 }
968 if ( empty( $account_id ) ) {
969 return false;
970 }
971
972 $account = Stripe_Helper::get_account( $account_id );
973 if ( ! is_array( $account ) ) {
974 return false;
975 }
976
977 $webhook_id = isset( $account[ $mode . '_webhook_id' ] ) && is_string( $account[ $mode . '_webhook_id' ] )
978 ? $account[ $mode . '_webhook_id' ]
979 : '';
980
981 if ( '' === $webhook_id ) {
982 return false;
983 }
984
985 $existing = Stripe_Helper::stripe_api_request(
986 'webhook_endpoints/' . $webhook_id,
987 'GET',
988 [],
989 [
990 'mode' => $mode,
991 'account_id' => $account_id,
992 ]
993 );
994
995 if ( is_wp_error( $existing ) ) {
996 return $existing;
997 }
998
999 $current = isset( $existing['enabled_events'] ) && is_array( $existing['enabled_events'] )
1000 ? array_values( array_filter( $existing['enabled_events'], 'is_string' ) )
1001 : [];
1002 $wanted = self::get_webhook_events();
1003
1004 // Stripe accepts '*' as "every event"; leave such an endpoint alone
1005 // rather than narrowing what it already receives.
1006 if ( in_array( '*', $current, true ) || ! array_diff( $wanted, $current ) ) {
1007 return false;
1008 }
1009
1010 $updated = Stripe_Helper::stripe_api_request(
1011 'webhook_endpoints/' . $webhook_id,
1012 'POST',
1013 // Merged, not replaced, so events added by hand in the Stripe
1014 // dashboard survive. This also makes the events filter additive:
1015 // removing an entry from it will not unsubscribe an existing endpoint.
1016 [ 'enabled_events' => array_values( array_unique( array_merge( $current, $wanted ) ) ) ],
1017 [
1018 'mode' => $mode,
1019 'account_id' => $account_id,
1020 ]
1021 );
1022
1023 if ( is_wp_error( $updated ) ) {
1024 return $updated;
1025 }
1026
1027 return true;
1028 }
1029
1030 /**
1031 * Create Stripe webhook for a mode.
1032 *
1033 * @param string $mode Payment mode.
1034 * @param string|null $account_id Account id; the default account is used when empty.
1035 * @return array<string, mixed>|WP_Error Webhook data, or a `webhook_exists` error
1036 * when the mode is already fully provisioned.
1037 * @since 0.0.1
1038 */
1039 private function create_webhook_for_mode( $mode, $account_id = null ) {
1040 if ( empty( $account_id ) ) {
1041 $account_id = Stripe_Helper::get_default_account_id();
1042 }
1043 if ( empty( $account_id ) ) {
1044 return new WP_Error( 'no_account', __( 'No Stripe account to create a webhook for.', 'suredonation' ) );
1045 }
1046
1047 // Refuse to create a second endpoint for a mode that already has a usable
1048 // one: the stored secret would be overwritten, so the existing endpoint's
1049 // deliveries would start failing verification while still consuming one of
1050 // Stripe's limited per-mode slots. Both the id and the secret must be
1051 // present — an id with no secret cannot verify anything, so that state has
1052 // to stay re-creatable rather than being locked in by this guard.
1053 $account = Stripe_Helper::get_account( $account_id );
1054 if ( is_array( $account )
1055 && ! empty( $account[ "{$mode}_webhook_id" ] )
1056 && ! empty( $account[ "{$mode}_webhook_secret" ] ) ) {
1057 return new WP_Error(
1058 'webhook_exists',
1059 __( 'A webhook is already configured for this mode.', 'suredonation' ),
1060 [ 'status' => 409 ]
1061 );
1062 }
1063
1064 $webhook_url = Stripe_Helper::get_webhook_url( $mode );
1065 $enabled_events = self::get_webhook_events();
1066
1067 $webhook_data = [
1068 'url' => $webhook_url,
1069 'enabled_events' => $enabled_events,
1070 'description' => 'SureDonation ' . ucfirst( $mode ) . ' Webhook',
1071 'api_version' => '2025-07-30.basil',
1072 ];
1073
1074 // Create webhook via Stripe API with explicit mode + account.
1075 $response = Stripe_Helper::stripe_api_request(
1076 'webhook_endpoints',
1077 'POST',
1078 $webhook_data,
1079 [
1080 'mode' => $mode,
1081 'account_id' => $account_id,
1082 ]
1083 );
1084
1085 if ( is_wp_error( $response ) ) {
1086 return $response;
1087 }
1088
1089 // Store the webhook data on the account record.
1090 Stripe_Helper::update_account_fields(
1091 $account_id,
1092 [
1093 "{$mode}_webhook_id" => $response['id'] ?? '',
1094 "{$mode}_webhook_secret" => $response['secret'] ?? '',
1095 "{$mode}_webhook_url" => $webhook_url,
1096 ]
1097 );
1098
1099 return $response;
1100 }
1101
1102 /**
1103 * Delete webhook for mode
1104 *
1105 * @param string $mode Payment mode.
1106 * @param string|null $account_id Account id; the default account is used when empty.
1107 * @return void
1108 * @since 0.0.1
1109 */
1110 private function delete_webhook_for_mode( $mode, $account_id = null ) {
1111 if ( empty( $account_id ) ) {
1112 $account_id = Stripe_Helper::get_default_account_id();
1113 }
1114 if ( empty( $account_id ) ) {
1115 return;
1116 }
1117
1118 $account = Stripe_Helper::get_account( $account_id );
1119 $webhook_id = isset( $account[ "{$mode}_webhook_id" ] ) && is_string( $account[ "{$mode}_webhook_id" ] ) ? $account[ "{$mode}_webhook_id" ] : '';
1120
1121 if ( empty( $webhook_id ) ) {
1122 return;
1123 }
1124
1125 // Delete webhook via Stripe API with explicit mode + account.
1126 Stripe_Helper::stripe_api_request(
1127 'webhook_endpoints/' . $webhook_id,
1128 'DELETE',
1129 [],
1130 [
1131 'mode' => $mode,
1132 'account_id' => $account_id,
1133 ]
1134 );
1135
1136 // Clear the webhook data on the account record.
1137 Stripe_Helper::update_account_fields(
1138 $account_id,
1139 [
1140 "{$mode}_webhook_id" => '',
1141 "{$mode}_webhook_secret" => '',
1142 "{$mode}_webhook_url" => '',
1143 ]
1144 );
1145 }
1146
1147 /**
1148 * Handle Stripe OAuth callback
1149 * Routes to success or error handler based on response.
1150 *
1151 * SECURITY: This private method is ONLY called from intercept_stripe_callback() after:
1152 * 1. current_user_can('manage_options') check passed
1153 * 2. wp_verify_nonce() validated the 'stripe-connect' nonce
1154 * 3. Nonce matched the user-specific transient
1155 *
1156 * @return void
1157 * @since 0.0.1
1158 */
1159 private function handle_stripe_callback() {
1160 // Sanitize callback parameters immediately.
1161 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in intercept_stripe_callback() before this private method is called.
1162 $response = isset( $_GET['response'] ) ? sanitize_text_field( wp_unslash( $_GET['response'] ) ) : '';
1163 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in intercept_stripe_callback() before this private method is called.
1164 $error = isset( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : '';
1165
1166 // Success response.
1167 if ( ! empty( $response ) ) {
1168 $this->process_oauth_success( $response );
1169 return;
1170 }
1171
1172 // Error response.
1173 if ( ! empty( $error ) ) {
1174 $this->process_oauth_error( $error );
1175 return;
1176 }
1177
1178 // No response or error, redirect with generic error.
1179 $redirect_url = add_query_arg(
1180 [
1181 'page' => 'suredonation',
1182 'error' => rawurlencode( __( 'OAuth callback missing response data.', 'suredonation' ) ),
1183 ],
1184 admin_url( 'admin.php' )
1185 );
1186 $redirect_url .= '#/settings?tab=payments&subpage=stripe';
1187
1188 wp_safe_redirect( $redirect_url );
1189 exit;
1190 }
1191
1192 /**
1193 * Process OAuth success response
1194 * Handles successful OAuth callback and stores API keys.
1195 *
1196 * SECURITY: This private method is ONLY called from handle_stripe_callback() after
1197 * intercept_stripe_callback() has verified:
1198 * 1. User capability: current_user_can('manage_options')
1199 * 2. Nonce verification: wp_verify_nonce($nonce, 'stripe-connect')
1200 * 3. Transient match: nonce matches stored user-specific transient
1201 *
1202 * @param string $response_data Sanitized response data from OAuth callback.
1203 * @return void
1204 * @since 0.0.1
1205 */
1206 private function process_oauth_success( $response_data ) {
1207 $decoded = base64_decode( $response_data, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1208 $response = false;
1209
1210 if ( is_string( $decoded ) ) {
1211 $response = json_decode( $decoded, true );
1212 }
1213
1214 if ( ! is_array( $response ) ) {
1215 wp_die(
1216 esc_html__( 'Invalid OAuth response format.', 'suredonation' ),
1217 esc_html__( 'Stripe Connect Error', 'suredonation' ),
1218 [ 'response' => 400 ]
1219 );
1220 }
1221
1222 // The live block carries the account id (stripe_user_id) that keys the account.
1223 $live = isset( $response['live'] ) && is_array( $response['live'] ) ? $response['live'] : [];
1224 $test = isset( $response['test'] ) && is_array( $response['test'] ) ? $response['test'] : [];
1225 $account_id = sanitize_text_field( $live['stripe_user_id'] ?? '' );
1226
1227 if ( '' === $account_id ) {
1228 wp_die(
1229 esc_html__( 'Stripe did not return an account identifier.', 'suredonation' ),
1230 esc_html__( 'Stripe Connect Error', 'suredonation' ),
1231 [ 'response' => 400 ]
1232 );
1233 }
1234
1235 // Upsert the connected account (append; re-connecting the same account refreshes its tokens).
1236 Stripe_Helper::upsert_account(
1237 [
1238 'account_id' => $account_id,
1239 'connected' => true,
1240 'email' => isset( $response['account'], $response['account']['email'] ) ? sanitize_email( $response['account']['email'] ) : '',
1241 'live_publishable_key' => sanitize_text_field( $live['stripe_publishable_key'] ?? '' ),
1242 'live_secret_key' => sanitize_text_field( $live['access_token'] ?? '' ),
1243 'test_publishable_key' => sanitize_text_field( $test['stripe_publishable_key'] ?? '' ),
1244 'test_secret_key' => sanitize_text_field( $test['access_token'] ?? '' ),
1245 ]
1246 );
1247
1248 // Fetch and store the account name/label from Stripe.
1249 $account_name = $this->get_account_name( $account_id );
1250 if ( ! empty( $account_name ) && is_string( $account_name ) ) {
1251 Stripe_Helper::update_account_fields( $account_id, [ 'label' => $account_name ] );
1252 }
1253
1254 // Clean up transients.
1255 delete_transient( 'suredonation_stripe_connect_nonce_' . get_current_user_id() );
1256
1257 // Create webhooks for both live and test mode on this account.
1258 $this->setup_stripe_webhooks( $account_id );
1259
1260 // Redirect to SureDonation payments settings.
1261 wp_safe_redirect( admin_url( 'admin.php?page=suredonation&connected=1#/settings?tab=payments&subpage=stripe' ) );
1262 exit;
1263 }
1264
1265 /**
1266 * Process OAuth error response
1267 * Handles errors from the Stripe OAuth callback.
1268 *
1269 * SECURITY: This private method is ONLY called from handle_stripe_callback() after
1270 * intercept_stripe_callback() has verified:
1271 * 1. User capability: current_user_can('manage_options')
1272 * 2. Nonce verification: wp_verify_nonce($nonce, 'stripe-connect')
1273 * 3. Transient match: nonce matches stored user-specific transient
1274 *
1275 * @param string $error_data Sanitized error data from OAuth callback.
1276 * @return void
1277 * @since 0.0.1
1278 */
1279 private function process_oauth_error( $error_data ) {
1280 // Defense-in-depth: Re-verify user capabilities (already checked in intercept_stripe_callback).
1281 if ( ! current_user_can( 'manage_options' ) ) {
1282 wp_die(
1283 esc_html__( 'You do not have permission to connect Stripe.', 'suredonation' ),
1284 esc_html__( 'Permission Denied', 'suredonation' ),
1285 [ 'response' => 403 ]
1286 );
1287 }
1288
1289 // Decode error data (already sanitized in handle_stripe_callback).
1290 $decoded = base64_decode( $error_data, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1291 $error = is_string( $decoded ) ? json_decode( $decoded, true ) : [];
1292 if ( ! is_array( $error ) ) {
1293 $error = [];
1294 }
1295
1296 $error_message = __( 'Failed to connect to Stripe.', 'suredonation' );
1297 if ( isset( $error['message'] ) && is_string( $error['message'] ) ) {
1298 $error_message = sanitize_text_field( $error['message'] );
1299 }
1300
1301 // Clean up transients.
1302 delete_transient( 'suredonation_stripe_connect_nonce_' . get_current_user_id() );
1303
1304 // Redirect with error.
1305 $redirect_url = add_query_arg(
1306 [
1307 'page' => 'suredonation',
1308 'error' => rawurlencode( $error_message ),
1309 ],
1310 admin_url( 'admin.php' )
1311 );
1312 $redirect_url .= '#/settings?tab=payments&subpage=stripe';
1313
1314 wp_safe_redirect( $redirect_url );
1315 exit;
1316 }
1317
1318 /**
1319 * Setup Stripe webhooks for both test and live modes
1320 *
1321 * @param string|null $account_id Account id; the default account is used when empty.
1322 * @return array<string, mixed> Result of webhook creation.
1323 * @since 0.0.1
1324 */
1325 private function setup_stripe_webhooks( $account_id = null ) {
1326 if ( empty( $account_id ) ) {
1327 $account_id = Stripe_Helper::get_default_account_id();
1328 }
1329
1330 $modes = [ 'test', 'live' ];
1331 $webhooks_created = 0;
1332 $webhooks_skipped = 0;
1333 $errors = [];
1334
1335 foreach ( $modes as $mode ) {
1336 $secret_key = Stripe_Helper::get_stripe_secret_key( $mode, $account_id );
1337
1338 if ( empty( $secret_key ) ) {
1339 continue;
1340 }
1341
1342 $result = $this->create_webhook_for_mode( $mode, $account_id );
1343
1344 if ( ! is_wp_error( $result ) ) {
1345 ++$webhooks_created;
1346 } elseif ( 'webhook_exists' === $result->get_error_code() ) {
1347 // Already provisioned for this mode; the guard lives in
1348 // create_webhook_for_mode() so both callers share it.
1349 ++$webhooks_skipped;
1350 } else {
1351 /* translators: 1: payment mode (test or live), 2: error message from Stripe. */
1352 $errors[ $mode ] = sprintf( __( '%1$s: %2$s', 'suredonation' ), ucfirst( $mode ), $result->get_error_message() );
1353 }
1354 }
1355
1356 // One mode failing must not discard another mode's success: the created
1357 // webhook is already persisted, and reporting overall failure leaves the
1358 // admin retrying an action that has partly succeeded. Only the modes
1359 // listed in `errors` need attention.
1360 return [
1361 'success' => ( $webhooks_created + $webhooks_skipped ) > 0,
1362 'created' => $webhooks_created,
1363 'skipped' => $webhooks_skipped,
1364 'errors' => $errors,
1365 'message' => implode( ' ', $errors ),
1366 ];
1367 }
1368
1369 /**
1370 * Sanitize settings
1371 *
1372 * @param array<string, mixed> $settings Settings array.
1373 * @return array<string, mixed> Sanitized settings.
1374 * @since 0.0.1
1375 */
1376 private function sanitize_settings( $settings ) {
1377 $sanitized = [];
1378
1379 $text_fields = [
1380 'stripe_account_id',
1381 'stripe_account_email',
1382 'account_name',
1383 'stripe_live_publishable_key',
1384 'stripe_live_secret_key',
1385 'stripe_test_publishable_key',
1386 'stripe_test_secret_key',
1387 'webhook_test_secret',
1388 'webhook_test_url',
1389 'webhook_test_id',
1390 'webhook_live_secret',
1391 'webhook_live_url',
1392 'webhook_live_id',
1393 ];
1394
1395 foreach ( $text_fields as $field ) {
1396 if ( isset( $settings[ $field ] ) ) {
1397 $value = $settings[ $field ];
1398 $sanitized[ $field ] = is_string( $value ) ? sanitize_text_field( $value ) : '';
1399 }
1400 }
1401
1402 if ( isset( $settings['stripe_connected'] ) ) {
1403 $sanitized['stripe_connected'] = (bool) $settings['stripe_connected'];
1404 }
1405
1406 return $sanitized;
1407 }
1408 }
1409