PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.1
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.1
2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / payments / stripe / stripe-webhook.php
sureforms / inc / payments / stripe Last commit date
admin-stripe-handler.php 2 months ago payments-settings.php 5 months ago stripe-helper.php 2 months ago stripe-webhook.php 2 months ago
stripe-webhook.php
1328 lines
1 <?php
2 /**
3 * SureForms Webhook Class
4 *
5 * @package sureforms
6 * @since 2.0.0
7 */
8
9 namespace SRFM\Inc\Payments\Stripe;
10
11 use SRFM\Inc\Database\Tables\Payments;
12 use SRFM\Inc\Helper;
13 use SRFM\Inc\Payments\Payment_Helper;
14 use SRFM\Inc\Traits\Get_Instance;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit; // Exit if accessed directly.
18 }
19
20 /**
21 * Stripe Webhook handler class.
22 *
23 * @since 2.0.0
24 */
25 class Stripe_Webhook {
26 use Get_Instance;
27 public const SRFM_LIVE_BEGAN_AT = 'srfm_live_webhook_began_at';
28 public const SRFM_LIVE_LAST_SUCCESS_AT = 'srfm_live_webhook_last_success_at';
29 public const SRFM_LIVE_LAST_FAILURE_AT = 'srfm_live_webhook_last_failure_at';
30 public const SRFM_LIVE_LAST_ERROR = 'srfm_live_webhook_last_error';
31
32 public const SRFM_TEST_BEGAN_AT = 'srfm_test_webhook_began_at';
33 public const SRFM_TEST_LAST_SUCCESS_AT = 'srfm_test_webhook_last_success_at';
34 public const SRFM_TEST_LAST_FAILURE_AT = 'srfm_test_webhook_last_failure_at';
35 public const SRFM_TEST_LAST_ERROR = 'srfm_test_webhook_last_error';
36
37 /**
38 * Payment mode.
39 *
40 * @var string
41 * @since 2.0.0
42 */
43 private $mode = 'test';
44
45 /**
46 * Constructor function.
47 *
48 * @since 2.0.0
49 */
50 public function __construct() {
51 add_action( 'rest_api_init', [ $this, 'register_endpoints' ] );
52 }
53
54 /**
55 * Registers endpoint for webhook.
56 *
57 * @since 2.0.0
58 * @return void
59 */
60 public function register_endpoints() {
61 // Test mode webhook endpoint.
62 register_rest_route(
63 'sureforms',
64 '/webhook_test',
65 [
66 'methods' => 'POST',
67 'callback' => function() {
68 $this->webhook_listener( 'test' );
69 },
70 'permission_callback' => function() {
71 return $this->validate_webhook_permission( 'test' );
72 },
73 ]
74 );
75
76 // Live mode webhook endpoint.
77 register_rest_route(
78 'sureforms',
79 '/webhook_live',
80 [
81 'methods' => 'POST',
82 'callback' => function() {
83 $this->webhook_listener( 'live' );
84 },
85 'permission_callback' => function() {
86 return $this->validate_webhook_permission( 'live' );
87 },
88 ]
89 );
90 }
91
92 /**
93 * Validates webhook permission by verifying the Stripe signature locally.
94 * Used as the permission_callback for webhook REST endpoints.
95 *
96 * @param string $mode The payment mode ('test' or 'live').
97 * @since 2.6.0
98 * @return true|\WP_Error
99 */
100 public function validate_webhook_permission( $mode ) {
101 $payload = file_get_contents( 'php://input' );
102 // phpcs:disable
103 $sig_header = ! empty( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) && is_string( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ) : '';
104 // phpcs:enable
105
106 if ( empty( $payload ) || empty( $sig_header ) ) {
107 return new \WP_Error( 'srfm_webhook_unauthorized', __( 'Missing webhook payload or signature.', 'sureforms' ), [ 'status' => 401 ] );
108 }
109
110 $settings = Stripe_Helper::get_all_stripe_settings();
111 if ( ! is_array( $settings ) ) {
112 $settings = [];
113 }
114
115 $this->mode = in_array( $mode, [ 'test', 'live' ], true ) ? $mode : 'test';
116
117 $secret_key = 'live' === $this->mode ? 'webhook_live_secret' : 'webhook_test_secret';
118 $webhook_secret = isset( $settings[ $secret_key ] ) && is_string( $settings[ $secret_key ] ) ? (string) $settings[ $secret_key ] : '';
119
120 if ( empty( $webhook_secret ) ) {
121 return new \WP_Error( 'srfm_webhook_unauthorized', __( 'Webhook secret not configured.', 'sureforms' ), [ 'status' => 401 ] );
122 }
123
124 if ( ! $this->verify_stripe_signature_locally( $payload, $sig_header, $webhook_secret ) ) {
125 return new \WP_Error( 'srfm_webhook_unauthorized', __( 'Invalid webhook signature.', 'sureforms' ), [ 'status' => 401 ] );
126 }
127
128 return true;
129 }
130
131 /**
132 * Validates the Stripe signature for webhook requests through middleware.
133 *
134 * @deprecated 2.6.0 Use validate_webhook_permission() instead. Signature is now verified locally in the permission callback.
135 * @param string|null $mode The payment mode ('test' or 'live'). If null, uses setting.
136 * @since 2.0.0
137 * @return array<string, mixed>|bool
138 */
139 public function validate_stripe_signature( $mode = null ) {
140 // Get the raw payload and Stripe signature header.
141 $payload = file_get_contents( 'php://input' );
142 // phpcs:disable
143 $signature = ! empty( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) && is_string( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ? sanitize_text_field( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) : '';
144 // phpcs:enable
145 $signature = trim( $signature );
146
147 if ( empty( $payload ) || empty( $signature ) ) {
148 Helper::srfm_log( 'Missing webhook payload or signature.' );
149 return false;
150 }
151
152 // Get payment settings using the new format.
153 $settings = Stripe_Helper::get_all_stripe_settings();
154 if ( ! is_array( $settings ) ) {
155 $settings = [];
156 }
157
158 // Determine mode: use parameter if provided, otherwise fall back to global payment mode.
159 $validate_mode = ! empty( $mode ) && in_array( $mode, [ 'test', 'live' ], true ) ? $mode : Payment_Helper::get_payment_mode();
160 $this->mode = ! empty( $validate_mode ) && is_string( $validate_mode ) ? $validate_mode : 'test';
161
162 // Get the appropriate webhook secret based on payment mode.
163 $webhook_secret = '';
164 if ( 'live' === $this->mode ) {
165 $webhook_secret = is_string( $settings['webhook_live_secret'] ?? '' ) ? $settings['webhook_live_secret'] : '';
166 } else {
167 $webhook_secret = is_string( $settings['webhook_test_secret'] ?? '' ) ? $settings['webhook_test_secret'] : '';
168 }
169
170 if ( empty( $webhook_secret ) ) {
171 Helper::srfm_log( 'Webhook secret not configured for mode: ' . $this->mode . '.' );
172 return false;
173 }
174
175 // Prepare request data for middleware.
176 $middleware_request_data = [
177 'payload' => $payload,
178 'signature' => $signature,
179 'webhook_secret' => $webhook_secret,
180 ];
181
182 $endpoint = Stripe_Helper::middle_ware_base_url() . 'webhook/validate-signature';
183
184 // Make request to middleware for signature verification.
185 $response = wp_remote_post(
186 $endpoint,
187 [
188 'body' => Helper::srfm_base64_json_encode( $middleware_request_data ),
189 'headers' => [
190 'Content-Type' => 'application/json',
191 ],
192 'timeout' => 10, // 10 second timeout.
193 'sslverify' => true,
194 ]
195 );
196
197 // Handle middleware communication errors.
198 if ( is_wp_error( $response ) ) {
199 Helper::srfm_log( 'Middleware request failed: ' . $response->get_error_message() . '.' );
200 return false;
201 }
202
203 $response_code = wp_remote_retrieve_response_code( $response );
204 $response_body = wp_remote_retrieve_body( $response );
205
206 // Parse middleware response.
207 $validation_result = json_decode( $response_body, true );
208
209 if ( 200 === $response_code && is_array( $validation_result ) ) {
210 return $validation_result;
211 }
212
213 return false;
214 }
215
216 /**
217 * Development version - skips signature validation for testing.
218 * This function is intended for development purposes only and should not be used in production.
219 *
220 * @since 2.0.0
221 * @return array<string, mixed>|bool
222 */
223 public function dev_validate_stripe_signature() {
224 // Get the raw payload.
225 $payload = file_get_contents( 'php://input' );
226
227 if ( empty( $payload ) ) {
228 Helper::srfm_log( 'Missing webhook payload.', 'SureForms DEV: ' );
229 return false;
230 }
231
232 // Parse JSON payload directly (no signature verification).
233 $event = json_decode( $payload, true );
234
235 if ( ! $event || ! is_array( $event ) ) {
236 Helper::srfm_log( 'Invalid JSON payload.', 'SureForms DEV: ' );
237 return false;
238 }
239
240 Helper::srfm_log( 'Event type: ' . ( $event['type'] ?? 'unknown' ) . '.', 'SureForms DEV: ' );
241
242 return $event;
243 }
244
245 /**
246 * This function listens webhook events.
247 *
248 * @param string|null $mode The payment mode ('test' or 'live'). If null, uses setting.
249 * @since 2.0.0
250 * @return void
251 */
252 public function webhook_listener( $mode = null ) {
253 // Signature already verified in permission callback (validate_webhook_permission).
254 // Parse the payload directly.
255 $payload = file_get_contents( 'php://input' );
256 $event = ! empty( $payload ) ? json_decode( $payload, true ) : null;
257
258 if ( ! is_array( $event ) || ! isset( $event['type'] ) ) {
259 Helper::srfm_log( 'Invalid webhook event.' );
260 return;
261 }
262
263 // Set mode for downstream usage.
264 $this->mode = ! empty( $mode ) && in_array( $mode, [ 'test', 'live' ], true ) ? $mode : 'test';
265
266 Helper::srfm_log( 'Processing event type: ' . $event['type'] . '.' );
267 Helper::srfm_log( $event, 'Processing ectual event : ' );
268
269 switch ( $event['type'] ) {
270 case 'charge.refund.updated':
271 // Handle refund webhook event.
272 $event_data = isset( $event['data'] ) && is_array( $event['data'] ) ? $event['data'] : [];
273 if ( ! isset( $event_data['object'] ) ) {
274 Helper::srfm_log( 'charge.refund.updated: Invalid webhook event - missing data object.' );
275 return;
276 }
277 // Note: $event['data']['object'] is a Refund object, not a Charge object.
278 $refund = $event_data['object'] ?? [];
279 $this->handle_refund_record( $refund );
280 break;
281
282 case 'invoice.payment_succeeded':
283 $event_data = isset( $event['data'] ) && is_array( $event['data'] ) ? $event['data'] : [];
284 if ( ! isset( $event_data['object'] ) ) {
285 Helper::srfm_log( 'Invalid webhook event.' );
286 return;
287 }
288 $invoice = $event_data['object'] ?? [];
289 $this->handle_invoice_payment_succeeded( $invoice );
290 break;
291
292 case 'customer.subscription.deleted':
293 $event_data = isset( $event['data'] ) && is_array( $event['data'] ) ? $event['data'] : [];
294 if ( ! isset( $event_data['object'] ) ) {
295 Helper::srfm_log( 'customer.subscription.deleted: Invalid webhook event - missing data object.' );
296 return;
297 }
298 $subscription = $event_data['object'] ?? [];
299 $this->handle_subscription_deleted( $subscription );
300 break;
301
302 default:
303 Helper::srfm_log( 'Unhandled event type: ' . $event['type'] . '.' );
304 break;
305 }
306
307 $success = constant( 'self::SRFM_' . strtoupper( $this->mode ) . '_LAST_SUCCESS_AT' );
308 if ( is_string( $success ) ) {
309 update_option( $success, time() );
310 }
311 http_response_code( 200 );
312 }
313
314 /**
315 * Handles refund record - both creation and cancellation via webhook call.
316 *
317 * @param array<string, mixed> $refund Refund object from Stripe webhook.
318 * @since 2.0.0
319 * @return void
320 */
321 public function handle_refund_record( $refund ) {
322 $refund_id = ! empty( $refund['id'] ) && is_string( $refund['id'] ) ? sanitize_text_field( $refund['id'] ) : '';
323 Helper::srfm_log( 'Processing refund: ' . $refund_id );
324
325 // Extract payment identifiers from refund object.
326 $payment_intent = ! empty( $refund['payment_intent'] ) && is_string( $refund['payment_intent'] ) ? sanitize_text_field( $refund['payment_intent'] ) : '';
327 $charge_id = ! empty( $refund['charge'] ) && is_string( $refund['charge'] ) ? sanitize_text_field( $refund['charge'] ) : '';
328
329 Helper::srfm_log(
330 sprintf(
331 'Refund lookup info - Refund ID: %s, Payment Intent: %s, Charge ID: %s',
332 $refund_id,
333 $payment_intent ? $payment_intent : 'null',
334 $charge_id ? $charge_id : 'null'
335 )
336 );
337
338 $get_payment_entry = null;
339 $lookup_method = '';
340
341 // Method 1: Try to find payment by payment_intent (for one-time payments).
342 if ( ! empty( $payment_intent ) ) {
343 Helper::srfm_log( 'Attempting lookup by payment_intent: ' . $payment_intent );
344 $get_payment_entry = Payments::get_by_transaction_id( $payment_intent );
345 if ( $get_payment_entry ) {
346 $lookup_method = 'payment_intent';
347 Helper::srfm_log( 'Found payment entry by payment_intent' );
348 }
349 }
350
351 // Method 2: Try to find by charge ID (for subscription payments and one-time fallback).
352 if ( ! $get_payment_entry && ! empty( $charge_id ) ) {
353 Helper::srfm_log( 'Attempting lookup by charge_id: ' . $charge_id );
354 $get_payment_entry = Payments::get_by_transaction_id( $charge_id );
355 if ( $get_payment_entry ) {
356 $lookup_method = 'charge_id';
357 Helper::srfm_log( 'Found payment entry by charge_id' );
358 }
359 }
360
361 // Method 3: Try to find in payment_data for one-time payments that might have charge stored there.
362 if ( ! $get_payment_entry && ! empty( $charge_id ) ) {
363 Helper::srfm_log( 'Attempting lookup in payment_data by charge_id' );
364 // This would require a custom query or storing charge_id differently.
365 // For now, log that we're trying this method.
366 }
367
368 // Final check: If still not found, log detailed error and return.
369 if ( ! $get_payment_entry ) {
370 Helper::srfm_log(
371 sprintf(
372 'REFUND FAILED: Could not find payment entry. Refund ID: %s, Payment Intent: %s, Charge ID: %s. Full refund object: %s',
373 $refund_id,
374 $payment_intent ? $payment_intent : 'null',
375 $charge_id ? $charge_id : 'null',
376 wp_json_encode( $refund )
377 )
378 );
379 return;
380 }
381
382 // Extract refund details.
383 $payment_entry_id = ! empty( $get_payment_entry['id'] ) && is_numeric( $get_payment_entry['id'] ) ? intval( $get_payment_entry['id'] ) : 0;
384 $refund_amount = isset( $refund['amount'] ) && ( is_numeric( $refund['amount'] ) || is_float( $refund['amount'] ) || is_string( $refund['amount'] ) ) ? $refund['amount'] : 0;
385 $currency = ! empty( $refund['currency'] ) && is_string( $refund['currency'] ) ? sanitize_text_field( strtolower( $refund['currency'] ) ) : 'usd';
386 $refund_status = ! empty( $refund['status'] ) && is_string( $refund['status'] ) ? sanitize_text_field( $refund['status'] ) : 'unknown';
387
388 Helper::srfm_log(
389 sprintf(
390 'Processing refund for payment entry ID: %d, Amount: %s %s, Status: %s',
391 $payment_entry_id,
392 Stripe_Helper::amount_from_stripe_format( $refund_amount, $currency ),
393 strtoupper( $currency ),
394 $refund_status
395 )
396 );
397
398 // Route based on refund status.
399 if ( 'canceled' === $refund_status ) {
400 // Handle refund cancellation.
401 Helper::srfm_log( 'Refund status is canceled - processing refund cancellation.' );
402 $this->process_refund_cancellation( $payment_entry_id, $refund, $currency, $lookup_method );
403 return;
404 }
405
406 // Handle refund creation (succeeded status).
407 if ( 'succeeded' !== $refund_status ) {
408 Helper::srfm_log( 'Unexpected refund status: ' . $refund_status . '. Skipping processing.' );
409 return;
410 }
411
412 // Update refund data in database.
413 $update_refund_data = $this->update_refund_data( $payment_entry_id, $refund, $refund_amount, $currency, 'webhook' );
414
415 if ( ! $update_refund_data ) {
416 Helper::srfm_log( 'REFUND FAILED: Failed to update refund data for payment entry ID: ' . $payment_entry_id );
417 return;
418 }
419
420 Helper::srfm_log(
421 sprintf(
422 'REFUND SUCCESS: Payment refunded successfully. Refund ID: %s, Amount: %s %s, Payment Entry ID: %d (found via %s)',
423 $refund_id,
424 Stripe_Helper::amount_from_stripe_format( $refund_amount, $currency ),
425 $currency,
426 $payment_entry_id,
427 $lookup_method
428 )
429 );
430 }
431
432 /**
433 * Handles invoice.payment_succeeded webhook for subscription payments.
434 *
435 * @param array<string, mixed> $invoice Invoice object from Stripe.
436 * @since 2.0.0
437 * @return void
438 */
439 public function handle_invoice_payment_succeeded( $invoice ) {
440 $invoice_id = $invoice['id'] ?? 'unknown';
441 Helper::srfm_log( 'Processing invoice.payment_succeeded webhook. Invoice ID: ' . $invoice_id . '.' );
442
443 // Validate billing reason is subscription cycle.
444 $billing_reason = ! empty( $invoice['billing_reason'] ) && is_string( $invoice['billing_reason'] ) ? sanitize_text_field( $invoice['billing_reason'] ) : '';
445 if ( 'subscription_cycle' !== $billing_reason ) {
446 Helper::srfm_log( 'Invoice payment succeeded - not a subscription cycle payment. Billing reason: ' . $billing_reason . '.' );
447 return;
448 }
449
450 Helper::srfm_log( 'Billing reason validated: subscription_cycle.' );
451
452 // Extract subscription ID using backward-compatible helper.
453 $subscription_id = $this->extract_subscription_id_from_invoice( $invoice );
454 if ( empty( $subscription_id ) ) {
455 Helper::srfm_log( 'Invoice payment succeeded - missing subscription ID. Invoice ID: ' . $invoice_id . '.' );
456 return;
457 }
458
459 Helper::srfm_log( 'Subscription ID extracted successfully: ' . $subscription_id . '.' );
460
461 // Find subscription record in database.
462 $subscription_record = Payments::get_main_subscription_record( $subscription_id );
463 if ( ! $subscription_record ) {
464 Helper::srfm_log( 'Invoice payment succeeded - subscription not found in database: ' . $subscription_id . '.' );
465 return;
466 }
467
468 Helper::srfm_log( 'Subscription record found in database. Record ID: ' . ( ! empty( $subscription_record['id'] ) && is_numeric( $subscription_record['id'] ) ? intval( $subscription_record['id'] ) : 'unknown' ) . '.' );
469
470 // Extract invoice data using backward-compatible helpers.
471 $charge_id = $this->extract_charge_id_from_invoice( $invoice );
472 $amount_paid = isset( $invoice['amount_paid'] ) && ( is_numeric( $invoice['amount_paid'] ) || is_float( $invoice['amount_paid'] ) || is_string( $invoice['amount_paid'] ) ) ? $invoice['amount_paid'] : 0;
473 $currency = ! empty( $invoice['currency'] ) && is_string( $invoice['currency'] ) ? sanitize_text_field( strtolower( $invoice['currency'] ) ) : 'usd';
474
475 Helper::srfm_log(
476 sprintf(
477 'Invoice details - Charge ID: %s, Amount: %s %s.',
478 $charge_id ? $charge_id : 'empty',
479 Stripe_Helper::amount_from_stripe_format( $amount_paid, $currency ),
480 $currency
481 )
482 );
483
484 // Check if this payment was already processed.
485 if ( ! empty( $charge_id ) ) {
486 $existing_payment = Payments::get_by_transaction_id( $charge_id );
487 if ( $existing_payment ) {
488 Helper::srfm_log( 'Invoice payment already processed. Charge ID: ' . $charge_id . '.' );
489 return;
490 }
491 }
492
493 // Extract block_id from line items metadata.
494 $block_id = '';
495 $invoice_lines = isset( $invoice['lines'] ) && is_array( $invoice['lines'] ) ? $invoice['lines'] : [];
496 $invoice_line_data = isset( $invoice_lines['data'] ) && is_array( $invoice_lines['data'] ) ? $invoice_lines['data'] : [];
497 $invoice_line_data_0 = isset( $invoice_line_data[0] ) && is_array( $invoice_line_data[0] ) ? $invoice_line_data[0] : [];
498 $metadata = isset( $invoice_line_data_0['metadata'] ) && is_array( $invoice_line_data_0['metadata'] ) ? $invoice_line_data_0['metadata'] : [];
499 $block_id = ! empty( $metadata['block_id'] ) && is_string( $metadata['block_id'] ) ? sanitize_text_field( $metadata['block_id'] ) : '';
500
501 Helper::srfm_log( 'Block ID from metadata: ' . ( $block_id ? $block_id : 'not found' ) . '.' );
502
503 // Check if this is the initial payment or a renewal.
504 $is_initial_payment = empty( $subscription_record['transaction_id'] ?? '' );
505
506 Helper::srfm_log(
507 sprintf(
508 'Payment type detected: %s. Subscription record has transaction_id: %s.',
509 $is_initial_payment ? 'Initial Payment' : 'Renewal Payment',
510 ! empty( $subscription_record['transaction_id'] ?? '' ) ? 'YES' : 'NO'
511 )
512 );
513
514 if ( $is_initial_payment ) {
515 Helper::srfm_log( 'Processing as initial subscription payment...' );
516 $this->process_initial_subscription_payment( $subscription_record, $invoice, $charge_id );
517 } else {
518 Helper::srfm_log( 'Processing as subscription renewal payment...' );
519 $this->process_subscription_renewal_payment( $subscription_record, $invoice, $charge_id, $block_id );
520 }
521
522 Helper::srfm_log(
523 sprintf(
524 'Subscription payment processed successfully. Type: %s, Subscription ID: %s, Amount: %s %s.',
525 $is_initial_payment ? 'Initial' : 'Renewal',
526 $subscription_id,
527 Stripe_Helper::amount_from_stripe_format( $amount_paid, $currency ),
528 $currency
529 )
530 );
531 }
532
533 /**
534 * Handles customer.subscription.deleted webhook for subscription cancellations.
535 *
536 * @param array<string, mixed> $subscription Subscription object from Stripe.
537 * @since 2.0.0
538 * @return void
539 */
540 public function handle_subscription_deleted( $subscription ) {
541 $subscription_id = ! empty( $subscription['id'] ) && is_string( $subscription['id'] ) ? sanitize_text_field( $subscription['id'] ) : '';
542 Helper::srfm_log( 'Processing customer.subscription.deleted webhook. Subscription ID: ' . $subscription_id . '.' );
543
544 if ( empty( $subscription_id ) ) {
545 Helper::srfm_log( 'Subscription deleted - missing subscription ID.' );
546 return;
547 }
548
549 Helper::srfm_log( 'Subscription ID extracted successfully: ' . $subscription_id . '.' );
550
551 // Find subscription record in database.
552 $subscription_record = Payments::get_main_subscription_record( $subscription_id );
553 if ( ! $subscription_record ) {
554 Helper::srfm_log( 'Subscription deleted - subscription not found in database: ' . $subscription_id . '.' );
555 return;
556 }
557
558 $subscription_db_id = ! empty( $subscription_record['id'] ) && is_numeric( $subscription_record['id'] ) ? intval( $subscription_record['id'] ) : 0;
559 Helper::srfm_log( 'Subscription record found in database. Record ID: ' . $subscription_db_id . '.' );
560
561 // Extract cancellation details from subscription object.
562 $canceled_at = isset( $subscription['canceled_at'] ) && is_numeric( $subscription['canceled_at'] ) ? intval( $subscription['canceled_at'] ) : time();
563 $cancellation_details = isset( $subscription['cancellation_details'] ) && is_array( $subscription['cancellation_details'] ) ? $subscription['cancellation_details'] : [];
564 $cancellation_reason = ! empty( $cancellation_details['reason'] ) && is_string( $cancellation_details['reason'] ) ? sanitize_text_field( $cancellation_details['reason'] ) : '';
565 $cancellation_feedback = ! empty( $cancellation_details['feedback'] ) && is_string( $cancellation_details['feedback'] ) ? sanitize_text_field( $cancellation_details['feedback'] ) : '';
566 $status = ! empty( $subscription['status'] ) && is_string( $subscription['status'] ) ? sanitize_text_field( $subscription['status'] ) : 'canceled';
567
568 // Prepare log entry for subscription cancellation.
569 $current_logs = isset( $subscription_record['log'] ) && is_array( $subscription_record['log'] ) ? $subscription_record['log'] : [];
570 $log_messages = [
571 /* translators: %s: Subscription ID */
572 sprintf( __( 'Subscription ID: %s', 'sureforms' ), $subscription_id ),
573 /* translators: %s: Payment Gateway */
574 sprintf( __( 'Payment Gateway: %s', 'sureforms' ), 'Stripe' ),
575 /* translators: %s: Status */
576 sprintf( __( 'Status: %s', 'sureforms' ), ucfirst( $status ) ),
577 /* translators: %s: Canceled date */
578 sprintf( __( 'Canceled at: %s', 'sureforms' ), gmdate( 'Y-m-d H:i:s', $canceled_at ) ),
579 ];
580
581 if ( ! empty( $cancellation_reason ) ) {
582 $log_messages[] = sprintf(
583 /* translators: %s: Cancellation reason */
584 __( 'Cancellation Reason: %s', 'sureforms' ),
585 ucfirst( str_replace( '_', ' ', $cancellation_reason ) )
586 );
587 }
588
589 if ( ! empty( $cancellation_feedback ) ) {
590 $log_messages[] = sprintf(
591 /* translators: %s: Cancellation feedback */
592 __( 'Feedback: %s', 'sureforms' ),
593 ucfirst( str_replace( '_', ' ', $cancellation_feedback ) )
594 );
595 }
596
597 $new_log = [
598 'title' => __( 'Subscription Canceled', 'sureforms' ),
599 'created_at' => current_time( 'mysql' ),
600 'messages' => $log_messages,
601 ];
602
603 $current_logs[] = $new_log;
604
605 // Track lifecycle on `subscription_status` and leave the transaction `status`
606 // (e.g. 'succeeded') untouched so admins can still refund the initial payment
607 // after Stripe emits customer.subscription.deleted.
608 $update_data = [
609 'subscription_status' => 'canceled',
610 'log' => $current_logs,
611 ];
612
613 $result = Payments::update( $subscription_db_id, $update_data );
614
615 if ( false === $result ) {
616 Helper::srfm_log( 'Failed to update subscription record for cancellation. Subscription ID: ' . $subscription_id . ', DB ID: ' . $subscription_db_id . '.' );
617 return;
618 }
619
620 Helper::srfm_log(
621 sprintf(
622 'Subscription canceled successfully. Subscription ID: %s, DB ID: %d, Canceled at: %s.',
623 $subscription_id,
624 $subscription_db_id,
625 gmdate( 'Y-m-d H:i:s', $canceled_at )
626 )
627 );
628
629 // Notify consumers that the subscription has reached a terminal canceled state.
630 $canceled_record = Payments::get( $subscription_db_id );
631 if ( is_array( $canceled_record ) ) {
632 /**
633 * Fires when a subscription reaches its terminal `canceled` state (for
634 * Stripe, after the billing period ends — `customer.subscription.deleted`).
635 *
636 * @param array<string, mixed> $subscription_record The canceled subscription payment record.
637 * @param array<string, mixed> $context Resolved context: form_id, entry_id,
638 * user_id (0 for guests), customer_email,
639 * type, gateway, mode.
640 * @since 2.12.0
641 */
642 do_action( 'srfm_subscription_canceled', $canceled_record, Payment_Helper::build_payment_context( $canceled_record ) );
643 }
644 }
645
646 /**
647 * Update refund data for a payment.
648 *
649 * @param int $payment_id Payment ID.
650 * @param array<string, mixed> $refund_response Refund response data.
651 * @param int|float|string $refund_amount Refund amount in cents.
652 * @param string $currency Currency code.
653 * @param string|null $payment Payment method.
654 * @since 2.0.0
655 * @return bool Whether the update was successful.
656 */
657 public function update_refund_data( $payment_id, $refund_response, $refund_amount, $currency, $payment = null ) {
658 if ( empty( $payment_id ) || empty( $refund_response ) ) {
659 return false;
660 }
661
662 // Get payment record if not provided.
663 $payment = Payments::get( $payment_id );
664 if ( ! $payment ) {
665 Helper::srfm_log( 'Payment record not found for ID: ' . $payment_id . '.' );
666 return false;
667 }
668
669 $check_if_refund_already_exists = $this->check_if_refund_already_exists( $payment, $refund_response );
670 if ( $check_if_refund_already_exists ) {
671 return true;
672 }
673
674 // Prepare refund data for payment_data column.
675 $refund_data = [
676 'refund_id' => ! empty( $refund_response['id'] ) && is_string( $refund_response['id'] ) ? sanitize_text_field( $refund_response['id'] ) : '',
677 'amount' => absint( $refund_amount ),
678 'currency' => sanitize_text_field( strtoupper( $currency ) ),
679 'status' => ! empty( $refund_response['status'] ) && is_string( $refund_response['status'] ) ? sanitize_text_field( $refund_response['status'] ) : 'processed',
680 'created' => time(),
681 'reason' => ! empty( $refund_response['reason'] ) && is_string( $refund_response['reason'] ) ? sanitize_text_field( $refund_response['reason'] ) : 'requested_by_customer',
682 'description' => ! empty( $refund_response['description'] ) && is_string( $refund_response['description'] ) ? sanitize_text_field( $refund_response['description'] ) : '',
683 'receipt_number' => ! empty( $refund_response['receipt_number'] ) && is_string( $refund_response['receipt_number'] ) ? sanitize_text_field( $refund_response['receipt_number'] ) : '',
684 'refunded_by' => 'stripe_dashboard',
685 'refunded_at' => gmdate( 'Y-m-d H:i:s' ),
686 ];
687
688 // Validate refund amount to prevent over-refunding.
689 $original_amount = floatval( $payment['total_amount'] );
690 $existing_refunds = floatval( $payment['refunded_amount'] ?? 0 ); // Use column directly.
691 $new_refund_amount = Stripe_Helper::amount_from_stripe_format( $refund_amount, $currency );
692 $total_after_refund = $existing_refunds + $new_refund_amount;
693
694 if ( $total_after_refund > $original_amount ) {
695 Helper::srfm_log(
696 sprintf(
697 'Over-refund attempt blocked. Payment ID: %d, Original: $%s, Existing refunds: $%s, New refund: $%s.',
698 $payment_id,
699 number_format( $original_amount, 2 ),
700 number_format( $existing_refunds, 2 ),
701 number_format( $new_refund_amount, 2 )
702 )
703 );
704 return false;
705 }
706
707 // Add refund data to payment_data column (for audit trail).
708 $payment_data_result = Payments::add_refund_to_payment_data( $payment_id, $refund_data );
709
710 // Update the refunded_amount column.
711 $refund_amount_result = Payments::add_refund_amount( $payment_id, $new_refund_amount );
712
713 // Calculate appropriate payment status.
714 $payment_status = 'succeeded'; // Default to current status.
715 if ( $total_after_refund >= $original_amount ) {
716 $payment_status = 'refunded'; // Fully refunded.
717 } elseif ( $total_after_refund > 0 ) {
718 $payment_status = 'partially_refunded'; // Partially refunded.
719 }
720
721 // Update payment status and log.
722 $current_logs = Helper::get_array_value( $payment['log'] );
723 $refund_type = $total_after_refund >= $original_amount ? __( 'Full', 'sureforms' ) : __( 'Partial', 'sureforms' );
724 $new_log = [
725 // translators: %s: Refund type (e.g., Full, Partial).
726 'title' => sprintf( __( '%s Payment Refund', 'sureforms' ), $refund_type ),
727 'created_at' => current_time( 'mysql' ),
728 'messages' => [
729 // translators: %s: Refund ID.
730 sprintf( __( 'Refund ID: %s', 'sureforms' ), ! empty( $refund_response['id'] ) && is_string( $refund_response['id'] ) ? sanitize_text_field( $refund_response['id'] ) : 'N/A' ),
731 // translators: %s: Payment gateway name (e.g., Stripe).
732 sprintf( __( 'Payment Gateway: %s', 'sureforms' ), 'Stripe' ),
733 // translators: 1: Refund amount, 2: Currency.
734 sprintf( __( 'Refund Amount: %1$s %2$s', 'sureforms' ), number_format( Stripe_Helper::amount_from_stripe_format( $refund_amount, $currency ), 2 ), strtoupper( $currency ) ),
735 sprintf(
736 /* translators: 1: Total refunded amount, 2: Currency, 3: Original amount, 4: Currency */
737 __( 'Total Refunded: %1$s %2$s of %3$s %4$s', 'sureforms' ),
738 number_format( $total_after_refund, 2 ),
739 strtoupper( $currency ),
740 number_format( $original_amount, 2 ),
741 strtoupper( $currency )
742 ),
743 // translators: %s: Refund status (e.g., succeeded, failed).
744 sprintf( __( 'Refund Status: %s', 'sureforms' ), ! empty( $refund_response['status'] ) && is_string( $refund_response['status'] ) ? sanitize_text_field( $refund_response['status'] ) : 'processed' ),
745 // translators: %s: Payment status (e.g., refunded, partially refunded).
746 sprintf( __( 'Payment Status: %s', 'sureforms' ), ucfirst( str_replace( '_', ' ', $payment_status ) ) ),
747 // translators: %s: Refunded by method (e.g., Webhook).
748 sprintf( __( 'Refunded by: %s', 'sureforms' ), __( 'Webhook', 'sureforms' ) ),
749 ],
750 ];
751 $current_logs[] = $new_log;
752
753 $update_data = [
754 'status' => $payment_status,
755 'log' => $current_logs,
756 ];
757
758 // Update payment record with status and log.
759 $payment_update_result = Payments::update( $payment_id, $update_data );
760
761 // Check if all operations succeeded.
762 if ( false === $payment_data_result ) {
763 Helper::srfm_log( 'Failed to store refund data in payment_data for payment ID: ' . $payment_id . '.' );
764 }
765
766 if ( false === $refund_amount_result ) {
767 Helper::srfm_log( 'Failed to update refunded_amount column for payment ID: ' . $payment_id . '.' );
768 return false;
769 }
770
771 if ( false === $payment_update_result ) {
772 Helper::srfm_log( 'Failed to update payment status and log for payment ID: ' . $payment_id . '.' );
773 return false;
774 }
775
776 Helper::srfm_log(
777 sprintf(
778 /* translators: %d: Payment ID, %s: Refund ID, %s: Amount, %s: Currency */
779 'Refund processed successfully. Payment ID: %d, Refund ID: %s, Amount: %s %s.',
780 $payment_id,
781 $refund_data['refund_id'],
782 Stripe_Helper::amount_from_stripe_format( $refund_amount, $currency ),
783 $currency
784 )
785 );
786
787 // Notify consumers that a refund was recorded against this payment.
788 $is_full_refund = $total_after_refund >= $original_amount;
789 $refunded_payment = Payments::get( $payment_id );
790 $refunded_payment = is_array( $refunded_payment ) ? $refunded_payment : $payment;
791 /**
792 * Fires when a refund is recorded against a SureForms payment (covers both
793 * the Stripe webhook and admin-initiated refund paths).
794 *
795 * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
796 * @param float $refund_amount Refunded amount for this event, in the store's decimal currency.
797 * @param bool $is_full_refund Whether the cumulative refunds now cover the full payment.
798 * @param array<string, mixed> $context Resolved context: form_id, entry_id,
799 * user_id (0 for guests), customer_email,
800 * type, gateway, mode.
801 * @since 2.12.0
802 */
803 do_action( 'srfm_payment_refunded', $refunded_payment, (float) $new_refund_amount, $is_full_refund, Payment_Helper::build_payment_context( $refunded_payment ) );
804
805 return true;
806 }
807
808 /**
809 * Process refund cancellation - reverses a previously processed refund.
810 *
811 * @param int $payment_id Payment ID.
812 * @param array<string, mixed> $refund Refund object from Stripe webhook.
813 * @param string $currency Currency code.
814 * @param string $lookup_method How the payment was found.
815 * @since 2.0.0
816 * @return void
817 */
818 private function process_refund_cancellation( $payment_id, $refund, $currency, $lookup_method ) {
819 $refund_id = ! empty( $refund['id'] ) && is_string( $refund['id'] ) ? sanitize_text_field( $refund['id'] ) : '';
820
821 Helper::srfm_log( 'Processing refund cancellation for refund ID: ' . $refund_id );
822
823 // Get payment record.
824 $payment = Payments::get( $payment_id );
825 if ( ! $payment ) {
826 Helper::srfm_log( 'REFUND CANCELLATION FAILED: Payment record not found for ID: ' . $payment_id );
827 return;
828 }
829
830 // Get payment_data and check if refund exists.
831 $payment_data = Helper::get_array_value( $payment['payment_data'] ?? [] );
832 $refunds = isset( $payment_data['refunds'] ) && is_array( $payment_data['refunds'] ) ? $payment_data['refunds'] : [];
833
834 // Check if the refund exists in the payment data.
835 if ( ! isset( $refunds[ $refund_id ] ) ) {
836 Helper::srfm_log(
837 sprintf(
838 'REFUND CANCELLATION SKIPPED: Refund ID %s not found in payment data for payment ID %d. It may have already been canceled or never existed.',
839 $refund_id,
840 $payment_id
841 )
842 );
843 return;
844 }
845
846 // Get the refund data to extract the amount and currency.
847 $existing_refund = $refunds[ $refund_id ];
848 $canceled_refund_amount_cents = isset( $existing_refund['amount'] ) && is_numeric( $existing_refund['amount'] ) ? $existing_refund['amount'] : 0;
849 $refund_currency = ! empty( $existing_refund['currency'] ) && is_string( $existing_refund['currency'] ) ? sanitize_text_field( strtolower( $existing_refund['currency'] ) ) : $currency;
850
851 if ( $canceled_refund_amount_cents <= 0 ) {
852 Helper::srfm_log( 'REFUND CANCELLATION FAILED: Invalid refund amount in existing refund data.' );
853 return;
854 }
855
856 // Convert from Stripe format (cents) to decimal format (handles zero-decimal currencies).
857 $canceled_refund_amount = Stripe_Helper::amount_from_stripe_format( $canceled_refund_amount_cents, $refund_currency );
858
859 // Remove the refund from payment_data.
860 unset( $refunds[ $refund_id ] );
861 $payment_data['refunds'] = $refunds;
862
863 // Update payment_data in database.
864 $payment_data_update = Payments::update(
865 $payment_id,
866 [
867 'payment_data' => $payment_data,
868 ]
869 );
870
871 if ( false === $payment_data_update ) {
872 Helper::srfm_log( 'REFUND CANCELLATION FAILED: Could not update payment_data for payment ID: ' . $payment_id );
873 return;
874 }
875
876 // Subtract the refund amount from refunded_amount column.
877 $current_refunded_amount = floatval( $payment['refunded_amount'] ?? 0 );
878 $new_refunded_amount = max( 0, $current_refunded_amount - $canceled_refund_amount );
879
880 $refund_amount_update = Payments::update(
881 $payment_id,
882 [
883 'refunded_amount' => $new_refunded_amount,
884 ]
885 );
886
887 if ( false === $refund_amount_update ) {
888 Helper::srfm_log( 'REFUND CANCELLATION FAILED: Could not update refunded_amount for payment ID: ' . $payment_id );
889 return;
890 }
891
892 // Recalculate payment status based on new refunded amount.
893 $original_amount = floatval( $payment['total_amount'] );
894 $payment_status = 'succeeded'; // Default.
895
896 if ( $new_refunded_amount >= $original_amount ) {
897 $payment_status = 'refunded'; // Fully refunded.
898 } elseif ( $new_refunded_amount > 0 ) {
899 $payment_status = 'partially_refunded'; // Partially refunded.
900 }
901
902 // Extract failure reason from refund object.
903 $failure_reason = ! empty( $refund['failure_reason'] ) && is_string( $refund['failure_reason'] ) ? sanitize_text_field( $refund['failure_reason'] ) : 'unknown';
904
905 // Add log entry for the cancellation.
906 $current_logs = Helper::get_array_value( $payment['log'] );
907 $new_log = [
908 'title' => __( 'Refund Canceled', 'sureforms' ),
909 'created_at' => current_time( 'mysql' ),
910 'messages' => [
911 // translators: %s: Refund ID.
912 sprintf( __( 'Refund ID: %s', 'sureforms' ), $refund_id ),
913 // translators: %s: Payment gateway name (e.g., Stripe).
914 sprintf( __( 'Payment Gateway: %s', 'sureforms' ), 'Stripe' ),
915 // translators: 1: Canceled amount, 2: Currency.
916 sprintf( __( 'Canceled Refund Amount: %1$s %2$s', 'sureforms' ), number_format( $canceled_refund_amount, 2 ), strtoupper( $refund_currency ) ),
917 sprintf(
918 /* translators: 1: Remaining refunded amount, 2: Currency, 3: Original amount, 4: Currency */
919 __( 'Remaining Refunded: %1$s %2$s of %3$s %4$s', 'sureforms' ),
920 number_format( $new_refunded_amount, 2 ),
921 strtoupper( $refund_currency ),
922 number_format( $original_amount, 2 ),
923 strtoupper( $refund_currency )
924 ),
925 // translators: %s: Failure reason.
926 sprintf( __( 'Cancellation Reason: %s', 'sureforms' ), ucfirst( str_replace( '_', ' ', $failure_reason ) ) ),
927 // translators: %s: Payment status (e.g., succeeded, partially refunded).
928 sprintf( __( 'Payment Status: %s', 'sureforms' ), ucfirst( str_replace( '_', ' ', $payment_status ) ) ),
929 // translators: %s: Canceled by method (e.g., Webhook).
930 sprintf( __( 'Canceled by: %s', 'sureforms' ), __( 'Webhook', 'sureforms' ) ),
931 ],
932 ];
933
934 $current_logs[] = $new_log;
935
936 // Update payment status and log.
937 $status_update = Payments::update(
938 $payment_id,
939 [
940 'status' => $payment_status,
941 'log' => $current_logs,
942 ]
943 );
944
945 if ( false === $status_update ) {
946 Helper::srfm_log( 'REFUND CANCELLATION: Updated amounts but failed to update payment status and log for payment ID: ' . $payment_id );
947 return;
948 }
949
950 Helper::srfm_log(
951 sprintf(
952 'REFUND CANCELLATION SUCCESS: Refund ID: %s, Canceled Amount: %s %s, Remaining Refunded: %s %s, Payment Status: %s, Payment ID: %d (found via %s)',
953 $refund_id,
954 number_format( $canceled_refund_amount, 2 ),
955 strtoupper( $refund_currency ),
956 number_format( $new_refunded_amount, 2 ),
957 strtoupper( $refund_currency ),
958 $payment_status,
959 $payment_id,
960 $lookup_method
961 )
962 );
963 }
964
965 /**
966 * Extracts subscription ID from invoice object with backward compatibility.
967 * Handles both old and new Stripe API structures.
968 *
969 * @param array<string, mixed> $invoice Invoice object from Stripe.
970 * @since 2.0.0
971 * @return string Subscription ID or empty string if not found.
972 */
973 private function extract_subscription_id_from_invoice( $invoice ) {
974 // Method 1: Parent subscription_details (new API structure - 2025+).
975 $subscription_parent = isset( $invoice['parent'] ) && is_array( $invoice['parent'] ) ? $invoice['parent'] : [];
976 $subscription_details = isset( $subscription_parent['subscription_details'] ) && is_array( $subscription_parent['subscription_details'] ) ? $subscription_parent['subscription_details'] : [];
977 $subscription = isset( $subscription_details['subscription'] ) && is_string( $subscription_details['subscription'] ) ? sanitize_text_field( $subscription_details['subscription'] ) : '';
978 if ( ! empty( $subscription ) ) {
979 Helper::srfm_log( 'Subscription ID found at: $invoice[\'parent\'][\'subscription_details\'][\'subscription\'].' );
980 return $subscription;
981 }
982
983 // Not found - log invoice structure for debugging.
984 Helper::srfm_log(
985 sprintf(
986 'Subscription ID not found in invoice. Invoice ID: %s, Keys present: %s.',
987 ! empty( $invoice['id'] ) && is_string( $invoice['id'] ) ? sanitize_text_field( $invoice['id'] ) : 'unknown',
988 implode( ', ', array_keys( $invoice ) )
989 )
990 );
991
992 return '';
993 }
994
995 /**
996 * Extracts charge ID from invoice object with backward compatibility.
997 * Falls back to payment_intent, fetching from API, or invoice ID if charge is not available.
998 *
999 * @param array<string, mixed> $invoice Invoice object from Stripe.
1000 * @since 2.0.0
1001 * @return string Charge ID, payment_intent, or invoice ID.
1002 */
1003 private function extract_charge_id_from_invoice( $invoice ) {
1004 // Method 1: Direct charge field (old API structure and most common).
1005 if ( ! empty( $invoice['charge'] ) ) {
1006 Helper::srfm_log( 'Charge ID found at: $invoice[\'charge\'].' );
1007 return ! empty( $invoice['charge'] ) && is_string( $invoice['charge'] ) ? sanitize_text_field( $invoice['charge'] ) : '';
1008 }
1009
1010 // Method 2: Payment intent (alternative in newer API or pending payments).
1011 if ( ! empty( $invoice['payment_intent'] ) ) {
1012 Helper::srfm_log( 'Charge ID not found, using payment_intent as transaction ID: $invoice[\'payment_intent\'].' );
1013 return ! empty( $invoice['payment_intent'] ) && is_string( $invoice['payment_intent'] ) ? sanitize_text_field( $invoice['payment_intent'] ) : '';
1014 }
1015
1016 // Method 3: Fetch invoice from Stripe API to get charge ID.
1017 if ( ! empty( $invoice['id'] ) ) {
1018 $invoice_id = ! empty( $invoice['id'] ) && is_string( $invoice['id'] ) ? sanitize_text_field( $invoice['id'] ) : '';
1019 Helper::srfm_log( 'Attempting to fetch charge ID from Stripe API using invoice ID: ' . $invoice_id . '.' );
1020
1021 $api_response = Stripe_Helper::stripe_api_request( 'invoices', 'GET', [], $invoice_id, [ 'mode' => $this->mode ] );
1022
1023 if ( $api_response['success'] && ! empty( $api_response['data']['charge'] ) ) {
1024 $charge_id = sanitize_text_field( $api_response['data']['charge'] );
1025 Helper::srfm_log( 'Charge ID successfully retrieved from Stripe API: ' . $charge_id . '.' );
1026 return $charge_id;
1027 }
1028
1029 Helper::srfm_log( 'Failed to retrieve charge ID from Stripe API. Response: ' . wp_json_encode( $api_response ) . '.' );
1030 }
1031
1032 // Method 4: Invoice ID as absolute last resort (for tracking purposes).
1033 if ( ! empty( $invoice['id'] ) ) {
1034 Helper::srfm_log( 'WARNING: Using invoice ID as transaction ID (last resort): $invoice[\'id\'].' );
1035 return ! empty( $invoice['id'] ) && is_string( $invoice['id'] ) ? sanitize_text_field( $invoice['id'] ) : '';
1036 }
1037
1038 Helper::srfm_log( 'CRITICAL: No transaction identifier found in invoice object.' );
1039 return '';
1040 }
1041
1042 /**
1043 * Process initial subscription payment.
1044 *
1045 * @param array<string, mixed> $subscription_record Subscription record from database.
1046 * @param array<string, mixed> $invoice Invoice object from Stripe.
1047 * @param string $charge_id Charge ID from Stripe.
1048 * @since 2.0.0
1049 * @return void
1050 */
1051 private function process_initial_subscription_payment( $subscription_record, $invoice, $charge_id ) {
1052 $subscription_id = ! empty( $subscription_record['id'] ) && is_numeric( $subscription_record['id'] ) ? intval( $subscription_record['id'] ) : 0;
1053
1054 if ( ! $subscription_id ) {
1055 Helper::srfm_log( 'Invalid subscription record for initial payment processing.' );
1056 return;
1057 }
1058
1059 // Update subscription record with transaction ID and set status to active.
1060 $update_data = [
1061 'transaction_id' => $charge_id,
1062 'status' => 'succeeded',
1063 ];
1064
1065 // Generate srfm_txn_id if it's not already set.
1066 $current_srfm_txn_id = ! empty( $subscription_record['srfm_txn_id'] ) && is_string( $subscription_record['srfm_txn_id'] ) ? sanitize_text_field( $subscription_record['srfm_txn_id'] ) : '';
1067 if ( empty( $current_srfm_txn_id ) ) {
1068 $unique_payment_id = Stripe_Helper::generate_unique_payment_id( $subscription_id );
1069 $update_data['srfm_txn_id'] = $unique_payment_id;
1070 Helper::srfm_log( 'Generated srfm_txn_id for initial subscription payment: ' . $unique_payment_id . '.' );
1071 }
1072
1073 $currency = isset( $invoice['currency'] ) && is_string( $invoice['currency'] ) ? sanitize_text_field( strtolower( $invoice['currency'] ) ) : 'usd';
1074 $invoice_amount = isset( $invoice['amount_paid'] ) && ( is_numeric( $invoice['amount_paid'] ) || is_float( $invoice['amount_paid'] ) || is_string( $invoice['amount_paid'] ) ) ? $invoice['amount_paid'] : 0;
1075 $invoice_id = isset( $invoice['id'] ) && is_string( $invoice['id'] ) ? sanitize_text_field( $invoice['id'] ) : '';
1076
1077 // Add log entry for initial payment success.
1078 $current_logs = isset( $subscription_record['log'] ) && is_array( $subscription_record['log'] ) ? $subscription_record['log'] : [];
1079 $new_log = [
1080 'title' => __( 'Initial Subscription Payment Succeeded', 'sureforms' ),
1081 'created_at' => current_time( 'mysql' ),
1082 'messages' => [
1083 /* translators: %s: Charge ID */
1084 sprintf( __( 'Charge ID: %s', 'sureforms' ), $charge_id ),
1085 /* translators: %s: Invoice ID */
1086 sprintf( __( 'Invoice ID: %s', 'sureforms' ), $invoice_id ),
1087 sprintf(
1088 /* translators: 1: Amount, 2: Currency */
1089 __( 'Amount: %1$s %2$s', 'sureforms' ),
1090 number_format( Stripe_Helper::amount_from_stripe_format( $invoice_amount, $currency ), 2 ),
1091 strtoupper( $currency )
1092 ),
1093 __( 'Payment Status: Succeeded', 'sureforms' ),
1094 __( 'Subscription Status: Active', 'sureforms' ),
1095 ],
1096 ];
1097 $current_logs[] = $new_log;
1098 $update_data['log'] = $current_logs;
1099
1100 $result = Payments::update( $subscription_id, $update_data );
1101
1102 if ( false === $result ) {
1103 Helper::srfm_log( 'Failed to update subscription record for initial payment. Subscription ID: ' . $subscription_id . '.' );
1104 } else {
1105 Helper::srfm_log( 'Initial subscription payment processed successfully. Subscription ID: ' . $subscription_id . '.' );
1106
1107 // Notify consumers that the initial subscription charge succeeded.
1108 $payment = Payments::get( $subscription_id );
1109 if ( is_array( $payment ) ) {
1110 /**
1111 * Fires when a SureForms payment reaches the `succeeded` state — a
1112 * one-time payment, or the initial charge of a subscription.
1113 *
1114 * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
1115 * @param array<string, mixed> $context Resolved context: form_id, entry_id,
1116 * user_id (0 for guests), customer_email,
1117 * type, gateway, mode.
1118 * @since 2.12.0
1119 */
1120 do_action( 'srfm_payment_completed', $payment, Payment_Helper::build_payment_context( $payment ) );
1121 }
1122 }
1123 }
1124
1125 /**
1126 * Process subscription renewal payment.
1127 *
1128 * @param array<string, mixed> $subscription_record Subscription record from database.
1129 * @param array<string, mixed> $invoice Invoice object from Stripe.
1130 * @param string $charge_id Charge ID from Stripe.
1131 * @param string $block_id Block ID from metadata.
1132 * @since 2.0.0
1133 * @return void
1134 */
1135 private function process_subscription_renewal_payment( $subscription_record, $invoice, $charge_id, $block_id ) {
1136 $customer_id = ! empty( $subscription_record['customer_id'] ) && is_string( $subscription_record['customer_id'] ) ? sanitize_text_field( $subscription_record['customer_id'] ) : '';
1137 $customer_email = ! empty( $subscription_record['customer_email'] ) && is_string( $subscription_record['customer_email'] ) ? sanitize_email( $subscription_record['customer_email'] ) : '';
1138 $customer_name = ! empty( $subscription_record['customer_name'] ) && is_string( $subscription_record['customer_name'] ) ? sanitize_text_field( $subscription_record['customer_name'] ) : '';
1139
1140 $invoice_amount = isset( $invoice['amount_paid'] ) && ( is_numeric( $invoice['amount_paid'] ) || is_float( $invoice['amount_paid'] ) || is_string( $invoice['amount_paid'] ) ) ? $invoice['amount_paid'] : 0;
1141 $currency = isset( $invoice['currency'] ) && is_string( $invoice['currency'] ) ? sanitize_text_field( strtolower( $invoice['currency'] ) ) : 'usd';
1142 $amount_paid = Stripe_Helper::amount_from_stripe_format( $invoice_amount, $currency );
1143
1144 $block_id = empty( $block_id ) || ! is_string( $block_id ) ? '' : $block_id;
1145 $block_id = empty( $block_id ) && ! empty( $subscription_record['block_id'] ) && is_string( $subscription_record['block_id'] ) ? sanitize_text_field( $subscription_record['block_id'] ) : '';
1146
1147 $form_id = ! empty( $subscription_record['form_id'] ) && is_numeric( $subscription_record['form_id'] ) ? intval( $subscription_record['form_id'] ) : 0;
1148 $entry_id = ! empty( $subscription_record['entry_id'] ) && is_numeric( $subscription_record['entry_id'] ) ? intval( $subscription_record['entry_id'] ) : 0;
1149
1150 $subscription_id = ! empty( $subscription_record['subscription_id'] ) && is_string( $subscription_record['subscription_id'] ) ? $subscription_record['subscription_id'] : '';
1151
1152 $invoice_id = ! empty( $invoice['id'] ) && is_string( $invoice['id'] ) ? sanitize_text_field( $invoice['id'] ) : '';
1153 $payment_intent = ! empty( $invoice['payment_intent'] ) && is_string( $invoice['payment_intent'] ) ? sanitize_text_field( $invoice['payment_intent'] ) : '';
1154 $billing_reason = ! empty( $invoice['billing_reason'] ) && is_string( $invoice['billing_reason'] ) ? sanitize_text_field( $invoice['billing_reason'] ) : '';
1155
1156 $logs = [
1157 [
1158 'title' => __( 'Subscription Charge Payment', 'sureforms' ),
1159 'created_at' => current_time( 'mysql' ),
1160 'messages' => [
1161 /* translators: %s: Charge ID */
1162 sprintf( __( 'Transaction ID: %s', 'sureforms' ), $charge_id ),
1163 /* translators: %s: Payment Gateway */
1164 sprintf( __( 'Payment Gateway: %s', 'sureforms' ), 'Stripe' ),
1165 /* translators: 1: Amount, 2: Currency */
1166 sprintf( __( 'Amount: %1$s %2$s', 'sureforms' ), $amount_paid, strtoupper( $currency ) ),
1167 /* translators: %s: Status */
1168 sprintf( __( 'Status: %s', 'sureforms' ), __( 'Succeeded', 'sureforms' ) ),
1169 /* translators: %s: Subscription ID */
1170 sprintf( __( 'Subscription ID: %s', 'sureforms' ), $subscription_id ),
1171 /* translators: %s: Invoice ID */
1172 sprintf( __( 'Invoice ID: %s', 'sureforms' ), $invoice_id ),
1173 /* translators: %s: Customer ID */
1174 sprintf( __( 'Customer ID: %s', 'sureforms' ), $customer_id ),
1175 /* translators: %s: Customer Email */
1176 sprintf( __( 'Customer Email: %s', 'sureforms' ), $customer_email ),
1177 /* translators: %s: Customer Name */
1178 sprintf( __( 'Customer Name: %s', 'sureforms' ), $customer_name ),
1179 __( 'Created via subscription billing cycle', 'sureforms' ),
1180 ],
1181 ],
1182 ];
1183
1184 // Get parent subscription database ID for linking renewal payments.
1185 $parent_subscription_db_id = ! empty( $subscription_record['id'] ) && is_numeric( $subscription_record['id'] ) ? intval( $subscription_record['id'] ) : 0;
1186
1187 // Prepare renewal payment data.
1188 $payment_data = [
1189 'form_id' => $form_id,
1190 'block_id' => $block_id,
1191 'status' => 'succeeded',
1192 'total_amount' => $amount_paid,
1193 'currency' => $currency,
1194 'entry_id' => $entry_id,
1195 'type' => 'renewal',
1196 'transaction_id' => $charge_id,
1197 'gateway' => 'stripe',
1198 'mode' => $this->mode,
1199 'subscription_id' => $subscription_id,
1200 'parent_subscription_id' => $parent_subscription_db_id,
1201 'srfm_txn_id' => '', // Will be updated after getting payment entry ID.
1202 'customer_email' => $customer_email,
1203 'customer_name' => $customer_name,
1204 'customer_id' => $customer_id,
1205 'payment_data' => [
1206 'invoice_id' => $invoice_id,
1207 'payment_intent' => $payment_intent,
1208 'billing_reason' => $billing_reason,
1209 'amount_paid' => $amount_paid,
1210 ],
1211 'log' => $logs,
1212 ];
1213
1214 // Create the renewal payment record.
1215 $payment_entry_id = Payments::add( $payment_data );
1216
1217 if ( $payment_entry_id ) {
1218 // Generate unique payment ID using the auto-increment ID and update the entry.
1219 $unique_payment_id = Stripe_Helper::generate_unique_payment_id( $payment_entry_id );
1220 Payments::update( $payment_entry_id, [ 'srfm_txn_id' => $unique_payment_id ] );
1221 Helper::srfm_log( 'Renewal payment record created with srfm_txn_id: ' . $unique_payment_id . ', Payment ID: ' . $payment_entry_id . '.' );
1222
1223 // Send payment data to middleware for analytics.
1224 if ( ! empty( $charge_id ) ) {
1225 $get_secret_key = Stripe_Helper::get_stripe_secret_key( $this->mode );
1226 Stripe_Helper::intersect_payment( $charge_id, $get_secret_key, '', 'SureForms' );
1227 }
1228
1229 // Notify consumers that a subscription renewal charge succeeded.
1230 $renewal_payment = Payments::get( $payment_entry_id );
1231 if ( is_array( $renewal_payment ) ) {
1232 /**
1233 * Fires when a subscription renewal charge succeeds and its payment
1234 * row has been recorded.
1235 *
1236 * @param array<string, mixed> $payment Renewal payment record (a `sureforms_payments` row).
1237 * @param array<string, mixed> $parent_subscription The parent subscription payment record.
1238 * @param array<string, mixed> $context Resolved context: form_id, entry_id,
1239 * user_id (0 for guests), customer_email,
1240 * type, gateway, mode.
1241 * @since 2.12.0
1242 */
1243 do_action( 'srfm_subscription_renewed', $renewal_payment, $subscription_record, Payment_Helper::build_payment_context( $renewal_payment ) );
1244 }
1245 } else {
1246 Helper::srfm_log( 'Failed to create renewal payment record.' );
1247 }
1248 }
1249
1250 /**
1251 * Check if the refund already exists.
1252 *
1253 * @param array<string, mixed> $payment Payment record data.
1254 * @param array<string, mixed> $refund Refund response from Stripe.
1255 * @return bool True if refund already exists, false otherwise.
1256 * @since 2.0.0
1257 */
1258 private function check_if_refund_already_exists( $payment, $refund ) {
1259 $refund_id = $refund['id'] ?? '';
1260
1261 if ( empty( $refund_id ) ) {
1262 return false;
1263 }
1264
1265 // Use Helper::get_array_value() to handle stdClass objects.
1266 $payment_data = Helper::get_array_value( $payment['payment_data'] ?? [] );
1267
1268 if ( empty( $payment_data['refunds'] ) ) {
1269 return false;
1270 }
1271
1272 // O(1) lookup using refund ID as array key.
1273 return isset( $payment_data['refunds'][ $refund_id ] );
1274 }
1275
1276 /**
1277 * Verify Stripe webhook signature locally using HMAC-SHA256.
1278 * Implements the same algorithm as Stripe's SDK without external dependencies.
1279 *
1280 * @param string $payload Raw request body.
1281 * @param string $sig_header Stripe-Signature header value.
1282 * @param string $secret Webhook signing secret (whsec_...).
1283 * @param int $tolerance Maximum age in seconds for replay protection.
1284 * @since 2.6.0
1285 * @return bool True if signature is valid, false otherwise.
1286 */
1287 private function verify_stripe_signature_locally( $payload, $sig_header, $secret, $tolerance = 300 ) {
1288 // Parse the Stripe-Signature header (format: t=timestamp,v1=signature,...).
1289 $parts = explode( ',', $sig_header );
1290 $timestamp = '';
1291 $signature = '';
1292
1293 foreach ( $parts as $part ) {
1294 $pair = explode( '=', $part, 2 );
1295 if ( 2 !== count( $pair ) ) {
1296 continue;
1297 }
1298 if ( 't' === $pair[0] ) {
1299 $timestamp = $pair[1];
1300 } elseif ( 'v1' === $pair[0] ) {
1301 $signature = $pair[1];
1302 }
1303 }
1304
1305 if ( empty( $timestamp ) || empty( $signature ) ) {
1306 Helper::srfm_log( 'Webhook signature verification failed: missing timestamp or v1 signature.' );
1307 return false;
1308 }
1309
1310 // Replay protection: reject requests older than tolerance.
1311 if ( absint( $timestamp ) < time() - $tolerance ) {
1312 Helper::srfm_log( 'Webhook signature verification failed: timestamp too old.' );
1313 return false;
1314 }
1315
1316 // Compute expected signature: HMAC-SHA256 of "timestamp.payload" with the secret.
1317 $signed_payload = $timestamp . '.' . $payload;
1318 $expected_signature = hash_hmac( 'sha256', $signed_payload, $secret );
1319
1320 if ( ! hash_equals( $expected_signature, $signature ) ) {
1321 Helper::srfm_log( 'Webhook signature verification failed: signature mismatch.' );
1322 return false;
1323 }
1324
1325 return true;
1326 }
1327 }
1328