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

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

968 lines 33.4 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 default:
304 /**
305 * Allow extensions to handle additional webhook events.
306 *
307 * Pro plugin uses this to handle subscription events
308 * (customer.subscription.created, invoice.payment_succeeded, etc.)
309 *
310 * Return contract:
311 * - null : Filter did not handle the event.
312 * - true : Handled successfully.
313 * - WP_Error with code 'permanent_failure' or 'invalid_event'
314 * : Handled but unrecoverable — webhook is acked
315 * to Stripe so retries stop. Use this for
316 * malformed event data the callback will
317 * never be able to process.
318 * - Any other WP_Error: Transient failure — returned as-is so
319 * Stripe retries on its standard schedule.
320 *
321 * @param mixed $result Initially null; callbacks may return true, WP_Error, or null.
322 * @param string $event_type The Stripe event type.
323 * @param array<string, mixed> $event_data The event object data.
324 * @param string $mode Payment mode (live/test).
325 * @param string $account_id Connected Stripe account whose signing secret verified
326 * this event. Callbacks that call the Stripe API back
327 * must pass it as `stripe_api_request()`'s
328 * `account_id` extra arg, or they will hit the default
329 * account instead of the one that owns the event.
330 * @since 1.0.0
331 */
332 $result = apply_filters( 'suredonation_webhook_handle_event', null, $event_type, $event_data, $mode, $account_id );
333
334 // Only accept null (unhandled), true (success), or WP_Error (failure).
335 // Reject other return types (e.g. false from buggy callbacks) to ensure Stripe retries.
336 if ( null !== $result ) {
337 if ( true === $result ) {
338 return true;
339 }
340
341 if ( is_wp_error( $result ) ) {
342 // Drop permanent-failure errors so Stripe stops retrying events
343 // the pro callback will never be able to process (e.g. malformed
344 // event data). Transient errors fall through and are returned
345 // as-is so Stripe retries on its standard schedule.
346 if ( in_array( $result->get_error_code(), [ 'permanent_failure', 'invalid_event' ], true ) ) {
347 return true;
348 }
349
350 return $result;
351 }
352
353 return new \WP_Error( 'webhook_filter_invalid', __( 'Webhook filter returned unexpected value', 'suredonation' ) );
354 }
355
356 // Event not handled, but not an error.
357 return true;
358 }
359 }
360
361 /**
362 * Handle payment succeeded event.
363 *
364 * @param array<string, mixed> $data Payment intent data.
365 * @param string $mode Payment mode.
366 * @return bool|\WP_Error True on success.
367 * @since 0.0.1
368 */
369 private function handle_payment_succeeded( $data, $mode ) {
370 $payment_intent_id = isset( $data['id'] ) && is_string( $data['id'] ) ? $data['id'] : '';
371
372 if ( empty( $payment_intent_id ) ) {
373 return new \WP_Error( 'missing_payment_intent_id', 'Payment intent ID not found in event data' );
374 }
375
376 // Security: Verify payment amount matches expected amount.
377 // This detects payment amount manipulation attacks.
378 $actual_amount = isset( $data['amount'] ) && is_numeric( $data['amount'] ) ? (int) $data['amount'] : 0;
379 $currency = isset( $data['currency'] ) && is_string( $data['currency'] ) ? $data['currency'] : 'usd';
380
381 $amount_verification = Payment_Helper::verify_payment_intent_amount(
382 $payment_intent_id,
383 $actual_amount,
384 $currency
385 );
386
387 if ( is_wp_error( $amount_verification ) ) {
388 // Find donation and mark as suspicious for manual review.
389 $donation = Donations::get_by_transaction_id( $payment_intent_id );
390 if ( $donation && isset( $donation['id'] ) && is_numeric( $donation['id'] ) ) {
391 $donation_id = absint( $donation['id'] );
392 $error_data = $amount_verification->get_error_data();
393 Donations::update_status( $donation_id, 'suspicious' );
394 Donations::add_log(
395 $donation_id,
396 'security_warning',
397 $amount_verification->get_error_message(),
398 [
399 'payment_intent_id' => $payment_intent_id,
400 'expected_amount' => is_array( $error_data ) && isset( $error_data['expected'] ) ? $error_data['expected'] : 'unknown',
401 'actual_amount' => $actual_amount,
402 'mode' => $mode,
403 ]
404 );
405 }
406
407 return new \WP_Error(
408 'amount_mismatch',
409 $amount_verification->get_error_message()
410 );
411 }
412
413 // Find donation by transaction ID.
414 $donation = Donations::get_by_transaction_id( $payment_intent_id );
415
416 if ( ! $donation || ! isset( $donation['id'] ) || ! is_numeric( $donation['id'] ) ) {
417 return new \WP_Error( 'donation_not_found', 'Donation record not found for transaction: ' . $payment_intent_id );
418 }
419
420 $donation_id = absint( $donation['id'] );
421
422 // Update donation status to completed.
423 $updated = Donations::update_status( $donation_id, 'completed' );
424
425 if ( ! $updated ) {
426 return new \WP_Error( 'update_failed', 'Failed to update donation record' );
427 }
428
429 // Get fees covered from donation record.
430 $fees_covered = isset( $donation['fees_covered'] ) && is_numeric( $donation['fees_covered'] ) ? (float) $donation['fees_covered'] : 0.0;
431
432 // Add log entry.
433 Donations::add_log(
434 $donation_id,
435 'payment_succeeded',
436 __( 'Payment completed successfully via webhook', 'suredonation' ),
437 [
438 'payment_intent_id' => $payment_intent_id,
439 'amount_verified' => true,
440 'fees_covered' => $fees_covered,
441 'mode' => $mode,
442 ]
443 );
444
445 // Update donor statistics.
446 if ( ! empty( $donation['donor_id'] ) ) {
447 $donor_id_val = $donation['donor_id'];
448 $amount_val = $donation['amount'] ?? 0;
449 $donor_id = is_numeric( $donor_id_val ) ? (int) $donor_id_val : 0;
450 $amount = is_numeric( $amount_val ) ? (float) $amount_val : 0.0;
451 if ( $donor_id > 0 ) {
452 Donors::record_donation( $donor_id, $amount );
453 }
454 }
455
456 // Send confirmation emails if not already sent by complete_donation().
457 // Skip for recurring/renewal donations — pro plugin handles subscription emails.
458 $was_pending = 'pending' === ( $donation['payment_status'] ?? '' );
459 $is_subscription = in_array( $donation['donation_type'] ?? '', [ 'recurring', 'renewal' ], true );
460 if ( $was_pending && ! $is_subscription ) {
461 $campaign_id = isset( $donation['campaign_id'] ) && is_numeric( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
462 $form_id = isset( $donation['form_id'] ) && is_numeric( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
463 $donation_data = [
464 'id' => $donation_id,
465 'donor_name' => $donation['donor_name'] ?? '',
466 'donor_email' => $donation['donor_email'] ?? '',
467 'amount' => $donation['amount'] ?? 0,
468 'fees_covered' => $donation['fees_covered'] ?? 0,
469 'currency' => $donation['currency'] ?? 'USD',
470 'transaction_id' => $payment_intent_id,
471 'gateway' => 'stripe',
472 'donation_type' => $donation['donation_type'] ?? 'one-time',
473 ];
474
475 Email_Handler::send_donation_confirmation( $donation_id, $campaign_id, $donation_data, $form_id );
476 }
477
478 return true;
479 }
480
481 /**
482 * Handle payment failed event.
483 *
484 * @param array<string, mixed> $data Payment intent data.
485 * @param string $mode Payment mode.
486 * @return bool|\WP_Error True on success.
487 * @since 0.0.1
488 */
489 private function handle_payment_failed( $data, $mode ) {
490 $payment_intent_id = isset( $data['id'] ) && is_string( $data['id'] ) ? $data['id'] : '';
491
492 if ( empty( $payment_intent_id ) ) {
493 return new \WP_Error( 'missing_payment_intent_id', 'Payment intent ID not found in event data' );
494 }
495
496 // Find donation by transaction ID.
497 $donation = Donations::get_by_transaction_id( $payment_intent_id );
498
499 if ( ! $donation || ! isset( $donation['id'] ) || ! is_numeric( $donation['id'] ) ) {
500 return new \WP_Error( 'donation_not_found', 'Donation record not found' );
501 }
502
503 $donation_id = absint( $donation['id'] );
504
505 // Update donation status to failed.
506 Donations::update_status( $donation_id, 'failed' );
507
508 // Get error message safely.
509 $error_message = '';
510 if ( isset( $data['last_payment_error'] ) && is_array( $data['last_payment_error'] ) && isset( $data['last_payment_error']['message'] ) ) {
511 $error_message = is_string( $data['last_payment_error']['message'] ) ? $data['last_payment_error']['message'] : '';
512 }
513
514 // Add log entry.
515 Donations::add_log(
516 $donation_id,
517 'payment_failed',
518 __( 'Payment failed via webhook', 'suredonation' ),
519 [
520 'payment_intent_id' => $payment_intent_id,
521 'mode' => $mode,
522 'error' => $error_message,
523 ]
524 );
525
526 // Send donation failed emails.
527 $campaign_id = isset( $donation['campaign_id'] ) && is_numeric( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
528 $form_id = isset( $donation['form_id'] ) && is_numeric( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
529 $donation_data = [
530 'id' => $donation_id,
531 'donor_name' => $donation['donor_name'] ?? '',
532 'donor_email' => $donation['donor_email'] ?? '',
533 'amount' => $donation['amount'] ?? 0,
534 'currency' => $donation['currency'] ?? 'USD',
535 'donation_type' => $donation['donation_type'] ?? 'one-time',
536 'gateway' => 'stripe',
537 ];
538
539 Email_Handler::send_donation_failed( $donation_id, $campaign_id, $donation_data, $form_id );
540
541 return true;
542 }
543
544 /**
545 * Handle charge.refund.updated event.
546 *
547 * This event is triggered when a refund status changes, including when created from Stripe dashboard.
548 * The event data contains a Refund object (not a Charge object).
549 * This matches the SureForms pattern for handling refunds.
550 *
551 * @param array<string, mixed> $refund Refund object data from webhook.
552 * @param string $mode Payment mode.
553 * @return bool|\WP_Error True on success.
554 * @since 0.0.1
555 */
556 private function handle_refund_updated( $refund, $mode ) {
557 $refund_id = isset( $refund['id'] ) && is_string( $refund['id'] ) ? $refund['id'] : '';
558
559 // Extract payment identifiers from refund object.
560 $payment_intent_id = isset( $refund['payment_intent'] ) && is_string( $refund['payment_intent'] ) ? $refund['payment_intent'] : '';
561 $charge_id = isset( $refund['charge'] ) && is_string( $refund['charge'] ) ? $refund['charge'] : '';
562
563 $donation = null;
564
565 // Method 1: Try to find donation by payment_intent.
566 if ( ! empty( $payment_intent_id ) ) {
567 $donation = Donations::get_by_transaction_id( $payment_intent_id );
568 }
569
570 // Method 2: Try to find by charge ID as fallback.
571 if ( ! $donation && ! empty( $charge_id ) ) {
572 $donation = Donations::get_by_transaction_id( $charge_id );
573 }
574
575 if ( ! $donation || ! isset( $donation['id'] ) || ! is_numeric( $donation['id'] ) ) {
576 return new \WP_Error( 'donation_not_found', 'Donation record not found for refund' );
577 }
578
579 $donation_id = absint( $donation['id'] );
580
581 // Extract refund details from the Refund object.
582 $refund_amount_cents = isset( $refund['amount'] ) && is_numeric( $refund['amount'] ) ? (int) $refund['amount'] : 0;
583 $currency = isset( $refund['currency'] ) && is_string( $refund['currency'] ) ? strtolower( $refund['currency'] ) : 'usd';
584 $refund_status = isset( $refund['status'] ) && is_string( $refund['status'] ) ? $refund['status'] : 'unknown';
585
586 // Handle refund cancellation.
587 if ( 'canceled' === $refund_status ) {
588 return $this->process_refund_cancellation( $donation, $refund, $currency, $mode );
589 }
590
591 // Only process succeeded refunds.
592 if ( 'succeeded' !== $refund_status ) {
593 return true; // Not an error, just skip.
594 }
595
596 // Check if this refund was already processed (duplicate prevention with lock).
597 $lock_key = 'suredonation_refund_lock_' . $refund_id;
598 if ( get_transient( $lock_key ) || Donations::check_refund_exists( $donation_id, $refund_id ) ) {
599 return true; // Already processed or being processed.
600 }
601 set_transient( $lock_key, true, 60 );
602
603 // Get current donation data.
604 $original_amount = isset( $donation['amount'] ) && is_numeric( $donation['amount'] ) ? (float) $donation['amount'] : 0;
605 $existing_refunded = isset( $donation['refunded_amount'] ) && is_numeric( $donation['refunded_amount'] ) ? (float) $donation['refunded_amount'] : 0;
606 $new_refund_amount = Payment_Helper::amount_from_stripe_format( $refund_amount_cents, $currency );
607 $total_refunded = $existing_refunded + $new_refund_amount;
608
609 // Prevent over-refunding.
610 if ( $total_refunded > $original_amount ) {
611 return new \WP_Error( 'over_refund', 'Refund would exceed original donation amount' );
612 }
613
614 // Determine new payment status.
615 $payment_status = 'completed';
616 if ( $total_refunded >= $original_amount ) {
617 $payment_status = 'refunded';
618 } elseif ( $total_refunded > 0 ) {
619 $payment_status = 'partially_refunded';
620 }
621
622 // Store refund in donation_data for audit trail and duplicate prevention.
623 $refund_data = [
624 'refund_id' => $refund_id,
625 'amount' => absint( $refund_amount_cents ),
626 'currency' => strtoupper( $currency ),
627 'status' => $refund_status,
628 'created' => time(),
629 'reason' => isset( $refund['reason'] ) && is_string( $refund['reason'] ) ? $refund['reason'] : 'requested_by_customer',
630 'description' => isset( $refund['description'] ) && is_string( $refund['description'] ) ? $refund['description'] : '',
631 'receipt_number' => isset( $refund['receipt_number'] ) && is_string( $refund['receipt_number'] ) ? $refund['receipt_number'] : '',
632 'refunded_by' => 'stripe_dashboard',
633 'refunded_at' => gmdate( 'Y-m-d H:i:s' ),
634 ];
635 Donations::add_refund_to_donation_data( $donation_id, $refund_data );
636
637 // Update donation with new refunded amount and status.
638 Donations::update(
639 $donation_id,
640 [
641 'payment_status' => $payment_status,
642 'refunded_amount' => $total_refunded,
643 ]
644 );
645
646 // Send refund processed emails.
647 $campaign_id = isset( $donation['campaign_id'] ) && is_numeric( $donation['campaign_id'] ) ? absint( $donation['campaign_id'] ) : 0;
648 $form_id = isset( $donation['form_id'] ) && is_numeric( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
649 $donation_data = [
650 'id' => $donation_id,
651 'donor_name' => $donation['donor_name'] ?? '',
652 'donor_email' => $donation['donor_email'] ?? '',
653 'amount' => $donation['amount'] ?? 0,
654 'currency' => strtoupper( $currency ),
655 'refund_amount' => $new_refund_amount,
656 'donation_type' => $donation['donation_type'] ?? 'one-time',
657 'gateway' => 'stripe',
658 ];
659
660 Email_Handler::send_refund_processed( $donation_id, $campaign_id, $donation_data, $form_id );
661
662 // Determine refund type for log message.
663 $refund_type = $total_refunded >= $original_amount
664 ? __( 'Full', 'suredonation' )
665 : __( 'Partial', 'suredonation' );
666
667 // Add log entry.
668 Donations::add_log(
669 $donation_id,
670 'refund',
671 sprintf(
672 /* translators: %s: Refund type (Full/Partial) */
673 __( '%s refund processed via webhook', 'suredonation' ),
674 $refund_type
675 ),
676 [
677 'refund_id' => $refund_id,
678 'refund_amount' => $new_refund_amount,
679 'total_refunded' => $total_refunded,
680 'original_amount' => $original_amount,
681 'payment_status' => $payment_status,
682 'currency' => strtoupper( $currency ),
683 'mode' => $mode,
684 'payment_intent_id' => $payment_intent_id,
685 ]
686 );
687
688 return true;
689 }
690
691 /**
692 * Process refund cancellation - reverses a previously processed refund.
693 *
694 * @param array<string, mixed> $donation Donation record.
695 * @param array<string, mixed> $refund Refund object from Stripe webhook.
696 * @param string $currency Currency code.
697 * @param string $mode Payment mode.
698 * @return bool|\WP_Error True on success.
699 * @since 0.0.1
700 */
701 private function process_refund_cancellation( $donation, $refund, $currency, $mode ) {
702 $refund_id = isset( $refund['id'] ) && is_string( $refund['id'] ) ? $refund['id'] : '';
703 $donation_id = isset( $donation['id'] ) && is_numeric( $donation['id'] ) ? absint( $donation['id'] ) : 0;
704
705 if ( ! $donation_id ) {
706 return new \WP_Error( 'invalid_donation', 'Invalid donation record for refund cancellation' );
707 }
708
709 // Use shared method to remove refund and get the refund data.
710 $remove_result = Donations::remove_refund_from_donation_data( $donation_id, $refund_id );
711
712 if ( ! $remove_result['removed'] ) {
713 return true; // Not an error, just skip - refund may not have been tracked.
714 }
715
716 $existing_refund = $remove_result['refund_data'];
717 $canceled_refund_amount_cents = isset( $existing_refund['amount'] ) && is_numeric( $existing_refund['amount'] ) ? (int) $existing_refund['amount'] : 0;
718 $refund_currency = isset( $existing_refund['currency'] ) && is_string( $existing_refund['currency'] ) ? strtolower( $existing_refund['currency'] ) : $currency;
719
720 if ( $canceled_refund_amount_cents <= 0 ) {
721 return new \WP_Error( 'invalid_amount', 'Invalid refund cancellation amount' );
722 }
723
724 // Convert from Stripe format (cents) to decimal format.
725 $canceled_refund_amount = Payment_Helper::amount_from_stripe_format( $canceled_refund_amount_cents, $refund_currency );
726
727 // Calculate new refunded amount after cancellation.
728 $current_refunded = isset( $donation['refunded_amount'] ) && is_numeric( $donation['refunded_amount'] ) ? (float) $donation['refunded_amount'] : 0;
729 $new_refunded_amount = max( 0, $current_refunded - $canceled_refund_amount );
730
731 // Recalculate payment status.
732 $original_amount = isset( $donation['amount'] ) && is_numeric( $donation['amount'] ) ? (float) $donation['amount'] : 0;
733 $payment_status = 'completed';
734
735 if ( $new_refunded_amount >= $original_amount ) {
736 $payment_status = 'refunded';
737 } elseif ( $new_refunded_amount > 0 ) {
738 $payment_status = 'partially_refunded';
739 }
740
741 // Extract failure reason from refund object.
742 $failure_reason = isset( $refund['failure_reason'] ) && is_string( $refund['failure_reason'] ) ? $refund['failure_reason'] : 'unknown';
743
744 // Update donation record with new status and refunded amount.
745 Donations::update(
746 $donation_id,
747 [
748 'payment_status' => $payment_status,
749 'refunded_amount' => $new_refunded_amount,
750 ]
751 );
752
753 // Add log entry.
754 Donations::add_log(
755 $donation_id,
756 'refund_canceled',
757 __( 'Refund canceled via webhook', 'suredonation' ),
758 [
759 'refund_id' => $refund_id,
760 'canceled_amount' => $canceled_refund_amount,
761 'remaining_refunded' => $new_refunded_amount,
762 'original_amount' => $original_amount,
763 'payment_status' => $payment_status,
764 'cancellation_reason' => $failure_reason,
765 'currency' => strtoupper( $refund_currency ),
766 'mode' => $mode,
767 ]
768 );
769
770 return true;
771 }
772
773 /**
774 * Handle payment canceled event.
775 *
776 * @param array<string, mixed> $data Payment intent data.
777 * @param string $mode Payment mode.
778 * @return bool|\WP_Error True on success.
779 * @since 0.0.1
780 */
781 private function handle_payment_canceled( $data, $mode ) {
782 $payment_intent_id = isset( $data['id'] ) && is_string( $data['id'] ) ? $data['id'] : '';
783
784 if ( empty( $payment_intent_id ) ) {
785 return new \WP_Error( 'missing_payment_intent_id', 'Payment intent ID not found' );
786 }
787
788 // Find donation by transaction ID.
789 $donation = Donations::get_by_transaction_id( $payment_intent_id );
790
791 if ( ! $donation || ! isset( $donation['id'] ) || ! is_numeric( $donation['id'] ) ) {
792 return new \WP_Error( 'donation_not_found', 'Donation record not found' );
793 }
794
795 $donation_id = absint( $donation['id'] );
796
797 // Update donation status to cancelled.
798 Donations::update_status( $donation_id, 'cancelled' );
799
800 // Add log entry.
801 Donations::add_log(
802 $donation_id,
803 'payment_canceled',
804 __( 'Payment canceled via webhook', 'suredonation' ),
805 [
806 'payment_intent_id' => $payment_intent_id,
807 'mode' => $mode,
808 ]
809 );
810
811 return true;
812 }
813
814 /**
815 * Log webhook event.
816 *
817 * @param string $mode Payment mode.
818 * @param string $type Event type (began, success, failure, error).
819 * @param string $message Log message.
820 * @param array<string, mixed> $data Additional data.
821 * @return void
822 * @since 0.0.1
823 */
824 private function log_webhook_event( $mode, $type, $message, $data = [] ) {
825 $transient_key = "suredonation_webhook_{$mode}_status";
826 $status = get_transient( $transient_key );
827
828 if ( ! is_array( $status ) ) {
829 $status = [];
830 }
831
832 $timestamp = time();
833
834 switch ( $type ) {
835 case 'began':
836 $status['began_at'] = $timestamp;
837 break;
838
839 case 'success':
840 $status['last_success_at'] = $timestamp;
841 break;
842
843 case 'failure':
844 case 'error':
845 $status['last_failure_at'] = $timestamp;
846 $status['last_error'] = $message;
847 break;
848 }
849
850 // Store event type if available.
851 if ( ! empty( $data['type'] ) ) {
852 $status['last_event_type'] = $data['type'];
853 }
854
855 set_transient( $transient_key, $status, DAY_IN_SECONDS );
856 }
857
858 /**
859 * Validate the source IP address of the webhook request.
860 *
861 * @return bool True if the IP is valid or IP checking is disabled.
862 * @since 0.0.1
863 */
864 private function validate_source_ip() {
865 // Check if IP validation is enabled in settings.
866 $stripe_settings = Payment_Helper::get_gateway_settings( 'stripe' );
867 $enable_ip_validation = $stripe_settings['enable_webhook_ip_validation'] ?? false;
868
869 if ( ! $enable_ip_validation ) {
870 return true; // IP validation disabled.
871 }
872
873 $client_ip = $this->get_client_ip();
874 if ( empty( $client_ip ) ) {
875 return false; // Could not determine client IP.
876 }
877
878 // Get Stripe's current webhook IP ranges.
879 $allowed_ips = $this->get_stripe_webhook_ips();
880
881 foreach ( $allowed_ips as $allowed_range ) {
882 if ( $this->ip_in_range( $client_ip, $allowed_range ) ) {
883 return true;
884 }
885 }
886
887 return false;
888 }
889
890 /**
891 * Get client IP address safely.
892 *
893 * Only uses REMOTE_ADDR for security-critical IP validation.
894 * Forwarded headers (X-Forwarded-For, CF-Connecting-IP, etc.) are
895 * spoofable by attackers and must not be trusted for IP whitelisting.
896 *
897 * @return string Client IP address or empty string if not found.
898 * @since 0.0.1
899 */
900 private function get_client_ip() {
901 if ( empty( $_SERVER['REMOTE_ADDR'] ) ) {
902 return '';
903 }
904
905 $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
906
907 // Validate IP format.
908 if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
909 return $ip;
910 }
911
912 return '';
913 }
914
915 /**
916 * Get Stripe webhook IP addresses.
917 *
918 * These are Stripe's documented webhook IP ranges.
919 * Updated as of 2024 - should be periodically reviewed.
920 *
921 * @return array<string> Array of IP ranges in CIDR notation.
922 * @since 0.0.1
923 */
924 private function get_stripe_webhook_ips() {
925 // Allow filtering for custom IP ranges or updates.
926 return apply_filters(
927 'suredonation_stripe_webhook_ips',
928 [
929 '3.18.12.0/26',
930 '3.130.192.0/25',
931 '13.235.14.237/32',
932 '13.235.122.149/32',
933 '18.211.135.69/32',
934 '35.154.171.200/32',
935 '52.15.183.38/32',
936 '54.88.130.119/32',
937 '54.88.130.237/32',
938 '54.187.174.169/32',
939 '54.187.205.235/32',
940 '54.187.216.72/32',
941 ]
942 );
943 }
944
945 /**
946 * Check if an IP address is within a given CIDR range.
947 *
948 * @param string $ip The IP address to check.
949 * @param string $range The CIDR range (e.g., '192.168.1.0/24').
950 * @return bool True if IP is in range.
951 * @since 0.0.1
952 */
953 private function ip_in_range( $ip, $range ) {
954 if ( strpos( $range, '/' ) === false ) {
955 // Single IP address.
956 return $ip === $range;
957 }
958
959 [ $subnet, $mask ] = explode( '/', $range );
960
961 $ip_long = ip2long( $ip );
962 $subnet_long = ip2long( $subnet );
963 $mask_long = -1 << 32 - (int) $mask;
964
965 return ( $ip_long & $mask_long ) === ( $subnet_long & $mask_long );
966 }
967 }
968