PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
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.6.1, at inc/payments/stripe/stripe-settings.php

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