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