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 |