PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 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 All 98 releases
sureforms / inc / payments / front-end.php

front-end.php in SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz 2.12.8, at inc/payments/front-end.php

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