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-webhook.php

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

1,020 lines 35.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Stripe Webhook Handler
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\Database\Tables\Donations;
16 use SureDonation\Inc\Database\Tables\Donors;
17 use SureDonation\Inc\Emails\Email_Handler;
18 use SureDonation\Inc\Payments\Payment_Helper;
19 use SureDonation\Inc\Traits\Get_Instance;
20 use WP_REST_Request;
21 use WP_REST_Response;
22 use WP_REST_Server;
23
24 /**
25 * Stripe_Webhook class
26 * Handles Stripe webhook events
27 *
28 * @since 0.0.1
29 */
30 class Stripe_Webhook {
31 use Get_Instance;
32
33 /**
34 * Constructor
35 *
36 * @since 0.0.1
37 */
38 public function __construct() {
39 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
40 }
41
42 /**
43 * Register webhook endpoints.
44 *
45 * @return void
46 * @since 0.0.1
47 */
48 public function register_routes() {
49 // Real Stripe deliveries never send `mode` (it falls back to the route
50 // default), so this only rejects a caller-supplied value that is not an
51 // allowed mode — preventing it from reaching the interpolated transient
52 // key in log_webhook_event() before the signature is verified.
53 $validate_mode = static function ( $value ) {
54 return in_array( $value, [ 'test', 'live' ], true );
55 };
56
57 // Test mode webhook.
58 register_rest_route(
59 'suredonation',
60 '/webhook_test',
61 [
62 'methods' => WP_REST_Server::CREATABLE,
63 'callback' => [ $this, 'handle_webhook' ],
64 'permission_callback' => '__return_true', // Stripe signature validation handles security.
65 'args' => [
66 'mode' => [
67 'default' => 'test',
68 'type' => 'string',
69 'enum' => [ 'test', 'live' ],
70 'validate_callback' => $validate_mode,
71 ],
72 ],
73 ]
74 );
75
76 // Live mode webhook.
77 register_rest_route(
78 'suredonation',
79 '/webhook_live',
80 [
81 'methods' => WP_REST_Server::CREATABLE,
82 'callback' => [ $this, 'handle_webhook' ],
83 'permission_callback' => '__return_true', // Stripe signature validation handles security.
84 'args' => [
85 'mode' => [
86 'default' => 'live',
87 'type' => 'string',
88 'enum' => [ 'test', 'live' ],
89 'validate_callback' => $validate_mode,
90 ],
91 ],
92 ]
93 );
94 }
95
96 /**
97 * Handle webhook event
98 *
99 * @param WP_REST_Request $request Request object.
100 * @return WP_REST_Response Response object.
101 * @since 0.0.1
102 */
103 public function handle_webhook( $request ) {
104 $mode = $request->get_param( 'mode' );
105
106 // Validate source IP if IP whitelisting is enabled.
107 if ( ! $this->validate_source_ip() ) {
108 return new WP_REST_Response( [ 'error' => 'Access denied' ], 403 );
109 }
110
111 // Get raw body.
112 $payload = $request->get_body();
113
114 // Get Stripe signature.
115 $sig_header = $request->get_header( 'stripe_signature' );
116
117 if ( empty( $sig_header ) ) {
118 $this->log_webhook_event( $mode, 'error', 'Missing Stripe signature header' );
119 return new WP_REST_Response( [ 'error' => 'Missing signature' ], 400 );
120 }
121
122 // Verify signature (tries every connected account's secret for this mode).
123 $account_id = '';
124 $event = $this->verify_webhook_signature( $payload, $sig_header, $mode, $account_id );
125
126 if ( is_wp_error( $event ) ) {
127 $this->log_webhook_event( $mode, 'error', $event->get_error_message() );
128 return new WP_REST_Response( [ 'error' => 'Webhook signature verification failed' ], 400 );
129 }
130
131 // Log webhook began.
132 $this->log_webhook_event( $mode, 'began', 'Webhook processing started', $event );
133
134 // Process event.
135 $result = $this->process_event( $event, $mode, $account_id );
136
137 if ( is_wp_error( $result ) ) {
138 $this->log_webhook_event( $mode, 'failure', $result->get_error_message(), $event );
139 return new WP_REST_Response( [ 'error' => 'Webhook processing failed' ], 500 );
140 }
141
142 // Log success.
143 $this->log_webhook_event( $mode, 'success', 'Webhook processed successfully', $event );
144
145 return new WP_REST_Response( [ 'success' => true ], 200 );
146 }
147
148 /**
149 * Verify the Stripe webhook signature locally.
150 *
151 * Validates the Stripe-Signature header against the configured webhook
152 * secret using HMAC-SHA256 (Stripe's documented signing scheme), so the
153 * webhook secret never leaves the server. On success the verified event
154 * payload is decoded and returned.
155 *
156 * @param string $payload Raw webhook request body.
157 * @param string $sig_header Signature header value.
158 * @param string $mode Payment mode ('test' or 'live') the endpoint received. Both are tried when empty.
159 * @param string $account_id Out: the connected account whose secret verified the signature.
160 * @return array<string, mixed>|\WP_Error Decoded event data on success, WP_Error on failure.
161 * @since 0.0.1
162 */
163 private function verify_webhook_signature( $payload, $sig_header, $mode = '', &$account_id = '' ) {
164
165 // Each connected account has its own webhook endpoint (and signing secret)
166 // pointing at this shared URL, so try every account's secret for the mode.
167 $modes = in_array( $mode, [ 'test', 'live' ], true ) ? [ $mode ] : [ 'test', 'live' ];
168 $secrets = [];
169
170 foreach ( Stripe_Helper::get_all_accounts() as $account ) {
171 if ( ! is_array( $account ) ) {
172 continue;
173 }
174 foreach ( $modes as $candidate_mode ) {
175 $secret = $account[ $candidate_mode . '_webhook_secret' ] ?? '';
176 if ( is_string( $secret ) && '' !== $secret ) {
177 // Keep the owning account with each secret: whichever secret
178 // verifies identifies the account the event belongs to, which
179 // downstream handlers need to call the Stripe API back.
180 $secrets[] = [
181 'account_id' => is_string( $account['account_id'] ?? null ) ? $account['account_id'] : '',
182 'secret' => $secret,
183 ];
184 }
185 }
186 }
187
188 if ( empty( $secrets ) ) {
189 return new \WP_Error( 'no_webhook_secret', __( 'Webhook secret not configured', 'suredonation' ) );
190 }
191
192 $verified = false;
193 foreach ( $secrets as $candidate ) {
194 if ( $this->verify_signature_locally( $payload, $sig_header, $candidate['secret'] ) ) {
195 $verified = true;
196 $account_id = $candidate['account_id'];
197 break;
198 }
199 }
200
201 if ( ! $verified ) {
202 return new \WP_Error( 'signature_verification_failed', __( 'Webhook signature verification failed', 'suredonation' ) );
203 }
204
205 // Signature verified — decode the event payload.
206 $event = json_decode( $payload, true );
207
208 if ( ! is_array( $event ) ) {
209 return new \WP_Error( 'invalid_payload', __( 'Invalid webhook payload', 'suredonation' ) );
210 }
211
212 /** Verified event data. @var array<string, mixed> $event */
213 return $event;
214 }
215
216 /**
217 * Verify a Stripe webhook signature locally using HMAC-SHA256.
218 *
219 * Implements Stripe's signature scheme without any external dependency or
220 * SDK: parse the timestamp (t) and signature (v1) from the Stripe-Signature
221 * header, recompute HMAC-SHA256 over "{timestamp}.{payload}" with the
222 * webhook secret, and compare in constant time. Requests older than the
223 * tolerance window are rejected to mitigate replay attacks.
224 *
225 * @param string $payload Raw webhook request body.
226 * @param string $sig_header Stripe-Signature header value.
227 * @param string $secret Webhook signing secret (whsec_...).
228 * @param int $tolerance Maximum accepted age of the signature, in seconds.
229 * @return bool True if the signature is valid and within tolerance.
230 * @since 1.1.1
231 */
232 private function verify_signature_locally( $payload, $sig_header, $secret, $tolerance = 300 ) {
233 if ( ! is_string( $payload ) || ! is_string( $sig_header ) || '' === $sig_header ) {
234 return false;
235 }
236
237 // Parse the Stripe-Signature header (format: t=timestamp,v1=signature,...).
238 $timestamp = '';
239 $signature = '';
240
241 foreach ( explode( ',', $sig_header ) as $part ) {
242 $pair = explode( '=', $part, 2 );
243 if ( 2 !== count( $pair ) ) {
244 continue;
245 }
246 if ( 't' === $pair[0] ) {
247 $timestamp = $pair[1];
248 } elseif ( 'v1' === $pair[0] ) {
249 $signature = $pair[1];
250 }
251 }
252
253 if ( '' === $timestamp || '' === $signature ) {
254 return false;
255 }
256
257 // Replay protection: reject signatures older than the tolerance window.
258 if ( absint( $timestamp ) < time() - $tolerance ) {
259 return false;
260 }
261
262 // Recompute the expected signature and compare in constant time.
263 $expected_signature = hash_hmac( 'sha256', $timestamp . '.' . $payload, $secret );
264
265 return hash_equals( $expected_signature, $signature );
266 }
267
268 /**
269 * Process webhook event.
270 *
271 * @param array<string, mixed> $event Event data.
272 * @param string $mode Payment mode.
273 * @param string $account_id Connected account whose secret verified the event.
274 * @return bool|\WP_Error True on success, WP_Error on failure.
275 * @since 0.0.1
276 */
277 private function process_event( $event, $mode, $account_id = '' ) {
278 $event_type = isset( $event['type'] ) && is_string( $event['type'] ) ? $event['type'] : '';
279
280 // Extract event data safely.
281 $event_data = [];
282 if ( isset( $event['data'] ) && is_array( $event['data'] ) && isset( $event['data']['object'] ) && is_array( $event['data']['object'] ) ) {
283 $event_data = $event['data']['object'];
284 }
285
286 /** Event object data. @var array<string, mixed> $event_data */
287
288 switch ( $event_type ) {
289 case 'payment_intent.succeeded':
290 return $this->handle_payment_succeeded( $event_data, $mode );
291
292 case 'payment_intent.payment_failed':
293 return $this->handle_payment_failed( $event_data, $mode );
294
295 case 'charge.refund.updated':
296 // This event sends a Refund object (not a Charge object).
297 // Matches SureForms pattern for handling refunds from Stripe dashboard.
298 return $this->handle_refund_updated( $event_data, $mode );
299
300 case 'payment_intent.canceled':
301 return $this->handle_payment_canceled( $event_data, $mode );
302
303 case 'account.updated':
304 return $this->handle_account_updated( $event_data, $mode, $account_id );
305
306 default:
307 /**
308 * Allow extensions to handle additional webhook events.
309 *
310 * Pro plugin uses this to handle subscription events
311 * (customer.subscription.created, invoice.payment_succeeded, etc.)
312 *
313 * Return contract:
314 * - null : Filter did not handle the event.
315 * - true : Handled successfully.
316 * - WP_Error with code 'permanent_failure' or 'invalid_event'
317 * : Handled but unrecoverable — webhook is acked
318 * to Stripe so retries stop. Use this for
319 * malformed event data the callback will
320 * never be able to process.
321 * - Any other WP_Error: Transient failure — returned as-is so
322 * Stripe retries on its standard schedule.
323 *
324 * @param mixed $result Initially null; callbacks may return true, WP_Error, or null.
325 * @param string $event_type The Stripe event type.
326 * @param array<string, mixed> $event_data The event object data.
327 * @param string $mode Payment mode (live/test).
328 * @param string $account_id Connected Stripe account whose signing secret verified
329 * this event. Callbacks that call the Stripe API back
330 * must pass it as `stripe_api_request()`'s
331 * `account_id` extra arg, or they will hit the default
332 * account instead of the one that owns the event.
333 * @since 1.0.0
334 */
335 $result = apply_filters( 'suredonation_webhook_handle_event', null, $event_type, $event_data, $mode, $account_id );
336
337 // Only accept null (unhandled), true (success), or WP_Error (failure).
338 // Reject other return types (e.g. false from buggy callbacks) to ensure Stripe retries.
339 if ( null !== $result ) {
340 if ( true === $result ) {
341 return true;
342 }
343
344 if ( is_wp_error( $result ) ) {
345 // Drop permanent-failure errors so Stripe stops retrying events
346 // the pro callback will never be able to process (e.g. malformed
347 // event data). Transient errors fall through and are returned
348 // as-is so Stripe retries on its standard schedule.
349 if ( in_array( $result->get_error_code(), [ 'permanent_failure', 'invalid_event' ], true ) ) {
350 return true;
351 }
352
353 return $result;
354 }
355
356 return new \WP_Error( 'webhook_filter_invalid', __( 'Webhook filter returned unexpected value', 'suredonation' ) );
357 }
358
359 // Event not handled, but not an error.
360 return true;
361 }
362 }
363
364 /**
365 * Handle payment succeeded event.
366 *
367 * @param array<string, mixed> $data Payment intent data.
368 * @param string $mode Payment mode.
369 * @return bool|\WP_Error True on success.
370 * @since 0.0.1
371 */
372 private function handle_payment_succeeded( $data, $mode ) {
373 $payment_intent_id = isset( $data['id'] ) && is_string( $data['id'] ) ? $data['id'] : '';
374
375 if ( empty( $payment_intent_id ) ) {
376 return new \WP_Error( 'missing_payment_intent_id', 'Payment intent ID not found in event data' );
377 }
378
379 // Security: Verify payment amount matches expected amount.
380 // This detects payment amount manipulation attacks.
381 $actual_amount = isset( $data['amount'] ) && is_numeric( $data['amount'] ) ? (int) $data['amount'] : 0;
382 $currency = isset( $data['currency'] ) && is_string( $data['currency'] ) ? $data['currency'] : 'usd';
383
384 $amount_verification = Payment_Helper::verify_payment_intent_amount(
385 $payment_intent_id,
386 $actual_amount,
387 $currency
388 );
389
390 if ( is_wp_error( $amount_verification ) ) {
391 // Find donation and mark as suspicious for manual review.
392 $donation = Donations::get_by_transaction_id( $payment_intent_id );
393 if ( $donation && isset( $donation['id'] ) && is_numeric( $donation['id'] ) ) {
394 $donation_id = absint( $donation['id'] );
395 $error_data = $amount_verification->get_error_data();
396 Donations::update_status( $donation_id, 'suspicious' );
397 Donations::add_log(
398 $donation_id,
399 'security_warning',
400 $amount_verification->get_error_message(),
401 [
402 'payment_intent_id' => $payment_intent_id,
403 'expected_amount' => is_array( $error_data ) && isset( $error_data['expected'] ) ? $error_data['expected'] : 'unknown',
404 'actual_amount' => $actual_amount,
405 'mode' => $mode,
406 ]
407 );
408 }
409
410 return new \WP_Error(
411 'amount_mismatch',
412 $amount_verification->get_error_message()
413 );
414 }
415
416 // Find donation by transaction ID.
417 $donation = Donations::get_by_transaction_id( $payment_intent_id );
418
419 if ( ! $donation || ! isset( $donation['id'] ) || ! is_numeric( $donation['id'] ) ) {
420 return new \WP_Error( 'donation_not_found', 'Donation record not found for transaction: ' . $payment_intent_id );
421 }
422
423 $donation_id = absint( $donation['id'] );
424
425 // Update donation status to completed.
426 $updated = Donations::update_status( $donation_id, 'completed' );
427
428 if ( ! $updated ) {
429 return new \WP_Error( 'update_failed', 'Failed to update donation record' );
430 }
431
432 // Get fees covered from donation record.
433 $fees_covered = isset( $donation['fees_covered'] ) && is_numeric( $donation['fees_covered'] ) ? (float) $donation['fees_covered'] : 0.0;
434
435 // Add log entry.
436 Donations::add_log(
437 $donation_id,
438 'payment_succeeded',
439 __( 'Payment completed successfully via webhook', 'suredonation' ),
440 [
441 'payment_intent_id' => $payment_intent_id,
442 'amount_verified' => true,
443 'fees_covered' => $fees_covered,
444 'mode' => $mode,
445 ]
446 );
447
448 // Update donor statistics.
449 if ( ! empty( $donation['donor_id'] ) ) {
450 $donor_id_val = $donation['donor_id'];
451 $amount_val = $donation['amount'] ?? 0;
452 $donor_id = is_numeric( $donor_id_val ) ? (int) $donor_id_val : 0;
453 $amount = is_numeric( $amount_val ) ? (float) $amount_val : 0.0;
454 if ( $donor_id > 0 ) {
455 // The client-side confirm records this too. Whichever arrives
456 // first wins; the other is a no-op, so a donor's total is not
457 // doubled when both run.
458 Donors::record_donation_once( $donor_id, $amount, $donation_id );
459 }
460 }
461
462 // Send confirmation emails if not already sent by complete_donation().
463 // Skip for recurring/renewal donations — pro plugin handles subscription emails.
464 $was_pending = 'pending' === ( $donation['payment_status'] ?? '' );
465 $is_subscription = in_array( $donation['donation_type'] ?? '', [ 'recurring', 'renewal' ], true );
466 if ( $was_pending && ! $is_subscription ) {
467 $campaign_id = isset( $donation['campaign_id'] ) && is_numeric( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
468 $form_id = isset( $donation['form_id'] ) && is_numeric( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
469 $donation_data = [
470 'id' => $donation_id,
471 'donor_name' => $donation['donor_name'] ?? '',
472 'donor_email' => $donation['donor_email'] ?? '',
473 'amount' => $donation['amount'] ?? 0,
474 'fees_covered' => $donation['fees_covered'] ?? 0,
475 'currency' => $donation['currency'] ?? 'USD',
476 'transaction_id' => $payment_intent_id,
477 'gateway' => 'stripe',
478 'donation_type' => $donation['donation_type'] ?? 'one-time',
479 ];
480
481 Email_Handler::send_donation_confirmation( $donation_id, $campaign_id, $donation_data, $form_id );
482 }
483
484 return true;
485 }
486
487 /**
488 * Handle payment failed event.
489 *
490 * @param array<string, mixed> $data Payment intent data.
491 * @param string $mode Payment mode.
492 * @return bool|\WP_Error True on success.
493 * @since 0.0.1
494 */
495 private function handle_payment_failed( $data, $mode ) {
496 $payment_intent_id = isset( $data['id'] ) && is_string( $data['id'] ) ? $data['id'] : '';
497
498 if ( empty( $payment_intent_id ) ) {
499 return new \WP_Error( 'missing_payment_intent_id', 'Payment intent ID not found in event data' );
500 }
501
502 // Find donation by transaction ID.
503 $donation = Donations::get_by_transaction_id( $payment_intent_id );
504
505 if ( ! $donation || ! isset( $donation['id'] ) || ! is_numeric( $donation['id'] ) ) {
506 return new \WP_Error( 'donation_not_found', 'Donation record not found' );
507 }
508
509 $donation_id = absint( $donation['id'] );
510
511 // Update donation status to failed.
512 Donations::update_status( $donation_id, 'failed' );
513
514 // Get error message safely.
515 $error_message = '';
516 if ( isset( $data['last_payment_error'] ) && is_array( $data['last_payment_error'] ) && isset( $data['last_payment_error']['message'] ) ) {
517 $error_message = is_string( $data['last_payment_error']['message'] ) ? $data['last_payment_error']['message'] : '';
518 }
519
520 // Add log entry.
521 Donations::add_log(
522 $donation_id,
523 'payment_failed',
524 __( 'Payment failed via webhook', 'suredonation' ),
525 [
526 'payment_intent_id' => $payment_intent_id,
527 'mode' => $mode,
528 'error' => $error_message,
529 ]
530 );
531
532 // Send donation failed emails.
533 $campaign_id = isset( $donation['campaign_id'] ) && is_numeric( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
534 $form_id = isset( $donation['form_id'] ) && is_numeric( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
535 $donation_data = [
536 'id' => $donation_id,
537 'donor_name' => $donation['donor_name'] ?? '',
538 'donor_email' => $donation['donor_email'] ?? '',
539 'amount' => $donation['amount'] ?? 0,
540 'currency' => $donation['currency'] ?? 'USD',
541 'donation_type' => $donation['donation_type'] ?? 'one-time',
542 'gateway' => 'stripe',
543 ];
544
545 Email_Handler::send_donation_failed( $donation_id, $campaign_id, $donation_data, $form_id );
546
547 return true;
548 }
549
550 /**
551 * Handle charge.refund.updated event.
552 *
553 * This event is triggered when a refund status changes, including when created from Stripe dashboard.
554 * The event data contains a Refund object (not a Charge object).
555 * This matches the SureForms pattern for handling refunds.
556 *
557 * @param array<string, mixed> $refund Refund object data from webhook.
558 * @param string $mode Payment mode.
559 * @return bool|\WP_Error True on success.
560 * @since 0.0.1
561 */
562 private function handle_refund_updated( $refund, $mode ) {
563 $refund_id = isset( $refund['id'] ) && is_string( $refund['id'] ) ? $refund['id'] : '';
564
565 // Extract payment identifiers from refund object.
566 $payment_intent_id = isset( $refund['payment_intent'] ) && is_string( $refund['payment_intent'] ) ? $refund['payment_intent'] : '';
567 $charge_id = isset( $refund['charge'] ) && is_string( $refund['charge'] ) ? $refund['charge'] : '';
568
569 $donation = null;
570
571 // Method 1: Try to find donation by payment_intent.
572 if ( ! empty( $payment_intent_id ) ) {
573 $donation = Donations::get_by_transaction_id( $payment_intent_id );
574 }
575
576 // Method 2: Try to find by charge ID as fallback.
577 if ( ! $donation && ! empty( $charge_id ) ) {
578 $donation = Donations::get_by_transaction_id( $charge_id );
579 }
580
581 if ( ! $donation || ! isset( $donation['id'] ) || ! is_numeric( $donation['id'] ) ) {
582 return new \WP_Error( 'donation_not_found', 'Donation record not found for refund' );
583 }
584
585 $donation_id = absint( $donation['id'] );
586
587 // Extract refund details from the Refund object.
588 $refund_amount_cents = isset( $refund['amount'] ) && is_numeric( $refund['amount'] ) ? (int) $refund['amount'] : 0;
589 $currency = isset( $refund['currency'] ) && is_string( $refund['currency'] ) ? strtolower( $refund['currency'] ) : 'usd';
590 $refund_status = isset( $refund['status'] ) && is_string( $refund['status'] ) ? $refund['status'] : 'unknown';
591
592 // Handle refund cancellation.
593 if ( 'canceled' === $refund_status ) {
594 return $this->process_refund_cancellation( $donation, $refund, $currency, $mode );
595 }
596
597 // Only process succeeded refunds.
598 if ( 'succeeded' !== $refund_status ) {
599 return true; // Not an error, just skip.
600 }
601
602 // Check if this refund was already processed (duplicate prevention with lock).
603 $lock_key = 'suredonation_refund_lock_' . $refund_id;
604 if ( get_transient( $lock_key ) || Donations::check_refund_exists( $donation_id, $refund_id ) ) {
605 return true; // Already processed or being processed.
606 }
607 set_transient( $lock_key, true, 60 );
608
609 // Get current donation data.
610 $original_amount = isset( $donation['amount'] ) && is_numeric( $donation['amount'] ) ? (float) $donation['amount'] : 0;
611 $existing_refunded = isset( $donation['refunded_amount'] ) && is_numeric( $donation['refunded_amount'] ) ? (float) $donation['refunded_amount'] : 0;
612 $new_refund_amount = Payment_Helper::amount_from_stripe_format( $refund_amount_cents, $currency );
613 $total_refunded = $existing_refunded + $new_refund_amount;
614
615 // Prevent over-refunding.
616 if ( $total_refunded > $original_amount ) {
617 return new \WP_Error( 'over_refund', 'Refund would exceed original donation amount' );
618 }
619
620 // Determine new payment status.
621 $payment_status = 'completed';
622 if ( $total_refunded >= $original_amount ) {
623 $payment_status = 'refunded';
624 } elseif ( $total_refunded > 0 ) {
625 $payment_status = 'partially_refunded';
626 }
627
628 // Store refund in donation_data for audit trail and duplicate prevention.
629 $refund_data = [
630 'refund_id' => $refund_id,
631 'amount' => absint( $refund_amount_cents ),
632 'currency' => strtoupper( $currency ),
633 'status' => $refund_status,
634 'created' => time(),
635 'reason' => isset( $refund['reason'] ) && is_string( $refund['reason'] ) ? $refund['reason'] : 'requested_by_customer',
636 'description' => isset( $refund['description'] ) && is_string( $refund['description'] ) ? $refund['description'] : '',
637 'receipt_number' => isset( $refund['receipt_number'] ) && is_string( $refund['receipt_number'] ) ? $refund['receipt_number'] : '',
638 'refunded_by' => 'stripe_dashboard',
639 'refunded_at' => gmdate( 'Y-m-d H:i:s' ),
640 ];
641 Donations::add_refund_to_donation_data( $donation_id, $refund_data );
642
643 // Update donation with new refunded amount and status.
644 Donations::update(
645 $donation_id,
646 [
647 'payment_status' => $payment_status,
648 'refunded_amount' => $total_refunded,
649 ]
650 );
651
652 // Send refund processed emails.
653 $campaign_id = isset( $donation['campaign_id'] ) && is_numeric( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
654 $form_id = isset( $donation['form_id'] ) && is_numeric( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
655 $donation_data = [
656 'id' => $donation_id,
657 'donor_name' => $donation['donor_name'] ?? '',
658 'donor_email' => $donation['donor_email'] ?? '',
659 'amount' => $donation['amount'] ?? 0,
660 'currency' => strtoupper( $currency ),
661 'refund_amount' => $new_refund_amount,
662 'donation_type' => $donation['donation_type'] ?? 'one-time',
663 'gateway' => 'stripe',
664 ];
665
666 Email_Handler::send_refund_processed( $donation_id, $campaign_id, $donation_data, $form_id );
667
668 // Determine refund type for log message.
669 $refund_type = $total_refunded >= $original_amount
670 ? __( 'Full', 'suredonation' )
671 : __( 'Partial', 'suredonation' );
672
673 // Add log entry.
674 Donations::add_log(
675 $donation_id,
676 'refund',
677 sprintf(
678 /* translators: %s: Refund type (Full/Partial) */
679 __( '%s refund processed via webhook', 'suredonation' ),
680 $refund_type
681 ),
682 [
683 'refund_id' => $refund_id,
684 'refund_amount' => $new_refund_amount,
685 'total_refunded' => $total_refunded,
686 'original_amount' => $original_amount,
687 'payment_status' => $payment_status,
688 'currency' => strtoupper( $currency ),
689 'mode' => $mode,
690 'payment_intent_id' => $payment_intent_id,
691 ]
692 );
693
694 return true;
695 }
696
697 /**
698 * Process refund cancellation - reverses a previously processed refund.
699 *
700 * @param array<string, mixed> $donation Donation record.
701 * @param array<string, mixed> $refund Refund object from Stripe webhook.
702 * @param string $currency Currency code.
703 * @param string $mode Payment mode.
704 * @return bool|\WP_Error True on success.
705 * @since 0.0.1
706 */
707 private function process_refund_cancellation( $donation, $refund, $currency, $mode ) {
708 $refund_id = isset( $refund['id'] ) && is_string( $refund['id'] ) ? $refund['id'] : '';
709 $donation_id = isset( $donation['id'] ) && is_numeric( $donation['id'] ) ? absint( $donation['id'] ) : 0;
710
711 if ( ! $donation_id ) {
712 return new \WP_Error( 'invalid_donation', 'Invalid donation record for refund cancellation' );
713 }
714
715 // Use shared method to remove refund and get the refund data.
716 $remove_result = Donations::remove_refund_from_donation_data( $donation_id, $refund_id );
717
718 if ( ! $remove_result['removed'] ) {
719 return true; // Not an error, just skip - refund may not have been tracked.
720 }
721
722 $existing_refund = $remove_result['refund_data'];
723 $canceled_refund_amount_cents = isset( $existing_refund['amount'] ) && is_numeric( $existing_refund['amount'] ) ? (int) $existing_refund['amount'] : 0;
724 $refund_currency = isset( $existing_refund['currency'] ) && is_string( $existing_refund['currency'] ) ? strtolower( $existing_refund['currency'] ) : $currency;
725
726 if ( $canceled_refund_amount_cents <= 0 ) {
727 return new \WP_Error( 'invalid_amount', 'Invalid refund cancellation amount' );
728 }
729
730 // Convert from Stripe format (cents) to decimal format.
731 $canceled_refund_amount = Payment_Helper::amount_from_stripe_format( $canceled_refund_amount_cents, $refund_currency );
732
733 // Calculate new refunded amount after cancellation.
734 $current_refunded = isset( $donation['refunded_amount'] ) && is_numeric( $donation['refunded_amount'] ) ? (float) $donation['refunded_amount'] : 0;
735 $new_refunded_amount = max( 0, $current_refunded - $canceled_refund_amount );
736
737 // Recalculate payment status.
738 $original_amount = isset( $donation['amount'] ) && is_numeric( $donation['amount'] ) ? (float) $donation['amount'] : 0;
739 $payment_status = 'completed';
740
741 if ( $new_refunded_amount >= $original_amount ) {
742 $payment_status = 'refunded';
743 } elseif ( $new_refunded_amount > 0 ) {
744 $payment_status = 'partially_refunded';
745 }
746
747 // Extract failure reason from refund object.
748 $failure_reason = isset( $refund['failure_reason'] ) && is_string( $refund['failure_reason'] ) ? $refund['failure_reason'] : 'unknown';
749
750 // Update donation record with new status and refunded amount.
751 Donations::update(
752 $donation_id,
753 [
754 'payment_status' => $payment_status,
755 'refunded_amount' => $new_refunded_amount,
756 ]
757 );
758
759 // Add log entry.
760 Donations::add_log(
761 $donation_id,
762 'refund_canceled',
763 __( 'Refund canceled via webhook', 'suredonation' ),
764 [
765 'refund_id' => $refund_id,
766 'canceled_amount' => $canceled_refund_amount,
767 'remaining_refunded' => $new_refunded_amount,
768 'original_amount' => $original_amount,
769 'payment_status' => $payment_status,
770 'cancellation_reason' => $failure_reason,
771 'currency' => strtoupper( $refund_currency ),
772 'mode' => $mode,
773 ]
774 );
775
776 return true;
777 }
778
779 /**
780 * Handle payment canceled event.
781 *
782 * @param array<string, mixed> $data Payment intent data.
783 * @param string $mode Payment mode.
784 * @return bool|\WP_Error True on success.
785 * @since 0.0.1
786 */
787 private function handle_payment_canceled( $data, $mode ) {
788 $payment_intent_id = isset( $data['id'] ) && is_string( $data['id'] ) ? $data['id'] : '';
789
790 if ( empty( $payment_intent_id ) ) {
791 return new \WP_Error( 'missing_payment_intent_id', 'Payment intent ID not found' );
792 }
793
794 // Find donation by transaction ID.
795 $donation = Donations::get_by_transaction_id( $payment_intent_id );
796
797 if ( ! $donation || ! isset( $donation['id'] ) || ! is_numeric( $donation['id'] ) ) {
798 return new \WP_Error( 'donation_not_found', 'Donation record not found' );
799 }
800
801 $donation_id = absint( $donation['id'] );
802
803 // Update donation status to cancelled.
804 Donations::update_status( $donation_id, 'cancelled' );
805
806 // Add log entry.
807 Donations::add_log(
808 $donation_id,
809 'payment_canceled',
810 __( 'Payment canceled via webhook', 'suredonation' ),
811 [
812 'payment_intent_id' => $payment_intent_id,
813 'mode' => $mode,
814 ]
815 );
816
817 return true;
818 }
819
820 /**
821 * Handle an account.updated event.
822 *
823 * Keeps the stored capability snapshot honest. Stripe can restrict an
824 * account long after connect, and the connect-time check alone would leave
825 * the settings warning silently out of date — the exact failure this whole
826 * feature exists to end. The event payload *is* the account object, so this
827 * costs no API call.
828 *
829 * The account is taken from the webhook secret that verified the event, not
830 * from the payload: only one account's signing secret can have produced it,
831 * and trusting `data.object.id` would let a verified event write state onto
832 * a different connected account.
833 *
834 * @param array<string, mixed> $data Account object from the event.
835 * @param string $mode Payment mode.
836 * @param string $account_id Connected account whose secret verified the event.
837 * @return bool|\WP_Error True on success, WP_Error when unrecoverable.
838 * @since 1.5.1
839 */
840 private function handle_account_updated( $data, $mode, $account_id = '' ) {
841 if ( ! is_string( $account_id ) || '' === $account_id ) {
842 // Nothing to attribute the state to. Permanent: a retry produces the
843 // same event with the same missing context, so ack it and stop.
844 return new \WP_Error( 'invalid_event', 'No connected account resolved for account.updated' );
845 }
846
847 if ( ! is_array( $data ) || empty( $data ) ) {
848 return new \WP_Error( 'invalid_event', 'account.updated carried no account object' );
849 }
850
851 $updated = Stripe_Helper::update_account_fields(
852 $account_id,
853 [ "{$mode}_account_state" => Stripe_Helper::extract_account_state( $data ) ]
854 );
855
856 if ( ! $updated ) {
857 // The account is no longer stored (disconnected between the event
858 // being sent and processed). Nothing to record, and retrying will
859 // not bring it back.
860 return new \WP_Error( 'invalid_event', 'Connected account not found for account.updated' );
861 }
862
863 return true;
864 }
865
866 /**
867 * Log webhook event.
868 *
869 * @param string $mode Payment mode.
870 * @param string $type Event type (began, success, failure, error).
871 * @param string $message Log message.
872 * @param array<string, mixed> $data Additional data.
873 * @return void
874 * @since 0.0.1
875 */
876 private function log_webhook_event( $mode, $type, $message, $data = [] ) {
877 $transient_key = "suredonation_webhook_{$mode}_status";
878 $status = get_transient( $transient_key );
879
880 if ( ! is_array( $status ) ) {
881 $status = [];
882 }
883
884 $timestamp = time();
885
886 switch ( $type ) {
887 case 'began':
888 $status['began_at'] = $timestamp;
889 break;
890
891 case 'success':
892 $status['last_success_at'] = $timestamp;
893 break;
894
895 case 'failure':
896 case 'error':
897 $status['last_failure_at'] = $timestamp;
898 $status['last_error'] = $message;
899 break;
900 }
901
902 // Store event type if available.
903 if ( ! empty( $data['type'] ) ) {
904 $status['last_event_type'] = $data['type'];
905 }
906
907 set_transient( $transient_key, $status, DAY_IN_SECONDS );
908 }
909
910 /**
911 * Validate the source IP address of the webhook request.
912 *
913 * @return bool True if the IP is valid or IP checking is disabled.
914 * @since 0.0.1
915 */
916 private function validate_source_ip() {
917 // Check if IP validation is enabled in settings.
918 $stripe_settings = Payment_Helper::get_gateway_settings( 'stripe' );
919 $enable_ip_validation = $stripe_settings['enable_webhook_ip_validation'] ?? false;
920
921 if ( ! $enable_ip_validation ) {
922 return true; // IP validation disabled.
923 }
924
925 $client_ip = $this->get_client_ip();
926 if ( empty( $client_ip ) ) {
927 return false; // Could not determine client IP.
928 }
929
930 // Get Stripe's current webhook IP ranges.
931 $allowed_ips = $this->get_stripe_webhook_ips();
932
933 foreach ( $allowed_ips as $allowed_range ) {
934 if ( $this->ip_in_range( $client_ip, $allowed_range ) ) {
935 return true;
936 }
937 }
938
939 return false;
940 }
941
942 /**
943 * Get client IP address safely.
944 *
945 * Only uses REMOTE_ADDR for security-critical IP validation.
946 * Forwarded headers (X-Forwarded-For, CF-Connecting-IP, etc.) are
947 * spoofable by attackers and must not be trusted for IP whitelisting.
948 *
949 * @return string Client IP address or empty string if not found.
950 * @since 0.0.1
951 */
952 private function get_client_ip() {
953 if ( empty( $_SERVER['REMOTE_ADDR'] ) ) {
954 return '';
955 }
956
957 $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
958
959 // Validate IP format.
960 if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
961 return $ip;
962 }
963
964 return '';
965 }
966
967 /**
968 * Get Stripe webhook IP addresses.
969 *
970 * These are Stripe's documented webhook IP ranges.
971 * Updated as of 2024 - should be periodically reviewed.
972 *
973 * @return array<string> Array of IP ranges in CIDR notation.
974 * @since 0.0.1
975 */
976 private function get_stripe_webhook_ips() {
977 // Allow filtering for custom IP ranges or updates.
978 return apply_filters(
979 'suredonation_stripe_webhook_ips',
980 [
981 '3.18.12.0/26',
982 '3.130.192.0/25',
983 '13.235.14.237/32',
984 '13.235.122.149/32',
985 '18.211.135.69/32',
986 '35.154.171.200/32',
987 '52.15.183.38/32',
988 '54.88.130.119/32',
989 '54.88.130.237/32',
990 '54.187.174.169/32',
991 '54.187.205.235/32',
992 '54.187.216.72/32',
993 ]
994 );
995 }
996
997 /**
998 * Check if an IP address is within a given CIDR range.
999 *
1000 * @param string $ip The IP address to check.
1001 * @param string $range The CIDR range (e.g., '192.168.1.0/24').
1002 * @return bool True if IP is in range.
1003 * @since 0.0.1
1004 */
1005 private function ip_in_range( $ip, $range ) {
1006 if ( strpos( $range, '/' ) === false ) {
1007 // Single IP address.
1008 return $ip === $range;
1009 }
1010
1011 [ $subnet, $mask ] = explode( '/', $range );
1012
1013 $ip_long = ip2long( $ip );
1014 $subnet_long = ip2long( $subnet );
1015 $mask_long = -1 << 32 - (int) $mask;
1016
1017 return ( $ip_long & $mask_long ) === ( $subnet_long & $mask_long );
1018 }
1019 }
1020