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 / admin-stripe-handler.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
admin-stripe-handler.php
1378 lines
1 <?php
2 /**
3 * Admin Stripe Handler for SureForms
4 *
5 * Handles admin-related Stripe operations including refunds for payments and subscriptions.
6 *
7 * @package SureForms
8 * @since 2.0.0
9 */
10
11 namespace SRFM\Inc\Payments\Stripe;
12
13 use SRFM\Inc\Database\Tables\Payments;
14 use SRFM\Inc\Helper;
15 use SRFM\Inc\Traits\Get_Instance;
16
17 // Exit if accessed directly.
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * Admin Stripe Handler class.
24 *
25 * Manages admin operations for Stripe payments including refunds, cancellations,
26 * and payment management for both one-time and subscription payments.
27 *
28 * @since 2.0.0
29 */
30 class Admin_Stripe_Handler {
31 use Get_Instance;
32
33 /**
34 * Payment mode.
35 *
36 * @var string
37 * @since 2.0.0
38 */
39 private string $payment_mode = 'test';
40
41 /**
42 * Constructor
43 */
44 public function __construct() {
45 // AJAX handlers for admin refund operations.
46 add_action( 'wp_ajax_srfm_stripe_cancel_subscription', [ $this, 'ajax_cancel_subscription' ] );
47 add_action( 'wp_ajax_srfm_stripe_pause_subscription', [ $this, 'ajax_pause_subscription' ] );
48 // Hook into unified refund filter system.
49 add_filter( 'srfm_process_transaction_refund', [ $this, 'process_stripe_refund' ], 10, 2 );
50 // Hook into unified subscription cancellation filter system.
51 add_filter( 'srfm_process_subscription_cancellation', [ $this, 'process_stripe_subscription_cancellation' ], 10, 2 );
52 // Admin notices.
53 add_action( 'admin_notices', [ $this, 'webhook_configuration_notice' ] );
54 }
55
56 /**
57 * AJAX handler for subscription cancellation (following WPForms pattern)
58 *
59 * @since 2.0.0
60 */
61 /**
62 * AJAX handler for subscription cancellation (following WPForms pattern)
63 *
64 * @since 2.0.0
65 * @return void
66 */
67 public function ajax_cancel_subscription() {
68 // Security checks.
69 if ( ! isset( $_POST['payment_id'] ) ) {
70 wp_send_json_error( [ 'message' => esc_html__( 'Missing payment ID.', 'sureforms' ) ] );
71 }
72
73 // Verify nonce.
74 if (
75 ! wp_verify_nonce(
76 sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ),
77 'srfm_payment_admin_nonce'
78 )
79 ) {
80 wp_send_json_error( __( 'Invalid nonce.', 'sureforms' ) );
81 }
82
83 if ( ! current_user_can( 'manage_options' ) ) {
84 wp_send_json_error( [ 'message' => esc_html__( 'You are not allowed to perform this action.', 'sureforms' ) ] );
85 }
86
87 $payment_id = absint( $_POST['payment_id'] );
88
89 // Get payment record.
90 $payment = Payments::get( $payment_id );
91
92 $this->payment_mode = ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? $payment['mode'] : 'test';
93 if ( ! $payment ) {
94 wp_send_json_error( [ 'message' => esc_html__( 'Payment not found in the database.', 'sureforms' ) ] );
95 }
96
97 // Validate it's a subscription payment.
98 if ( empty( $payment['type'] ) || 'subscription' !== $payment['type'] ) {
99 wp_send_json_error( [ 'message' => esc_html__( 'This is not a subscription payment.', 'sureforms' ) ] );
100 }
101
102 if ( empty( $payment['subscription_id'] ) ) {
103 wp_send_json_error( [ 'message' => esc_html__( 'Subscription ID not found.', 'sureforms' ) ] );
104 }
105
106 // Cancel via the gateway-agnostic filter so the payment's OWN gateway (Stripe or
107 // PayPal) performs the cancellation. Previously this called the Stripe API directly,
108 // which failed for PayPal subscriptions cancelled from the admin screen. Both gateways
109 // hook 'srfm_process_subscription_cancellation' (Stripe + PayPal), matching the
110 // frontend cancellation path.
111 $cancel_result = apply_filters(
112 'srfm_process_subscription_cancellation',
113 [
114 'success' => false,
115 'message' => __( 'Cancellation is not supported for this payment gateway.', 'sureforms' ),
116 ],
117 $payment
118 );
119
120 if ( empty( $cancel_result['success'] ) ) {
121 wp_send_json_error(
122 [
123 'message' => ! empty( $cancel_result['message'] ) && is_string( $cancel_result['message'] )
124 ? esc_html( $cancel_result['message'] )
125 : esc_html__( 'Subscription cancellation failed.', 'sureforms' ),
126 ]
127 );
128 }
129
130 // The gateway callback (Stripe/PayPal) is the single source of truth: it has already
131 // cancelled at the gateway AND persisted subscription_status + the "Subscription Canceled"
132 // activity log. Just report success here — mirroring the frontend cancel path — so we don't
133 // write the DB a second time or append a duplicate log entry.
134 wp_send_json_success(
135 [
136 'message' => ! empty( $cancel_result['message'] ) && is_string( $cancel_result['message'] )
137 ? esc_html( $cancel_result['message'] )
138 : esc_html__( 'Subscription cancelled successfully.', 'sureforms' ),
139 ]
140 );
141 }
142
143 /**
144 * Process Stripe subscription cancellation via filter system.
145 *
146 * Filter callback for 'srfm_process_subscription_cancellation'.
147 * Used by both admin and frontend to cancel Stripe subscriptions.
148 *
149 * @since 2.8.0
150 * @param array<string,mixed> $result Default result array.
151 * @param array<string,mixed> $payment Payment record from database.
152 * @return array<string,mixed> Result with success status and message.
153 */
154 public function process_stripe_subscription_cancellation( $result, $payment ) {
155 // Process Stripe payments. Stripe is the only gateway in the free plugin, and the
156 // `gateway` column defaults to '' for legacy/imported rows — so an empty gateway is
157 // treated as Stripe. Only an explicitly different gateway (e.g. 'paypal') is skipped.
158 if ( ! empty( $payment['gateway'] ) && 'stripe' !== $payment['gateway'] ) {
159 return $result;
160 }
161
162 if ( empty( $payment['subscription_id'] ) || ! is_string( $payment['subscription_id'] ) ) {
163 return [
164 'success' => false,
165 'message' => __( 'Subscription ID not found.', 'sureforms' ),
166 ];
167 }
168
169 $subscription_id = $payment['subscription_id'];
170 $this->payment_mode = ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? $payment['mode'] : 'test';
171
172 $cancel_result = $this->cancel_subscription( $subscription_id );
173 if ( ! $cancel_result ) {
174 return [
175 'success' => false,
176 'message' => __( 'Subscription cancellation failed.', 'sureforms' ),
177 ];
178 }
179
180 // Build log entry.
181 $current_logs = Helper::get_array_value( $payment['log'] );
182 $log_messages = [
183 sprintf(
184 /* translators: %s: Stripe subscription ID */
185 __( 'Subscription ID: %s', 'sureforms' ),
186 $subscription_id
187 ),
188 sprintf(
189 /* translators: %s: payment gateway name */
190 __( 'Payment Gateway: %s', 'sureforms' ),
191 'Stripe'
192 ),
193 sprintf(
194 /* translators: %s: subscription status */
195 __( 'Subscription Status: %s', 'sureforms' ),
196 __( 'Canceled', 'sureforms' )
197 ),
198 sprintf(
199 /* translators: %s: user display name */
200 __( 'Canceled by: %s', 'sureforms' ),
201 wp_get_current_user()->display_name
202 ),
203 ];
204
205 $current_logs[] = [
206 'title' => __( 'Subscription Canceled', 'sureforms' ),
207 'created_at' => current_time( 'mysql' ),
208 'messages' => $log_messages,
209 ];
210
211 $payment_id = isset( $payment['id'] ) && is_numeric( $payment['id'] ) ? absint( $payment['id'] ) : 0;
212 // Preserve the transaction `status` so the admin Refund option stays enabled
213 // after the customer cancels from the My Account page.
214 Payments::update(
215 $payment_id,
216 [
217 'subscription_status' => 'canceled',
218 'log' => $current_logs,
219 ]
220 );
221
222 return [
223 'success' => true,
224 'message' => __( 'Subscription cancelled successfully.', 'sureforms' ),
225 ];
226 }
227
228 /**
229 * Process Stripe payment refund via filter system.
230 *
231 * Filter callback for 'srfm_process_transaction_refund' that handles Stripe refunds.
232 * Only processes refunds for payments with gateway = 'stripe'.
233 *
234 * @since 2.0.0
235 * @param array<string,mixed> $refund_result Default refund result.
236 * @param array<string,mixed> $refund_args {
237 * Refund arguments from admin handler.
238 *
239 * @type array $payment Full payment record from database.
240 * @type int $payment_id Payment record ID.
241 * @type string $transaction_id Transaction/charge ID from Stripe.
242 * @type int $refund_amount Refund amount in smallest currency unit (cents for USD).
243 * @type string $refund_notes Optional refund notes/reason.
244 * @type string $gateway Payment gateway identifier.
245 * }
246 * @return array<string,mixed> Refund result with success status and message.
247 */
248 public function process_stripe_refund( $refund_result, $refund_args ) {
249 // Only process if this is a Stripe payment.
250 if ( empty( $refund_args['gateway'] ) || 'stripe' !== $refund_args['gateway'] ) {
251 return $refund_result;
252 }
253
254 // Extract arguments.
255 $payment = isset( $refund_args['payment'] ) && is_array( $refund_args['payment'] ) ? $refund_args['payment'] : [];
256 $payment_id = isset( $refund_args['payment_id'] ) && is_numeric( $refund_args['payment_id'] ) ? intval( $refund_args['payment_id'] ) : 0;
257 $transaction_id = isset( $refund_args['transaction_id'] ) && is_string( $refund_args['transaction_id'] ) ? $refund_args['transaction_id'] : '';
258 $refund_amount = isset( $refund_args['refund_amount'] ) && is_numeric( $refund_args['refund_amount'] ) ? intval( $refund_args['refund_amount'] ) : 0;
259 $refund_notes = isset( $refund_args['refund_notes'] ) && is_string( $refund_args['refund_notes'] ) ? $refund_args['refund_notes'] : '';
260
261 // Validate required data.
262 if ( empty( $payment ) || empty( $payment_id ) || empty( $transaction_id ) || $refund_amount <= 0 ) {
263 return [
264 'success' => false,
265 'message' => __( 'Invalid refund parameters.', 'sureforms' ),
266 'data' => [],
267 ];
268 }
269
270 try {
271 $this->payment_mode = ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? $payment['mode'] : 'test';
272
273 // Detect subscription payments and route to specialized handler (following WPForms pattern).
274 if ( isset( $payment['type'], $payment['subscription_id'] ) && ! empty( $payment['type'] ) && ! empty( $payment['subscription_id'] ) ) {
275 return $this->refund_subscription_payment_via_filter( $payment, $refund_amount, $refund_notes );
276 }
277
278 // Verify payment status (for one-time payments).
279 if ( isset( $payment['status'] ) && 'succeeded' !== $payment['status'] && 'partially_refunded' !== $payment['status'] ) {
280 return [
281 'success' => false,
282 'message' => __( 'Only succeeded or partially refunded payments can be refunded.', 'sureforms' ),
283 'data' => [],
284 ];
285 }
286
287 // Verify transaction ID matches.
288 if ( isset( $payment['transaction_id'] ) && $transaction_id !== $payment['transaction_id'] ) {
289 return [
290 'success' => false,
291 'message' => __( 'Transaction ID mismatch.', 'sureforms' ),
292 'data' => [],
293 ];
294 }
295
296 // Create refund using Stripe API directly.
297 $stripe_refund_data = [
298 'amount' => $refund_amount,
299 'metadata' => [
300 'source' => 'SureForms',
301 'payment_id' => $payment_id,
302 'refunded_at' => time(),
303 'refunded_by' => get_current_user_id(),
304 ],
305 ];
306
307 // Add refund notes/reason to Stripe API request if provided.
308 if ( ! empty( $refund_notes && is_string( $refund_notes ) ) ) {
309 // Add to metadata for detailed notes.
310 $stripe_refund_data['metadata']['refund_notes'] = esc_html( $refund_notes );
311 // Set reason as requested_by_customer (Stripe accepts: duplicate, fraudulent, requested_by_customer).
312 $stripe_refund_data['reason'] = 'requested_by_customer';
313 }
314
315 // Determine if we're refunding by charge ID or payment intent ID.
316 if ( is_string( $transaction_id ) && strpos( $transaction_id, 'ch_' ) === 0 ) {
317 $stripe_refund_data['charge'] = $transaction_id;
318 } elseif ( is_string( $transaction_id ) && strpos( $transaction_id, 'pi_' ) === 0 ) {
319 $stripe_refund_data['payment_intent'] = $transaction_id;
320 } else {
321 return [
322 'success' => false,
323 'message' => __( 'Invalid transaction ID format for refund.', 'sureforms' ),
324 'data' => [],
325 ];
326 }
327
328 $refund_response = Stripe_Helper::stripe_api_request( 'refunds', 'POST', $stripe_refund_data, '', [ 'mode' => $this->payment_mode ] );
329
330 if ( ! $refund_response['success'] ) {
331 $error_message = $refund_response['error']['message'] ?? __( 'Failed to process refund through Stripe API.', 'sureforms' );
332 return [
333 'success' => false,
334 'message' => $error_message,
335 'data' => [],
336 ];
337 }
338
339 $refund = $refund_response['data'];
340 $currency = isset( $payment['currency'] ) && is_string( $payment['currency'] ) ? $payment['currency'] : 'USD';
341 // Store refund data and update payment status/log.
342 $refund_stored = $this->update_refund_data( $payment_id, $refund, $refund_amount, $currency, null, $refund_notes );
343 if ( ! $refund_stored ) {
344 return [
345 'success' => false,
346 'message' => __( 'Failed to update payment record after refund.', 'sureforms' ),
347 'data' => [],
348 ];
349 }
350
351 return [
352 'success' => true,
353 'message' => __( 'Payment refunded successfully.', 'sureforms' ),
354 'data' => [
355 'refund_id' => is_array( $refund ) && isset( $refund['id'] ) ? $refund['id'] : '',
356 'status' => is_array( $refund ) && isset( $refund['status'] ) ? $refund['status'] : 'processed',
357 ],
358 ];
359
360 } catch ( \Exception $e ) {
361 return [
362 'success' => false,
363 'message' => __( 'Failed to process refund. Please try again.', 'sureforms' ),
364 'data' => [],
365 ];
366 }
367 }
368
369 /**
370 * Cancel subscription (following WPForms pattern)
371 *
372 * @param string $subscription_id Subscription ID.
373 * @since 2.0.0
374 * @return bool Success status.
375 */
376 public function cancel_subscription( $subscription_id ) {
377 try {
378 // Retrieve the subscription using direct Stripe API.
379 $subscription_response = Stripe_Helper::stripe_api_request( 'subscriptions', 'GET', [], $subscription_id, [ 'mode' => $this->payment_mode ] );
380
381 if ( ! $subscription_response['success'] ) {
382 return false;
383 }
384
385 $subscription = $subscription_response['data'];
386
387 // If subscription is valid, check the status. If status is not 'active', return true early.
388 if ( isset( $subscription['status'] ) && ! in_array( $subscription['status'], [ 'active', 'trialing' ], true ) ) {
389 return true;
390 }
391
392 $updated_metadata = array_merge(
393 isset( $subscription['metadata'] ) && is_array( $subscription['metadata'] ) ? $subscription['metadata'] : [],
394 [
395 'canceled_by' => 'sureforms_dashboard',
396 ]
397 );
398
399 Stripe_Helper::stripe_api_request(
400 'subscriptions',
401 'POST',
402 [
403 'metadata' => $updated_metadata,
404 ],
405 $subscription_id,
406 [ 'mode' => $this->payment_mode ]
407 );
408
409 // Cancel the subscription.
410 $cancelled_subscription_response = Stripe_Helper::stripe_api_request(
411 'subscriptions',
412 'DELETE',
413 [],
414 $subscription_id,
415 [ 'mode' => $this->payment_mode ]
416 );
417
418 if ( ! $cancelled_subscription_response['success'] ) {
419 return false;
420 }
421
422 return true;
423
424 } catch ( \Exception $e ) {
425 return false;
426 }
427 }
428
429 /**
430 * AJAX handler for subscription pause
431 *
432 * @since 2.0.0
433 * @return void
434 */
435 public function ajax_pause_subscription() {
436 // Security checks.
437 if ( ! isset( $_POST['payment_id'] ) ) {
438 wp_send_json_error( [ 'message' => esc_html__( 'Missing payment ID.', 'sureforms' ) ] );
439 }
440
441 // Verify nonce.
442 if (
443 ! wp_verify_nonce(
444 sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ),
445 'srfm_payment_admin_nonce'
446 )
447 ) {
448 wp_send_json_error( __( 'Invalid nonce.', 'sureforms' ) );
449 }
450
451 if ( ! current_user_can( 'manage_options' ) ) {
452 wp_send_json_error( [ 'message' => esc_html__( 'You are not allowed to perform this action.', 'sureforms' ) ] );
453 }
454
455 $payment_id = absint( $_POST['payment_id'] );
456
457 // Get payment record.
458 $payment = Payments::get( $payment_id );
459 if ( ! $payment ) {
460 wp_send_json_error( [ 'message' => esc_html__( 'Payment not found in the database.', 'sureforms' ) ] );
461 }
462
463 $this->payment_mode = ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? $payment['mode'] : 'test';
464
465 // Validate it's a subscription payment.
466 if ( empty( $payment['type'] ) || 'subscription' !== $payment['type'] ) {
467 wp_send_json_error( [ 'message' => esc_html__( 'This is not a subscription payment.', 'sureforms' ) ] );
468 }
469
470 if ( empty( $payment['subscription_id'] ) ) {
471 wp_send_json_error( [ 'message' => esc_html__( 'Subscription ID not found.', 'sureforms' ) ] );
472 }
473
474 // Pause the subscription.
475 $pause_result = $this->pause_subscription( $payment['subscription_id'] );
476 if ( ! $pause_result ) {
477 wp_send_json_error( [ 'message' => esc_html__( 'Subscription pause failed.', 'sureforms' ) ] );
478 }
479
480 // Get current logs and add pause log entry.
481 $current_logs = Helper::get_array_value( $payment['log'] );
482
483 // Build log messages array.
484 $log_messages = [
485 sprintf(
486 /* translators: %s: Stripe subscription ID */
487 __( 'Subscription ID: %s', 'sureforms' ),
488 $payment['subscription_id']
489 ),
490 sprintf(
491 /* translators: %s: payment gateway name */
492 __( 'Payment Gateway: %s', 'sureforms' ),
493 'Stripe'
494 ),
495 sprintf(
496 /* translators: %s: subscription status */
497 __( 'Subscription Status: %s', 'sureforms' ),
498 __( 'Paused', 'sureforms' )
499 ),
500 sprintf(
501 /* translators: %s: user display name */
502 __( 'Paused by: %s', 'sureforms' ),
503 wp_get_current_user()->display_name
504 ),
505 __( 'Note: The subscription billing has been paused. No charges will occur until the subscription is resumed.', 'sureforms' ),
506 ];
507
508 // Create new log entry.
509 $new_log = [
510 'title' => __( 'Subscription Paused', 'sureforms' ),
511 'created_at' => current_time( 'mysql' ),
512 'messages' => $log_messages,
513 ];
514 $current_logs[] = $new_log;
515
516 // Update database status to paused with log.
517 $updated = Payments::update(
518 $payment_id,
519 [
520 'subscription_status' => 'paused',
521 'log' => $current_logs,
522 ]
523 );
524 if ( ! $updated ) {
525 wp_send_json_error( [ 'message' => esc_html__( 'Failed to update subscription status in database.', 'sureforms' ) ] );
526 }
527
528 wp_send_json_success( [ 'message' => esc_html__( 'Subscription paused successfully!', 'sureforms' ) ] );
529 }
530
531 /**
532 * Pause subscription
533 *
534 * @param string $subscription_id Subscription ID.
535 * @since 2.0.0
536 * @return bool Success status.
537 */
538 public function pause_subscription( $subscription_id ) {
539 try {
540 // Retrieve subscription using direct Stripe API.
541 $subscription_response = Stripe_Helper::stripe_api_request( 'subscriptions', 'GET', [], $subscription_id, [ 'mode' => $this->payment_mode ] );
542
543 if ( ! $subscription_response['success'] ) {
544 return false;
545 }
546
547 $subscription = $subscription_response['data'];
548
549 $updated_metadata = array_merge(
550 isset( $subscription['metadata'] ) && is_array( $subscription['metadata'] ) ? $subscription['metadata'] : [],
551 [
552 'paused_by' => 'sureforms_dashboard',
553 ]
554 );
555
556 // Pause the subscription using pause_collection.
557 $paused_subscription_response = Stripe_Helper::stripe_api_request(
558 'subscriptions',
559 'POST',
560 [
561 'pause_collection' => [
562 'behavior' => 'void',
563 ],
564 'metadata' => $updated_metadata,
565 ],
566 $subscription_id,
567 [ 'mode' => $this->payment_mode ]
568 );
569
570 if ( ! $paused_subscription_response['success'] ) {
571 return false;
572 }
573
574 return true;
575
576 } catch ( \Exception $e ) {
577 return false;
578 }
579 }
580
581 /**
582 * Update refund data in payment_data column and log
583 *
584 * @param int $payment_id Payment record ID.
585 * @param array<string,mixed> $refund_response Refund response from Stripe.
586 * @param int $refund_amount Refund amount in cents.
587 * @param string $currency Currency code.
588 * @param array<string,mixed>|null $payment Payment record data.
589 * @param string $refund_notes Refund notes.
590 * @since 2.0.0
591 * @return bool True if successful, false otherwise.
592 */
593 public function update_refund_data(
594 $payment_id,
595 $refund_response,
596 $refund_amount,
597 $currency,
598 $payment = null,
599 $refund_notes = ''
600 ) {
601 if ( empty( $payment_id ) || empty( $refund_response ) ) {
602 return false;
603 }
604
605 // Get payment record if not provided.
606 $payment = Payments::get( $payment_id );
607 if ( ! $payment ) {
608 return false;
609 }
610
611 $check_if_refund_already_exists = $this->check_if_refund_already_exists( $payment, $refund_response );
612 if ( $check_if_refund_already_exists ) {
613 return true;
614 }
615
616 // Prepare refund data for payment_data column.
617 $refund_data = [
618 'refund_id' => is_string( $refund_response['id'] ) ? sanitize_text_field( $refund_response['id'] ) : '',
619 'amount' => absint( $refund_amount ),
620 'currency' => sanitize_text_field( strtoupper( $currency ) ),
621 'status' => is_string( $refund_response['status'] ) ? sanitize_text_field( $refund_response['status'] ) : 'processed',
622 'created' => time(),
623 'reason' => is_string( $refund_response['reason'] ) ? sanitize_text_field( $refund_response['reason'] ) : 'requested_by_customer',
624 'description' => is_string( $refund_response['description'] ) ? sanitize_text_field( $refund_response['description'] ) : '',
625 'receipt_number' => is_string( $refund_response['receipt_number'] ) ? sanitize_text_field( $refund_response['receipt_number'] ) : '',
626 'refunded_by' => is_string( wp_get_current_user()->display_name ) ? sanitize_text_field( wp_get_current_user()->display_name ) : 'System',
627 'refunded_at' => gmdate( 'Y-m-d H:i:s' ),
628 ];
629
630 // Validate refund amount to prevent over-refunding.
631 $original_amount = floatval( $payment['total_amount'] );
632 $existing_refunds = floatval( $payment['refunded_amount'] ); // Use column directly.
633 $new_refund_amount = Stripe_Helper::amount_from_stripe_format( $refund_amount, $currency );
634 $total_after_refund = $existing_refunds + $new_refund_amount;
635
636 if ( $total_after_refund > $original_amount ) {
637 return false;
638 }
639
640 // Add refund data to payment_data column (for audit trail).
641 Payments::add_refund_to_payment_data( $payment_id, $refund_data );
642
643 // Update the refunded_amount column.
644 $refund_amount_result = Payments::add_refund_amount( $payment_id, $new_refund_amount );
645
646 // Calculate appropriate payment status.
647 $payment_status = 'succeeded'; // Default to current status.
648 if ( $total_after_refund >= $original_amount ) {
649 $payment_status = 'refunded'; // Fully refunded.
650 } elseif ( $total_after_refund > 0 ) {
651 $payment_status = 'partially_refunded'; // Partially refunded.
652 }
653
654 // Update payment status and log.
655 $current_logs = Helper::get_array_value( $payment['log'] );
656 $refund_type = $total_after_refund >= $original_amount ? __( 'Full', 'sureforms' ) : __( 'Partial', 'sureforms' );
657
658 // Build log messages array.
659 $log_messages = [
660 sprintf(
661 /* translators: %s: refund ID */
662 __( 'Refund ID: %s', 'sureforms' ),
663 is_string( $refund_response['id'] ) ? $refund_response['id'] : 'N/A'
664 ),
665 sprintf(
666 /* translators: %s: payment gateway name (e.g., Stripe) */
667 __( 'Payment Gateway: %s', 'sureforms' ),
668 'Stripe'
669 ),
670 sprintf(
671 /* translators: 1: refund amount, 2: currency */
672 __( 'Refund Amount: %1$s %2$s', 'sureforms' ),
673 number_format( Stripe_Helper::amount_from_stripe_format( $refund_amount, $currency ), 2 ),
674 strtoupper( $currency )
675 ),
676 sprintf(
677 /* translators: 1: total refunded, 2: currency, 3: original total, 4: currency */
678 __( 'Total Refunded: %1$s %2$s of %3$s %4$s', 'sureforms' ),
679 number_format( $total_after_refund, 2 ),
680 strtoupper( $currency ),
681 number_format( $original_amount, 2 ),
682 strtoupper( $currency )
683 ),
684 sprintf(
685 /* translators: %s: status (e.g., succeeded, processed) */
686 __( 'Refund Status: %s', 'sureforms' ),
687 is_string( $refund_response['status'] ) ? $refund_response['status'] : 'processed'
688 ),
689 sprintf(
690 /* translators: %s: payment status (e.g., succeeded, refunded, partially_refunded) */
691 __( 'Payment Status: %s', 'sureforms' ),
692 ucfirst( str_replace( '_', ' ', $payment_status ) )
693 ),
694 sprintf(
695 /* translators: %s: user display name */
696 __( 'Refunded by: %s', 'sureforms' ),
697 wp_get_current_user()->display_name
698 ),
699 ];
700
701 // Add refund notes to log if provided.
702 if ( ! empty( $refund_notes && is_string( $refund_notes ) ) ) {
703 $log_messages[] = sprintf(
704 /* translators: %s: refund notes */
705 __( 'Refund Notes: %s', 'sureforms' ),
706 esc_html( $refund_notes )
707 );
708 }
709
710 /* translators: %s: refund type (Full or Partial) */
711 $new_log = [
712 'title' => sprintf(
713 /* translators: %s: refund type (Full or Partial) */
714 __( '%s Payment Refund', 'sureforms' ),
715 $refund_type
716 ),
717 'created_at' => current_time( 'mysql' ),
718 'messages' => $log_messages,
719 ];
720 $current_logs[] = $new_log;
721
722 $update_data = [
723 'status' => $payment_status,
724 'log' => $current_logs,
725 ];
726
727 // Update payment record with status and log.
728 $payment_update_result = Payments::update( $payment_id, $update_data );
729
730 if ( false === $refund_amount_result ) {
731 return false;
732 }
733
734 if ( false === $payment_update_result ) {
735 return false;
736 }
737
738 return true;
739 }
740
741 /**
742 * Display admin notice for webhook configuration issues.
743 *
744 * Shows a warning notice when webhooks are not properly configured.
745 * The notice will automatically disappear when a new Stripe request comes in.
746 *
747 * @since 2.0.0
748 * @return void
749 */
750 public function webhook_configuration_notice() {
751 // Only show on admin pages.
752 if ( ! is_admin() ) {
753 return;
754 }
755
756 // Only show to users with manage_options capability.
757 if ( ! current_user_can( 'manage_options' ) ) {
758 return;
759 }
760
761 // Check if Stripe is connected.
762 if ( ! Stripe_Helper::is_stripe_connected() ) {
763 return;
764 }
765
766 // Check if webhooks are configured.
767 if ( Stripe_Helper::is_webhook_configured() ) {
768 return;
769 }
770
771 // Display the notice.
772 ?>
773 <div class="notice notice-error is-dismissible">
774 <p>
775 <?php
776 printf(
777 /* translators: %1$s: Payment settings link */
778 esc_html__(
779 'Webhooks keep SureForms in sync with Stripe by automatically updating payment and subscription data. Please %1$s Webhook.',
780 'sureforms'
781 ),
782 sprintf(
783 '<a href="%s">%s</a>',
784 esc_url( Stripe_Helper::get_stripe_settings_url() ),
785 esc_html__( 'configure', 'sureforms' )
786 )
787 );
788 ?>
789 </p>
790 </div>
791 <?php
792 }
793
794 /**
795 * Refund subscription payment via filter system.
796 *
797 * IMPORTANT: This method refunds the INITIAL/FIRST charge of a subscription only.
798 * The transaction_id field contains the charge ID from the first subscription payment.
799 * Subsequent renewal charges are NOT refunded by this method and should be refunded
800 * individually through their own payment records.
801 *
802 * @param array<string,mixed> $payment Payment record.
803 * @param int $refund_amount Refund amount in cents.
804 * @param string $refund_notes Refund notes.
805 * @since 2.0.0
806 * @return array<string,mixed> Refund result with success status and message.
807 * @throws \Exception If unable to determine the appropriate refund method.
808 */
809 private function refund_subscription_payment_via_filter( $payment, $refund_amount, $refund_notes = '' ) {
810 try {
811 // Step 1: Validate input parameters.
812 if ( empty( $payment ) || ! is_array( $payment ) || $refund_amount <= 0 ) {
813 return [
814 'success' => false,
815 'message' => __( 'Invalid refund parameters provided.', 'sureforms' ),
816 'data' => [],
817 ];
818 }
819
820 $payment_id = isset( $payment['id'] ) && is_numeric( $payment['id'] ) ? intval( $payment['id'] ) : 0;
821 $transaction_id = isset( $payment['transaction_id'] ) && is_string( $payment['transaction_id'] ) ? $payment['transaction_id'] : '';
822 $currency = is_string( $payment['currency'] ) ? $payment['currency'] : 'USD';
823
824 // Step 2: Verify this is a subscription-related payment.
825 $is_subscription_payment = $this->is_subscription_related_payment( $payment );
826 if ( ! $is_subscription_payment ) {
827 return [
828 'success' => false,
829 'message' => __( 'This payment is not related to a subscription.', 'sureforms' ),
830 'data' => [],
831 ];
832 }
833
834 // Step 3: Verify subscription payment status.
835 // Note: 'active' status is used for subscription records, while 'succeeded' is used for one-time payments.
836 // 'canceled' is accepted because the initial charge on a canceled subscription is still refundable
837 // (and historical rows persisted with `status='canceled'` should remain refundable).
838 $refundable_statuses = [ 'active', 'succeeded', 'partially_refunded', 'canceled' ];
839 if ( empty( $payment['status'] ) || ! in_array( $payment['status'], $refundable_statuses, true ) ) {
840 return [
841 'success' => false,
842 'message' => __( 'Only active, succeeded, or partially refunded subscription payments can be refunded.', 'sureforms' ),
843 'data' => [],
844 ];
845 }
846
847 // Step 4: Validate refund amount limits.
848 $validation_result = $this->validate_subscription_refund_amount( $payment, $refund_amount );
849 if ( ! $validation_result['valid'] ) {
850 return [
851 'success' => false,
852 'message' => $validation_result['message'],
853 'data' => [],
854 ];
855 }
856
857 // Step 5: Validate Stripe connection.
858 if ( ! Stripe_Helper::is_stripe_connected() ) {
859 return [
860 'success' => false,
861 'message' => __( 'Stripe is not connected.', 'sureforms' ),
862 'data' => [],
863 ];
864 }
865
866 // Step 6: Create refund using appropriate method based on transaction ID type.
867 $refund = $this->create_subscription_refund( $payment, $transaction_id, $refund_amount, $refund_notes );
868
869 if ( ! $refund || empty( $refund['id'] ) ) {
870 return [
871 'success' => false,
872 'message' => __( 'Stripe refund creation failed. Please check your Stripe dashboard for more details.', 'sureforms' ),
873 'data' => [],
874 ];
875 }
876
877 // Step 7: Update database with refund information.
878 $refund_stored = $this->update_subscription_refund_data( $payment_id, $refund, $refund_amount, $currency, $refund_notes );
879
880 if ( ! $refund_stored ) {
881 return [
882 'success' => false,
883 'message' => __( 'Refund was processed by Stripe but failed to update local records. Please check your payment records manually.', 'sureforms' ),
884 'data' => [],
885 ];
886 }
887
888 // Step 8: Success response.
889 return [
890 'success' => true,
891 'message' => __( 'Subscription payment refunded successfully.', 'sureforms' ),
892 'data' => [
893 'refund_id' => isset( $refund['id'] ) && is_string( $refund['id'] ) ? $refund['id'] : '',
894 'status' => isset( $refund['status'] ) && is_string( $refund['status'] ) ? $refund['status'] : '',
895 'type' => 'subscription_refund',
896 'charge_id' => isset( $refund['charge'] ) && is_string( $refund['charge'] ) ? $refund['charge'] : '',
897 'refund_amount' => number_format( $refund_amount / 100, 2 ),
898 'currency' => strtoupper( $currency ),
899 ],
900 ];
901
902 } catch ( \Exception $e ) {
903 // Provide more specific error messages based on error type.
904 $error_message = $this->get_user_friendly_refund_error( $e->getMessage() );
905 return [
906 'success' => false,
907 'message' => $error_message,
908 'data' => [],
909 ];
910 }
911 }
912
913 /**
914 * Check if payment is subscription-related
915 *
916 * @param array<string,mixed> $payment Payment record.
917 * @since 2.0.0
918 * @return bool True if payment is subscription-related, false otherwise.
919 */
920 private function is_subscription_related_payment( $payment ) {
921 // Check if it's a main subscription record.
922 if ( ! empty( $payment['type'] ) && 'renewal' === $payment['type'] ) {
923 return true;
924 }
925
926 // Check if it's a subscription billing cycle payment (has subscription_id).
927 if ( ! empty( $payment['subscription_id'] ) ) {
928 return true;
929 }
930
931 return false;
932 }
933
934 /**
935 * Validate subscription refund amount
936 *
937 * @param array<string,mixed> $payment Payment record.
938 * @param int $refund_amount Refund amount in cents.
939 * @since 2.0.0
940 * @return array{valid: bool, message: string} Validation result with 'valid' boolean and 'message' string.
941 */
942 private function validate_subscription_refund_amount( $payment, $refund_amount ) {
943 $currency = isset( $payment['currency'] ) && is_string( $payment['currency'] ) ? $payment['currency'] : 'USD';
944
945 $total_amount = isset( $payment['total_amount'] ) && is_string( $payment['total_amount'] ) ? floatval( $payment['total_amount'] ) : 0;
946 $total_amount = Stripe_Helper::amount_to_stripe_format( $total_amount, $currency );
947
948 $refunded_amount = isset( $payment['refunded_amount'] ) && is_string( $payment['refunded_amount'] ) ? floatval( $payment['refunded_amount'] ) : 0;
949 $refunded_amount = Stripe_Helper::amount_to_stripe_format( $refunded_amount, $currency );
950
951 $available_for_refund = $total_amount - $refunded_amount;
952
953 if ( $refund_amount > $available_for_refund ) {
954 return [
955 'valid' => false,
956 'message' => sprintf(
957 /* translators: 1: Maximum refundable amount (numeric), 2: Currency code (e.g. USD) */
958 __( 'Refund amount exceeds available amount. Maximum refundable: %1$s %2$s', 'sureforms' ),
959 number_format( $available_for_refund / 100, 2 ),
960 isset( $payment['currency'] ) && is_string( $payment['currency'] ) ? strtoupper( $payment['currency'] ) : 'USD'
961 ),
962 ];
963 }
964
965 if ( $refund_amount <= 0 ) {
966 return [
967 'valid' => false,
968 'message' => __( 'Refund amount must be greater than zero.', 'sureforms' ),
969 ];
970 }
971
972 // Stripe minimum refund amount (usually $0.50 for most currencies).
973 if ( $refund_amount < 50 ) {
974 return [
975 'valid' => false,
976 'message' => __( 'Refund amount must be at least $0.50.', 'sureforms' ),
977 ];
978 }
979
980 return [
981 'valid' => true,
982 'message' => '',
983 ];
984 }
985
986 /**
987 * Create refund for subscription payment using the most appropriate method
988 *
989 * For subscriptions, the transaction_id field contains the charge ID from the FIRST/INITIAL payment.
990 * This ensures refunds are processed against the initial charge only, not any subsequent renewal charges.
991 * Subsequent renewal charges should be refunded individually through their own payment records.
992 *
993 * @param array<string,mixed> $payment Payment record.
994 * @param string $transaction_id Transaction ID (charge ID from first payment for subscriptions).
995 * @param int $refund_amount Refund amount in cents.
996 * @param string $refund_notes Refund notes.
997 * @since 2.0.0
998 * @return array<string,mixed>|false Refund data or false on failure.
999 * @throws \Exception If unable to determine the appropriate refund method.
1000 */
1001 private function create_subscription_refund( $payment, $transaction_id, $refund_amount, $refund_notes = '' ) {
1002 // Method 1: Use charge ID directly (default for subscriptions - contains first payment charge).
1003 // For subscription payments, transaction_id contains the charge ID from the initial payment.
1004 if ( is_string( $transaction_id ) && strpos( $transaction_id, 'ch_' ) === 0 ) {
1005 return $this->create_refund_by_charge( $payment, $transaction_id, $refund_amount, $refund_notes );
1006 }
1007
1008 // Method 2: Use payment intent ID if provided (fallback for legacy data).
1009 if ( is_string( $transaction_id ) && strpos( $transaction_id, 'pi_' ) === 0 ) {
1010 return $this->create_refund_by_payment_intent( $payment, $transaction_id, $refund_amount, $refund_notes );
1011 }
1012
1013 // Method 3: Try to find charge ID in payment data (fallback for edge cases).
1014 $charge_id = $this->get_charge_id_from_payment( $payment );
1015 if ( is_string( $charge_id ) && '' !== $charge_id ) {
1016 return $this->create_refund_by_charge( $payment, $charge_id, $refund_amount, $refund_notes );
1017 }
1018
1019 throw new \Exception( esc_html__( 'Unable to determine the appropriate refund method for this subscription payment.', 'sureforms' ) );
1020 }
1021
1022 /**
1023 * Create refund using charge ID
1024 *
1025 * @param array<string,mixed> $payment Payment record.
1026 * @param string $charge_id Stripe charge ID.
1027 * @param int $refund_amount Refund amount in cents.
1028 * @param string $refund_notes Refund notes.
1029 * @since 2.0.0
1030 * @return array<string,mixed>|false Refund data or false on failure.
1031 */
1032 private function create_refund_by_charge( $payment, $charge_id, $refund_amount, $refund_notes = '' ) {
1033 $metadata = [
1034 'refunded_by' => 'sureforms_dashboard',
1035 'subscription_id' => $payment['subscription_id'] ?? '',
1036 'source' => 'SureForms',
1037 'payment_id' => $payment['id'] ?? '',
1038 'refunded_at' => time(),
1039 'refund_type' => 'subscription_billing',
1040 'refund_method' => 'charge_refund',
1041 ];
1042
1043 // Add refund notes to metadata if provided.
1044 if ( ! empty( $refund_notes ) ) {
1045 $metadata['refund_notes'] = $refund_notes;
1046 }
1047
1048 $refund_response = Stripe_Helper::stripe_api_request(
1049 'refunds',
1050 'POST',
1051 [
1052 'charge' => $charge_id,
1053 'amount' => $refund_amount,
1054 'reason' => 'requested_by_customer',
1055 'metadata' => $metadata,
1056 ],
1057 '',
1058 [ 'mode' => $this->payment_mode ]
1059 );
1060
1061 return $refund_response['success'] ? $refund_response['data'] : false;
1062 }
1063
1064 /**
1065 * Create refund using payment intent ID
1066 *
1067 * @param array<string,mixed> $payment Payment record.
1068 * @param string $payment_intent_id Stripe payment intent ID.
1069 * @param int $refund_amount Refund amount in cents.
1070 * @param string $refund_notes Refund notes.
1071 * @since 2.0.0
1072 * @return array<string,mixed>|false Refund data or false on failure.
1073 */
1074 private function create_refund_by_payment_intent( $payment, $payment_intent_id, $refund_amount, $refund_notes = '' ) {
1075 $metadata = [
1076 'refunded_by' => 'sureforms_dashboard',
1077 'subscription_id' => $payment['subscription_id'] ?? '',
1078 'source' => 'SureForms',
1079 'payment_id' => $payment['id'] ?? '',
1080 'refunded_at' => time(),
1081 'refund_type' => 'subscription_billing',
1082 'refund_method' => 'payment_intent_refund',
1083 ];
1084
1085 // Add refund notes to metadata if provided.
1086 if ( ! empty( $refund_notes ) ) {
1087 $metadata['refund_notes'] = $refund_notes;
1088 }
1089
1090 $refund_response = Stripe_Helper::stripe_api_request(
1091 'refunds',
1092 'POST',
1093 [
1094 'payment_intent' => $payment_intent_id,
1095 'amount' => $refund_amount,
1096 'reason' => 'requested_by_customer',
1097 'metadata' => $metadata,
1098 ],
1099 '',
1100 [ 'mode' => $this->payment_mode ]
1101 );
1102
1103 return $refund_response['success'] ? $refund_response['data'] : false;
1104 }
1105
1106 /**
1107 * Update subscription refund data in database
1108 *
1109 * @param int $payment_id Payment record ID.
1110 * @param array<string,mixed> $refund_response Refund response from Stripe.
1111 * @param int $refund_amount Refund amount in cents.
1112 * @param string $currency Currency code.
1113 * @param string $refund_notes Refund notes.
1114 * @since 2.0.0
1115 * @return bool True if successful, false otherwise.
1116 */
1117 private function update_subscription_refund_data(
1118 int $payment_id,
1119 array $refund_response,
1120 int $refund_amount,
1121 string $currency,
1122 ?string $refund_notes = null
1123 ) {
1124 if ( empty( $payment_id ) || empty( $refund_response ) ) {
1125 return false;
1126 }
1127
1128 // Get payment record.
1129 $payment = Payments::get( $payment_id );
1130 if ( ! $payment ) {
1131 return false;
1132 }
1133
1134 // Prepare refund data for payment_data column.
1135 $refund_data = [
1136 'refund_id' => is_string( $refund_response['id'] ) ? sanitize_text_field( $refund_response['id'] ) : '',
1137 'amount' => absint( $refund_amount ),
1138 'currency' => is_string( $currency ) ? sanitize_text_field( strtoupper( $currency ) ) : 'USD',
1139 'status' => is_string( $refund_response['status'] ) ? sanitize_text_field( $refund_response['status'] ) : 'processed',
1140 'created' => time(),
1141 'reason' => is_string( $refund_response['reason'] ) ? sanitize_text_field( $refund_response['reason'] ) : 'requested_by_customer',
1142 'description' => is_string( $refund_response['description'] ) ? sanitize_text_field( $refund_response['description'] ) : '',
1143 'receipt_number' => is_string( $refund_response['receipt_number'] ) ? sanitize_text_field( $refund_response['receipt_number'] ) : '',
1144 'refunded_by' => is_string( wp_get_current_user()->display_name ) ? sanitize_text_field( wp_get_current_user()->display_name ) : 'System',
1145 'refunded_at' => gmdate( 'Y-m-d H:i:s' ),
1146 'type' => 'subscription_refund',
1147 ];
1148
1149 // Validate refund amount to prevent over-refunding.
1150 $original_amount = floatval( $payment['total_amount'] );
1151 $existing_refunds = floatval( $payment['refunded_amount'] ?? 0 ); // Use column directly.
1152 $new_refund_amount = Stripe_Helper::amount_from_stripe_format( $refund_amount, $currency );
1153 $total_after_refund = $existing_refunds + $new_refund_amount;
1154
1155 if ( $total_after_refund > $original_amount ) {
1156 return false;
1157 }
1158
1159 // Add refund data to payment_data column (for audit trail).
1160 $payment_data_result = Payments::add_refund_to_payment_data( $payment_id, $refund_data );
1161 if ( ! $payment_data_result ) {
1162 return false;
1163 }
1164
1165 // Update the refunded_amount column.
1166 $refund_amount_result = Payments::add_refund_amount( $payment_id, $new_refund_amount );
1167 if ( ! $refund_amount_result ) {
1168 return false;
1169 }
1170
1171 // Determine new payment status.
1172 $total_amount = (float) $payment['total_amount'];
1173 $total_refunded = Payments::get_refunded_amount( $payment_id );
1174 $payment_status = $total_refunded >= $total_amount ? 'refunded' : 'partially_refunded';
1175
1176 // Prepare comprehensive log entry.
1177 $current_logs = Helper::get_array_value( $payment['log'] );
1178 $original_amount = $total_amount;
1179 $total_after_refund = $total_refunded;
1180 $refund_type = $total_after_refund >= $original_amount ? __( 'Full', 'sureforms' ) : __( 'Partial', 'sureforms' );
1181
1182 // Build log messages array.
1183 $log_messages = [
1184 sprintf(
1185 /* translators: %s: refund ID */
1186 __( 'Refund ID: %s', 'sureforms' ),
1187 is_string( $refund_response['id'] ) ? $refund_response['id'] : 'N/A'
1188 ),
1189 sprintf(
1190 /* translators: %s: payment gateway */
1191 __( 'Payment Gateway: %s', 'sureforms' ),
1192 'Stripe'
1193 ),
1194 sprintf(
1195 /* translators: 1: refund amount, 2: currency code */
1196 __( 'Refund Amount: %1$s %2$s', 'sureforms' ),
1197 number_format( Stripe_Helper::amount_from_stripe_format( $refund_amount, $currency ), 2 ),
1198 strtoupper( $currency )
1199 ),
1200 sprintf(
1201 /* translators: 1: total refunded, 2: currency, 3: original amount, 4: currency */
1202 __( 'Total Refunded: %1$s %2$s of %3$s %4$s', 'sureforms' ),
1203 number_format( $total_after_refund, 2 ),
1204 strtoupper( $currency ),
1205 number_format( $original_amount, 2 ),
1206 strtoupper( $currency )
1207 ),
1208 sprintf(
1209 /* translators: %s: refund status */
1210 __( 'Refund Status: %s', 'sureforms' ),
1211 is_string( $refund_response['status'] ) ? $refund_response['status'] : 'processed'
1212 ),
1213 sprintf(
1214 /* translators: %s: payment status */
1215 __( 'Payment Status: %s', 'sureforms' ),
1216 ucfirst( str_replace( '_', ' ', $payment_status ) )
1217 ),
1218 sprintf(
1219 /* translators: %s: refunded by user */
1220 __( 'Refunded by: %s', 'sureforms' ),
1221 wp_get_current_user()->display_name
1222 ),
1223 ];
1224
1225 // Add refund notes to log if provided.
1226 if ( ! empty( $refund_notes ) ) {
1227 $log_messages[] = sprintf(
1228 /* translators: %s: refund notes */
1229 __( 'Refund Notes: %s', 'sureforms' ),
1230 $refund_notes
1231 );
1232 }
1233
1234 $new_log = [
1235 'title' => sprintf(
1236 /* translators: %s: refund type (Full/Partial) */
1237 __( '%s Subscription Payment Refund', 'sureforms' ),
1238 $refund_type
1239 ),
1240 'created_at' => current_time( 'mysql' ),
1241 'messages' => $log_messages,
1242 ];
1243 $current_logs[] = $new_log;
1244
1245 $update_data = [
1246 'status' => $payment_status,
1247 'log' => $current_logs,
1248 ];
1249
1250 // Update payment record with status and log.
1251 $payment_update_result = Payments::update( $payment_id, $update_data );
1252
1253 if ( ! $payment_update_result ) {
1254 return false;
1255 }
1256
1257 return true;
1258 }
1259
1260 /**
1261 * Convert technical error messages to user-friendly ones
1262 *
1263 * @param string $technical_error Technical error message.
1264 * @since 2.0.0
1265 * @return string User-friendly error message.
1266 */
1267 private function get_user_friendly_refund_error( $technical_error ) {
1268 $error_patterns = [
1269 '/charge.*already.*refunded/i' => __( 'This payment has already been fully refunded.', 'sureforms' ),
1270 '/charge.*not.*found/i' => __( 'The payment could not be found in Stripe.', 'sureforms' ),
1271 '/amount.*exceeds/i' => __( 'The refund amount exceeds the available refundable amount.', 'sureforms' ),
1272 '/payment.*intent.*not.*found/i' => __( 'The payment for this subscription could not be found.', 'sureforms' ),
1273 '/subscription.*not.*found/i' => __( 'The subscription could not be found in Stripe.', 'sureforms' ),
1274 '/no.*successful.*payments/i' => __( 'This subscription has no successful payments to refund.', 'sureforms' ),
1275 '/invalid.*payment.*method/i' => __( 'The payment method for this subscription is invalid.', 'sureforms' ),
1276 '/insufficient.*permissions/i' => __( 'Insufficient permissions to process refunds.', 'sureforms' ),
1277 '/rate.*limit/i' => __( 'Too many requests. Please try again in a moment.', 'sureforms' ),
1278 '/network.*error|connection.*failed|timeout/i' => __( 'Network error. Please check your connection and try again.', 'sureforms' ),
1279 ];
1280
1281 foreach ( $error_patterns as $pattern => $friendly_message ) {
1282 if ( preg_match( $pattern, $technical_error ) ) {
1283 return $friendly_message;
1284 }
1285 }
1286
1287 // Default fallback message.
1288 // translators: %s: technical error message returned from Stripe.
1289 return sprintf( __( 'Subscription refund failed: %s', 'sureforms' ), $technical_error );
1290 }
1291
1292 /**
1293 * Check if refund already exists for this payment
1294 *
1295 * @param array<string,mixed> $payment Payment record.
1296 * @param array<string,mixed> $refund_response Refund response from Stripe.
1297 * @since 2.0.0
1298 * @return bool True if refund already exists, false otherwise.
1299 */
1300 private function check_if_refund_already_exists( $payment, $refund_response ) {
1301 if ( empty( $payment['payment_data'] ) || empty( $refund_response['id'] ) ) {
1302 return false;
1303 }
1304
1305 $payment_data = Helper::get_array_value( $payment['payment_data'] );
1306 if ( empty( $payment_data['refunds'] ) ) {
1307 return false;
1308 }
1309
1310 $refund_id = $refund_response['id'];
1311
1312 // O(1) lookup using refund ID as array key.
1313 return isset( $payment_data['refunds'][ $refund_id ] );
1314 }
1315
1316 /**
1317 * Get charge ID from payment data
1318 *
1319 * @param array<string,mixed> $payment Payment record.
1320 * @since 2.0.0
1321 * @return string|null Charge ID or null if not found.
1322 */
1323 private function get_charge_id_from_payment( $payment ) {
1324 // Check if transaction_id is already a charge ID.
1325 if ( ! empty( $payment['transaction_id'] ) && is_string( $payment['transaction_id'] ) && strpos( $payment['transaction_id'], 'ch_' ) === 0 ) {
1326 return $payment['transaction_id'];
1327 }
1328
1329 // Look in payment_data for charge_id.
1330 if ( empty( $payment['payment_data'] ) ) {
1331 return null;
1332 }
1333
1334 $payment_data = Helper::get_array_value( $payment['payment_data'] );
1335 if ( empty( $payment_data ) ) {
1336 return null;
1337 }
1338
1339 // Look for charge ID in various places in payment_data.
1340 $charge_keys = [
1341 'charge_id',
1342 'charge',
1343 'invoice_charge_id',
1344 ];
1345
1346 foreach ( $charge_keys as $key ) {
1347 $charge_id = $this->get_nested_value( $payment_data, $key );
1348 if ( ! empty( $charge_id ) && is_string( $charge_id ) && strpos( $charge_id, 'ch_' ) === 0 ) {
1349 return $charge_id;
1350 }
1351 }
1352
1353 return null;
1354 }
1355
1356 /**
1357 * Get nested value from array using dot notation
1358 *
1359 * @param array<string,mixed> $array Array to search.
1360 * @param string $key Dot-separated key path.
1361 * @since 2.0.0
1362 * @return mixed Value or null if not found.
1363 */
1364 private function get_nested_value( $array, $key ) {
1365 $keys = explode( '.', $key );
1366 $value = $array;
1367
1368 foreach ( $keys as $k ) {
1369 if ( ! is_array( $value ) || ! isset( $value[ $k ] ) ) {
1370 return null;
1371 }
1372 $value = $value[ $k ];
1373 }
1374
1375 return $value;
1376 }
1377 }
1378