| 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 |
|