PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.3
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.3
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 3 weeks ago payment-helper.php 3 weeks ago payment-history-shortcode.php 3 weeks ago payments.php 4 months ago
front-end.php
1700 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 // Loop through form data to find payment fields.
489 foreach ( $form_data as $field_name => $field_value ) {
490 // Check if field name contains "-lbl-" pattern.
491 if ( strpos( $field_name, '-lbl-' ) === false ) {
492 continue;
493 }
494
495 // Split field name by "-lbl-" delimiter.
496 $name_parts = explode( '-lbl-', $field_name );
497
498 // Check if we have the expected parts.
499 if ( count( $name_parts ) < 2 ) {
500 continue;
501 }
502
503 // Check if the first part starts with "srfm-payment-".
504 if ( ! ( strpos( $name_parts[0], 'srfm-payment-' ) === 0 ) ) {
505 continue;
506 }
507
508 // Value will be in the form of the json string.
509 $payment_value = json_decode( $field_value, true );
510
511 if ( empty( $payment_value ) || ! is_array( $payment_value ) ) {
512 continue;
513 }
514
515 // Extract payment ID - this will be the payment intent ID for one-time payments,
516 // or the payment method ID (result.setupIntent.payment_method) for subscriptions.
517 $payment_id = ! empty( $payment_value['paymentId'] ) ? $payment_value['paymentId'] : '';
518 $setup_intent = ! empty( $payment_value['setupIntent'] ) ? $payment_value['setupIntent'] : '';
519
520 // introduced during the paypal implementation and in the other payment methods, we use the transactionId to verify the payment.
521 $transaction_id = ! empty( $payment_value['transactionId'] ) ? $payment_value['transactionId'] : '';
522
523 if ( empty( $payment_id ) && empty( $setup_intent ) && empty( $transaction_id ) ) {
524 continue;
525 }
526
527 $block_id = ! empty( $payment_value['blockId'] ) ? $payment_value['blockId'] : '';
528 $payment_type = ! empty( $payment_value['paymentType'] ) ? $payment_value['paymentType'] : '';
529
530 $payment_method = ! empty( $payment_value['paymentMethod'] ) ? $payment_value['paymentMethod'] : 'stripe';
531
532 if ( empty( $block_id ) || empty( $payment_type ) ) {
533 continue;
534 }
535
536 if ( 'stripe' === $payment_method ) {
537 $payment_response = $this->verify_stripe_payment( $payment_value, $payment_id, $block_id, $form_data, $payment_type );
538 } else {
539 $payment_response = apply_filters(
540 'srfm_verify_payment_value',
541 [
542 'payment_value' => $payment_value,
543 'class' => $this,
544 'block_id' => $block_id,
545 'form_data' => $form_data,
546 ]
547 );
548 }
549
550 if ( ! empty( $payment_response ) && isset( $payment_response['payment_id'] ) ) {
551 // Modify the form data with the payment ID.
552 $form_data[ $field_name ] = $payment_response['payment_id'];
553
554 $verified_block_ids[ Helper::get_string_value( $block_id ) ] = true;
555 }
556 }
557
558 if ( ! empty( $payment_response ) && isset( $payment_response['error'] ) ) {
559 return array_merge( $form_data, $payment_response );
560 }
561
562 return $this->require_verified_payments( $form_data, $verified_block_ids );
563 }
564
565 /**
566 * Verify Stripe payment
567 *
568 * @param array<mixed> $payment_value Payment value.
569 * @param string $payment_id Payment ID.
570 * @param string $block_id Block ID.
571 * @param array<mixed> $form_data Form data.
572 * @param string $payment_type Payment type.
573 * @since 2.0.0
574 * @return array<mixed> Payment response.
575 */
576 public function verify_stripe_payment( $payment_value, $payment_id, $block_id, $form_data, $payment_type ) {
577 if ( 'stripe-subscription' === $payment_type ) {
578
579 /**
580 * For subscription payments, we receive the following data structure:
581 * - paymentMethod: Stripe payment method ID (e.g., "pm_1S82ZkHqS7N4oFQhruGV67u1")
582 * - setupIntent: Stripe setup intent ID (e.g., "seti_1S82ZkHqS7N4oFQhPa4LYPYg")
583 * - subscriptionId: Stripe subscription ID (e.g., "sub_1S82ZiHqS7N4oFQhPGhm2eNR")
584 * - customerId: Stripe customer ID (e.g., "cus_T4Apjla33GlYAk")
585 * - blockId: Form block identifier (e.g., "be920796")
586 * - paymentType: Payment type identifier ("stripe-subscription")
587 * - status: Payment status ("succeeded")
588 */
589 $payment_response = $this->verify_stripe_subscription_intent_and_save( $payment_value, $block_id, $form_data );
590 } else {
591 $payment_response = $this->verify_stripe_payment_intent_and_save( $payment_value, $payment_id, $block_id, $form_data );
592 }
593
594 return ! empty( $payment_response ) && is_array( $payment_response ) ? $payment_response : [];
595 }
596
597 /**
598 * Simplified subscription verification using simple-stripe-subscriptions approach
599 *
600 * @param array<mixed> $subscription_value Subscription data from frontend.
601 * @param string $block_id Block ID.
602 * @param array<mixed> $form_data Form data.
603 * @since 2.0.0
604 * @return void|array<mixed> True if subscription is verified and saved successfully.
605 */
606 public function verify_stripe_subscription_intent_and_save( $subscription_value, $block_id, $form_data ) {
607 $subscription_id = ! empty( $subscription_value['subscriptionId'] ) && is_string( $subscription_value['subscriptionId'] ) ? $subscription_value['subscriptionId'] : '';
608
609 if ( empty( $subscription_id ) ) {
610 return [
611 'error' => __( 'Subscription ID not found.', 'sureforms' ),
612 ];
613 }
614
615 $customer_id = ! empty( $subscription_value['customerId'] ) ? $subscription_value['customerId'] : '';
616 $setup_intent_id = ! empty( $subscription_value['setupIntent'] ) && is_string( $subscription_value['setupIntent'] ) ? $subscription_value['setupIntent'] : '';
617
618 // Verify payment intent with comprehensive validation including form data.
619 // BOTH MODE: pass 'subscription' so per-type amount config is used for verification.
620 $verification_result = Payment_Helper::verify_payment_intent( $block_id, $setup_intent_id, $form_data, 'subscription' );
621
622 if ( false === $verification_result['valid'] ) {
623 return [
624 'error' => $verification_result['message'],
625 ];
626 }
627
628 if ( empty( $customer_id ) ) {
629 return [
630 'error' => __( 'Customer ID not found for the payment.', 'sureforms' ),
631 ];
632 }
633
634 try {
635 // Get payment mode and secret key.
636 $payment_mode = Stripe_Helper::get_stripe_mode();
637 $secret_key = Stripe_Helper::get_stripe_secret_key();
638
639 if ( empty( $secret_key ) ) {
640 return [
641 'error' => __( 'Stripe secret key not found.', 'sureforms' ),
642 ];
643 }
644
645 // Update subscription with payment method from setup intent if available.
646 $paid_invoice = [];
647 if ( ! empty( $setup_intent_id ) ) {
648 try {
649 $setup_intent_response = Stripe_Helper::stripe_api_request(
650 'setup_intents',
651 'GET',
652 [],
653 $setup_intent_id
654 );
655
656 if ( ! $setup_intent_response['success'] ) {
657 return [
658 'error' => $setup_intent_response['error']['message'] ?? __( 'Failed to retrieve setup intent.', 'sureforms' ),
659 ];
660 }
661
662 $setup_intent = $setup_intent_response['data'];
663
664 if ( ( isset( $setup_intent['payment_method'] ) && ! empty( $setup_intent['payment_method'] ) && is_string( $setup_intent['payment_method'] ) ) ) {
665
666 // Prepare subscription update data.
667 $subscription_update_data = [
668 'default_payment_method' => $setup_intent['payment_method'],
669 'collection_method' => 'charge_automatically',
670 ];
671
672 // Override interval + billing cycles with the values stored in the
673 // form's block config. These come from the data attributes the
674 // server itself rendered, so they cannot legitimately diverge from
675 // the admin's saved subscriptionPlan. Trusting the submitted values
676 // would let an attacker DevTools-flip cancel_at to 'ongoing'.
677 $form_id_for_config = isset( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? intval( $form_data['form-id'] ) : 0;
678 if ( $form_id_for_config > 0 && ! empty( $block_id ) ) {
679 $stored_block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id_for_config );
680 if ( is_array( $stored_block_config ) && isset( $stored_block_config[ $block_id ] ) && is_array( $stored_block_config[ $block_id ] ) ) {
681 $stored_payment_config = $stored_block_config[ $block_id ];
682 if ( isset( $stored_payment_config['subscription_interval'] ) ) {
683 $subscription_value['subscriptionInterval'] = $stored_payment_config['subscription_interval'];
684 }
685 if ( isset( $stored_payment_config['subscription_billing_cycles'] ) ) {
686 $subscription_value['subscriptionBillingCycles'] = $stored_payment_config['subscription_billing_cycles'];
687 }
688 }
689 }
690
691 // Calculate cancel_at timestamp based on billing cycles and interval.
692 $cancel_at = $this->prepare_cancel_at( $subscription_value );
693 if ( ! empty( $cancel_at ) ) {
694 $subscription_update_data['cancel_at'] = $cancel_at;
695 }
696
697 $subscription_update_response = Stripe_Helper::stripe_api_request(
698 'subscriptions',
699 'POST',
700 $subscription_update_data,
701 $subscription_id
702 );
703
704 if ( ! $subscription_update_response['success'] ) {
705 return [
706 'error' => $subscription_update_response['error']['message'] ?? __( 'Failed to update subscription.', 'sureforms' ),
707 ];
708 }
709
710 $subscription_update = $subscription_update_response['data'];
711
712 if ( empty( $subscription_update['latest_invoice'] ) ) {
713 return [
714 'error' => __( 'Latest invoice not found on subscription.', 'sureforms' ),
715 ];
716 }
717
718 $invoice_response = Stripe_Helper::stripe_api_request(
719 'invoices',
720 'GET',
721 [],
722 $subscription_update['latest_invoice']
723 );
724
725 if ( ! $invoice_response['success'] ) {
726 return [
727 'error' => $invoice_response['error']['message'] ?? __( 'Failed to retrieve invoice.', 'sureforms' ),
728 ];
729 }
730
731 $invoice = $invoice_response['data'];
732
733 // Ensure invoice auto-advance is enabled for recurring payments.
734 // This tells Stripe to automatically finalize and charge future invoices.
735 if ( empty( $invoice['auto_advance'] ) && ! empty( $invoice['id'] ) && is_string( $invoice['id'] ) ) {
736 Stripe_Helper::stripe_api_request(
737 'invoices',
738 'POST',
739 [ 'auto_advance' => true ],
740 $invoice['id']
741 );
742 }
743
744 // Extract payment intent from the invoice.
745 $payment_intent_id = isset( $invoice['payment_intent'] ) && ! empty( $invoice['payment_intent'] ) && is_string( $invoice['payment_intent'] ) ? $invoice['payment_intent'] : '';
746
747 if ( empty( $payment_intent_id ) ) {
748 return [
749 'error' => __( 'Payment intent not found on invoice.', 'sureforms' ),
750 ];
751 }
752
753 // Confirm the payment intent with payment method.
754 // This completes the payment and activates the subscription.
755 $paid_invoice_response = Stripe_Helper::stripe_api_request(
756 'payment_intents',
757 'POST',
758 [ 'payment_method' => $setup_intent['payment_method'] ],
759 $payment_intent_id . '/confirm'
760 );
761
762 if ( ! $paid_invoice_response['success'] ) {
763 return [
764 'error' => $paid_invoice_response['error']['message'] ?? __( 'Failed to confirm payment.', 'sureforms' ),
765 ];
766 }
767
768 $paid_invoice = $paid_invoice_response['data'];
769
770 // Get the subscription.
771 $subscription_response = Stripe_Helper::stripe_api_request(
772 'subscriptions',
773 'GET',
774 [],
775 $subscription_id
776 );
777
778 if ( ! $subscription_response['success'] ) {
779 return [
780 'error' => $subscription_response['error']['message'] ?? __( 'Failed to retrieve subscription.', 'sureforms' ),
781 ];
782 }
783
784 $subscription = $subscription_response['data'];
785 }
786 } catch ( \Exception $e ) {
787 return [
788 'error' => $e->getMessage(),
789 ];
790 }
791 }
792
793 if ( empty( $subscription ) ) {
794 return [
795 'error' => __( 'Subscription not found for the payment.', 'sureforms' ),
796 ];
797 }
798
799 // Use simple-stripe-subscriptions validation logic - check if subscription is in good state.
800 $is_subscription_active = in_array( $subscription['status'], [ 'active', 'trialing' ], true );
801 $final_status = $is_subscription_active ? 'active' : 'failed';
802
803 $amount = isset( $paid_invoice['amount'] ) && ! empty( $paid_invoice['amount'] ) ? $paid_invoice['amount'] : 0;
804 $currency = isset( $paid_invoice['currency'] ) && ! empty( $paid_invoice['currency'] ) ? $paid_invoice['currency'] : 'usd';
805 $form_id = isset( $form_data['form-id'] ) && ! empty( $form_data['form-id'] ) ? $form_data['form-id'] : 0;
806 $subscription_status = isset( $subscription['status'] ) && ! empty( $subscription['status'] ) && is_string( $subscription['status'] ) ? $subscription['status'] : '';
807
808 // Defense-in-depth: re-validate the amount Stripe actually invoiced against the form's
809 // server-side configuration. The recurring price is the invoiced amount, so an
810 // underpayment here would otherwise repeat every billing cycle.
811 $charged_amount = Stripe_Helper::amount_from_stripe_format( is_numeric( $amount ) ? (int) $amount : 0, is_string( $currency ) ? $currency : 'usd' );
812 $charge_validation = Payment_Helper::validate_amount_against_config( $block_id, is_numeric( $form_id ) ? (int) $form_id : 0, $form_data, $charged_amount, 'subscription' );
813 if ( false === $charge_validation['valid'] ) {
814 return [
815 'error' => $charge_validation['message'],
816 ];
817 }
818
819 $invoice_status = isset( $paid_invoice['status'] ) && ! empty( $paid_invoice['status'] ) && is_string( $paid_invoice['status'] ) ? $paid_invoice['status'] : '';
820
821 // Extract customer data.
822 $customer_data = $this->extract_customer_data( $subscription_value );
823
824 // Extract charge ID from the first payment intent for refund purposes.
825 // For subscriptions, we store the charge ID in transaction_id so refunds can be processed.
826 $charge_id = '';
827 if ( ! empty( $paid_invoice['latest_charge'] ) && is_string( $paid_invoice['latest_charge'] ) ) {
828 $charge_id = $paid_invoice['latest_charge'];
829 } elseif ( ! empty( $paid_invoice['charges']['data'][0]['id'] ) && is_string( $paid_invoice['charges']['data'][0]['id'] ) ) {
830 $charge_id = $paid_invoice['charges']['data'][0]['id'];
831 }
832
833 // Use charge ID as transaction_id if available, otherwise fall back to subscription ID.
834 $transaction_id = ! empty( $charge_id ) ? $charge_id : $subscription_id;
835
836 // Send payment data to middleware for analytics.
837 if ( ! empty( $charge_id ) ) {
838 Stripe_Helper::intersect_payment( $charge_id, $secret_key, '', 'SureForms' );
839 }
840
841 // Prepare minimal subscription data for database.
842 $entry_data = [
843 'form_id' => $form_id,
844 'block_id' => $block_id,
845 'status' => $final_status,
846 'total_amount' => Stripe_Helper::amount_from_stripe_format( $amount, $currency ),
847 'currency' => $currency,
848 'entry_id' => 0,
849 'gateway' => 'stripe',
850 'type' => 'subscription',
851 'mode' => $payment_mode,
852 'transaction_id' => $transaction_id,
853 'customer_id' => $customer_id,
854 'subscription_id' => $subscription_id,
855 'subscription_status' => $subscription_status,
856 'srfm_txn_id' => '', // Will be updated after getting payment entry ID.
857 'customer_email' => $customer_data['email'],
858 'customer_name' => $customer_data['name'],
859 'payment_data' => [
860 'initial_invoice' => $paid_invoice,
861 'subscription' => $subscription,
862 'payment_value' => $subscription_value,
863 ],
864 ];
865
866 // Get user ID if logged in.
867 $user_id = get_current_user_id();
868 $user_info = $user_id > 0
869 /* translators: %d: User ID */
870 ? sprintf( __( 'User ID: %d', 'sureforms' ), $user_id )
871 /* translators: Message for guest user in payment logs */
872 : __( 'Guest User', 'sureforms' );
873
874 // If invoice is not paid then we need to set the status in the subscription log and return error.
875 $paid_invoice_log = '';
876 if ( 'paid' !== $invoice_status ) {
877 /* translators: %s: Invoice status */
878 $paid_invoice_log = sprintf( __( 'Invoice Status: %s', 'sureforms' ), $invoice_status );
879 }
880
881 // Add simple log entry.
882 $entry_data['log'] = [
883 [
884 /* translators: Title for subscription verification log */
885 'title' => __( 'Subscription Verification', 'sureforms' ),
886 'created_at' => current_time( 'mysql' ),
887 'messages' => [
888 /* translators: %s: Subscription ID */
889 sprintf( __( 'Subscription ID: %s', 'sureforms' ), $subscription_id ),
890 /* translators: %s: Payment Gateway */
891 sprintf( __( 'Payment Gateway: %s', 'sureforms' ), 'Stripe' ),
892 /* translators: %s: Payment Intent ID */
893 sprintf( __( 'Payment Intent ID: %s', 'sureforms' ), $setup_intent_id ),
894 /* translators: %s: Charge ID */
895 sprintf( __( 'Charge ID: %s', 'sureforms' ), ! empty( $charge_id ) ? $charge_id : 'N/A' ),
896 /* translators: %s: Subscription Status */
897 sprintf( __( 'Subscription Status: %s', 'sureforms' ), $subscription_status ),
898 /* translators: %s: Customer ID */
899 sprintf( __( 'Customer ID: %s', 'sureforms' ), $customer_id ),
900 /* translators: 1: Amount, 2: Currency */
901 sprintf( __( 'Amount: %1$s %2$s', 'sureforms' ), number_format( Stripe_Helper::amount_from_stripe_format( $amount, $currency ), 2 ), strtoupper( $currency ) ),
902 $user_info,
903 /* translators: %s: Payment mode (e.g. Live or Test) */
904 sprintf( __( 'Mode: %s', 'sureforms' ), ucfirst( $payment_mode ) ),
905 $paid_invoice_log,
906 ],
907 ],
908 ];
909
910 // Save to database.
911 $payment_entry_id = Payments::add( $entry_data );
912
913 if ( $payment_entry_id ) {
914 // Generate unique payment ID using the auto-increment ID and update the entry.
915 $unique_payment_id = Stripe_Helper::generate_unique_payment_id( $payment_entry_id );
916 // For initial subscription, set parent_subscription_id to itself (it's the parent).
917 Payments::update(
918 $payment_entry_id,
919 [
920 'srfm_txn_id' => $unique_payment_id,
921 'parent_subscription_id' => $payment_entry_id,
922 ]
923 );
924
925 // Store in static array for later entry linking.
926 $this->stripe_payment_entries[] = [
927 'payment_id' => $transaction_id,
928 'block_id' => $block_id,
929 'form_id' => $form_id,
930 ];
931
932 return [
933 'payment_id' => $payment_entry_id,
934 ];
935 }
936 } catch ( \Exception $e ) {
937 return [
938 'error' => empty( $e->getMessage() ) ? __( 'Failed to verify subscription.', 'sureforms' ) : $e->getMessage(),
939 ];
940 }
941 }
942
943 /**
944 * Prepare cancel_at timestamp for subscription based on billing cycles and interval.
945 *
946 * @param array<string,mixed> $input_value Array containing subscriptionBillingCycles and subscriptionInterval.
947 * @since 2.0.0
948 * @return int|false|null Unix timestamp for cancel_at, or null if not applicable.
949 */
950 public function prepare_cancel_at( $input_value ) {
951 $subscription_billing_cycles = ! empty( $input_value['subscriptionBillingCycles'] ) ? $input_value['subscriptionBillingCycles'] : 0;
952 $subscription_interval = ! empty( $input_value['subscriptionInterval'] ) ? $input_value['subscriptionInterval'] : '';
953
954 // Return null if billing cycles is 0, empty, or equals 'ongoing'.
955 if ( empty( $subscription_billing_cycles ) || 'ongoing' === $subscription_billing_cycles || ! is_numeric( $subscription_billing_cycles ) ) {
956 return null;
957 }
958
959 // Convert billing cycles to integer.
960 $billing_cycles = (int) $subscription_billing_cycles;
961
962 // Return null if billing cycles is less than or equal to 0.
963 if ( $billing_cycles <= 0 ) {
964 return null;
965 }
966
967 // Calculate cancel_at timestamp based on interval.
968 $current_time = time();
969 $cancel_at = null;
970
971 switch ( $subscription_interval ) {
972 case 'day':
973 // Add days: cycles * 1 day.
974 $cancel_at = strtotime( "+{$billing_cycles} days", $current_time );
975 break;
976
977 case 'week':
978 // Add weeks: cycles * 7 days.
979 $cancel_at = strtotime( "+{$billing_cycles} weeks", $current_time );
980 break;
981
982 case 'month':
983 // Add months: cycles * 1 month.
984 $cancel_at = strtotime( "+{$billing_cycles} months", $current_time );
985 break;
986
987 case 'quarter':
988 // Add quarters: cycles * 3 months.
989 $total_months = $billing_cycles * 3;
990 $cancel_at = strtotime( "+{$total_months} months", $current_time );
991 break;
992
993 case 'year':
994 // Add years: cycles * 1 year.
995 $cancel_at = strtotime( "+{$billing_cycles} years", $current_time );
996 break;
997
998 default:
999 // Invalid interval, return null.
1000 return null;
1001 }
1002
1003 return $cancel_at;
1004 }
1005
1006 /**
1007 * Handle form submit and update payment entries with entry_id
1008 *
1009 * This function is called after a form submission to link the created entry
1010 * with any associated Stripe payment records. It matches payment entries
1011 * by form_id and updates them with the newly created entry_id.
1012 *
1013 * @param array<string,mixed> $form_submit_response The form submission response containing entry_id and form_id.
1014 * @since 2.0.0
1015 * @return void
1016 */
1017 public function update_payment_entry_id_form_submit( $form_submit_response ) {
1018 // Check if entry_id exists in the form_submit_response.
1019 if ( ! empty( $form_submit_response['entry_id'] ) && ! empty( $this->stripe_payment_entries ) ) {
1020 $entry_id = is_numeric( $form_submit_response['entry_id'] ) ? intval( $form_submit_response['entry_id'] ) : 0;
1021
1022 // Loop through stored payment entries to update with entry_id.
1023 foreach ( $this->stripe_payment_entries as $stripe_payment_entry ) {
1024 if ( ! empty( $stripe_payment_entry['payment_id'] ) && ! empty( $stripe_payment_entry['form_id'] ) ) {
1025 // Check if form_id matches.
1026 $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;
1027 $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;
1028
1029 $payment_id = is_string( $stripe_payment_entry['payment_id'] ) ? sanitize_text_field( $stripe_payment_entry['payment_id'] ) : '';
1030
1031 if ( ! empty( $stored_form_id ) && $stored_form_id === $response_form_id ) {
1032 // Update the payment entry with the entry_id.
1033 $this->update_payment_entry_id( $payment_id, $entry_id );
1034 }
1035 } elseif ( ! empty( $stripe_payment_entry['subscription_id'] ) && ! empty( $stripe_payment_entry['form_id'] ) ) {
1036 // Check if form_id matches for subscription-based payment.
1037 $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;
1038 $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;
1039
1040 $subscription_id = is_string( $stripe_payment_entry['subscription_id'] ) ? sanitize_text_field( $stripe_payment_entry['subscription_id'] ) : '';
1041
1042 if ( ! empty( $stored_form_id ) && $stored_form_id === $response_form_id ) {
1043 // Update the payment entry with the entry_id using subscription_id.
1044 $this->update_payment_entry_id_by_subscription_id( $subscription_id, $entry_id );
1045 }
1046 }
1047 }
1048 }
1049 }
1050
1051 /**
1052 * Add payment entry for later linking with form submission.
1053 *
1054 * Allows payment gateways (Stripe, PayPal, etc.) to register their entries
1055 * for linking with form submissions. The entries are stored in memory and
1056 * linked when the form is successfully submitted.
1057 *
1058 * @param array<string,mixed> $entry Payment entry containing payment_id, block_id, and form_id.
1059 * @since 2.0.0
1060 * @return void
1061 */
1062 public function add_payment_entry_for_linking( $entry ) {
1063 if ( ! empty( $entry ) && is_array( $entry ) ) {
1064 $this->stripe_payment_entries[] = $entry;
1065 }
1066 }
1067
1068 /**
1069 * Filter callback to determine if a payment field should be included in all data output.
1070 *
1071 * Excludes payment-related fields (like Stripe payment blocks) from being
1072 * rendered in submission summaries, emails, exports, etc., as these fields
1073 * serve as backend tracking data instead of user input.
1074 *
1075 * @since 2.0.0
1076 *
1077 * @param bool $should_add_field_row Whether this row should be output.
1078 * @param array<string | mixed> $args Args describing the field row. Should contain 'block_name'.
1079 * @return bool False for payment blocks; otherwise, original filter value.
1080 */
1081 public function skip_payment_fields_from_all_data( $should_add_field_row, $args ) {
1082 // Check if the block is a payment block by inspecting the block name.
1083 $block_name = isset( $args['block_name'] ) && is_string( $args['block_name'] ) ? $args['block_name'] : '';
1084 if ( 'srfm-payment' === $block_name ) {
1085 return false;
1086 }
1087 return $should_add_field_row;
1088 }
1089
1090 /**
1091 * Skip payment fields from submission data.
1092 *
1093 * This function checks if a field is a payment field by validating its key prefix.
1094 * Payment fields have keys that start with 'srfm-payment-' and should be skipped
1095 * from certain data operations.
1096 *
1097 * @param bool $default_value The default skip value.
1098 * @param array<mixed> $args Field arguments containing 'key', 'slug', and 'value'.
1099 * @since 2.0.0
1100 * @return bool True if the field should be skipped (is a payment field), false otherwise.
1101 */
1102 public function skip_payment_fields_from_submission_data( $default_value, $args ) {
1103 // Validate that args is an array and has the 'key' parameter.
1104 if ( ! is_array( $args ) || ! isset( $args['key'] ) || ! is_string( $args['key'] ) ) {
1105 return $default_value;
1106 }
1107
1108 // Check if the key starts with 'srfm-payment-' to identify payment fields.
1109 if ( 0 === strpos( $args['key'], 'srfm-payment-' ) ) {
1110 return true;
1111 }
1112
1113 return $default_value;
1114 }
1115
1116 /**
1117 * Skip payment fields from sample data.
1118 *
1119 * This function determines if a field associated with a "srfm/payment" block
1120 * should be skipped when processing sample data. If the provided arguments
1121 * specify a block with the name 'srfm/payment', the function returns true to
1122 * indicate that the field should be skipped. Otherwise, it returns the given
1123 * default value.
1124 *
1125 * @param bool $default_value The default skip value.
1126 * @param array<mixed> $args Field arguments containing at least 'block_name'.
1127 * @since 2.0.0
1128 * @return bool True if the field should be skipped (is a payment block), false otherwise.
1129 */
1130 public function skip_payment_fields_from_sample_data( $default_value, $args ) {
1131 if ( ! is_array( $args ) || ! isset( $args['block_name'] ) || ! is_string( $args['block_name'] ) ) {
1132 return $default_value;
1133 }
1134
1135 if ( 'srfm/payment' === $args['block_name'] ) {
1136 return true;
1137 }
1138
1139 return $default_value;
1140 }
1141
1142 /**
1143 * Fail closed when a form's payment field carries no verified payment.
1144 *
1145 * SECURITY INVARIANT — the payment requirement must come from the stored form
1146 * config, never from the submitted payload. Verification driven by what the client
1147 * sent can only confirm the payments it was given; it cannot know about one that
1148 * was never presented. Deriving the requirement from the saved form keeps a
1149 * submission that carries no payment field from being treated as complete.
1150 *
1151 * @param array<mixed> $form_data Form data.
1152 * @param array<string,true> $verified_block_ids Payment block IDs verified on this submission.
1153 *
1154 * @since 2.12.3
1155 * @return array<mixed> Form data, carrying an `error` key when a payment is missing.
1156 */
1157 private function require_verified_payments( $form_data, $verified_block_ids ) {
1158 // absint() to match the normalisation the submit token was verified against.
1159 $form_id = isset( $form_data['form-id'] ) ? absint( Helper::get_string_value( $form_data['form-id'] ) ) : 0;
1160
1161 if ( 0 === $form_id ) {
1162 return $form_data;
1163 }
1164
1165 foreach ( Payment_Helper::get_required_payment_block_ids( $form_id ) as $block_id ) {
1166 if ( isset( $verified_block_ids[ Helper::get_string_value( $block_id ) ] ) ) {
1167 continue;
1168 }
1169
1170 $form_data['error'] = Payment_Helper::get_error_message_by_key( 'payment_required' );
1171
1172 break;
1173 }
1174
1175 return $form_data;
1176 }
1177
1178 /**
1179 * Verify payment intent status
1180 *
1181 * @param array<mixed> $payment_value Payment value.
1182 * @param string $payment_id Payment ID.
1183 * @param string $block_id Block ID.
1184 * @param array<mixed> $form_data Form data.
1185 *
1186 * @since 2.0.0
1187 * @return void|array<mixed>
1188 */
1189 private function verify_stripe_payment_intent_and_save( $payment_value, $payment_id, $block_id, $form_data ) {
1190 try {
1191 $payment_mode = Stripe_Helper::get_stripe_mode();
1192 $secret_key = Stripe_Helper::get_stripe_secret_key();
1193
1194 if ( empty( $secret_key ) ) {
1195 return [
1196 'error' => __( 'Stripe secret key not found.', 'sureforms' ),
1197 ];
1198 }
1199
1200 // Verify payment intent with comprehensive validation including form data.
1201 // BOTH MODE: pass 'one-time' so per-type amount config is used for verification.
1202 $verification_result = Payment_Helper::verify_payment_intent( $block_id, $payment_id, $form_data, 'one-time' );
1203
1204 if ( false === $verification_result['valid'] ) {
1205 return [
1206 'error' => $verification_result['message'],
1207 ];
1208 }
1209
1210 $get_stripe_account_id = Stripe_Helper::get_stripe_account_id();
1211
1212 // Retrieve confirmed payment intent status.
1213 $retrieve_body = apply_filters(
1214 'srfm_retrieve_payment_intent_data',
1215 [
1216 'secret_key' => $secret_key,
1217 'payment_intent_id' => $payment_id,
1218 'stripe_account_id' => $get_stripe_account_id,
1219 'plugin_name' => 'SureForms',
1220 ]
1221 );
1222
1223 $retrieve_body = wp_json_encode( $retrieve_body );
1224 $retrieve_body = is_string( $retrieve_body ) ? $retrieve_body : '';
1225 $retrieve_body = base64_encode( $retrieve_body ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1226
1227 if ( empty( $retrieve_body ) ) {
1228 return [
1229 'error' => __( 'Failed to retrieve payment intent.', 'sureforms' ),
1230 ];
1231 }
1232
1233 // Call middleware retrieve endpoint to get confirmed payment intent.
1234 $retrieve_response = wp_remote_post(
1235 Stripe_Helper::middle_ware_base_url() . 'payment-intent/capture',
1236 [
1237 'timeout' => 60,
1238 'body' => $retrieve_body,
1239 'headers' => [
1240 'Content-Type' => 'application/json',
1241 ],
1242 ]
1243 );
1244
1245 if ( is_wp_error( $retrieve_response ) ) {
1246 return [
1247 'error' => __( 'Failed to retrieve payment intent.', 'sureforms' ),
1248 ];
1249 }
1250
1251 $confirmed_payment_intent = json_decode( wp_remote_retrieve_body( $retrieve_response ), true );
1252
1253 if ( empty( $confirmed_payment_intent ) && ! is_array( $confirmed_payment_intent ) ) {
1254 return [
1255 'error' => __( 'Failed to retrieve payment intent.', 'sureforms' ),
1256 ];
1257 }
1258
1259 // Strict type validation and array check to resolve phpstan errors.
1260 if ( is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['status'] ) && 'error' === $confirmed_payment_intent['status'] ) {
1261 return [
1262 'error' => __( 'Failed to retrieve payment intent.', 'sureforms' ),
1263 ];
1264 }
1265
1266 // Check if payment was actually confirmed successfully, safely.
1267 $confirmed_status = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['status'] ) ? (string) $confirmed_payment_intent['status'] : '';
1268 if ( ! in_array( $confirmed_status, [ 'succeeded', 'requires_capture' ], true ) ) {
1269 return [
1270 'error' => __( 'Payment was not confirmed successfully.', 'sureforms' ),
1271 ];
1272 }
1273
1274 $entry_data = [];
1275
1276 $form_id = isset( $form_data['form-id'] ) && ! empty( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? intval( $form_data['form-id'] ) : 0;
1277 $confirm_payment_status = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['status'] ) && ! empty( $confirmed_payment_intent['status'] ) ? (string) $confirmed_payment_intent['status'] : '';
1278 $confirm_payment_amount = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['amount'] ) && ! empty( $confirmed_payment_intent['amount'] ) ? intval( $confirmed_payment_intent['amount'] ) : 0;
1279 $confirm_payment_currency = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['currency'] ) && ! empty( $confirmed_payment_intent['currency'] ) ? (string) $confirmed_payment_intent['currency'] : 'usd';
1280 $confirm_payment_id = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['id'] ) && ! empty( $confirmed_payment_intent['id'] ) ? (string) $confirmed_payment_intent['id'] : '';
1281
1282 // Defense-in-depth: re-validate the amount Stripe actually charged against the form's
1283 // server-side configuration — not only the amount recorded when the intent was created.
1284 $charged_amount = Stripe_Helper::amount_from_stripe_format( $confirm_payment_amount, $confirm_payment_currency );
1285 $charge_validation = Payment_Helper::validate_amount_against_config( $block_id, $form_id, $form_data, $charged_amount, 'one-time' );
1286 if ( false === $charge_validation['valid'] ) {
1287 return [
1288 'error' => $charge_validation['message'],
1289 ];
1290 }
1291
1292 // Extract customer data.
1293 $customer_data = $this->extract_customer_data( $payment_value );
1294
1295 // update payment status and save to the payment entries table.
1296 $entry_data['form_id'] = $form_id;
1297 $entry_data['block_id'] = $block_id;
1298 $entry_data['status'] = $confirm_payment_status;
1299 $entry_data['total_amount'] = Stripe_Helper::amount_from_stripe_format( $confirm_payment_amount, $confirm_payment_currency );
1300 $entry_data['currency'] = $confirm_payment_currency;
1301 $entry_data['entry_id'] = 0;
1302 $entry_data['gateway'] = 'stripe';
1303 $entry_data['type'] = 'payment';
1304 $entry_data['mode'] = $payment_mode;
1305 $entry_data['transaction_id'] = $confirm_payment_id;
1306 $entry_data['srfm_txn_id'] = ''; // Will be updated after getting payment entry ID.
1307 $entry_data['customer_email'] = $customer_data['email'];
1308 $entry_data['customer_name'] = $customer_data['name'];
1309 $entry_data['customer_id'] = $customer_data['customer_id'];
1310 $entry_data['payment_data'] = [
1311 'payment_value' => $payment_value,
1312 ];
1313
1314 // Get user ID if logged in.
1315 $user_id = get_current_user_id();
1316 /* translators: %d: User ID */
1317 $user_info = $user_id > 0 ? sprintf( __( 'User ID: %d', 'sureforms' ), $user_id ) : __( 'Guest User', 'sureforms' );
1318
1319 // Add initial log entry for audit trail.
1320 $entry_data['log'] = [
1321 [
1322 'title' => __( 'Payment Verification', 'sureforms' ),
1323 'created_at' => current_time( 'mysql' ),
1324 'messages' => [
1325 /* translators: %s: Stripe transaction ID */
1326 sprintf( __( 'Transaction ID: %s', 'sureforms' ), $confirm_payment_id ),
1327 /* translators: %s: Payment gateway name. */
1328 sprintf( __( 'Payment Gateway: %s', 'sureforms' ), 'Stripe' ),
1329 /* translators: %1$s: amount, %2$s: currency. */
1330 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 ) ),
1331 /* translators: %s: payment status */
1332 sprintf( __( 'Status: %s', 'sureforms' ), ucfirst( str_replace( '_', ' ', $confirm_payment_status ) ) ),
1333 $user_info,
1334 /* translators: %s: payment mode */
1335 sprintf( __( 'Mode: %s', 'sureforms' ), ucfirst( $payment_mode ) ),
1336 ],
1337 ],
1338 ];
1339
1340 $get_payment_entry_id = Payments::add( $entry_data );
1341
1342 if ( $get_payment_entry_id ) {
1343 // Generate unique payment ID using the auto-increment ID and update the entry.
1344 $unique_payment_id = Stripe_Helper::generate_unique_payment_id( $get_payment_entry_id );
1345 Payments::update( $get_payment_entry_id, [ 'srfm_txn_id' => $unique_payment_id ] );
1346
1347 $add_in_static_value = [
1348 'payment_id' => $confirm_payment_id,
1349 'block_id' => $block_id,
1350 'form_id' => $form_id,
1351 ];
1352
1353 $this->stripe_payment_entries[] = $add_in_static_value;
1354
1355 // Clean up transient after successful verification to prevent reuse.
1356 Payment_Helper::delete_payment_intent_metadata( $block_id, $payment_id );
1357
1358 return [
1359 'payment_id' => $get_payment_entry_id,
1360 ];
1361 }
1362 } catch ( \Exception $e ) {
1363 return [
1364 'error' => $e->getMessage(),
1365 ];
1366 }
1367 }
1368
1369 /**
1370 * Get or create Stripe customer
1371 *
1372 * @param array<string,string> $customer_data Customer data containing 'email' and 'name' from POST.
1373 * @since 2.0.0
1374 * @return string|false Customer ID on success, false on failure.
1375 */
1376 private function get_or_create_stripe_customer( $customer_data = [] ) {
1377 $current_user = wp_get_current_user();
1378
1379 if ( $current_user->ID > 0 ) {
1380 // Logged-in user - check for existing customer ID in user meta.
1381 $customer_id = get_user_meta( $current_user->ID, 'srfm_stripe_customer_id', true );
1382
1383 if ( ! empty( $customer_id ) && is_string( $customer_id ) && $this->verify_stripe_customer( $customer_id ) ) {
1384 return $customer_id;
1385 }
1386
1387 // Create new customer for logged-in user.
1388 return $this->create_stripe_customer_for_user( $current_user, $customer_data );
1389 }
1390
1391 // Non-logged-in user - create temporary customer.
1392 return $this->create_stripe_customer_for_guest( $customer_data );
1393 }
1394
1395 /**
1396 * Create Stripe customer for logged-in user
1397 *
1398 * @param \WP_User $user WordPress user object.
1399 * @param array<string,string> $post_customer_data Customer data from POST containing 'email' and 'name'.
1400 * @since 2.0.0
1401 * @return string|false Customer ID on success, false on failure.
1402 * @throws \Exception When Stripe API request fails.
1403 */
1404 private function create_stripe_customer_for_user( $user, $post_customer_data = [] ) {
1405 try {
1406 // Use POST email if provided, else use logged-in user email.
1407 $customer_email = ! empty( $post_customer_data['email'] ) ? $post_customer_data['email'] : $user->user_email;
1408
1409 // Use POST name if provided, else use logged-in user name.
1410 $customer_name = ! empty( $post_customer_data['name'] ) ? $post_customer_data['name'] : ( trim( $user->first_name . ' ' . $user->last_name ) );
1411 $customer_name = ! empty( $customer_name ) ? $customer_name : $user->display_name;
1412
1413 // Build description with provided email and name.
1414 $description_parts = [];
1415 if ( ! empty( $customer_email ) ) {
1416 $description_parts[] = $customer_email;
1417 }
1418 if ( ! empty( $customer_name ) ) {
1419 $description_parts[] = $customer_name;
1420 }
1421 $description = ! empty( $description_parts ) ? implode( ', ', $description_parts ) : sprintf( 'WordPress User ID: %d', $user->ID );
1422
1423 $customer_data = [
1424 'email' => $customer_email,
1425 'name' => $customer_name,
1426 'description' => $description,
1427 'metadata' => [
1428 'source' => 'SureForms',
1429 'wp_user_id' => $user->ID,
1430 'wp_username' => $user->user_login,
1431 'wp_user_email' => $user->user_email,
1432 ],
1433 ];
1434
1435 $customer_response = Stripe_Helper::stripe_api_request( 'customers', 'POST', $customer_data );
1436
1437 if ( ! $customer_response['success'] || empty( $customer_response['data']['id'] ) ) {
1438 throw new \Exception( __( 'Failed to create Stripe customer.', 'sureforms' ) );
1439 }
1440
1441 $customer = $customer_response['data'];
1442
1443 // Save customer ID to user meta for future use.
1444 update_user_meta( $user->ID, 'srfm_stripe_customer_id', $customer['id'] );
1445
1446 return $customer['id'];
1447
1448 } catch ( \Exception $e ) {
1449 return false;
1450 }
1451 }
1452
1453 /**
1454 * Create Stripe customer for guest user
1455 *
1456 * @param array<string,string> $post_customer_data Customer data from POST containing 'email' and 'name'.
1457 * @since 2.0.0
1458 * @return string|false Customer ID on success, false on failure.
1459 * @throws \Exception When Stripe API request fails.
1460 */
1461 private function create_stripe_customer_for_guest( $post_customer_data = [] ) {
1462 try {
1463 // Use email and name from POST data.
1464 $customer_email = ! empty( $post_customer_data['email'] ) ? sanitize_email( $post_customer_data['email'] ) : '';
1465 $customer_name = ! empty( $post_customer_data['name'] ) ? sanitize_text_field( $post_customer_data['name'] ) : '';
1466
1467 // Build description with provided email and name.
1468 $description_parts = [];
1469 if ( ! empty( $customer_email ) ) {
1470 $description_parts[] = $customer_email;
1471 }
1472 if ( ! empty( $customer_name ) ) {
1473 $description_parts[] = $customer_name;
1474 }
1475 $description = ! empty( $description_parts ) ? implode( ', ', $description_parts ) : 'Guest User - SureForms Subscription';
1476
1477 $customer_data = [
1478 'description' => $description,
1479 'metadata' => [
1480 'source' => 'SureForms',
1481 'user_type' => 'guest',
1482 'created_at' => current_time( 'mysql' ),
1483 'ip_address' => $this->get_user_ip(),
1484 ],
1485 ];
1486
1487 // Add email if available from POST data.
1488 if ( ! empty( $customer_email ) ) {
1489 $customer_data['email'] = $customer_email;
1490 $customer_data['metadata']['form_email'] = $customer_email;
1491 }
1492
1493 // Add name if available from POST data.
1494 if ( ! empty( $customer_name ) ) {
1495 $customer_data['name'] = $customer_name;
1496 $customer_data['metadata']['form_name'] = $customer_name;
1497 }
1498
1499 $customer_response = Stripe_Helper::stripe_api_request( 'customers', 'POST', $customer_data );
1500
1501 if ( ! $customer_response['success'] || empty( $customer_response['data']['id'] ) ) {
1502 throw new \Exception( __( 'Failed to create Stripe guest customer.', 'sureforms' ) );
1503 }
1504
1505 $customer = $customer_response['data'];
1506
1507 return $customer['id'];
1508
1509 } catch ( \Exception $e ) {
1510 return false;
1511 }
1512 }
1513
1514 /**
1515 * Verify Stripe customer exists
1516 *
1517 * @param string $customer_id Stripe customer ID.
1518 * @since 2.0.0
1519 * @return bool True if customer exists, false otherwise.
1520 */
1521 private function verify_stripe_customer( $customer_id ) {
1522 try {
1523 $customer_response = Stripe_Helper::stripe_api_request( 'customers', 'GET', [], $customer_id );
1524
1525 if ( ! $customer_response['success'] ) {
1526 return false;
1527 }
1528
1529 $customer = $customer_response['data'] ?? [];
1530 /**
1531 * Stripe API returns customer object with the following structure:
1532 * {
1533 * "id": "cus_Syq4hfWO9S5XC2",
1534 * "object": "customer",
1535 * "deleted": true // Present and true only if customer is deleted
1536 * }
1537 *
1538 * When a customer is deleted, the 'deleted' property is set to true.
1539 * Active customers do not have this property or it's set to false.
1540 */
1541
1542 $is_deleted_customer = isset( $customer['deleted'] ) && true === $customer['deleted'];
1543
1544 return ! empty( $customer['id'] ) && false === $is_deleted_customer;
1545 } catch ( \Exception $e ) {
1546 return false;
1547 }
1548 }
1549
1550 /**
1551 * Get user IP address
1552 *
1553 * @since 2.0.0
1554 * @return string User IP address.
1555 */
1556 private function get_user_ip() {
1557 // Check for various IP address headers.
1558 $ip_keys = [ 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR' ];
1559
1560 foreach ( $ip_keys as $key ) {
1561 if ( ! empty( $_SERVER[ $key ] ) ) {
1562 $ip = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
1563 // Handle comma-separated IPs (from proxies).
1564 if ( strpos( $ip, ',' ) !== false ) {
1565 $ip = trim( explode( ',', $ip )[0] );
1566 }
1567 if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
1568 return $ip;
1569 }
1570 }
1571 }
1572
1573 return '127.0.0.1'; // Fallback.
1574 }
1575
1576 /**
1577 * Update payment entry with entry_id
1578 *
1579 * @param string $payment_id Payment intent ID.
1580 * @param int $entry_id Entry ID to update.
1581 * @since 2.0.0
1582 * @return bool True if payment entry updated, false otherwise.
1583 */
1584 private function update_payment_entry_id( $payment_id, $entry_id ) {
1585 // Find the payment entry by transaction_id.
1586 $payment_entries = Payments::get_instance()->get_results(
1587 [ 'transaction_id' => $payment_id ],
1588 'id'
1589 );
1590
1591 if ( ! empty( $payment_entries ) && is_array( $payment_entries ) && isset( $payment_entries[0] ) && is_array( $payment_entries[0] ) && isset( $payment_entries[0]['id'] ) ) {
1592 $payment_entry_id = intval( $payment_entries[0]['id'] );
1593
1594 // Update the payment entry with entry_id using Payments class.
1595 $updated = Payments::update( $payment_entry_id, [ 'entry_id' => $entry_id ] );
1596
1597 if ( $updated ) {
1598 $this->maybe_fire_payment_completed( $payment_entry_id );
1599 return true;
1600 }
1601
1602 return false;
1603 }
1604
1605 return false;
1606 }
1607
1608 /**
1609 * Fire the `srfm_payment_completed` action for a freshly linked payment.
1610 *
1611 * Called right after a payment row is linked to its form entry, so `entry_id`
1612 * (and therefore the submitting user) is resolvable. Gated on the `succeeded`
1613 * status so consumers never grant access for pending, failed or refunded
1614 * payments.
1615 *
1616 * @param int $payment_entry_id Primary key of the linked `sureforms_payments` row.
1617 * @since 2.12.0
1618 * @return void
1619 */
1620 private function maybe_fire_payment_completed( $payment_entry_id ) {
1621 $payment = Payments::get( $payment_entry_id );
1622 if ( ! is_array( $payment ) ) {
1623 return;
1624 }
1625
1626 $status = ! empty( $payment['status'] ) && is_string( $payment['status'] ) ? $payment['status'] : '';
1627 if ( 'succeeded' !== $status ) {
1628 return;
1629 }
1630
1631 /**
1632 * Fires when a SureForms payment reaches the `succeeded` state and has been
1633 * linked to its form entry — a one-time payment, or the initial charge of a
1634 * subscription.
1635 *
1636 * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
1637 * @param array<string, mixed> $context Resolved context: form_id, entry_id,
1638 * user_id (0 for guests), customer_email,
1639 * type, gateway, mode.
1640 * @since 2.12.0
1641 */
1642 do_action( 'srfm_payment_completed', $payment, Payment_Helper::build_payment_context( $payment ) );
1643 }
1644
1645 /**
1646 * Update payment entry with entry_id by subscription_id.
1647 *
1648 * Similar to update_payment_entry_id but looks up payment records by subscription_id
1649 * instead of transaction_id. This is useful for subscription payments (PayPal, Stripe)
1650 * where the subscription_id is available before the transaction_id.
1651 *
1652 * @param string $subscription_id The subscription ID from payment gateway.
1653 * @param int $entry_id The form entry ID to link with payment.
1654 * @since 2.4.0
1655 * @return bool True if payment entry updated, false otherwise.
1656 */
1657 private function update_payment_entry_id_by_subscription_id( $subscription_id, $entry_id ) {
1658 // Find the payment entry by subscription_id.
1659 $payment_entries = Payments::get_instance()->get_results(
1660 [ 'subscription_id' => $subscription_id ],
1661 'id'
1662 );
1663
1664 if ( ! empty( $payment_entries ) && is_array( $payment_entries ) && isset( $payment_entries[0] ) && is_array( $payment_entries[0] ) && isset( $payment_entries[0]['id'] ) ) {
1665 $payment_entry_id = intval( $payment_entries[0]['id'] );
1666
1667 // Update the payment entry with entry_id using Payments class.
1668 $updated = Payments::update( $payment_entry_id, [ 'entry_id' => $entry_id ] );
1669
1670 if ( $updated ) {
1671 $this->maybe_fire_payment_completed( $payment_entry_id );
1672 return true;
1673 }
1674
1675 return false;
1676 }
1677
1678 return false;
1679 }
1680
1681 /**
1682 * Extract customer name and email from form data
1683 *
1684 * Uses the payment block's customerNameField and customerEmailField attributes
1685 * to find the corresponding field slugs, then extracts the values from form data.
1686 *
1687 * @param array<string,mixed> $input_value Input value.
1688 * @since 2.0.0
1689 * @return array{name: string, email: string, customer_id: string} Customer data array.
1690 */
1691 private function extract_customer_data( $input_value ) {
1692 $email = ! empty( $input_value['email'] ) && is_string( $input_value['email'] ) ? sanitize_email( $input_value['email'] ) : '';
1693 return [
1694 'name' => ! empty( $input_value['name'] ) && is_string( $input_value['name'] ) ? sanitize_text_field( $input_value['name'] ) : '',
1695 'email' => $email,
1696 'customer_id' => ! empty( $input_value['customerId'] ) && is_string( $input_value['customerId'] ) ? sanitize_text_field( $input_value['customerId'] ) : '',
1697 ];
1698 }
1699 }
1700