PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.7.0
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.7.0
2.12.6 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 All 96 releases
sureforms / inc / payments / front-end.php
front-end.php
1,543 lines 59.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SureForms Payments Frontend Class.
4 *
5 * @package sureforms
6 * @since 2.0.0
7 */
8
9 namespace SRFM\Inc\Payments;
10
11 use SRFM\Inc\Database\Tables\Payments;
12 use SRFM\Inc\Payments\Stripe\Stripe_Helper;
13 use SRFM\Inc\Submit_Token;
14 use SRFM\Inc\Traits\Get_Instance;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit; // Exit if accessed directly.
18 }
19
20 /**
21 * SureForms Payments Frontend Class.
22 *
23 * @since 2.0.0
24 */
25 class Front_End {
26 use Get_Instance;
27
28 /**
29 * Stores payment entries for later linking with form submissions.
30 *
31 * @var array
32 * @since 2.0.0
33 */
34 private $stripe_payment_entries = [];
35
36 /**
37 * Constructor.
38 *
39 * @since 2.0.0
40 */
41 public function __construct() {
42 add_action( 'wp_ajax_srfm_create_payment_intent', [ $this, 'create_payment_intent' ] );
43 add_action( 'wp_ajax_nopriv_srfm_create_payment_intent', [ $this, 'create_payment_intent' ] );
44 add_action( 'wp_ajax_srfm_create_subscription_intent', [ $this, 'create_subscription_intent' ] );
45 add_action( 'wp_ajax_nopriv_srfm_create_subscription_intent', [ $this, 'create_subscription_intent' ] ); // For non-logged-in users.
46 add_filter( 'srfm_form_submit_data', [ $this, 'validate_payment_fields' ], 5, 1 );
47 add_action( 'srfm_form_submit', [ $this, 'update_payment_entry_id_form_submit' ], 10, 1 );
48 add_filter( 'srfm_show_options_values', [ $this, 'show_options_values' ], 10, 2 );
49 add_filter( 'srfm_all_data_field_row', [ $this, 'skip_payment_fields_from_all_data' ], 10, 2 );
50 add_filter( 'srfm_map_slug_to_submission_data_should_skip', [ $this, 'skip_payment_fields_from_submission_data' ], 10, 2 );
51 add_filter( 'srfm_should_skip_field_from_sample_data', [ $this, 'skip_payment_fields_from_sample_data' ], 10, 2 );
52 }
53
54 /**
55 * Show options values
56 *
57 * @param bool $default_value Default value.
58 * @param bool $value Value.
59 * @since 2.0.0
60 * @return bool
61 */
62 public function show_options_values( $default_value, $value ) {
63 return $value ? true : $default_value;
64 }
65 /**
66 * Create payment intent
67 *
68 * @throws \Exception When Stripe configuration is invalid.
69 * @since 2.0.0
70 * @return void
71 */
72 public function create_payment_intent() {
73 // Verify submit token.
74 $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verification replaces nonce.
75 $form_id = isset( $_POST['form_id'] ) && is_numeric( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
76 if ( ! Submit_Token::verify( $token, $form_id ) ) {
77 wp_send_json_error( __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) );
78 }
79
80 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified via Submit_Token::verify() above.
81 $amount = intval( $_POST['amount'] ?? 0 );
82 $currency = sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'usd' ) );
83 $description = sanitize_text_field( wp_unslash( $_POST['description'] ?? 'SureForms Payment' ) );
84 $block_id = sanitize_text_field( wp_unslash( $_POST['block_id'] ?? '' ) );
85 $customer_email = sanitize_email( wp_unslash( $_POST['customer_email'] ?? '' ) );
86 $customer_name = sanitize_text_field( wp_unslash( $_POST['customer_name'] ?? '' ) );
87 $form_id = isset( $_POST['form_id'] ) && is_numeric( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
88 // phpcs:enable WordPress.Security.NonceVerification.Missing
89
90 if ( $amount <= 0 ) {
91 wp_send_json_error( __( 'Invalid payment amount.', 'sureforms' ) );
92 }
93
94 $amount_processed_with_currency = Stripe_Helper::amount_from_stripe_format( $amount, $currency );
95 // Validate payment amount against stored form configuration.
96 if ( $form_id <= 0 || empty( $block_id ) ) {
97 wp_send_json_error( __( 'Invalid form configuration.', 'sureforms' ) );
98 }
99
100 $validation_result = Payment_Helper::validate_payment_amount( $amount_processed_with_currency, $currency, $form_id, $block_id );
101 if ( ! $validation_result['valid'] ) {
102 wp_send_json_error( $validation_result['message'] );
103 }
104
105 // Validate customer email (required for one-time payments).
106 if ( empty( $customer_email ) || ! is_email( $customer_email ) ) {
107 wp_send_json_error( __( 'Valid customer email is required for payments.', 'sureforms' ) );
108 }
109
110 try {
111 // Validate Stripe connection.
112 if ( ! Stripe_Helper::is_stripe_connected() ) {
113 throw new \Exception( __( 'Stripe is not connected.', 'sureforms' ) );
114 }
115
116 $secret_key = Stripe_Helper::get_stripe_secret_key();
117
118 if ( empty( $secret_key ) ) {
119 throw new \Exception( __( 'Stripe secret key not found.', 'sureforms' ) );
120 }
121
122 // Create or get customer ID for logged-in users.
123 $customer_id = null;
124 if ( is_user_logged_in() ) {
125 $customer_id = $this->get_or_create_stripe_customer(
126 [
127 'email' => $customer_email,
128 'name' => $customer_name,
129 ]
130 );
131 }
132
133 $license_key = Stripe_Helper::get_license_key();
134
135 // Create payment intent with confirm: true for immediate processing.
136 $payment_intent_data = [
137 'secret_key' => $secret_key,
138 'amount' => $amount,
139 'currency' => strtolower( $currency ),
140 'description' => $description,
141 'confirm' => false, // Will be confirmed by frontend.
142 'receipt_email' => $customer_email,
143 'license_key' => $license_key,
144 'automatic_payment_methods' => [
145 'enabled' => true,
146 'allow_redirects' => 'never',
147 ],
148 'metadata' => [
149 'source' => 'SureForms',
150 'block_id' => $block_id,
151 'original_amount' => $amount,
152 'receipt_email' => $customer_email,
153 'customer_name' => $customer_name,
154 ],
155 ];
156
157 // Add customer ID to payment intent data if user is logged in.
158 if ( ! empty( $customer_id ) ) {
159 $payment_intent_data['customer'] = $customer_id;
160 }
161
162 $payment_intent_data = apply_filters(
163 'srfm_create_payment_intent_data',
164 $payment_intent_data,
165 $customer_id
166 );
167
168 $payment_intent_data = wp_json_encode( $payment_intent_data );
169 $payment_intent_data = is_string( $payment_intent_data ) ? $payment_intent_data : '';
170 $payment_intent_data = base64_encode( $payment_intent_data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
171
172 $payment_intent = wp_remote_post(
173 Stripe_Helper::middle_ware_base_url() . 'payment-intent/create',
174 [
175 'body' => $payment_intent_data,
176 'headers' => [
177 'Content-Type' => 'application/json',
178 ],
179 ]
180 );
181
182 if ( is_wp_error( $payment_intent ) ) {
183 throw new \Exception( Payment_Helper::get_error_message_by_key( 'failed_to_create_payment' ) );
184 }
185
186 $payment_intent = json_decode( wp_remote_retrieve_body( $payment_intent ), true );
187 $payment_intent = is_array( $payment_intent ) ? $payment_intent : [];
188
189 // Check if we have an error from Stripe API (verify both status and code).
190 if ( isset( $payment_intent['status'] ) && 'error' === $payment_intent['status'] && isset( $payment_intent['code'] ) && ! empty( $payment_intent['code'] ) ) {
191 // Handle amount_too_small error with custom message.
192 if ( 'amount_too_small' === $payment_intent['code'] ) {
193 // Format the amount for display.
194 $currency_symbol = Stripe_Helper::get_currency_symbol( $currency );
195 $display_amount = $amount_processed_with_currency;
196 $formatted_amount = $currency_symbol . number_format( $display_amount, 2 );
197
198 throw new \Exception(
199 sprintf(
200 /* translators: %s: formatted payment amount */
201 __( 'The payment amount (%s) is below the minimum allowed. Stripe only processes amounts above 50¢.', 'sureforms' ),
202 $formatted_amount
203 )
204 );
205 }
206
207 // For other error codes, use the message from Stripe API if available.
208 if ( isset( $payment_intent['message'] ) && ! empty( $payment_intent['message'] ) ) {
209 throw new \Exception( $payment_intent['message'] );
210 }
211
212 // Fallback if we have error code but no message.
213 throw new \Exception( Payment_Helper::get_error_message_by_key( 'failed_to_create_payment' ) );
214 }
215
216 if ( ! isset( $payment_intent['client_secret'] ) || empty( $payment_intent['client_secret'] ) || ! isset( $payment_intent['id'] ) || empty( $payment_intent['id'] ) ) {
217 throw new \Exception( Payment_Helper::get_error_message_by_key( 'failed_to_create_payment' ) );
218 }
219
220 // Store payment intent metadata in transient for verification.
221 Payment_Helper::store_payment_intent_metadata(
222 $block_id,
223 $payment_intent['id'],
224 [
225 'form_id' => $form_id,
226 'block_id' => $block_id,
227 'amount' => $amount_processed_with_currency,
228 'currency' => strtolower( $currency ),
229 ]
230 );
231
232 wp_send_json_success(
233 [
234 'client_secret' => $payment_intent['client_secret'],
235 'payment_intent_id' => $payment_intent['id'],
236 'customer_id' => $customer_id,
237 ]
238 );
239 } catch ( \Exception $e ) {
240 $error_message = $e->getMessage();
241 $error_message = empty( $error_message ) ? Payment_Helper::get_error_message_by_key( 'failed_to_create_payment' ) : $error_message;
242 wp_send_json_error( $error_message );
243 }
244 }
245
246 /**
247 * Create subscription intent with improved error handling from simple-stripe-subscriptions
248 *
249 * @throws \Exception When Stripe configuration is invalid.
250 * @since 2.0.0
251 * @return void
252 */
253 public function create_subscription_intent() {
254 // Verify submit token.
255 $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- HMAC token verification replaces nonce.
256 $form_id = isset( $_POST['form_id'] ) && is_numeric( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
257 if ( ! Submit_Token::verify( $token, $form_id ) ) {
258 wp_send_json_error( __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) );
259 }
260
261 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified via Submit_Token::verify() above.
262
263 // Validate required fields like simple-stripe-subscriptions.
264 $required_fields = [ 'amount', 'currency', 'description', 'block_id', 'interval', 'plan_name' ];
265 foreach ( $required_fields as $field ) {
266 if ( empty( $_POST[ $field ] ) ) {
267 /* translators: %s: Field name */
268 wp_send_json_error( sprintf( __( 'Missing required field: %s', 'sureforms' ), $field ) );
269 }
270 }
271
272 $amount = intval( $_POST['amount'] ?? 0 );
273 $currency = sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'usd' ) );
274 $description = sanitize_text_field( wp_unslash( $_POST['description'] ?? 'SureForms Subscription' ) );
275 $block_id = sanitize_text_field( wp_unslash( $_POST['block_id'] ?? '' ) );
276
277 $subscription_interval = sanitize_text_field( wp_unslash( $_POST['interval'] ?? 'month' ) );
278 $plan_name = sanitize_text_field( wp_unslash( $_POST['plan_name'] ?? 'Subscription Plan' ) );
279 $customer_email = sanitize_email( wp_unslash( $_POST['customer_email'] ?? '' ) );
280 $customer_name = sanitize_text_field( wp_unslash( $_POST['customer_name'] ?? '' ) );
281 $form_id = isset( $_POST['form_id'] ) && is_numeric( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
282
283 // phpcs:enable WordPress.Security.NonceVerification.Missing
284
285 // Validate customer email (required for all subscriptions).
286 if ( empty( $customer_email ) || ! is_email( $customer_email ) ) {
287 wp_send_json_error( __( 'Valid customer email is required for subscriptions.', 'sureforms' ) );
288 }
289
290 // Validate customer name (required for subscriptions).
291 if ( empty( $customer_name ) ) {
292 wp_send_json_error( __( 'Customer name is required for subscriptions.', 'sureforms' ) );
293 }
294
295 $amount_processed_with_currency = Stripe_Helper::amount_from_stripe_format( $amount, $currency );
296 // Validate payment amount against stored form configuration.
297 if ( $form_id <= 0 || empty( $block_id ) ) {
298 wp_send_json_error( __( 'Invalid form configuration.', 'sureforms' ) );
299 }
300
301 $validation_result = Payment_Helper::validate_payment_amount( $amount_processed_with_currency, $currency, $form_id, $block_id );
302 if ( ! $validation_result['valid'] ) {
303 wp_send_json_error( $validation_result['message'] );
304 }
305
306 // Validate amount like simple-stripe-subscriptions.
307 if ( $amount <= 0 ) {
308 wp_send_json_error( __( 'Amount must be greater than 0', 'sureforms' ) );
309 }
310
311 // Validate interval like simple-stripe-subscriptions.
312 $valid_intervals = [ 'day', 'week', 'month', 'year' ];
313 if ( ! in_array( $subscription_interval, $valid_intervals, true ) ) {
314 wp_send_json_error( __( 'Invalid billing interval', 'sureforms' ) );
315 }
316
317 try {
318 // Validate Stripe connection.
319 if ( ! Stripe_Helper::is_stripe_connected() ) {
320 throw new \Exception( __( 'Stripe is not connected.', 'sureforms' ) );
321 }
322
323 $secret_key = Stripe_Helper::get_stripe_secret_key();
324
325 if ( empty( $secret_key ) ) {
326 throw new \Exception( __( 'Stripe secret key not found.', 'sureforms' ) );
327 }
328
329 // Get or create Stripe customer for subscriptions.
330 $customer_id = $this->get_or_create_stripe_customer(
331 [
332 'email' => $customer_email,
333 'name' => $customer_name,
334 ]
335 );
336 if ( ! $customer_id ) {
337 throw new \Exception( __( 'Failed to create customer for subscription.', 'sureforms' ) );
338 }
339
340 $license_key = Stripe_Helper::get_license_key();
341 // Prepare subscription data for middleware.
342 $subscription_data = apply_filters(
343 'srfm_create_subscription_data',
344 [
345 'secret_key' => $secret_key,
346 'customer_id' => $customer_id,
347 'amount' => $amount,
348 'currency' => strtolower( $currency ),
349 'description' => $description,
350 'interval' => $subscription_interval,
351 'license_key' => $license_key,
352 'block_id' => $block_id,
353 'plan_name' => $plan_name,
354 'metadata' => [
355 'source' => 'SureForms',
356 'block_id' => $block_id,
357 'original_amount' => $amount,
358 'billing_interval' => $subscription_interval,
359 ],
360 ]
361 );
362
363 $endpoint = Stripe_Helper::middle_ware_base_url() . 'subscription/create';
364
365 $subscription_data_body = wp_json_encode( $subscription_data );
366 $subscription_data_body = is_string( $subscription_data_body ) ? $subscription_data_body : '';
367 $subscription_data_body = base64_encode( $subscription_data_body ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
368
369 if ( empty( $subscription_data_body ) ) {
370 throw new \Exception( __( 'Failed to create subscription through middleware.', 'sureforms' ) );
371 }
372
373 // Call middleware subscription creation endpoint.
374 $subscription_response = wp_remote_post(
375 $endpoint,
376 [
377 'body' => $subscription_data_body,
378 'headers' => [
379 'Content-Type' => 'application/json',
380 ],
381 'timeout' => 60, // Subscription creation can take longer.
382 ]
383 );
384
385 if ( is_wp_error( $subscription_response ) ) {
386 throw new \Exception( __( 'Failed to create subscription through middleware.', 'sureforms' ) );
387 }
388
389 $response_body = wp_remote_retrieve_body( $subscription_response );
390 if ( empty( $response_body ) ) {
391 throw new \Exception( __( 'Empty response from subscription creation.', 'sureforms' ) );
392 }
393
394 $subscription = json_decode( $response_body, true );
395 if ( json_last_error() !== JSON_ERROR_NONE ) {
396 throw new \Exception( __( 'Invalid JSON response from subscription creation.', 'sureforms' ) );
397 }
398
399 if ( ! is_array( $subscription ) ) {
400 wp_send_json_error( __( 'Invalid subscription data.', 'sureforms' ) );
401 }
402
403 if ( 'error' === $subscription['status'] ) {
404 wp_send_json_error( isset( $subscription['message'] ) && ! empty( $subscription['message'] ) ? $subscription['message'] : __( 'Invalid subscription data.', 'sureforms' ) );
405 }
406
407 $payment_intent_id = isset( $subscription['setup_intent']['id'] ) && ! empty( $subscription['setup_intent']['id'] ) ? $subscription['setup_intent']['id'] : '';
408 $subscription_id = isset( $subscription['subscription_data']['id'] ) && ! empty( $subscription['subscription_data']['id'] ) ? $subscription['subscription_data']['id'] : '';
409 $client_secret = isset( $subscription['client_secret'] ) && ! empty( $subscription['client_secret'] ) ? $subscription['client_secret'] : '';
410 if ( empty( $client_secret ) || empty( $subscription_id ) || empty( $payment_intent_id ) ) {
411 throw new \Exception( __( 'Failed to create subscription.', 'sureforms' ) );
412 }
413
414 // Store subscription metadata in transient for verification.
415 Payment_Helper::store_payment_intent_metadata(
416 $block_id,
417 $payment_intent_id,
418 [
419 'form_id' => $form_id,
420 'block_id' => $block_id,
421 'amount' => $amount_processed_with_currency,
422 'currency' => strtolower( $currency ),
423 'subscription_id' => $subscription_id,
424 ]
425 );
426
427 $response = [
428 'type' => 'subscription',
429 'client_secret' => $client_secret,
430 'subscription_id' => $subscription_id,
431 'customer_id' => $customer_id,
432 'payment_intent_id' => $payment_intent_id,
433 'amount' => Stripe_Helper::amount_from_stripe_format( $amount, $currency ),
434 'interval' => $subscription_interval,
435 ];
436
437 wp_send_json_success( $response );
438
439 } catch ( \Exception $e ) {
440 /* translators: %s: Error message */
441 wp_send_json_error( sprintf( __( 'Unexpected error: %s', 'sureforms' ), $e->getMessage() ) );
442 }
443 }
444
445 /**
446 * Validate payment fields before form submission
447 *
448 * @param array<mixed> $form_data Form data.
449 * @since 2.0.0
450 * @return array<mixed>
451 */
452 public function validate_payment_fields( $form_data ) {
453 // Check if form data is valid.
454 if ( empty( $form_data ) || ! is_array( $form_data ) ) {
455 return $form_data;
456 }
457
458 $payment_response = [];
459
460 // Loop through form data to find payment fields.
461 foreach ( $form_data as $field_name => $field_value ) {
462 // Check if field name contains "-lbl-" pattern.
463 if ( strpos( $field_name, '-lbl-' ) === false ) {
464 continue;
465 }
466
467 // Split field name by "-lbl-" delimiter.
468 $name_parts = explode( '-lbl-', $field_name );
469
470 // Check if we have the expected parts.
471 if ( count( $name_parts ) < 2 ) {
472 continue;
473 }
474
475 // Check if the first part starts with "srfm-payment-".
476 if ( ! ( strpos( $name_parts[0], 'srfm-payment-' ) === 0 ) ) {
477 continue;
478 }
479
480 // Value will be in the form of the json string.
481 $payment_value = json_decode( $field_value, true );
482
483 if ( empty( $payment_value ) || ! is_array( $payment_value ) ) {
484 continue;
485 }
486
487 // Extract payment ID - this will be the payment intent ID for one-time payments,
488 // or the payment method ID (result.setupIntent.payment_method) for subscriptions.
489 $payment_id = ! empty( $payment_value['paymentId'] ) ? $payment_value['paymentId'] : '';
490 $setup_intent = ! empty( $payment_value['setupIntent'] ) ? $payment_value['setupIntent'] : '';
491
492 // introduced during the paypal implementation and in the other payment methods, we use the transactionId to verify the payment.
493 $transaction_id = ! empty( $payment_value['transactionId'] ) ? $payment_value['transactionId'] : '';
494
495 if ( empty( $payment_id ) && empty( $setup_intent ) && empty( $transaction_id ) ) {
496 continue;
497 }
498
499 $block_id = ! empty( $payment_value['blockId'] ) ? $payment_value['blockId'] : '';
500 $payment_type = ! empty( $payment_value['paymentType'] ) ? $payment_value['paymentType'] : '';
501
502 $payment_method = ! empty( $payment_value['paymentMethod'] ) ? $payment_value['paymentMethod'] : 'stripe';
503
504 if ( empty( $block_id ) || empty( $payment_type ) ) {
505 continue;
506 }
507
508 if ( 'stripe' === $payment_method ) {
509 $payment_response = $this->verify_stripe_payment( $payment_value, $payment_id, $block_id, $form_data, $payment_type );
510 } else {
511 $payment_response = apply_filters(
512 'srfm_verify_payment_value',
513 [
514 'payment_value' => $payment_value,
515 'class' => $this,
516 'block_id' => $block_id,
517 'form_data' => $form_data,
518 ]
519 );
520 }
521
522 if ( ! empty( $payment_response ) && isset( $payment_response['payment_id'] ) ) {
523 // Modify the form data with the payment ID.
524 $form_data[ $field_name ] = $payment_response['payment_id'];
525 }
526 }
527
528 if ( ! empty( $payment_response ) && isset( $payment_response['error'] ) ) {
529 $form_data = array_merge( $form_data, $payment_response );
530 }
531
532 return $form_data;
533 }
534
535 /**
536 * Verify Stripe payment
537 *
538 * @param array<mixed> $payment_value Payment value.
539 * @param string $payment_id Payment ID.
540 * @param string $block_id Block ID.
541 * @param array<mixed> $form_data Form data.
542 * @param string $payment_type Payment type.
543 * @since 2.0.0
544 * @return array<mixed> Payment response.
545 */
546 public function verify_stripe_payment( $payment_value, $payment_id, $block_id, $form_data, $payment_type ) {
547 if ( 'stripe-subscription' === $payment_type ) {
548
549 /**
550 * For subscription payments, we receive the following data structure:
551 * - paymentMethod: Stripe payment method ID (e.g., "pm_1S82ZkHqS7N4oFQhruGV67u1")
552 * - setupIntent: Stripe setup intent ID (e.g., "seti_1S82ZkHqS7N4oFQhPa4LYPYg")
553 * - subscriptionId: Stripe subscription ID (e.g., "sub_1S82ZiHqS7N4oFQhPGhm2eNR")
554 * - customerId: Stripe customer ID (e.g., "cus_T4Apjla33GlYAk")
555 * - blockId: Form block identifier (e.g., "be920796")
556 * - paymentType: Payment type identifier ("stripe-subscription")
557 * - status: Payment status ("succeeded")
558 */
559 $payment_response = $this->verify_stripe_subscription_intent_and_save( $payment_value, $block_id, $form_data );
560 } else {
561 $payment_response = $this->verify_stripe_payment_intent_and_save( $payment_value, $payment_id, $block_id, $form_data );
562 }
563
564 return ! empty( $payment_response ) && is_array( $payment_response ) ? $payment_response : [];
565 }
566
567 /**
568 * Simplified subscription verification using simple-stripe-subscriptions approach
569 *
570 * @param array<mixed> $subscription_value Subscription data from frontend.
571 * @param string $block_id Block ID.
572 * @param array<mixed> $form_data Form data.
573 * @since 2.0.0
574 * @return void|array<mixed> True if subscription is verified and saved successfully.
575 */
576 public function verify_stripe_subscription_intent_and_save( $subscription_value, $block_id, $form_data ) {
577 $subscription_id = ! empty( $subscription_value['subscriptionId'] ) && is_string( $subscription_value['subscriptionId'] ) ? $subscription_value['subscriptionId'] : '';
578
579 if ( empty( $subscription_id ) ) {
580 return [
581 'error' => __( 'Subscription ID not found.', 'sureforms' ),
582 ];
583 }
584
585 $customer_id = ! empty( $subscription_value['customerId'] ) ? $subscription_value['customerId'] : '';
586 $setup_intent_id = ! empty( $subscription_value['setupIntent'] ) && is_string( $subscription_value['setupIntent'] ) ? $subscription_value['setupIntent'] : '';
587
588 // Verify payment intent with comprehensive validation including form data.
589 $verification_result = Payment_Helper::verify_payment_intent( $block_id, $setup_intent_id, $form_data );
590
591 if ( false === $verification_result['valid'] ) {
592 return [
593 'error' => $verification_result['message'],
594 ];
595 }
596
597 if ( empty( $customer_id ) ) {
598 return [
599 'error' => __( 'Customer ID not found for the payment.', 'sureforms' ),
600 ];
601 }
602
603 try {
604 // Get payment mode and secret key.
605 $payment_mode = Stripe_Helper::get_stripe_mode();
606 $secret_key = Stripe_Helper::get_stripe_secret_key();
607
608 if ( empty( $secret_key ) ) {
609 return [
610 'error' => __( 'Stripe secret key not found.', 'sureforms' ),
611 ];
612 }
613
614 // Update subscription with payment method from setup intent if available.
615 $paid_invoice = [];
616 if ( ! empty( $setup_intent_id ) ) {
617 try {
618 $setup_intent_response = Stripe_Helper::stripe_api_request(
619 'setup_intents',
620 'GET',
621 [],
622 $setup_intent_id
623 );
624
625 if ( ! $setup_intent_response['success'] ) {
626 return [
627 'error' => $setup_intent_response['error']['message'] ?? __( 'Failed to retrieve setup intent.', 'sureforms' ),
628 ];
629 }
630
631 $setup_intent = $setup_intent_response['data'];
632
633 if ( ( isset( $setup_intent['payment_method'] ) && ! empty( $setup_intent['payment_method'] ) && is_string( $setup_intent['payment_method'] ) ) ) {
634
635 // Prepare subscription update data.
636 $subscription_update_data = [
637 'default_payment_method' => $setup_intent['payment_method'],
638 'collection_method' => 'charge_automatically',
639 ];
640
641 // Calculate cancel_at timestamp based on billing cycles and interval.
642 $cancel_at = $this->prepare_cancel_at( $subscription_value );
643 if ( ! empty( $cancel_at ) ) {
644 $subscription_update_data['cancel_at'] = $cancel_at;
645 }
646
647 $subscription_update_response = Stripe_Helper::stripe_api_request(
648 'subscriptions',
649 'POST',
650 $subscription_update_data,
651 $subscription_id
652 );
653
654 if ( ! $subscription_update_response['success'] ) {
655 return [
656 'error' => $subscription_update_response['error']['message'] ?? __( 'Failed to update subscription.', 'sureforms' ),
657 ];
658 }
659
660 $subscription_update = $subscription_update_response['data'];
661
662 if ( empty( $subscription_update['latest_invoice'] ) ) {
663 return [
664 'error' => __( 'Latest invoice not found on subscription.', 'sureforms' ),
665 ];
666 }
667
668 $invoice_response = Stripe_Helper::stripe_api_request(
669 'invoices',
670 'GET',
671 [],
672 $subscription_update['latest_invoice']
673 );
674
675 if ( ! $invoice_response['success'] ) {
676 return [
677 'error' => $invoice_response['error']['message'] ?? __( 'Failed to retrieve invoice.', 'sureforms' ),
678 ];
679 }
680
681 $invoice = $invoice_response['data'];
682
683 // Ensure invoice auto-advance is enabled for recurring payments.
684 // This tells Stripe to automatically finalize and charge future invoices.
685 if ( empty( $invoice['auto_advance'] ) && ! empty( $invoice['id'] ) && is_string( $invoice['id'] ) ) {
686 Stripe_Helper::stripe_api_request(
687 'invoices',
688 'POST',
689 [ 'auto_advance' => true ],
690 $invoice['id']
691 );
692 }
693
694 // Extract payment intent from the invoice.
695 $payment_intent_id = isset( $invoice['payment_intent'] ) && ! empty( $invoice['payment_intent'] ) && is_string( $invoice['payment_intent'] ) ? $invoice['payment_intent'] : '';
696
697 if ( empty( $payment_intent_id ) ) {
698 return [
699 'error' => __( 'Payment intent not found on invoice.', 'sureforms' ),
700 ];
701 }
702
703 // Confirm the payment intent with payment method.
704 // This completes the payment and activates the subscription.
705 $paid_invoice_response = Stripe_Helper::stripe_api_request(
706 'payment_intents',
707 'POST',
708 [ 'payment_method' => $setup_intent['payment_method'] ],
709 $payment_intent_id . '/confirm'
710 );
711
712 if ( ! $paid_invoice_response['success'] ) {
713 return [
714 'error' => $paid_invoice_response['error']['message'] ?? __( 'Failed to confirm payment.', 'sureforms' ),
715 ];
716 }
717
718 $paid_invoice = $paid_invoice_response['data'];
719
720 // Get the subscription.
721 $subscription_response = Stripe_Helper::stripe_api_request(
722 'subscriptions',
723 'GET',
724 [],
725 $subscription_id
726 );
727
728 if ( ! $subscription_response['success'] ) {
729 return [
730 'error' => $subscription_response['error']['message'] ?? __( 'Failed to retrieve subscription.', 'sureforms' ),
731 ];
732 }
733
734 $subscription = $subscription_response['data'];
735 }
736 } catch ( \Exception $e ) {
737 return [
738 'error' => $e->getMessage(),
739 ];
740 }
741 }
742
743 if ( empty( $subscription ) ) {
744 return [
745 'error' => __( 'Subscription not found for the payment.', 'sureforms' ),
746 ];
747 }
748
749 // Use simple-stripe-subscriptions validation logic - check if subscription is in good state.
750 $is_subscription_active = in_array( $subscription['status'], [ 'active', 'trialing' ], true );
751 $final_status = $is_subscription_active ? 'active' : 'failed';
752
753 $amount = isset( $paid_invoice['amount'] ) && ! empty( $paid_invoice['amount'] ) ? $paid_invoice['amount'] : 0;
754 $currency = isset( $paid_invoice['currency'] ) && ! empty( $paid_invoice['currency'] ) ? $paid_invoice['currency'] : 'usd';
755 $form_id = isset( $form_data['form-id'] ) && ! empty( $form_data['form-id'] ) ? $form_data['form-id'] : 0;
756 $subscription_status = isset( $subscription['status'] ) && ! empty( $subscription['status'] ) && is_string( $subscription['status'] ) ? $subscription['status'] : '';
757
758 $invoice_status = isset( $paid_invoice['status'] ) && ! empty( $paid_invoice['status'] ) && is_string( $paid_invoice['status'] ) ? $paid_invoice['status'] : '';
759
760 // Extract customer data.
761 $customer_data = $this->extract_customer_data( $subscription_value );
762
763 // Extract charge ID from the first payment intent for refund purposes.
764 // For subscriptions, we store the charge ID in transaction_id so refunds can be processed.
765 $charge_id = '';
766 if ( ! empty( $paid_invoice['latest_charge'] ) && is_string( $paid_invoice['latest_charge'] ) ) {
767 $charge_id = $paid_invoice['latest_charge'];
768 } elseif ( ! empty( $paid_invoice['charges']['data'][0]['id'] ) && is_string( $paid_invoice['charges']['data'][0]['id'] ) ) {
769 $charge_id = $paid_invoice['charges']['data'][0]['id'];
770 }
771
772 // Use charge ID as transaction_id if available, otherwise fall back to subscription ID.
773 $transaction_id = ! empty( $charge_id ) ? $charge_id : $subscription_id;
774
775 // Send payment data to middleware for analytics.
776 if ( ! empty( $charge_id ) ) {
777 Stripe_Helper::intersect_payment( $charge_id, $secret_key, '', 'SureForms' );
778 }
779
780 // Prepare minimal subscription data for database.
781 $entry_data = [
782 'form_id' => $form_id,
783 'block_id' => $block_id,
784 'status' => $final_status,
785 'total_amount' => Stripe_Helper::amount_from_stripe_format( $amount, $currency ),
786 'currency' => $currency,
787 'entry_id' => 0,
788 'gateway' => 'stripe',
789 'type' => 'subscription',
790 'mode' => $payment_mode,
791 'transaction_id' => $transaction_id,
792 'customer_id' => $customer_id,
793 'subscription_id' => $subscription_id,
794 'subscription_status' => $subscription_status,
795 'srfm_txn_id' => '', // Will be updated after getting payment entry ID.
796 'customer_email' => $customer_data['email'],
797 'customer_name' => $customer_data['name'],
798 'payment_data' => [
799 'initial_invoice' => $paid_invoice,
800 'subscription' => $subscription,
801 'payment_value' => $subscription_value,
802 ],
803 ];
804
805 // Get user ID if logged in.
806 $user_id = get_current_user_id();
807 $user_info = $user_id > 0
808 /* translators: %d: User ID */
809 ? sprintf( __( 'User ID: %d', 'sureforms' ), $user_id )
810 /* translators: Message for guest user in payment logs */
811 : __( 'Guest User', 'sureforms' );
812
813 // If invoice is not paid then we need to set the status in the subscription log and return error.
814 $paid_invoice_log = '';
815 if ( 'paid' !== $invoice_status ) {
816 /* translators: %s: Invoice status */
817 $paid_invoice_log = sprintf( __( 'Invoice Status: %s', 'sureforms' ), $invoice_status );
818 }
819
820 // Add simple log entry.
821 $entry_data['log'] = [
822 [
823 /* translators: Title for subscription verification log */
824 'title' => __( 'Subscription Verification', 'sureforms' ),
825 'created_at' => current_time( 'mysql' ),
826 'messages' => [
827 /* translators: %s: Subscription ID */
828 sprintf( __( 'Subscription ID: %s', 'sureforms' ), $subscription_id ),
829 /* translators: %s: Payment Gateway */
830 sprintf( __( 'Payment Gateway: %s', 'sureforms' ), 'Stripe' ),
831 /* translators: %s: Payment Intent ID */
832 sprintf( __( 'Payment Intent ID: %s', 'sureforms' ), $setup_intent_id ),
833 /* translators: %s: Charge ID */
834 sprintf( __( 'Charge ID: %s', 'sureforms' ), ! empty( $charge_id ) ? $charge_id : 'N/A' ),
835 /* translators: %s: Subscription Status */
836 sprintf( __( 'Subscription Status: %s', 'sureforms' ), $subscription_status ),
837 /* translators: %s: Customer ID */
838 sprintf( __( 'Customer ID: %s', 'sureforms' ), $customer_id ),
839 /* translators: 1: Amount, 2: Currency */
840 sprintf( __( 'Amount: %1$s %2$s', 'sureforms' ), number_format( Stripe_Helper::amount_from_stripe_format( $amount, $currency ), 2 ), strtoupper( $currency ) ),
841 $user_info,
842 /* translators: %s: Payment mode (e.g. Live or Test) */
843 sprintf( __( 'Mode: %s', 'sureforms' ), ucfirst( $payment_mode ) ),
844 $paid_invoice_log,
845 ],
846 ],
847 ];
848
849 // Save to database.
850 $payment_entry_id = Payments::add( $entry_data );
851
852 if ( $payment_entry_id ) {
853 // Generate unique payment ID using the auto-increment ID and update the entry.
854 $unique_payment_id = Stripe_Helper::generate_unique_payment_id( $payment_entry_id );
855 // For initial subscription, set parent_subscription_id to itself (it's the parent).
856 Payments::update(
857 $payment_entry_id,
858 [
859 'srfm_txn_id' => $unique_payment_id,
860 'parent_subscription_id' => $payment_entry_id,
861 ]
862 );
863
864 // Store in static array for later entry linking.
865 $this->stripe_payment_entries[] = [
866 'payment_id' => $transaction_id,
867 'block_id' => $block_id,
868 'form_id' => $form_id,
869 ];
870
871 return [
872 'payment_id' => $payment_entry_id,
873 ];
874 }
875 } catch ( \Exception $e ) {
876 return [
877 'error' => empty( $e->getMessage() ) ? __( 'Failed to verify subscription.', 'sureforms' ) : $e->getMessage(),
878 ];
879 }
880 }
881
882 /**
883 * Prepare cancel_at timestamp for subscription based on billing cycles and interval.
884 *
885 * @param array<string,mixed> $input_value Array containing subscriptionBillingCycles and subscriptionInterval.
886 * @since 2.0.0
887 * @return int|false|null Unix timestamp for cancel_at, or null if not applicable.
888 */
889 public function prepare_cancel_at( $input_value ) {
890 $subscription_billing_cycles = ! empty( $input_value['subscriptionBillingCycles'] ) ? $input_value['subscriptionBillingCycles'] : 0;
891 $subscription_interval = ! empty( $input_value['subscriptionInterval'] ) ? $input_value['subscriptionInterval'] : '';
892
893 // Return null if billing cycles is 0, empty, or equals 'ongoing'.
894 if ( empty( $subscription_billing_cycles ) || 'ongoing' === $subscription_billing_cycles || ! is_numeric( $subscription_billing_cycles ) ) {
895 return null;
896 }
897
898 // Convert billing cycles to integer.
899 $billing_cycles = (int) $subscription_billing_cycles;
900
901 // Return null if billing cycles is less than or equal to 0.
902 if ( $billing_cycles <= 0 ) {
903 return null;
904 }
905
906 // Calculate cancel_at timestamp based on interval.
907 $current_time = time();
908 $cancel_at = null;
909
910 switch ( $subscription_interval ) {
911 case 'day':
912 // Add days: cycles * 1 day.
913 $cancel_at = strtotime( "+{$billing_cycles} days", $current_time );
914 break;
915
916 case 'week':
917 // Add weeks: cycles * 7 days.
918 $cancel_at = strtotime( "+{$billing_cycles} weeks", $current_time );
919 break;
920
921 case 'month':
922 // Add months: cycles * 1 month.
923 $cancel_at = strtotime( "+{$billing_cycles} months", $current_time );
924 break;
925
926 case 'quarter':
927 // Add quarters: cycles * 3 months.
928 $total_months = $billing_cycles * 3;
929 $cancel_at = strtotime( "+{$total_months} months", $current_time );
930 break;
931
932 case 'year':
933 // Add years: cycles * 1 year.
934 $cancel_at = strtotime( "+{$billing_cycles} years", $current_time );
935 break;
936
937 default:
938 // Invalid interval, return null.
939 return null;
940 }
941
942 return $cancel_at;
943 }
944
945 /**
946 * Handle form submit and update payment entries with entry_id
947 *
948 * This function is called after a form submission to link the created entry
949 * with any associated Stripe payment records. It matches payment entries
950 * by form_id and updates them with the newly created entry_id.
951 *
952 * @param array<string,mixed> $form_submit_response The form submission response containing entry_id and form_id.
953 * @since 2.0.0
954 * @return void
955 */
956 public function update_payment_entry_id_form_submit( $form_submit_response ) {
957 // Check if entry_id exists in the form_submit_response.
958 if ( ! empty( $form_submit_response['entry_id'] ) && ! empty( $this->stripe_payment_entries ) ) {
959 $entry_id = is_numeric( $form_submit_response['entry_id'] ) ? intval( $form_submit_response['entry_id'] ) : 0;
960
961 // Loop through stored payment entries to update with entry_id.
962 foreach ( $this->stripe_payment_entries as $stripe_payment_entry ) {
963 if ( ! empty( $stripe_payment_entry['payment_id'] ) && ! empty( $stripe_payment_entry['form_id'] ) ) {
964 // Check if form_id matches.
965 $stored_form_id = isset( $stripe_payment_entry['form_id'] ) && ! empty( $stripe_payment_entry['form_id'] ) && is_numeric( $stripe_payment_entry['form_id'] ) ? intval( $stripe_payment_entry['form_id'] ) : 0;
966 $response_form_id = isset( $form_submit_response['form_id'] ) && ! empty( $form_submit_response['form_id'] ) && is_numeric( $form_submit_response['form_id'] ) ? intval( $form_submit_response['form_id'] ) : 0;
967
968 $payment_id = is_string( $stripe_payment_entry['payment_id'] ) ? sanitize_text_field( $stripe_payment_entry['payment_id'] ) : '';
969
970 if ( ! empty( $stored_form_id ) && $stored_form_id === $response_form_id ) {
971 // Update the payment entry with the entry_id.
972 $this->update_payment_entry_id( $payment_id, $entry_id );
973 }
974 } elseif ( ! empty( $stripe_payment_entry['subscription_id'] ) && ! empty( $stripe_payment_entry['form_id'] ) ) {
975 // Check if form_id matches for subscription-based payment.
976 $stored_form_id = isset( $stripe_payment_entry['form_id'] ) && ! empty( $stripe_payment_entry['form_id'] ) && is_numeric( $stripe_payment_entry['form_id'] ) ? intval( $stripe_payment_entry['form_id'] ) : 0;
977 $response_form_id = isset( $form_submit_response['form_id'] ) && ! empty( $form_submit_response['form_id'] ) && is_numeric( $form_submit_response['form_id'] ) ? intval( $form_submit_response['form_id'] ) : 0;
978
979 $subscription_id = is_string( $stripe_payment_entry['subscription_id'] ) ? sanitize_text_field( $stripe_payment_entry['subscription_id'] ) : '';
980
981 if ( ! empty( $stored_form_id ) && $stored_form_id === $response_form_id ) {
982 // Update the payment entry with the entry_id using subscription_id.
983 $this->update_payment_entry_id_by_subscription_id( $subscription_id, $entry_id );
984 }
985 }
986 }
987 }
988 }
989
990 /**
991 * Add payment entry for later linking with form submission.
992 *
993 * Allows payment gateways (Stripe, PayPal, etc.) to register their entries
994 * for linking with form submissions. The entries are stored in memory and
995 * linked when the form is successfully submitted.
996 *
997 * @param array<string,mixed> $entry Payment entry containing payment_id, block_id, and form_id.
998 * @since 2.0.0
999 * @return void
1000 */
1001 public function add_payment_entry_for_linking( $entry ) {
1002 if ( ! empty( $entry ) && is_array( $entry ) ) {
1003 $this->stripe_payment_entries[] = $entry;
1004 }
1005 }
1006
1007 /**
1008 * Filter callback to determine if a payment field should be included in all data output.
1009 *
1010 * Excludes payment-related fields (like Stripe payment blocks) from being
1011 * rendered in submission summaries, emails, exports, etc., as these fields
1012 * serve as backend tracking data instead of user input.
1013 *
1014 * @since 2.0.0
1015 *
1016 * @param bool $should_add_field_row Whether this row should be output.
1017 * @param array<string | mixed> $args Args describing the field row. Should contain 'block_name'.
1018 * @return bool False for payment blocks; otherwise, original filter value.
1019 */
1020 public function skip_payment_fields_from_all_data( $should_add_field_row, $args ) {
1021 // Check if the block is a payment block by inspecting the block name.
1022 $block_name = isset( $args['block_name'] ) && is_string( $args['block_name'] ) ? $args['block_name'] : '';
1023 if ( 'srfm-payment' === $block_name ) {
1024 return false;
1025 }
1026 return $should_add_field_row;
1027 }
1028
1029 /**
1030 * Skip payment fields from submission data.
1031 *
1032 * This function checks if a field is a payment field by validating its key prefix.
1033 * Payment fields have keys that start with 'srfm-payment-' and should be skipped
1034 * from certain data operations.
1035 *
1036 * @param bool $default_value The default skip value.
1037 * @param array<mixed> $args Field arguments containing 'key', 'slug', and 'value'.
1038 * @since 2.0.0
1039 * @return bool True if the field should be skipped (is a payment field), false otherwise.
1040 */
1041 public function skip_payment_fields_from_submission_data( $default_value, $args ) {
1042 // Validate that args is an array and has the 'key' parameter.
1043 if ( ! is_array( $args ) || ! isset( $args['key'] ) || ! is_string( $args['key'] ) ) {
1044 return $default_value;
1045 }
1046
1047 // Check if the key starts with 'srfm-payment-' to identify payment fields.
1048 if ( 0 === strpos( $args['key'], 'srfm-payment-' ) ) {
1049 return true;
1050 }
1051
1052 return $default_value;
1053 }
1054
1055 /**
1056 * Skip payment fields from sample data.
1057 *
1058 * This function determines if a field associated with a "srfm/payment" block
1059 * should be skipped when processing sample data. If the provided arguments
1060 * specify a block with the name 'srfm/payment', the function returns true to
1061 * indicate that the field should be skipped. Otherwise, it returns the given
1062 * default value.
1063 *
1064 * @param bool $default_value The default skip value.
1065 * @param array<mixed> $args Field arguments containing at least 'block_name'.
1066 * @since 2.0.0
1067 * @return bool True if the field should be skipped (is a payment block), false otherwise.
1068 */
1069 public function skip_payment_fields_from_sample_data( $default_value, $args ) {
1070 if ( ! is_array( $args ) || ! isset( $args['block_name'] ) || ! is_string( $args['block_name'] ) ) {
1071 return $default_value;
1072 }
1073
1074 if ( 'srfm/payment' === $args['block_name'] ) {
1075 return true;
1076 }
1077
1078 return $default_value;
1079 }
1080
1081 /**
1082 * Verify payment intent status
1083 *
1084 * @param array<mixed> $payment_value Payment value.
1085 * @param string $payment_id Payment ID.
1086 * @param string $block_id Block ID.
1087 * @param array<mixed> $form_data Form data.
1088 *
1089 * @since 2.0.0
1090 * @return void|array<mixed>
1091 */
1092 private function verify_stripe_payment_intent_and_save( $payment_value, $payment_id, $block_id, $form_data ) {
1093 try {
1094 $payment_mode = Stripe_Helper::get_stripe_mode();
1095 $secret_key = Stripe_Helper::get_stripe_secret_key();
1096
1097 if ( empty( $secret_key ) ) {
1098 return [
1099 'error' => __( 'Stripe secret key not found.', 'sureforms' ),
1100 ];
1101 }
1102
1103 // Verify payment intent with comprehensive validation including form data.
1104 $verification_result = Payment_Helper::verify_payment_intent( $block_id, $payment_id, $form_data );
1105
1106 if ( false === $verification_result['valid'] ) {
1107 return [
1108 'error' => $verification_result['message'],
1109 ];
1110 }
1111
1112 $get_stripe_account_id = Stripe_Helper::get_stripe_account_id();
1113
1114 // Retrieve confirmed payment intent status.
1115 $retrieve_body = apply_filters(
1116 'srfm_retrieve_payment_intent_data',
1117 [
1118 'secret_key' => $secret_key,
1119 'payment_intent_id' => $payment_id,
1120 'stripe_account_id' => $get_stripe_account_id,
1121 'plugin_name' => 'SureForms',
1122 ]
1123 );
1124
1125 $retrieve_body = wp_json_encode( $retrieve_body );
1126 $retrieve_body = is_string( $retrieve_body ) ? $retrieve_body : '';
1127 $retrieve_body = base64_encode( $retrieve_body ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1128
1129 if ( empty( $retrieve_body ) ) {
1130 return [
1131 'error' => __( 'Failed to retrieve payment intent.', 'sureforms' ),
1132 ];
1133 }
1134
1135 // Call middleware retrieve endpoint to get confirmed payment intent.
1136 $retrieve_response = wp_remote_post(
1137 Stripe_Helper::middle_ware_base_url() . 'payment-intent/capture',
1138 [
1139 'timeout' => 60,
1140 'body' => $retrieve_body,
1141 'headers' => [
1142 'Content-Type' => 'application/json',
1143 ],
1144 ]
1145 );
1146
1147 if ( is_wp_error( $retrieve_response ) ) {
1148 return [
1149 'error' => __( 'Failed to retrieve payment intent.', 'sureforms' ),
1150 ];
1151 }
1152
1153 $confirmed_payment_intent = json_decode( wp_remote_retrieve_body( $retrieve_response ), true );
1154
1155 if ( empty( $confirmed_payment_intent ) && ! is_array( $confirmed_payment_intent ) ) {
1156 return [
1157 'error' => __( 'Failed to retrieve payment intent.', 'sureforms' ),
1158 ];
1159 }
1160
1161 // Strict type validation and array check to resolve phpstan errors.
1162 if ( is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['status'] ) && 'error' === $confirmed_payment_intent['status'] ) {
1163 return [
1164 'error' => __( 'Failed to retrieve payment intent.', 'sureforms' ),
1165 ];
1166 }
1167
1168 // Check if payment was actually confirmed successfully, safely.
1169 $confirmed_status = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['status'] ) ? (string) $confirmed_payment_intent['status'] : '';
1170 if ( ! in_array( $confirmed_status, [ 'succeeded', 'requires_capture' ], true ) ) {
1171 return [
1172 'error' => __( 'Payment was not confirmed successfully.', 'sureforms' ),
1173 ];
1174 }
1175
1176 $entry_data = [];
1177
1178 $form_id = isset( $form_data['form-id'] ) && ! empty( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? intval( $form_data['form-id'] ) : 0;
1179 $confirm_payment_status = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['status'] ) && ! empty( $confirmed_payment_intent['status'] ) ? (string) $confirmed_payment_intent['status'] : '';
1180 $confirm_payment_amount = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['amount'] ) && ! empty( $confirmed_payment_intent['amount'] ) ? intval( $confirmed_payment_intent['amount'] ) : 0;
1181 $confirm_payment_currency = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['currency'] ) && ! empty( $confirmed_payment_intent['currency'] ) ? (string) $confirmed_payment_intent['currency'] : 'usd';
1182 $confirm_payment_id = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['id'] ) && ! empty( $confirmed_payment_intent['id'] ) ? (string) $confirmed_payment_intent['id'] : '';
1183
1184 // Extract customer data.
1185 $customer_data = $this->extract_customer_data( $payment_value );
1186
1187 // update payment status and save to the payment entries table.
1188 $entry_data['form_id'] = $form_id;
1189 $entry_data['block_id'] = $block_id;
1190 $entry_data['status'] = $confirm_payment_status;
1191 $entry_data['total_amount'] = Stripe_Helper::amount_from_stripe_format( $confirm_payment_amount, $confirm_payment_currency );
1192 $entry_data['currency'] = $confirm_payment_currency;
1193 $entry_data['entry_id'] = 0;
1194 $entry_data['gateway'] = 'stripe';
1195 $entry_data['type'] = 'payment';
1196 $entry_data['mode'] = $payment_mode;
1197 $entry_data['transaction_id'] = $confirm_payment_id;
1198 $entry_data['srfm_txn_id'] = ''; // Will be updated after getting payment entry ID.
1199 $entry_data['customer_email'] = $customer_data['email'];
1200 $entry_data['customer_name'] = $customer_data['name'];
1201 $entry_data['customer_id'] = $customer_data['customer_id'];
1202 $entry_data['payment_data'] = [
1203 'payment_value' => $payment_value,
1204 ];
1205
1206 // Get user ID if logged in.
1207 $user_id = get_current_user_id();
1208 /* translators: %d: User ID */
1209 $user_info = $user_id > 0 ? sprintf( __( 'User ID: %d', 'sureforms' ), $user_id ) : __( 'Guest User', 'sureforms' );
1210
1211 // Add initial log entry for audit trail.
1212 $entry_data['log'] = [
1213 [
1214 'title' => __( 'Payment Verification', 'sureforms' ),
1215 'created_at' => current_time( 'mysql' ),
1216 'messages' => [
1217 /* translators: %s: Stripe transaction ID */
1218 sprintf( __( 'Transaction ID: %s', 'sureforms' ), $confirm_payment_id ),
1219 /* translators: %s: Payment gateway name. */
1220 sprintf( __( 'Payment Gateway: %s', 'sureforms' ), 'Stripe' ),
1221 /* translators: %1$s: amount, %2$s: currency. */
1222 sprintf( __( 'Amount: %1$s %2$s', 'sureforms' ), number_format( Stripe_Helper::amount_from_stripe_format( $confirm_payment_amount, $confirm_payment_currency ), 2 ), strtoupper( $confirm_payment_currency ) ),
1223 /* translators: %s: payment status */
1224 sprintf( __( 'Status: %s', 'sureforms' ), ucfirst( str_replace( '_', ' ', $confirm_payment_status ) ) ),
1225 $user_info,
1226 /* translators: %s: payment mode */
1227 sprintf( __( 'Mode: %s', 'sureforms' ), ucfirst( $payment_mode ) ),
1228 ],
1229 ],
1230 ];
1231
1232 $get_payment_entry_id = Payments::add( $entry_data );
1233
1234 if ( $get_payment_entry_id ) {
1235 // Generate unique payment ID using the auto-increment ID and update the entry.
1236 $unique_payment_id = Stripe_Helper::generate_unique_payment_id( $get_payment_entry_id );
1237 Payments::update( $get_payment_entry_id, [ 'srfm_txn_id' => $unique_payment_id ] );
1238
1239 $add_in_static_value = [
1240 'payment_id' => $confirm_payment_id,
1241 'block_id' => $block_id,
1242 'form_id' => $form_id,
1243 ];
1244
1245 $this->stripe_payment_entries[] = $add_in_static_value;
1246
1247 // Clean up transient after successful verification to prevent reuse.
1248 Payment_Helper::delete_payment_intent_metadata( $block_id, $payment_id );
1249
1250 return [
1251 'payment_id' => $get_payment_entry_id,
1252 ];
1253 }
1254 } catch ( \Exception $e ) {
1255 return [
1256 'error' => $e->getMessage(),
1257 ];
1258 }
1259 }
1260
1261 /**
1262 * Get or create Stripe customer
1263 *
1264 * @param array<string,string> $customer_data Customer data containing 'email' and 'name' from POST.
1265 * @since 2.0.0
1266 * @return string|false Customer ID on success, false on failure.
1267 */
1268 private function get_or_create_stripe_customer( $customer_data = [] ) {
1269 $current_user = wp_get_current_user();
1270
1271 if ( $current_user->ID > 0 ) {
1272 // Logged-in user - check for existing customer ID in user meta.
1273 $customer_id = get_user_meta( $current_user->ID, 'srfm_stripe_customer_id', true );
1274
1275 if ( ! empty( $customer_id ) && is_string( $customer_id ) && $this->verify_stripe_customer( $customer_id ) ) {
1276 return $customer_id;
1277 }
1278
1279 // Create new customer for logged-in user.
1280 return $this->create_stripe_customer_for_user( $current_user, $customer_data );
1281 }
1282
1283 // Non-logged-in user - create temporary customer.
1284 return $this->create_stripe_customer_for_guest( $customer_data );
1285 }
1286
1287 /**
1288 * Create Stripe customer for logged-in user
1289 *
1290 * @param \WP_User $user WordPress user object.
1291 * @param array<string,string> $post_customer_data Customer data from POST containing 'email' and 'name'.
1292 * @since 2.0.0
1293 * @return string|false Customer ID on success, false on failure.
1294 * @throws \Exception When Stripe API request fails.
1295 */
1296 private function create_stripe_customer_for_user( $user, $post_customer_data = [] ) {
1297 try {
1298 // Use POST email if provided, else use logged-in user email.
1299 $customer_email = ! empty( $post_customer_data['email'] ) ? $post_customer_data['email'] : $user->user_email;
1300
1301 // Use POST name if provided, else use logged-in user name.
1302 $customer_name = ! empty( $post_customer_data['name'] ) ? $post_customer_data['name'] : ( trim( $user->first_name . ' ' . $user->last_name ) );
1303 $customer_name = ! empty( $customer_name ) ? $customer_name : $user->display_name;
1304
1305 // Build description with provided email and name.
1306 $description_parts = [];
1307 if ( ! empty( $customer_email ) ) {
1308 $description_parts[] = $customer_email;
1309 }
1310 if ( ! empty( $customer_name ) ) {
1311 $description_parts[] = $customer_name;
1312 }
1313 $description = ! empty( $description_parts ) ? implode( ', ', $description_parts ) : sprintf( 'WordPress User ID: %d', $user->ID );
1314
1315 $customer_data = [
1316 'email' => $customer_email,
1317 'name' => $customer_name,
1318 'description' => $description,
1319 'metadata' => [
1320 'source' => 'SureForms',
1321 'wp_user_id' => $user->ID,
1322 'wp_username' => $user->user_login,
1323 'wp_user_email' => $user->user_email,
1324 ],
1325 ];
1326
1327 $customer_response = Stripe_Helper::stripe_api_request( 'customers', 'POST', $customer_data );
1328
1329 if ( ! $customer_response['success'] || empty( $customer_response['data']['id'] ) ) {
1330 throw new \Exception( __( 'Failed to create Stripe customer.', 'sureforms' ) );
1331 }
1332
1333 $customer = $customer_response['data'];
1334
1335 // Save customer ID to user meta for future use.
1336 update_user_meta( $user->ID, 'srfm_stripe_customer_id', $customer['id'] );
1337
1338 return $customer['id'];
1339
1340 } catch ( \Exception $e ) {
1341 return false;
1342 }
1343 }
1344
1345 /**
1346 * Create Stripe customer for guest user
1347 *
1348 * @param array<string,string> $post_customer_data Customer data from POST containing 'email' and 'name'.
1349 * @since 2.0.0
1350 * @return string|false Customer ID on success, false on failure.
1351 * @throws \Exception When Stripe API request fails.
1352 */
1353 private function create_stripe_customer_for_guest( $post_customer_data = [] ) {
1354 try {
1355 // Use email and name from POST data.
1356 $customer_email = ! empty( $post_customer_data['email'] ) ? sanitize_email( $post_customer_data['email'] ) : '';
1357 $customer_name = ! empty( $post_customer_data['name'] ) ? sanitize_text_field( $post_customer_data['name'] ) : '';
1358
1359 // Build description with provided email and name.
1360 $description_parts = [];
1361 if ( ! empty( $customer_email ) ) {
1362 $description_parts[] = $customer_email;
1363 }
1364 if ( ! empty( $customer_name ) ) {
1365 $description_parts[] = $customer_name;
1366 }
1367 $description = ! empty( $description_parts ) ? implode( ', ', $description_parts ) : 'Guest User - SureForms Subscription';
1368
1369 $customer_data = [
1370 'description' => $description,
1371 'metadata' => [
1372 'source' => 'SureForms',
1373 'user_type' => 'guest',
1374 'created_at' => current_time( 'mysql' ),
1375 'ip_address' => $this->get_user_ip(),
1376 ],
1377 ];
1378
1379 // Add email if available from POST data.
1380 if ( ! empty( $customer_email ) ) {
1381 $customer_data['email'] = $customer_email;
1382 $customer_data['metadata']['form_email'] = $customer_email;
1383 }
1384
1385 // Add name if available from POST data.
1386 if ( ! empty( $customer_name ) ) {
1387 $customer_data['name'] = $customer_name;
1388 $customer_data['metadata']['form_name'] = $customer_name;
1389 }
1390
1391 $customer_response = Stripe_Helper::stripe_api_request( 'customers', 'POST', $customer_data );
1392
1393 if ( ! $customer_response['success'] || empty( $customer_response['data']['id'] ) ) {
1394 throw new \Exception( __( 'Failed to create Stripe guest customer.', 'sureforms' ) );
1395 }
1396
1397 $customer = $customer_response['data'];
1398
1399 return $customer['id'];
1400
1401 } catch ( \Exception $e ) {
1402 return false;
1403 }
1404 }
1405
1406 /**
1407 * Verify Stripe customer exists
1408 *
1409 * @param string $customer_id Stripe customer ID.
1410 * @since 2.0.0
1411 * @return bool True if customer exists, false otherwise.
1412 */
1413 private function verify_stripe_customer( $customer_id ) {
1414 try {
1415 $customer_response = Stripe_Helper::stripe_api_request( 'customers', 'GET', [], $customer_id );
1416
1417 if ( ! $customer_response['success'] ) {
1418 return false;
1419 }
1420
1421 $customer = $customer_response['data'] ?? [];
1422 /**
1423 * Stripe API returns customer object with the following structure:
1424 * {
1425 * "id": "cus_Syq4hfWO9S5XC2",
1426 * "object": "customer",
1427 * "deleted": true // Present and true only if customer is deleted
1428 * }
1429 *
1430 * When a customer is deleted, the 'deleted' property is set to true.
1431 * Active customers do not have this property or it's set to false.
1432 */
1433
1434 $is_deleted_customer = isset( $customer['deleted'] ) && true === $customer['deleted'];
1435
1436 return ! empty( $customer['id'] ) && false === $is_deleted_customer;
1437 } catch ( \Exception $e ) {
1438 return false;
1439 }
1440 }
1441
1442 /**
1443 * Get user IP address
1444 *
1445 * @since 2.0.0
1446 * @return string User IP address.
1447 */
1448 private function get_user_ip() {
1449 // Check for various IP address headers.
1450 $ip_keys = [ 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR' ];
1451
1452 foreach ( $ip_keys as $key ) {
1453 if ( ! empty( $_SERVER[ $key ] ) ) {
1454 $ip = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
1455 // Handle comma-separated IPs (from proxies).
1456 if ( strpos( $ip, ',' ) !== false ) {
1457 $ip = trim( explode( ',', $ip )[0] );
1458 }
1459 if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
1460 return $ip;
1461 }
1462 }
1463 }
1464
1465 return '127.0.0.1'; // Fallback.
1466 }
1467
1468 /**
1469 * Update payment entry with entry_id
1470 *
1471 * @param string $payment_id Payment intent ID.
1472 * @param int $entry_id Entry ID to update.
1473 * @since 2.0.0
1474 * @return bool True if payment entry updated, false otherwise.
1475 */
1476 private function update_payment_entry_id( $payment_id, $entry_id ) {
1477 // Find the payment entry by transaction_id.
1478 $payment_entries = Payments::get_instance()->get_results(
1479 [ 'transaction_id' => $payment_id ],
1480 'id'
1481 );
1482
1483 if ( ! empty( $payment_entries ) && is_array( $payment_entries ) && isset( $payment_entries[0] ) && is_array( $payment_entries[0] ) && isset( $payment_entries[0]['id'] ) ) {
1484 $payment_entry_id = intval( $payment_entries[0]['id'] );
1485
1486 // Update the payment entry with entry_id using Payments class.
1487 $updated = Payments::update( $payment_entry_id, [ 'entry_id' => $entry_id ] );
1488 return $updated ? true : false;
1489 }
1490
1491 return false;
1492 }
1493
1494 /**
1495 * Update payment entry with entry_id by subscription_id.
1496 *
1497 * Similar to update_payment_entry_id but looks up payment records by subscription_id
1498 * instead of transaction_id. This is useful for subscription payments (PayPal, Stripe)
1499 * where the subscription_id is available before the transaction_id.
1500 *
1501 * @param string $subscription_id The subscription ID from payment gateway.
1502 * @param int $entry_id The form entry ID to link with payment.
1503 * @since 2.4.0
1504 * @return bool True if payment entry updated, false otherwise.
1505 */
1506 private function update_payment_entry_id_by_subscription_id( $subscription_id, $entry_id ) {
1507 // Find the payment entry by subscription_id.
1508 $payment_entries = Payments::get_instance()->get_results(
1509 [ 'subscription_id' => $subscription_id ],
1510 'id'
1511 );
1512
1513 if ( ! empty( $payment_entries ) && is_array( $payment_entries ) && isset( $payment_entries[0] ) && is_array( $payment_entries[0] ) && isset( $payment_entries[0]['id'] ) ) {
1514 $payment_entry_id = intval( $payment_entries[0]['id'] );
1515
1516 // Update the payment entry with entry_id using Payments class.
1517 $updated = Payments::update( $payment_entry_id, [ 'entry_id' => $entry_id ] );
1518 return $updated ? true : false;
1519 }
1520
1521 return false;
1522 }
1523
1524 /**
1525 * Extract customer name and email from form data
1526 *
1527 * Uses the payment block's customerNameField and customerEmailField attributes
1528 * to find the corresponding field slugs, then extracts the values from form data.
1529 *
1530 * @param array<string,mixed> $input_value Input value.
1531 * @since 2.0.0
1532 * @return array{name: string, email: string, customer_id: string} Customer data array.
1533 */
1534 private function extract_customer_data( $input_value ) {
1535 $email = ! empty( $input_value['email'] ) && is_string( $input_value['email'] ) ? sanitize_email( $input_value['email'] ) : '';
1536 return [
1537 'name' => ! empty( $input_value['name'] ) && is_string( $input_value['name'] ) ? sanitize_text_field( $input_value['name'] ) : '',
1538 'email' => $email,
1539 'customer_id' => ! empty( $input_value['customerId'] ) && is_string( $input_value['customerId'] ) ? sanitize_text_field( $input_value['customerId'] ) : '',
1540 ];
1541 }
1542 }
1543