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 / payment-helper.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
payment-helper.php
1892 lines
1 <?php
2 /**
3 * Global Payment Helper functions for SureForms Payments.
4 *
5 * This class handles payment settings and operations that are common across
6 * all payment gateways (Stripe, PayPal, etc.). Gateway-specific logic should
7 * be in their respective helper classes.
8 *
9 * @package sureforms
10 * @since 2.0.0
11 */
12
13 namespace SRFM\Inc\Payments;
14
15 use SRFM\Inc\Database\Tables\Entries;
16 use SRFM\Inc\Field_Validation;
17 use SRFM\Inc\Helper;
18 use SRFM\Inc\Payments\Stripe\Stripe_Helper;
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 /**
25 * Global Payment Helper class for multi-gateway support.
26 *
27 * @since 2.0.0
28 */
29 class Payment_Helper {
30 /**
31 * Get all payment settings (global settings + all gateways).
32 *
33 * Retrieves the complete payment settings structure:
34 * payment_settings -> [currency, payment_mode, stripe, paypal, etc]
35 *
36 * @since 2.0.0
37 * @return array<string, mixed> The complete payment settings array.
38 */
39 public static function get_all_payment_settings() {
40 $payment_settings = Helper::get_srfm_option( 'payment_settings', [] );
41
42 if ( ! is_array( $payment_settings ) || empty( $payment_settings ) ) {
43 return self::get_default_payment_settings();
44 }
45
46 // Ensure required keys exist.
47 if ( ! isset( $payment_settings['currency'] ) ) {
48 $payment_settings['currency'] = 'USD';
49 }
50
51 if ( ! isset( $payment_settings['payment_mode'] ) ) {
52 $payment_settings['payment_mode'] = 'test';
53 }
54
55 if ( ! isset( $payment_settings['stripe'] ) ) {
56 $payment_settings['stripe'] = Stripe_Helper::get_default_stripe_settings();
57 }
58
59 return $payment_settings;
60 }
61
62 /**
63 * Update all payment settings.
64 *
65 * Stores the complete payment settings array in:
66 * srfm_options -> payment_settings
67 *
68 * @param array<string, mixed> $settings The complete payment settings array.
69 * @since 2.0.0
70 * @return bool True on success, false on failure.
71 */
72 public static function update_payment_settings( $settings ) {
73 if ( ! is_array( $settings ) ) {
74 return false;
75 }
76
77 Helper::update_srfm_option( 'payment_settings', $settings );
78 return true;
79 }
80
81 /**
82 * Get settings for a specific payment gateway.
83 *
84 * @param string $gateway Gateway identifier (e.g., 'stripe', 'paypal').
85 * @since 2.0.0
86 * @return array<string, mixed> Gateway settings array, or empty array if not found.
87 */
88 public static function get_gateway_settings( $gateway ) {
89 if ( ! is_string( $gateway ) || empty( $gateway ) ) {
90 return [];
91 }
92
93 $payment_settings = self::get_all_payment_settings();
94
95 return isset( $payment_settings[ $gateway ] ) && is_array( $payment_settings[ $gateway ] )
96 ? $payment_settings[ $gateway ]
97 : [];
98 }
99
100 /**
101 * Update settings for a specific payment gateway.
102 *
103 * @param string $gateway Gateway identifier (e.g., 'stripe', 'paypal').
104 * @param array<string, mixed> $settings Gateway settings to save.
105 * @since 2.0.0
106 * @return bool True on success, false on failure.
107 */
108 public static function update_gateway_settings( $gateway, $settings ) {
109 if ( ! is_string( $gateway ) || empty( $gateway ) || ! is_array( $settings ) ) {
110 return false;
111 }
112
113 $payment_settings = self::get_all_payment_settings();
114 $payment_settings[ $gateway ] = $settings;
115
116 return self::update_payment_settings( $payment_settings );
117 }
118
119 /**
120 * Get a global payment setting (currency or payment_mode).
121 *
122 * @param string $key Setting key (e.g., 'currency', 'payment_mode').
123 * @param mixed $default Default value if setting not found.
124 * @since 2.0.0
125 * @return mixed Setting value or default.
126 */
127 public static function get_global_setting( $key, $default = '' ) {
128 if ( ! is_string( $key ) || empty( $key ) ) {
129 return $default;
130 }
131
132 $payment_settings = self::get_all_payment_settings();
133
134 return $payment_settings[ $key ] ?? $default;
135 }
136
137 /**
138 * Update a global payment setting (currency or payment_mode).
139 *
140 * @param string $key Setting key to update.
141 * @param mixed $value Value to set.
142 * @since 2.0.0
143 * @return bool True on success, false on failure.
144 */
145 public static function update_global_setting( $key, $value ) {
146 if ( ! is_string( $key ) || empty( $key ) ) {
147 return false;
148 }
149
150 $payment_settings = self::get_all_payment_settings();
151 $payment_settings[ $key ] = $value;
152
153 return self::update_payment_settings( $payment_settings );
154 }
155
156 /**
157 * Get the default currency.
158 *
159 * @since 2.0.0
160 * @return string The currency code (e.g., 'USD').
161 */
162 public static function get_currency() {
163 $response = self::get_global_setting( 'currency', 'USD' );
164 return ! empty( $response ) && is_string( $response ) ? $response : 'USD';
165 }
166
167 /**
168 * Get the current payment mode (test or live).
169 *
170 * @since 2.0.0
171 * @return string The current payment mode ('test' or 'live').
172 */
173 public static function get_payment_mode() {
174 $response = self::get_global_setting( 'payment_mode', 'test' );
175 return ! empty( $response ) && is_string( $response ) ? $response : 'test';
176 }
177
178 /**
179 * Get comprehensive currency data for all supported currencies.
180 *
181 * This is the single source of truth for all currency-related data.
182 * Contains currency name, symbol, and decimal places.
183 *
184 * @since 2.0.0
185 * @return array<string, array<string, mixed>> Array of currency data keyed by currency code.
186 */
187 public static function get_all_currencies_data() {
188 return [
189 'USD' => [
190 'name' => __( 'US Dollar', 'sureforms' ),
191 'symbol' => '$',
192 'decimal_places' => 2,
193 ],
194 'EUR' => [
195 'name' => __( 'Euro', 'sureforms' ),
196 'symbol' => '',
197 'decimal_places' => 2,
198 ],
199 'GBP' => [
200 'name' => __( 'British Pound', 'sureforms' ),
201 'symbol' => '£',
202 'decimal_places' => 2,
203 ],
204 'JPY' => [
205 'name' => __( 'Japanese Yen', 'sureforms' ),
206 'symbol' => '¥',
207 'decimal_places' => 0,
208 ],
209 'AUD' => [
210 'name' => __( 'Australian Dollar', 'sureforms' ),
211 'symbol' => 'A$',
212 'decimal_places' => 2,
213 ],
214 'CAD' => [
215 'name' => __( 'Canadian Dollar', 'sureforms' ),
216 'symbol' => 'C$',
217 'decimal_places' => 2,
218 ],
219 'CHF' => [
220 'name' => __( 'Swiss Franc', 'sureforms' ),
221 'symbol' => 'CHF',
222 'decimal_places' => 2,
223 ],
224 'CNY' => [
225 'name' => __( 'Chinese Yuan', 'sureforms' ),
226 'symbol' => '¥',
227 'decimal_places' => 2,
228 ],
229 'SEK' => [
230 'name' => __( 'Swedish Krona', 'sureforms' ),
231 'symbol' => 'kr',
232 'decimal_places' => 2,
233 ],
234 'NZD' => [
235 'name' => __( 'New Zealand Dollar', 'sureforms' ),
236 'symbol' => 'NZ$',
237 'decimal_places' => 2,
238 ],
239 'MXN' => [
240 'name' => __( 'Mexican Peso', 'sureforms' ),
241 'symbol' => 'MX$',
242 'decimal_places' => 2,
243 ],
244 'SGD' => [
245 'name' => __( 'Singapore Dollar', 'sureforms' ),
246 'symbol' => 'S$',
247 'decimal_places' => 2,
248 ],
249 'HKD' => [
250 'name' => __( 'Hong Kong Dollar', 'sureforms' ),
251 'symbol' => 'HK$',
252 'decimal_places' => 2,
253 ],
254 'NOK' => [
255 'name' => __( 'Norwegian Krone', 'sureforms' ),
256 'symbol' => 'kr',
257 'decimal_places' => 2,
258 ],
259 'PLN' => [
260 'name' => __( 'Polish Złoty', 'sureforms' ),
261 'symbol' => '',
262 'decimal_places' => 2,
263 ],
264 'KRW' => [
265 'name' => __( 'South Korean Won', 'sureforms' ),
266 'symbol' => '',
267 'decimal_places' => 0,
268 ],
269 'TRY' => [
270 'name' => __( 'Turkish Lira', 'sureforms' ),
271 'symbol' => '',
272 'decimal_places' => 2,
273 ],
274 'RUB' => [
275 'name' => __( 'Russian Ruble', 'sureforms' ),
276 'symbol' => '',
277 'decimal_places' => 2,
278 ],
279 'INR' => [
280 'name' => __( 'Indian Rupee', 'sureforms' ),
281 'symbol' => '',
282 'decimal_places' => 2,
283 ],
284 'BRL' => [
285 'name' => __( 'Brazilian Real', 'sureforms' ),
286 'symbol' => 'R$',
287 'decimal_places' => 2,
288 ],
289 'ZAR' => [
290 'name' => __( 'South African Rand', 'sureforms' ),
291 'symbol' => 'R',
292 'decimal_places' => 2,
293 ],
294 'AED' => [
295 'name' => __( 'UAE Dirham', 'sureforms' ),
296 'symbol' => 'د.إ',
297 'decimal_places' => 2,
298 ],
299 'PHP' => [
300 'name' => __( 'Philippine Peso', 'sureforms' ),
301 'symbol' => '',
302 'decimal_places' => 2,
303 ],
304 'IDR' => [
305 'name' => __( 'Indonesian Rupiah', 'sureforms' ),
306 'symbol' => 'Rp',
307 'decimal_places' => 2,
308 ],
309 'MYR' => [
310 'name' => __( 'Malaysian Ringgit', 'sureforms' ),
311 'symbol' => 'RM',
312 'decimal_places' => 2,
313 ],
314 'THB' => [
315 'name' => __( 'Thai Baht', 'sureforms' ),
316 'symbol' => '฿',
317 'decimal_places' => 2,
318 ],
319 'BIF' => [
320 'name' => __( 'Burundian Franc', 'sureforms' ),
321 'symbol' => 'FBu',
322 'decimal_places' => 0,
323 ],
324 'CLP' => [
325 'name' => __( 'Chilean Peso', 'sureforms' ),
326 'symbol' => '$',
327 'decimal_places' => 0,
328 ],
329 'DJF' => [
330 'name' => __( 'Djiboutian Franc', 'sureforms' ),
331 'symbol' => 'Fdj',
332 'decimal_places' => 0,
333 ],
334 'GNF' => [
335 'name' => __( 'Guinean Franc', 'sureforms' ),
336 'symbol' => 'FG',
337 'decimal_places' => 0,
338 ],
339 'KMF' => [
340 'name' => __( 'Comorian Franc', 'sureforms' ),
341 'symbol' => 'CF',
342 'decimal_places' => 0,
343 ],
344 'MGA' => [
345 'name' => __( 'Malagasy Ariary', 'sureforms' ),
346 'symbol' => 'Ar',
347 'decimal_places' => 0,
348 ],
349 'PYG' => [
350 'name' => __( 'Paraguayan Guaraní', 'sureforms' ),
351 'symbol' => '',
352 'decimal_places' => 0,
353 ],
354 'RWF' => [
355 'name' => __( 'Rwandan Franc', 'sureforms' ),
356 'symbol' => 'FRw',
357 'decimal_places' => 0,
358 ],
359 'UGX' => [
360 'name' => __( 'Ugandan Shilling', 'sureforms' ),
361 'symbol' => 'USh',
362 'decimal_places' => 0,
363 ],
364 'VND' => [
365 'name' => __( 'Vietnamese Đồng', 'sureforms' ),
366 'symbol' => '',
367 'decimal_places' => 0,
368 ],
369 'VUV' => [
370 'name' => __( 'Vanuatu Vatu', 'sureforms' ),
371 'symbol' => 'VT',
372 'decimal_places' => 0,
373 ],
374 'XAF' => [
375 'name' => __( 'Central African CFA Franc', 'sureforms' ),
376 'symbol' => 'FCFA',
377 'decimal_places' => 0,
378 ],
379 'XOF' => [
380 'name' => __( 'West African CFA Franc', 'sureforms' ),
381 'symbol' => 'CFA',
382 'decimal_places' => 0,
383 ],
384 'XPF' => [
385 'name' => __( 'CFP Franc', 'sureforms' ),
386 'symbol' => '',
387 'decimal_places' => 0,
388 ],
389 ];
390 }
391
392 /**
393 * Get currency names for all supported currencies.
394 *
395 * @since 2.0.0
396 * @return array<string, mixed> Array of currency names keyed by currency code.
397 */
398 public static function get_currency_names() {
399 $currencies = self::get_all_currencies_data();
400 $names = [];
401
402 foreach ( $currencies as $code => $data ) {
403 $names[ $code ] = $data['name'];
404 }
405
406 return $names;
407 }
408
409 /**
410 * Get currency symbol.
411 *
412 * @param string $currency Currency code.
413 * @since 2.0.0
414 * @return string Currency symbol or empty string.
415 */
416 public static function get_currency_symbol( $currency ) {
417 if ( empty( $currency ) || ! is_string( $currency ) ) {
418 return '';
419 }
420
421 $currency = strtoupper( $currency );
422 $currencies = self::get_all_currencies_data();
423 $currency_data = $currencies[ $currency ] ?? null;
424
425 $symbol = ! empty( $currency_data ) ? $currency_data['symbol'] : '';
426 return is_string( $symbol ) ? $symbol : '';
427 }
428
429 /**
430 * Get list of zero-decimal currencies.
431 *
432 * Zero-decimal currencies don't use decimal points in payment APIs.
433 * For these currencies, amounts are passed as-is without multiplying/dividing by 100.
434 *
435 * @since 2.0.0
436 * @return array<string> Array of zero-decimal currency codes.
437 */
438 public static function get_zero_decimal_currencies() {
439 $currencies = self::get_all_currencies_data();
440 $zero_decimal_codes = [];
441
442 foreach ( $currencies as $code => $data ) {
443 if ( 0 === $data['decimal_places'] ) {
444 $zero_decimal_codes[] = $code;
445 }
446 }
447
448 return $zero_decimal_codes;
449 }
450
451 /**
452 * Check if currency is zero-decimal.
453 *
454 * @param string $currency Currency code.
455 * @since 2.0.0
456 * @return bool True if zero-decimal currency.
457 */
458 public static function is_zero_decimal_currency( $currency ) {
459 if ( empty( $currency ) || ! is_string( $currency ) ) {
460 return false;
461 }
462
463 $currency = strtoupper( $currency );
464 $currencies = self::get_all_currencies_data();
465 $currency_data = $currencies[ $currency ] ?? null;
466
467 return $currency_data && 0 === $currency_data['decimal_places'];
468 }
469
470 /**
471 * Get all payment-related translatable strings for frontend use.
472 *
473 * This is the single source of truth for all payment UI strings.
474 * Each string has a unique key (slug) for easy reference in JavaScript.
475 *
476 * @since 2.0.0
477 * @return array<string, string> Array of translatable strings keyed by slug.
478 */
479 public static function get_payment_strings() {
480 return [
481 'unknown_error' => __( 'An unknown error occurred. Please try again or contact the site administrator.', 'sureforms' ),
482 // Payment validation messages.
483 'payment_unavailable' => __( 'Payment is currently unavailable. Please contact the site administrator.', 'sureforms' ),
484 'payment_amount_not_configured' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the payment amount.', 'sureforms' ),
485 'invalid_variable_amount' => __( 'Invalid payment amount', 'sureforms' ),
486 'amount_below_minimum' => __( 'Payment amount must be at least {symbol}{amount}.', 'sureforms' ),
487 'payment_required' => __( 'This form requires a payment. Please complete the payment and submit again.', 'sureforms' ),
488
489 // Field mapping validation.
490 'payment_name_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer name field.', 'sureforms' ),
491 'payment_email_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer email field.', 'sureforms' ),
492 'payment_name_required' => __( 'Please enter your name.', 'sureforms' ),
493 'payment_email_required' => __( 'Please enter your email.', 'sureforms' ),
494
495 // Payment processing messages.
496 'payment_failed' => __( 'Payment failed', 'sureforms' ),
497 'payment_successful' => __( 'Payment successful', 'sureforms' ),
498 'payment_could_not_be_completed' => __( 'Unable to complete payment. Please try again or contact support.', 'sureforms' ),
499
500 // Stripe decline codes - Card declined errors.
501 'generic_decline' => __( 'Your card was declined. Please try a different payment method or contact your bank.', 'sureforms' ),
502 'card_declined' => __( 'Your card was declined. Please try a different payment method or contact your bank.', 'sureforms' ),
503 'insufficient_funds' => __( 'Your card has insufficient funds. Please use a different payment method.', 'sureforms' ),
504 'lost_card' => __( 'Your card was declined because it has been reported as lost. Please contact your bank.', 'sureforms' ),
505 'stolen_card' => __( 'Your card was declined because it has been reported as stolen. Please contact your bank.', 'sureforms' ),
506 'expired_card' => __( 'Your card has expired. Please use a different payment method.', 'sureforms' ),
507 'pickup_card' => __( 'Your card was declined. Please contact your bank for more information.', 'sureforms' ),
508 'restricted_card' => __( 'Your card was declined due to restrictions. Please contact your bank.', 'sureforms' ),
509 'security_violation' => __( 'Your card was declined due to a security violation. Please contact your bank.', 'sureforms' ),
510 'service_not_allowed' => __( 'Your card does not support this type of purchase. Please use a different payment method.', 'sureforms' ),
511 'stop_payment_order' => __( 'A stop payment order has been placed on this card. Please contact your bank.', 'sureforms' ),
512 'testmode_decline' => __( 'A test card was used in a live environment. Please use a real card.', 'sureforms' ),
513 'withdrawal_count_limit_exceeded' => __( 'Your card has exceeded its withdrawal limit. Please contact your bank.', 'sureforms' ),
514 'incorrect_cvc' => __( 'Your card\'s security code is incorrect. Please check and try again.', 'sureforms' ),
515 'incorrect_number' => __( 'Your card number is incorrect. Please check and try again.', 'sureforms' ),
516 'invalid_cvc' => __( 'Your card\'s security code is invalid. Please check and try again.', 'sureforms' ),
517 'invalid_expiry_month' => __( 'Your card\'s expiration month is invalid. Please check and try again.', 'sureforms' ),
518 'invalid_expiry_year' => __( 'Your card\'s expiration year is invalid. Please check and try again.', 'sureforms' ),
519 'invalid_number' => __( 'Your card number is invalid. Please check and try again.', 'sureforms' ),
520 'processing_error' => __( 'Unable to process card. Please try again.', 'sureforms' ),
521 'reenter_transaction' => __( 'Unable to process transaction. Please try again.', 'sureforms' ),
522 'card_not_supported' => __( 'Your card is not supported for this transaction. Please use a different payment method.', 'sureforms' ),
523 'currency_not_supported' => __( 'Your card does not support the currency used for this transaction. Please use a different payment method.', 'sureforms' ),
524 'duplicate_transaction' => __( 'A transaction with identical details was submitted recently. Please wait a moment and try again.', 'sureforms' ),
525 'invalid_account' => __( 'The account associated with your card is invalid. Please contact your bank.', 'sureforms' ),
526 'invalid_amount' => __( 'The payment amount is invalid. Please contact the site administrator.', 'sureforms' ),
527 'issuer_not_available' => __( 'Unable to reach card issuer. Please try again later.', 'sureforms' ),
528 'merchant_blacklist' => __( 'Your card was declined. Please contact your bank for more information.', 'sureforms' ),
529 'new_account_information_available' => __( 'Your card information needs to be updated. Please contact your bank.', 'sureforms' ),
530 'no_action_taken' => __( 'The card cannot be used for this transaction. Please contact your bank.', 'sureforms' ),
531 'not_permitted' => __( 'The transaction is not permitted. Please contact your bank.', 'sureforms' ),
532 'offline_pin_required' => __( 'Your card requires offline PIN authentication. Please try again.', 'sureforms' ),
533 'online_or_offline_pin_required' => __( 'Your card requires PIN authentication. Please try again.', 'sureforms' ),
534 'pin_try_exceeded' => __( 'You have exceeded the maximum number of PIN attempts. Please contact your bank.', 'sureforms' ),
535 'revocation_of_all_authorizations' => __( 'All authorizations for this card have been revoked. Please contact your bank.', 'sureforms' ),
536 'revocation_of_authorization' => __( 'The authorization for this transaction has been revoked. Please try again.', 'sureforms' ),
537 'transaction_not_allowed' => __( 'This transaction is not allowed. Please contact your bank.', 'sureforms' ),
538 'try_again_later' => __( 'Unable to process transaction. Please try again later.', 'sureforms' ),
539 'live_mode_test_card' => __( 'Your card was declined. Your request was in live mode, but used a known test card.', 'sureforms' ),
540 'test_mode_live_card' => __( 'Your card was declined. Your request was in test mode, but used a non test card. For a list of valid test cards, visit: https://stripe.com/docs/testing.', 'sureforms' ),
541
542 // Default values and placeholders.
543 'sureforms_subscription' => __( 'SureForms Subscription', 'sureforms' ),
544 'sureforms_payment' => __( 'SureForms Payment', 'sureforms' ),
545 'subscription_plan' => __( 'Subscription Plan', 'sureforms' ),
546 'sureforms_customer' => __( 'SureForms Customer', 'sureforms' ),
547 'customer_example_email' => 'customer@example.com', // Not translatable - example email.
548 'amount_placeholder' => __( 'Complete the form to view the amount.', 'sureforms' ),
549 'failed_to_create_payment' => __( 'Unable to create payment. Please contact support.', 'sureforms' ),
550 ];
551 }
552
553 /**
554 * Retrieve a user-friendly payment error message by error key.
555 *
556 * @param string $key Error key received from payment processing/Stripe.
557 *
558 * @since 2.0.0
559 * @return string Localized error message or a generic "Unknown error" message if not found.
560 */
561 public static function get_error_message_by_key( $key ) {
562 $messages = self::get_payment_strings();
563 if ( isset( $messages[ $key ] ) ) {
564 return $messages[ $key ];
565 }
566 return __( 'Unknown error', 'sureforms' );
567 }
568
569 /**
570 * Validate payment amount against stored form configuration.
571 *
572 * This function verifies that the payment amount and currency submitted
573 * match the configured values in the form's payment block settings.
574 * It handles both fixed and minimum amount validations for single and subscription payments.
575 *
576 * @since 2.2.2
577 * @param int|float $amount Amount in smallest currency unit (e.g., cents for USD).
578 * @param string $currency Currency code (e.g., 'usd', 'eur').
579 * @param int $form_id WordPress post ID of the form.
580 * @param string $block_id Block identifier for the payment block.
581 * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
582 * @return array {
583 * Validation result.
584 *
585 * @type bool $valid Whether the validation passed.
586 * @type string $message Error message if validation failed, empty if valid.
587 * }
588 */
589 public static function validate_payment_amount( $amount, $currency, $form_id, $block_id, $active_type = '' ) {
590 // Retrieve block configuration from post meta.
591 $block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
592
593 // Check if block config exists.
594 if ( empty( $block_config ) || ! is_array( $block_config ) ) {
595 return [
596 'valid' => false,
597 'message' => __( 'Invalid form configuration.', 'sureforms' ),
598 ];
599 }
600
601 // Check if payment block exists in configuration.
602 if ( ! isset( $block_config[ $block_id ] ) || ! is_array( $block_config[ $block_id ] ) ) {
603 return [
604 'valid' => false,
605 'message' => __( 'Payment configuration not found for this form.', 'sureforms' ),
606 ];
607 }
608
609 $payment_config = $block_config[ $block_id ];
610 $global_currency = strtolower( self::get_currency() );
611 $submitted_currency = strtolower( $currency );
612 if ( $global_currency !== $submitted_currency ) {
613 return [
614 'valid' => false,
615 /* translators: 1: expected currency, 2: received currency */
616 'message' => sprintf( __( 'Currency mismatch: expected %1$s, received %2$s.', 'sureforms' ), strtoupper( $global_currency ), strtoupper( $submitted_currency ) ),
617 ];
618 }
619
620 // Reject when the requested flow (one-time vs subscription) is not allowed by
621 // the form's stored payment_type. "both" mode allows either flow; pure modes
622 // allow only their matching flow. Without this guard, an attacker could call
623 // the wrong intent-creation route on a pure-subscription form and pay once for
624 // what should be a recurring charge (or vice versa).
625 $payment_type = isset( $payment_config['payment_type'] ) && is_string( $payment_config['payment_type'] ) ? $payment_config['payment_type'] : 'one-time';
626 if ( ! empty( $active_type ) && 'both' !== $payment_type && $active_type !== $payment_type ) {
627 return [
628 'valid' => false,
629 'message' => __( 'Payment type does not match the form configuration.', 'sureforms' ),
630 ];
631 }
632
633 // BOTH MODE: when payment_type is 'both', resolve the correct per-type
634 // config (amount_type, fixed_amount, minimum_amount, variable_amount_field)
635 // based on which flow the user actually chose (one-time vs subscription).
636 $resolved_config = self::resolve_payment_config_for_active_type( $payment_config, $active_type );
637
638 // Get amount type (fixed or minimum).
639 $amount_type = $resolved_config['amount_type'] ?? 'fixed';
640
641 // Validate based on amount type.
642 if ( 'fixed' === $amount_type ) {
643 // Fixed amount validation - must match exactly.
644 $configured_amount = isset( $resolved_config['fixed_amount'] ) ? floatval( $resolved_config['fixed_amount'] ) : 10.00;
645
646 // Allow small floating point difference (0.01) due to rounding.
647 if ( abs( $amount - $configured_amount ) > 0.01 ) {
648 return [
649 'valid' => false,
650 /* translators: 1: expected amount with currency */
651 'message' => sprintf( __( 'Payment amount must be exactly %1$s.', 'sureforms' ), $configured_amount . ' ' . strtoupper( $currency ) ),
652 ];
653 }
654 } elseif ( 'variable' === $amount_type ) {
655 // Minimum amount validation - must be >= minimum.
656 $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
657
658 if ( $amount < $minimum_amount ) {
659 return [
660 'valid' => false,
661 /* translators: 1: minimum amount with currency */
662 'message' => sprintf( __( 'Payment amount must be at least %1$s.', 'sureforms' ), $minimum_amount . ' ' . strtoupper( $currency ) ),
663 ];
664 }
665
666 // Validate dynamic amount from dropdown/multi-choice field.
667 $dynamic_amount_validation = self::validate_dynamic_amount_field(
668 $resolved_config,
669 $block_config,
670 $amount,
671 $currency
672 );
673
674 if ( null !== $dynamic_amount_validation ) {
675 return $dynamic_amount_validation;
676 }
677 }
678
679 // Validation passed.
680 return [
681 'valid' => true,
682 'message' => '',
683 ];
684 }
685
686 /**
687 * Store payment intent metadata in transient for verification.
688 *
689 * Stores payment intent details temporarily to verify that the payment intent
690 * was created through our system and hasn't been tampered with.
691 *
692 * @since 2.2.2
693 * @param string $block_id Block identifier.
694 * @param string $payment_intent_id Payment intent ID from Stripe.
695 * @param array<string, mixed> $metadata Payment metadata to store.
696 * @return bool True on success, false on failure.
697 */
698 public static function store_payment_intent_metadata( $block_id, $payment_intent_id, $metadata ) {
699 if ( empty( $block_id ) || empty( $payment_intent_id ) ) {
700 return false;
701 }
702
703 // Create transient key: srfm_pi_{block_id}_{payment_intent_id}.
704 $transient_key = 'srfm_pi_' . sanitize_key( $block_id ) . '_' . sanitize_key( $payment_intent_id );
705
706 // Add timestamp to metadata.
707 $metadata['created_at'] = time();
708
709 // Store for 1 hour (3600 seconds).
710 return set_transient( $transient_key, $metadata, 3600 );
711 }
712
713 /**
714 * Verify payment intent and validate amount.
715 *
716 * Verifies that the payment intent was created through our system and validates
717 * the payment amount matches the expected amount based on form configuration.
718 *
719 * @since 2.3.0
720 * @param string $block_id Block identifier.
721 * @param string $payment_intent_id Payment intent ID from Stripe.
722 * @param array<string, mixed> $form_data Submitted form data.
723 * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
724 * @return array {
725 * Verification result.
726 *
727 * @type bool $valid Whether verification passed.
728 * @type string $message Error message if verification failed, empty if valid.
729 * }
730 */
731 public static function verify_payment_intent( $block_id, $payment_intent_id, $form_data, $active_type = '' ) {
732 // Get form ID from form data for verification.
733 $form_id = isset( $form_data['form-id'] ) && ! empty( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? intval( $form_data['form-id'] ) : 0;
734
735 // Validate required parameters.
736 if ( empty( $block_id ) || empty( $payment_intent_id ) || empty( $form_id ) ) {
737 return [
738 'valid' => false,
739 'message' => __( 'Invalid payment verification parameters.', 'sureforms' ),
740 ];
741 }
742
743 // Verify payment intent was created through our system.
744 $transient_key = 'srfm_pi_' . sanitize_key( $block_id ) . '_' . sanitize_key( $payment_intent_id );
745 $metadata = get_transient( $transient_key );
746
747 if ( empty( $metadata ) || ! is_array( $metadata ) ) {
748 return [
749 'valid' => false,
750 'message' => __( 'Payment verification failed. Invalid payment intent.', 'sureforms' ),
751 ];
752 }
753
754 // Reject when the submit path's active_type does not match the type that
755 // was validated at intent-creation time. Prevents an attacker from passing
756 // a one-time intent_id through the subscription submit path (or vice versa)
757 // to replay a small one-time charge in place of a recurring subscription.
758 $stored_active_type = isset( $metadata['active_type'] ) && is_string( $metadata['active_type'] ) ? $metadata['active_type'] : '';
759 if ( ! empty( $active_type ) && ! empty( $stored_active_type ) && $active_type !== $stored_active_type ) {
760 return [
761 'valid' => false,
762 'message' => __( 'Payment verification failed. Payment type mismatch.', 'sureforms' ),
763 ];
764 }
765
766 $payment_amount = isset( $metadata['amount'] ) && ! empty( $metadata['amount'] ) && is_numeric( $metadata['amount'] ) ? floatval( $metadata['amount'] ) : 0;
767
768 // Validate payment amount matches configuration.
769 $amount_validation = self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount, $active_type );
770
771 if ( false === $amount_validation['valid'] ) {
772 return $amount_validation;
773 }
774
775 // Verification passed.
776 return [
777 'valid' => true,
778 'message' => '',
779 ];
780 }
781
782 /**
783 * Validate an arbitrary amount against the form's server-side payment configuration.
784 *
785 * Public wrapper around the amount validator so the submission flow can re-check the amount
786 * Stripe actually charged (defense-in-depth) — not only the amount recorded when the intent was
787 * created.
788 *
789 * @param string $block_id Block identifier.
790 * @param int $form_id Form post ID.
791 * @param array<string, mixed> $form_data Submitted form data.
792 * @param float $amount Amount to validate (decimal, in the form currency).
793 * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
794 * @since 2.11.1
795 * @return array<string, mixed> Validation result with 'valid' (bool) and 'message' (string) keys.
796 */
797 public static function validate_amount_against_config( $block_id, $form_id, $form_data, $amount, $active_type = '' ) {
798 return self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $amount, $active_type );
799 }
800
801 /**
802 * Delete payment intent metadata from transient.
803 *
804 * Cleans up stored metadata after successful payment verification.
805 *
806 * @since 2.2.2
807 * @param string $block_id Block identifier.
808 * @param string $payment_intent_id Payment intent ID from Stripe.
809 * @return bool True on success, false on failure.
810 */
811 public static function delete_payment_intent_metadata( $block_id, $payment_intent_id ) {
812 if ( empty( $block_id ) || empty( $payment_intent_id ) ) {
813 return false;
814 }
815
816 // Create transient key: srfm_pi_{block_id}_{payment_intent_id}.
817 $transient_key = 'srfm_pi_' . sanitize_key( $block_id ) . '_' . sanitize_key( $payment_intent_id );
818
819 return delete_transient( $transient_key );
820 }
821
822 /**
823 * Get currency sign position.
824 *
825 * @since 2.5.1
826 * @return string Currency sign position ('left', 'right', 'left_space', 'right_space').
827 */
828 public static function get_currency_sign_position() {
829 $result = self::get_global_setting( 'currency_sign_position', 'left' );
830
831 return ! empty( $result ) && is_string( $result ) ? $result : 'left';
832 }
833
834 /**
835 * Get a submitted form value by field slug.
836 *
837 * Matches the SureForms field-name convention `{block}-{block_id}-lbl-{label}-{slug}` by
838 * suffix, regardless of block type. Used to resolve `{form:slug}` tokens when recomputing a
839 * calculation server-side. Returns null when the slug is not present in the submission.
840 *
841 * @param string $slug The field slug to look up.
842 * @param array<mixed> $form_data Submitted form data.
843 * @since 2.11.1
844 * @return mixed|null The submitted value, or null when not found.
845 */
846 public static function get_submitted_value_by_slug( $slug, $form_data ) {
847 if ( empty( $slug ) || ! is_string( $slug ) || ! is_array( $form_data ) ) {
848 return null;
849 }
850
851 $suffix = '-' . $slug;
852 foreach ( $form_data as $field_key => $field_value ) {
853 if ( ! is_string( $field_key ) || false === strpos( $field_key, '-lbl-' ) ) {
854 continue;
855 }
856
857 if ( substr( $field_key, -strlen( $suffix ) ) === $suffix ) {
858 return $field_value;
859 }
860 }
861
862 return null;
863 }
864
865 /**
866 * Get the payment methods a payment block can actually offer.
867 *
868 * The block's enabled methods intersected with the methods that are registered
869 * and connected. Shared with Payment_Markup so the renderer and the submission
870 * guard can never disagree about whether a payment field is usable.
871 *
872 * @param array<mixed> $attrs Payment block attributes.
873 *
874 * @since 2.12.3
875 * @return array<string, mixed> Usable payment methods, keyed by method ID.
876 */
877 public static function get_registered_payment_methods( $attrs ) {
878 $methods = [];
879 $attrs = is_array( $attrs ) ? $attrs : [];
880 $enabled_methods = isset( $attrs['paymentMethods'] ) && is_array( $attrs['paymentMethods'] ) ? $attrs['paymentMethods'] : [ 'stripe' ];
881
882 // Filter to get method configurations - start with Stripe as default.
883 $available_methods = apply_filters(
884 'srfm_payment_methods_registry',
885 [
886 'stripe' => [
887 'id' => 'stripe',
888 'label' => __( 'Stripe', 'sureforms' ),
889 'description' => __( 'Pay with credit or debit card', 'sureforms' ),
890 'icon' => 'credit-card',
891 'enabled' => Stripe_Helper::is_stripe_connected(),
892 'container_class' => 'srfm-stripe-payment-element',
893 ],
894 ]
895 );
896
897 // Filter enabled methods.
898 foreach ( $enabled_methods as $method_id ) {
899 if ( is_array( $available_methods ) && isset( $available_methods[ $method_id ] ) && ! empty( $available_methods[ $method_id ]['enabled'] ) ) {
900 $methods[ $method_id ] = $available_methods[ $method_id ];
901 }
902 }
903
904 return $methods;
905 }
906
907 /**
908 * Whether a payment block's configuration produces a usable payment field.
909 *
910 * Mirrors the conditions under which Payment_Markup::markup() returns early with
911 * no markup: no usable payment method, or the customer field mappings the gateway
912 * needs are missing. A block that renders nothing cannot be required on submit.
913 *
914 * @param array<mixed> $attrs Payment block attributes.
915 *
916 * @since 2.12.3
917 * @return bool True when the block renders a payment field.
918 */
919 public static function is_payment_field_active( $attrs ) {
920 if ( ! is_array( $attrs ) ) {
921 return false;
922 }
923
924 if ( empty( self::get_registered_payment_methods( $attrs ) ) ) {
925 return false;
926 }
927
928 // Customer field mappings, including the legacy subscriptionPlan fallbacks.
929 $subscription_plan = isset( $attrs['subscriptionPlan'] ) && is_array( $attrs['subscriptionPlan'] ) ? $attrs['subscriptionPlan'] : [];
930 $email_field = ! empty( $attrs['customerEmailField'] ) ? $attrs['customerEmailField'] : ( $subscription_plan['customer_email'] ?? '' );
931
932 if ( empty( $email_field ) ) {
933 return false;
934 }
935
936 $payment_type = ! empty( $attrs['paymentType'] ) && is_string( $attrs['paymentType'] ) ? $attrs['paymentType'] : 'one-time';
937
938 // A subscription path also needs the customer name mapping.
939 if ( in_array( $payment_type, [ 'subscription', 'both' ], true ) ) {
940 $name_field = ! empty( $attrs['customerNameField'] ) ? $attrs['customerNameField'] : ( $subscription_plan['customer_name'] ?? '' );
941
942 if ( empty( $name_field ) ) {
943 return false;
944 }
945 }
946
947 return true;
948 }
949
950 /**
951 * Get the block IDs of payment fields a submission of this form must pay for.
952 *
953 * Derived from the stored form, never from what the client submitted. Blocks that
954 * render nothing (see is_payment_field_active()) and blocks under conditional logic
955 * are excluded: conditional logic is evaluated on the client, so a hidden payment
956 * field legitimately submits no payment value and must not be required here.
957 *
958 * @param int $form_id Form ID.
959 *
960 * @since 2.12.3
961 * @return array<string> Block IDs that require a verified payment.
962 */
963 public static function get_required_payment_block_ids( $form_id ) {
964 // absint(), matching Submit_Token::verify()'s normalisation in
965 // Form_Submit::submit_form_permissions_check(). get_integer_value() would keep a
966 // negative id and bail below, so the guard would resolve a different form from
967 // the one the submit token authorised.
968 $form_id = absint( $form_id );
969 $form = $form_id > 0 ? get_post( $form_id ) : null;
970
971 if ( ! $form instanceof \WP_Post || '' === $form->post_content ) {
972 return [];
973 }
974
975 $block_ids = self::collect_active_payment_block_ids( parse_blocks( $form->post_content ) );
976
977 if ( empty( $block_ids ) ) {
978 return [];
979 }
980
981 // Drop blocks that conditional logic can hide.
982 foreach ( self::get_conditional_logic_block_ids( $form_id ) as $conditional_id ) {
983 unset( $block_ids[ $conditional_id ] );
984 }
985
986 /**
987 * Filters the payment block IDs that require a verified payment on submit.
988 *
989 * Lets add-ons that can evaluate their own visibility rules server-side add or
990 * remove blocks — e.g. re-adding a conditionally shown payment field once the
991 * rule is known to have matched.
992 *
993 * @since 2.12.3
994 *
995 * @param array<string> $block_ids Block IDs requiring a verified payment.
996 * @param int $form_id Form ID.
997 */
998 $block_ids = apply_filters( 'srfm_required_payment_block_ids', array_keys( $block_ids ), $form_id );
999
1000 return is_array( $block_ids ) ? $block_ids : [];
1001 }
1002
1003 /**
1004 * Resolve the WordPress user associated with a payment record.
1005 *
1006 * Resolution order:
1007 * 1. The linked entry's `user_id` (set when a logged-in user submitted the form).
1008 * 2. A user matching the payment's `customer_email`.
1009 * 3. `0` for guest checkouts where no WordPress user can be resolved.
1010 *
1011 * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
1012 * @return int Resolved WordPress user ID, or 0 when none can be determined.
1013 * @since 2.12.0
1014 */
1015 public static function resolve_payment_user( $payment ) {
1016 if ( ! is_array( $payment ) ) {
1017 return 0;
1018 }
1019
1020 // 1. Prefer the user_id stored on the linked entry.
1021 $entry_id = ! empty( $payment['entry_id'] ) && is_numeric( $payment['entry_id'] ) ? intval( $payment['entry_id'] ) : 0;
1022 if ( $entry_id > 0 ) {
1023 $entry = Entries::get( $entry_id );
1024 if ( is_array( $entry ) && ! empty( $entry['user_id'] ) && is_numeric( $entry['user_id'] ) ) {
1025 $user_id = intval( $entry['user_id'] );
1026 if ( $user_id > 0 ) {
1027 return $user_id;
1028 }
1029 }
1030 }
1031
1032 // 2. Fall back to a user matching the customer email.
1033 $customer_email = ! empty( $payment['customer_email'] ) && is_string( $payment['customer_email'] ) ? sanitize_email( $payment['customer_email'] ) : '';
1034 if ( ! empty( $customer_email ) ) {
1035 $user = get_user_by( 'email', $customer_email );
1036 if ( $user instanceof \WP_User ) {
1037 return intval( $user->ID );
1038 }
1039 }
1040
1041 // 3. Guest checkout — no resolvable WordPress user.
1042 return 0;
1043 }
1044
1045 /**
1046 * Build the standard context array passed alongside payment-lifecycle actions.
1047 *
1048 * Gives consumers (membership, LMS and other plugins) a consistent, resolved
1049 * snapshot of who paid and through which form/gateway, without each consumer
1050 * having to re-derive it from the raw payment row.
1051 *
1052 * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
1053 * @return array{form_id:int, entry_id:int, user_id:int, customer_email:string, type:string, gateway:string, mode:string} Resolved payment context.
1054 * @since 2.12.0
1055 */
1056 public static function build_payment_context( $payment ) {
1057 $payment = is_array( $payment ) ? $payment : [];
1058
1059 return [
1060 'form_id' => ! empty( $payment['form_id'] ) && is_numeric( $payment['form_id'] ) ? intval( $payment['form_id'] ) : 0,
1061 'entry_id' => ! empty( $payment['entry_id'] ) && is_numeric( $payment['entry_id'] ) ? intval( $payment['entry_id'] ) : 0,
1062 'user_id' => self::resolve_payment_user( $payment ),
1063 'customer_email' => ! empty( $payment['customer_email'] ) && is_string( $payment['customer_email'] ) ? sanitize_email( $payment['customer_email'] ) : '',
1064 'type' => ! empty( $payment['type'] ) && is_string( $payment['type'] ) ? sanitize_text_field( $payment['type'] ) : '',
1065 'gateway' => ! empty( $payment['gateway'] ) && is_string( $payment['gateway'] ) ? sanitize_text_field( $payment['gateway'] ) : '',
1066 'mode' => ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? sanitize_text_field( $payment['mode'] ) : '',
1067 ];
1068 }
1069
1070 /**
1071 * Validate dynamic amount field from dropdown or multi-choice.
1072 *
1073 * @param array<string, mixed> $payment_config Payment block configuration.
1074 * @param array<string, mixed> $block_config All block configurations.
1075 * @param float $submitted_amount_decimal Submitted amount in decimal.
1076 * @param string $currency Currency code.
1077 * @return array|null Validation result array or null if validation passes.
1078 * @since 2.3.0
1079 */
1080 /**
1081 * Recursively collect the block IDs of payment blocks that render a payment field.
1082 *
1083 * Recurses into innerBlocks and expands core/block reusable/synced patterns, so a
1084 * payment block that renders from inside a pattern is still required. The payment
1085 * block sets "reusable": false, so reaching that state needs imported or
1086 * hand-authored post_content rather than the editor — but a payment field that
1087 * renders and is not required is exactly the hole this guard exists to close.
1088 *
1089 * @param array<mixed> $blocks Parsed blocks from parse_blocks().
1090 * @param array<int, true> $visited_refs Reusable-block post IDs already expanded,
1091 * keyed by ID — guards against reference cycles.
1092 *
1093 * @since 2.12.3
1094 * @return array<string,true> Active payment block IDs, keyed by block ID.
1095 */
1096 private static function collect_active_payment_block_ids( $blocks, &$visited_refs = [] ) {
1097 $block_ids = [];
1098
1099 foreach ( $blocks as $block ) {
1100 if ( ! is_array( $block ) ) {
1101 continue;
1102 }
1103
1104 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
1105
1106 if ( 'srfm/payment' === ( $block['blockName'] ?? '' )
1107 && ! empty( $attrs['block_id'] )
1108 && is_scalar( $attrs['block_id'] )
1109 && self::is_payment_field_active( $attrs )
1110 ) {
1111 $block_ids[ Helper::get_string_value( $attrs['block_id'] ) ] = true;
1112 }
1113
1114 if ( isset( $block['blockName'] ) && 'core/block' === $block['blockName'] && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) {
1115 $ref = absint( $attrs['ref'] );
1116
1117 if ( $ref && ! isset( $visited_refs[ $ref ] ) ) {
1118 $visited_refs[ $ref ] = true;
1119 $ref_post = get_post( $ref );
1120
1121 if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && '' !== $ref_post->post_content ) {
1122 $block_ids += self::collect_active_payment_block_ids( parse_blocks( $ref_post->post_content ), $visited_refs );
1123 }
1124 }
1125 }
1126
1127 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
1128 $block_ids += self::collect_active_payment_block_ids( $block['innerBlocks'], $visited_refs );
1129 }
1130 }
1131
1132 return $block_ids;
1133 }
1134
1135 /**
1136 * Get the block IDs a form has conditional logic rules for.
1137 *
1138 * Field visibility rules live in the `_srfm_conditional_logic` post meta, keyed by
1139 * block ID, and are evaluated on the client. A payment field under such a rule may
1140 * legitimately be hidden at submit time.
1141 *
1142 * @param int $form_id Form ID.
1143 *
1144 * @since 2.12.3
1145 * @return array<string> Block IDs carrying conditional logic rules.
1146 */
1147 private static function get_conditional_logic_block_ids( $form_id ) {
1148 // Only exempt when conditional logic can actually hide anything. The rules are
1149 // evaluated client-side by the add-on that registers this meta; without it
1150 // nothing hides, so a stale rule (e.g. written by a form importer on a site that
1151 // never had the add-on) must not buy a payment block an exemption.
1152 if ( ! registered_meta_key_exists( 'post', '_srfm_conditional_logic', SRFM_FORMS_POST_TYPE ) ) {
1153 return [];
1154 }
1155
1156 $conditional_logic = get_post_meta( $form_id, '_srfm_conditional_logic', true );
1157
1158 if ( ! is_array( $conditional_logic ) ) {
1159 return [];
1160 }
1161
1162 $block_ids = [];
1163
1164 foreach ( $conditional_logic as $item ) {
1165 if ( ! is_array( $item ) ) {
1166 continue;
1167 }
1168
1169 foreach ( $item as $block_id => $rule ) {
1170 if ( ! is_string( $block_id ) || '' === $block_id ) {
1171 continue;
1172 }
1173
1174 // An actionable rule only — a rule with no conditions can never match, so
1175 // the field's visibility is never altered and payment stays required.
1176 if ( self::has_actionable_conditional_rule( $rule ) ) {
1177 $block_ids[] = $block_id;
1178 }
1179 }
1180 }
1181
1182 return $block_ids;
1183 }
1184
1185 /**
1186 * Whether a stored conditional-logic rule can actually change a field's visibility.
1187 *
1188 * Both `show` and `hide` actions can leave the field hidden for a given submission
1189 * (show = hidden until the conditions match, hide = visible until they match), so the
1190 * action itself is not the discriminator — the presence of at least one real condition
1191 * is. Empty or malformed rules are left behind by editor cleanup and must not exempt
1192 * a payment block.
1193 *
1194 * @param mixed $rule Stored rule for a single block.
1195 *
1196 * @since 2.12.3
1197 * @return bool True when the rule carries at least one condition.
1198 */
1199 private static function has_actionable_conditional_rule( $rule ) {
1200 if ( ! is_array( $rule ) || empty( $rule['action'] ) || empty( $rule['logic'] ) || ! is_array( $rule['logic'] ) ) {
1201 return false;
1202 }
1203
1204 foreach ( $rule['logic'] as $conditions ) {
1205 if ( ! is_array( $conditions ) ) {
1206 continue;
1207 }
1208
1209 foreach ( $conditions as $condition ) {
1210 if ( is_array( $condition ) && ! empty( $condition['field'] ) ) {
1211 return true;
1212 }
1213 }
1214 }
1215
1216 return false;
1217 }
1218
1219 /**
1220 * BOTH MODE: resolve the correct amount config keys from the payment block
1221 * config based on which flow (one-time or subscription) the user chose.
1222 *
1223 * For pure one-time / subscription blocks, the config already has the correct
1224 * scalar keys (amount_type, fixed_amount, minimum_amount, etc.) so this method
1225 * returns them unchanged. For "both" blocks, it remaps the per-type keys
1226 * (one_time_* or subscription_*) into the scalar positions the validation
1227 * functions expect.
1228 *
1229 * @param array<mixed> $payment_config Full block config from _srfm_block_config.
1230 * @param string $active_type 'one-time' or 'subscription' — which flow is active.
1231 * @return array<mixed> Config array with amount_type, fixed_amount, minimum_amount,
1232 * variable_amount_field, variable_amount_field_block_name resolved
1233 * for the active type.
1234 * @since 2.8.2
1235 */
1236 private static function resolve_payment_config_for_active_type( $payment_config, $active_type ) {
1237 // Only remap when the block is in "both" mode and the caller told us the active type.
1238 if ( 'both' !== ( $payment_config['payment_type'] ?? '' ) || empty( $active_type ) ) {
1239 return $payment_config;
1240 }
1241
1242 $prefix = 'subscription' === $active_type ? 'subscription_' : 'one_time_';
1243
1244 $resolved = $payment_config; // Keep all original keys as fallback.
1245
1246 if ( isset( $payment_config[ $prefix . 'amount_type' ] ) ) {
1247 $resolved['amount_type'] = $payment_config[ $prefix . 'amount_type' ];
1248 }
1249 if ( isset( $payment_config[ $prefix . 'fixed_amount' ] ) ) {
1250 $resolved['fixed_amount'] = (float) $payment_config[ $prefix . 'fixed_amount' ];
1251 }
1252 if ( isset( $payment_config[ $prefix . 'minimum_amount' ] ) ) {
1253 $resolved['minimum_amount'] = (float) $payment_config[ $prefix . 'minimum_amount' ];
1254 }
1255 if ( isset( $payment_config[ $prefix . 'variable_amount_field' ] ) ) {
1256 $resolved['variable_amount_field'] = $payment_config[ $prefix . 'variable_amount_field' ];
1257 }
1258 if ( isset( $payment_config[ $prefix . 'variable_amount_field_block_name' ] ) ) {
1259 $resolved['variable_amount_field_block_name'] = $payment_config[ $prefix . 'variable_amount_field_block_name' ];
1260 }
1261
1262 return $resolved;
1263 }
1264
1265 /**
1266 * Validate that a submitted dynamic amount matches one of the options configured
1267 * on a linked dropdown/multi-choice block (when single-selection is enabled).
1268 *
1269 * @since 2.8.2
1270 * @param array<string, mixed> $payment_config Resolved payment block config (active for current mode).
1271 * @param array<string, mixed> $block_config All form block configs keyed by block_id.
1272 * @param float $submitted_amount_decimal Submitted amount as a decimal (not smallest unit).
1273 * @param string $currency ISO currency code.
1274 * @return array<string, mixed>|null Validation result array with 'valid' + 'message', or null when no validation is required.
1275 */
1276 private static function validate_dynamic_amount_field( $payment_config, $block_config, $submitted_amount_decimal, $currency ) {
1277 // Check if variable amount field is from dropdown or multi-choice block.
1278 $dynamic_amount_field_block_name = $payment_config['variable_amount_field_block_name'] ?? '';
1279
1280 if ( empty( $dynamic_amount_field_block_name ) ) {
1281 // Return null because it can be old form configuration.
1282 return null;
1283 }
1284
1285 if ( 'srfm/dropdown' !== $dynamic_amount_field_block_name && 'srfm/multi-choice' !== $dynamic_amount_field_block_name ) {
1286 return null; // Not a dropdown/multi-choice, skip validation.
1287 }
1288
1289 // Get the slug of the variable amount field.
1290 $variable_amount_field_slug = ! empty( $payment_config['variable_amount_field'] ) && is_string( $payment_config['variable_amount_field'] ) ? $payment_config['variable_amount_field'] : '';
1291
1292 // Find the block config for the variable amount field by matching slug and block name.
1293 $variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );
1294
1295 // Verify the variable amount block config was found.
1296 if ( empty( $variable_amount_block_config ) || ! is_array( $variable_amount_block_config ) ) {
1297 return [
1298 'valid' => false,
1299 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
1300 ];
1301 }
1302
1303 // Check if single selection is enabled (only validate for single selection).
1304 $is_single_selection = false;
1305 if ( 'srfm/dropdown' === $dynamic_amount_field_block_name ) {
1306 // For dropdown, check if multi_select is disabled (single selection).
1307 $is_single_selection = empty( $variable_amount_block_config['multi_select'] );
1308 } elseif ( 'srfm/multi-choice' === $dynamic_amount_field_block_name ) {
1309 // For multi-choice, check if single_selection is enabled.
1310 $is_single_selection = ! empty( $variable_amount_block_config['single_selection'] );
1311 }
1312
1313 // Only validate amount matches options if single selection is enabled.
1314 if ( $is_single_selection ) {
1315 // Validate that submitted amount matches one of the allowed option values.
1316 $allowed_options = $variable_amount_block_config['options'] ?? [];
1317 if ( empty( $allowed_options ) || ! is_array( $allowed_options ) ) {
1318 return [
1319 'valid' => false,
1320 'message' => __( 'No payment options are configured for this field.', 'sureforms' ),
1321 ];
1322 }
1323
1324 // Extract allowed values from options.
1325 $allowed_values = [];
1326 foreach ( $allowed_options as $option ) {
1327 if ( isset( $option['value'] ) && ! empty( $option['value'] ) ) {
1328 $allowed_values[] = floatval( $option['value'] );
1329 }
1330 }
1331
1332 // Check if submitted amount matches any allowed value.
1333 $amount_is_valid = false;
1334 foreach ( $allowed_values as $allowed_value ) {
1335 // Allow small floating point difference (0.01) due to rounding.
1336 if ( abs( $submitted_amount_decimal - $allowed_value ) <= 0.01 ) {
1337 $amount_is_valid = true;
1338 break;
1339 }
1340 }
1341
1342 if ( ! $amount_is_valid ) {
1343 return [
1344 'valid' => false,
1345 /* translators: %s: currency code */
1346 'message' => sprintf( __( 'Invalid payment amount. Please select a valid amount from the available options.', 'sureforms' ), strtoupper( $currency ) ),
1347 ];
1348 }
1349 }
1350
1351 // Validation passed for dynamic amount field.
1352 return null;
1353 }
1354
1355 /**
1356 * Validate payment intent amount matches form configuration.
1357 *
1358 * Validates that the payment amount from Stripe matches the expected amount
1359 * based on form configuration, including dynamic amounts from dropdown/multi-choice fields.
1360 *
1361 * @since 2.3.0
1362 * @param string $block_id Block identifier.
1363 * @param int $form_id Form post ID.
1364 * @param array<string, mixed> $form_data Submitted form data.
1365 * @param int|float $payment_amount Payment amount from Stripe (in smallest currency unit).
1366 * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
1367 * @return array {
1368 * Validation result.
1369 *
1370 * @type bool $valid Whether validation passed.
1371 * @type string $message Error message if validation failed, empty if valid.
1372 * }
1373 */
1374 private static function validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount, $active_type = '' ) {
1375 // Get block configuration.
1376 $block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
1377
1378 if ( empty( $block_config ) || ! isset( $block_config[ $block_id ] ) ) {
1379 return [
1380 'valid' => false,
1381 /* translators: %1$s: expected amount, %2$s: payment amount */
1382 'message' => __( 'Payment configuration not found.', 'sureforms' ),
1383 ];
1384 }
1385
1386 $payment_config = $block_config[ $block_id ];
1387 $resolved_config = self::resolve_payment_config_for_active_type( $payment_config, $active_type );
1388 $amount_type = $resolved_config['amount_type'] ?? 'fixed';
1389
1390 // For fixed amounts, validate against configured amount.
1391 if ( 'fixed' === $amount_type ) {
1392 $configured_amount = isset( $resolved_config['fixed_amount'] ) ? floatval( $resolved_config['fixed_amount'] ) : 0;
1393
1394 // Allow small floating point difference (0.01) due to rounding.
1395 if ( abs( $payment_amount - $configured_amount ) > 0.01 ) {
1396 return [
1397 'valid' => false,
1398 /* translators: %1$s: expected amount, %2$s: payment amount */
1399 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $configured_amount, $payment_amount ),
1400 ];
1401 }
1402
1403 return [
1404 'valid' => true,
1405 'message' => '',
1406 ];
1407 }
1408
1409 // For variable amounts, validate based on source field.
1410 if ( 'variable' === $amount_type ) {
1411 // Check if variable amount comes from dropdown/multi-choice.
1412 $dynamic_amount_field_block_name = $resolved_config['variable_amount_field_block_name'] ?? '';
1413 $variable_amount_field_slug = $resolved_config['variable_amount_field'] ?? '';
1414
1415 // "Variant B": legacy/stale config may not have recorded the amount-source field
1416 // (empty source reference). Without it we cannot derive a server-side expected
1417 // amount. First try to recover it by refreshing the block config from the form's
1418 // current content — forms saved with current code record the source — which
1419 // self-heals legacy forms whose source field still exists.
1420 if ( empty( $dynamic_amount_field_block_name ) || empty( $variable_amount_field_slug ) ) {
1421 $refreshed_config = self::refresh_block_config( $form_id );
1422 if ( is_array( $refreshed_config ) && isset( $refreshed_config[ $block_id ] ) && is_array( $refreshed_config[ $block_id ] ) ) {
1423 $block_config = $refreshed_config;
1424 $resolved_config = self::resolve_payment_config_for_active_type( $block_config[ $block_id ], $active_type );
1425 $dynamic_amount_field_block_name = $resolved_config['variable_amount_field_block_name'] ?? '';
1426 $variable_amount_field_slug = $resolved_config['variable_amount_field'] ?? '';
1427 }
1428 }
1429
1430 // The source still cannot be identified (e.g. the amount-source field was deleted from
1431 // the form while the payment amount type is still "variable", so there is no field-level
1432 // config left to derive an expected amount from).
1433 //
1434 // Security: this branch previously returned a valid result unconditionally for such
1435 // forms, which allowed an unauthenticated attacker to pay any amount (down to 1 cent
1436 // when the form had no minimum-amount floor).
1437 //
1438 // If the admin configured a positive minimum amount we enforce it as the authoritative
1439 // lower bound — the only server-side guarantee available for such a form — instead of
1440 // rejecting outright. This keeps legacy forms (whose source field still has a floor)
1441 // working without requiring a re-save. With no positive floor there is nothing safe to
1442 // validate against, so we MUST fail safe and reject to avoid reopening the bypass.
1443 if ( empty( $dynamic_amount_field_block_name ) || empty( $variable_amount_field_slug ) ) {
1444 $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
1445
1446 if ( $minimum_amount > 0 ) {
1447 if ( $payment_amount < $minimum_amount ) {
1448 return [
1449 'valid' => false,
1450 /* translators: %1$s: minimum amount, %2$s: payment amount */
1451 'message' => sprintf( __( 'Payment amount below minimum. Minimum: %1$s, received %2$s.', 'sureforms' ), $minimum_amount, $payment_amount ),
1452 ];
1453 }
1454
1455 return [
1456 'valid' => true,
1457 'message' => '',
1458 ];
1459 }
1460
1461 return [
1462 'valid' => false,
1463 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
1464 ];
1465 }
1466
1467 // The amount source is identified: validate the charged amount against the
1468 // server-derived expected amount for that source. The configured minimum-amount
1469 // floor below is always enforced as an additional lower bound.
1470 $submitted_field_value = self::get_form_submitted_value_by_slug_and_block_name( $variable_amount_field_slug, $dynamic_amount_field_block_name, $form_data );
1471
1472 if ( empty( $submitted_field_value ) ) {
1473 return [
1474 'valid' => false,
1475 'message' => __( 'Variable amount field value is required.', 'sureforms' ),
1476 ];
1477 }
1478
1479 if ( 'srfm/dropdown' === $dynamic_amount_field_block_name || 'srfm/multi-choice' === $dynamic_amount_field_block_name ) {
1480 // Get the block config for the variable amount field by matching slug and block name.
1481 $variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );
1482
1483 if ( empty( $variable_amount_block_config ) || ! is_string( $submitted_field_value ) ) {
1484 return [
1485 'valid' => false,
1486 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
1487 ];
1488 }
1489
1490 // The expected amount is read from the server-side option config keyed by the
1491 // submitted selection — the attacker chooses the option, never its price.
1492 $get_expected_amount = self::get_amount_by_the_config_options( $submitted_field_value, $variable_amount_block_config );
1493
1494 // Fail safe when the submitted selection doesn't map to a configured
1495 // option value: get_amount_by_the_config_options() returns null, and
1496 // abs( $payment_amount - null ) would coerce null to 0 — reject explicitly
1497 // so the comparison can never be silently weakened by that coercion.
1498 if ( ! is_numeric( $get_expected_amount ) ) {
1499 return [
1500 'valid' => false,
1501 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
1502 ];
1503 }
1504
1505 // Validate payment amount matches expected amount.
1506 if ( abs( $payment_amount - $get_expected_amount ) > 0.01 ) {
1507 return [
1508 'valid' => false,
1509 /* translators: %1$s: expected amount, %2$s: payment amount */
1510 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $get_expected_amount, $payment_amount ),
1511 ];
1512 }
1513 } else {
1514 // Number and hidden fields. Their value may be server-determined — a
1515 // configured default value, or a calculation computed from other fields.
1516 // In those cases the expected amount MUST be derived server-side and the
1517 // value submitted with the request must never be trusted as the price.
1518 $variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );
1519
1520 if ( empty( $variable_amount_block_config ) ) {
1521 return [
1522 'valid' => false,
1523 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
1524 ];
1525 }
1526
1527 $expected_amount = self::resolve_server_side_variable_amount( $variable_amount_block_config, $block_config, $form_data );
1528
1529 if ( null !== $expected_amount ) {
1530 // Authoritative server-side amount (static default value or a
1531 // server-recomputed calculation). Reject any mismatch.
1532 if ( abs( $payment_amount - floatval( $expected_amount ) ) > 0.01 ) {
1533 return [
1534 'valid' => false,
1535 /* translators: %1$s: expected amount, %2$s: payment amount */
1536 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), floatval( $expected_amount ), $payment_amount ),
1537 ];
1538 }
1539 } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
1540 // Calculation-driven number: a null server amount means the formula
1541 // could NOT be recomputed server-side (a referenced field was
1542 // non-numeric, or the formula used something the parser can't
1543 // evaluate). This is NOT "name your price" — we must fail safe and
1544 // reject, never fall back to the client-submitted amount, which
1545 // would reopen the unauthenticated underpayment bypass.
1546 if ( ! empty( $variable_amount_block_config['enableCalculation'] ) ) {
1547 return [
1548 'valid' => false,
1549 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
1550 ];
1551 }
1552
1553 // Plain user-entered number ("name your price"): the amount is the
1554 // customer's own choice, so confirm the charge matches what they entered.
1555 // The minimum-amount floor below guards the lower bound.
1556 $number_format_type = isset( $variable_amount_block_config['format_type'] ) && ! empty( $variable_amount_block_config['format_type'] ) ? $variable_amount_block_config['format_type'] : 'us-style';
1557 $submitted_field_value = Helper::get_string_value( $submitted_field_value );
1558 $converted_payment_amount = self::normalize_amount_by_format( $submitted_field_value, $number_format_type );
1559
1560 if ( ! is_numeric( $converted_payment_amount ) || $converted_payment_amount <= 0 ) {
1561 return [
1562 'valid' => false,
1563 'message' => __( 'Variable amount field value is required.', 'sureforms' ),
1564 ];
1565 }
1566
1567 if ( abs( $payment_amount - $converted_payment_amount ) > 0.01 ) {
1568 return [
1569 'valid' => false,
1570 /* translators: %1$s: expected amount, %2$s: payment amount */
1571 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $converted_payment_amount, $payment_amount ),
1572 ];
1573 }
1574 } else {
1575 // Unresolved hidden / dynamic source: resolve_server_side_variable_amount()
1576 // returned null (e.g. a hidden field whose default is a smart tag like
1577 // {get_input:amount}, stored raw and therefore non-numeric), so the submitted
1578 // value cannot be trusted as the price and there is no server-authoritative
1579 // amount to compare against. The configured minimum-amount floor is then the
1580 // ONLY server-side guarantee, so it must be a positive authoritative value.
1581 //
1582 // This mirrors the "amount source not identified" handling above: with a
1583 // positive minimum we fall through to the floor check below (the documented
1584 // dynamic-prefill case keeps working); with no positive minimum there is
1585 // nothing safe to validate against, so we MUST fail safe and reject rather than
1586 // letting the floor default to 0 and accept any amount down to the gateway cent
1587 // floor — which would reopen the unauthenticated underpayment bypass. Merchants
1588 // doing custom JS-driven dynamic pricing must supply a server-authoritative
1589 // amount via the `srfm_server_side_variable_amount` filter or a
1590 // calculation-enabled field rather than relying on the submitted value.
1591 $unresolved_minimum = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
1592
1593 if ( $unresolved_minimum <= 0 ) {
1594 return [
1595 'valid' => false,
1596 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
1597 ];
1598 }
1599 }
1600 }
1601
1602 // All variable amount sources are subject to the configured minimum amount floor.
1603 // Use resolved_config so 'both'-mode forms read the active type's per-type minimum
1604 // (oneTimeMinimumAmount / subscriptionMinimumAmount) instead of the unset legacy scalar.
1605 $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
1606
1607 if ( $payment_amount < $minimum_amount ) {
1608 return [
1609 'valid' => false,
1610 /* translators: %1$s: minimum amount, %2$s: payment amount */
1611 'message' => sprintf( __( 'Payment amount below minimum. Minimum: %1$s, received %2$s.', 'sureforms' ), $minimum_amount, $payment_amount ),
1612 ];
1613 }
1614 }
1615
1616 // Validation passed.
1617 return [
1618 'valid' => true,
1619 'message' => '',
1620 ];
1621 }
1622
1623 /**
1624 * Force a refresh of the form's stored block configuration from its current content.
1625 *
1626 * Recovers the amount-source field reference for legacy forms whose cached
1627 * _srfm_block_config predates server-side source tracking (an empty
1628 * variable_amount_field_block_name). Re-parses the form blocks and rebuilds the config —
1629 * forms saved with current code record the source — then returns the refreshed config.
1630 *
1631 * @param int $form_id Form post ID.
1632 * @since 2.11.1
1633 * @return array<mixed>|null Refreshed block configuration, or null if it cannot be rebuilt.
1634 */
1635 private static function refresh_block_config( $form_id ) {
1636 if ( ! is_int( $form_id ) || $form_id <= 0 ) {
1637 return null;
1638 }
1639
1640 $post = get_post( $form_id );
1641 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) || ! function_exists( 'parse_blocks' ) ) {
1642 return null;
1643 }
1644
1645 $blocks = parse_blocks( $post->post_content );
1646 if ( is_array( $blocks ) && ! empty( $blocks ) ) {
1647 Field_Validation::add_block_config( $blocks, $form_id );
1648 }
1649
1650 return Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
1651 }
1652
1653 /**
1654 * Resolve the authoritative server-side expected amount for a variable amount source.
1655 *
1656 * The expected amount is ALWAYS derived from server-side configuration — the field's
1657 * configured default value, or (for calculation-enabled fields) a value recomputed by
1658 * SureForms Pro from the submitted inputs. It is NEVER taken from the value submitted with
1659 * the request. Returns null when no authoritative amount can be determined server-side, in
1660 * which case the caller falls back to the configured minimum-amount floor.
1661 *
1662 * @param array<mixed> $source_config The amount-source field block config (block_name, slug, enableCalculation, defaultValue, calculationFormula, ...).
1663 * @param array<mixed> $block_config All block configurations for the form.
1664 * @param array<mixed> $form_data Submitted form data.
1665 * @since 2.11.1
1666 * @return float|null Expected amount, or null if it cannot be determined server-side.
1667 */
1668 private static function resolve_server_side_variable_amount( $source_config, $block_config, $form_data ) {
1669 if ( empty( $source_config ) || ! is_array( $source_config ) ) {
1670 return null;
1671 }
1672
1673 /**
1674 * Compute the authoritative server-side amount for a variable payment source.
1675 *
1676 * SureForms Pro hooks this to recompute a field's calculation formula from the
1677 * submitted field values. Handlers MUST return a numeric value derived only from
1678 * server-side configuration and other submitted inputs — never the raw value of the
1679 * amount field submitted with the request — or null if it cannot be computed.
1680 *
1681 * @since 2.11.1
1682 * @param float|null $amount The resolved amount. Default null.
1683 * @param array<string, mixed> $context Context: source_config, block_config, form_data.
1684 */
1685 $expected = apply_filters(
1686 'srfm_server_side_variable_amount',
1687 null,
1688 [
1689 'source_config' => $source_config,
1690 'block_config' => $block_config,
1691 'form_data' => $form_data,
1692 ]
1693 );
1694
1695 if ( is_numeric( $expected ) ) {
1696 return floatval( $expected );
1697 }
1698
1699 // Static hidden field: a *literal numeric* configured default value is the server-side
1700 // source of truth and is authoritative. A non-numeric default (e.g. a smart tag such as
1701 // {get_input:amount} stored raw, resolved to a runtime value only at render time) is NOT
1702 // treated as authoritative here — it returns null below so the caller validates against the
1703 // minimum-amount floor instead, preserving the documented dynamic-prefill behavior.
1704 $block_name = $source_config['block_name'] ?? ( $source_config['blockName'] ?? '' );
1705 if ( 'srfm/hidden' === $block_name && empty( $source_config['enableCalculation'] ) && isset( $source_config['defaultValue'] ) && is_numeric( $source_config['defaultValue'] ) ) {
1706 return floatval( $source_config['defaultValue'] );
1707 }
1708
1709 return null;
1710 }
1711
1712 /**
1713 * Get amount by matching submitted value with config options.
1714 *
1715 * @param string $submitted_field_value The submitted value (string, can be "value1 | value2" for multi-select).
1716 * @param array<mixed> $block_config Block configuration containing options.
1717 * @return float|null Expected amount if found, null otherwise.
1718 * @since 2.3.0
1719 */
1720 private static function get_amount_by_the_config_options( $submitted_field_value, $block_config ) {
1721 if ( empty( $submitted_field_value ) || ! is_string( $submitted_field_value ) ) {
1722 return null;
1723 }
1724
1725 // Get options from block config.
1726 $options = $block_config['options'] ?? [];
1727
1728 if ( empty( $options ) || ! is_array( $options ) ) {
1729 return null;
1730 }
1731
1732 // Check if multi-select is enabled.
1733 $is_multi_select = false;
1734 $block_name = $block_config['block_name'] ?? '';
1735
1736 if ( 'srfm/dropdown' === $block_name ) {
1737 $is_multi_select = ! empty( $block_config['multi_select'] );
1738 } elseif ( 'srfm/multi-choice' === $block_name ) {
1739 // For multi-choice, multi-select is when single_selection is disabled.
1740 $is_multi_select = empty( $block_config['single_selection'] );
1741 }
1742
1743 $expected_amount = null;
1744
1745 // Handle multi-select case (submitted value format: "value1 | value2").
1746 if ( $is_multi_select && false !== strpos( $submitted_field_value, ' | ' ) ) {
1747 // Explode the submitted value by " | " delimiter.
1748 $submitted_values = explode( ' | ', $submitted_field_value );
1749
1750 $combine_amount = 0;
1751
1752 foreach ( $options as $option ) {
1753 $option_label = isset( $option['label'] ) ? trim( $option['label'] ) : '';
1754
1755 foreach ( $submitted_values as $submitted_value ) {
1756 if ( trim( $submitted_value ) === $option_label ) {
1757 $combine_amount += floatval( $option['value'] );
1758 break;
1759 }
1760 }
1761 }
1762
1763 $expected_amount = $combine_amount;
1764
1765 } else {
1766 // Handle single select case (submitted value is a simple string).
1767 foreach ( $options as $option ) {
1768 $option_label = isset( $option['label'] ) ? trim( $option['label'] ) : '';
1769 if ( trim( $submitted_field_value ) === $option_label ) {
1770 $expected_amount = floatval( $option['value'] );
1771 break;
1772 }
1773 }
1774 }
1775
1776 return $expected_amount;
1777 }
1778
1779 /**
1780 * Get block configuration by block name and slug.
1781 *
1782 * @param array<mixed> $block_config All block configurations.
1783 * @param string $block_name Block name to search for.
1784 * @param string $slug Slug to match.
1785 * @return array|null Block configuration if found, null otherwise.
1786 * @since 2.3.0
1787 */
1788 private static function get_block_config_by_name_and_slug( $block_config, $block_name, $slug ) {
1789 foreach ( $block_config as $config ) {
1790 if ( empty( $config ) || ! is_array( $config ) ) {
1791 continue;
1792 }
1793
1794 // Core blocks store the block name under 'block_name'; Pro blocks (e.g. the hidden
1795 // field, registered via the srfm_block_config filter) store it under 'blockName'.
1796 // Accept either so Pro-sourced amount fields resolve correctly.
1797 $config_block_name = $config['block_name'] ?? ( $config['blockName'] ?? '' );
1798
1799 if ( isset( $config['slug'] ) && $config['slug'] === $slug && $config_block_name === $block_name ) {
1800 return $config;
1801 }
1802 }
1803 return null;
1804 }
1805
1806 /**
1807 * Normalize amount based on number format type (EU-style or US-style).
1808 *
1809 * @param string|float $amount The amount to normalize.
1810 * @param string $format_type The format type: 'eu-style' or 'us-style'.
1811 * @return float The normalized amount as a float.
1812 * @since 2.4.0
1813 */
1814 private static function normalize_amount_by_format( $amount, $format_type = 'us-style' ) {
1815 // If already a number, return it.
1816 if ( is_numeric( $amount ) && ! is_string( $amount ) ) {
1817 return floatval( $amount );
1818 }
1819
1820 // Convert to string and trim.
1821 $amount_str = trim( strval( $amount ) );
1822
1823 if ( 'eu-style' === $format_type ) {
1824 // EU-style: 1.234,56 (period = thousands, comma = decimal).
1825 // Remove periods (thousands separator) and replace comma with period (decimal).
1826 $amount_str = str_replace( '.', '', $amount_str );
1827 $amount_str = str_replace( ',', '.', $amount_str );
1828 } else {
1829 // US-style (default): 1,234.56 (comma = thousands, period = decimal).
1830 // Remove commas (thousands separator).
1831 $amount_str = str_replace( ',', '', $amount_str );
1832 }
1833
1834 return floatval( $amount_str );
1835 }
1836
1837 /**
1838 * Get form submitted value for a specific field by slug and block name.
1839 *
1840 * @param string $variable_amount_field_slug Slug of the field to find.
1841 * @param string $dynamic_amount_field_block_name Block name of the field.
1842 * @param array<mixed> $form_data Form submission data.
1843 * @return mixed|null Field value if found, null otherwise.
1844 * @since 2.3.0
1845 */
1846 private static function get_form_submitted_value_by_slug_and_block_name( $variable_amount_field_slug, $dynamic_amount_field_block_name, $form_data ) {
1847 $block_name = null;
1848 if ( 'srfm/dropdown' === $dynamic_amount_field_block_name ) {
1849 $block_name = 'srfm-dropdown';
1850 } elseif ( 'srfm/multi-choice' === $dynamic_amount_field_block_name ) {
1851 $block_name = 'srfm-input-multi-choice';
1852 } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
1853 $block_name = 'srfm-number';
1854 } elseif ( 'srfm/hidden' === $dynamic_amount_field_block_name ) {
1855 $block_name = 'srfm-hidden';
1856 }
1857
1858 // Now we need to get the submitted value.
1859 // Here is the structure of the form data name.
1860 // srfm-input-multi-choice-398dbcfe-lbl-UGxlYXNlIGNob29zZSBvcHRpb24-multi-choice
1861 // {block_name}-{block_id}-lbl-{combined-id}-{slug}.
1862 $submitted_field_value = null;
1863 foreach ( $form_data as $field_key => $field_value ) {
1864 // Check if field key starts with block_name- and ends with -slug.
1865 $is_start_with_block_name = strpos( $field_key, $block_name . '-' ) === 0;
1866 $is_last_with_slug = substr( $field_key, -strlen( '-' . $variable_amount_field_slug ) ) === '-' . $variable_amount_field_slug;
1867
1868 if ( $is_start_with_block_name && $is_last_with_slug ) {
1869 $submitted_field_value = $field_value;
1870 break;
1871 }
1872 }
1873
1874 return $submitted_field_value;
1875 }
1876
1877 /**
1878 * Get default payment settings (global + all gateways).
1879 *
1880 * @since 2.0.0
1881 * @return array<string, mixed> Default payment settings structure.
1882 */
1883 private static function get_default_payment_settings() {
1884 return [
1885 'currency' => 'USD',
1886 'payment_mode' => 'test',
1887 'currency_sign_position' => 'left',
1888 'stripe' => Stripe_Helper::get_default_stripe_settings(),
1889 ];
1890 }
1891 }
1892