PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.1
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.1
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 1 month ago payment-helper.php 2 months ago payment-history-shortcode.php 4 months ago payments.php 4 months ago
payment-helper.php
1614 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
488 // Field mapping validation.
489 'payment_name_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer name field.', 'sureforms' ),
490 'payment_email_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer email field.', 'sureforms' ),
491 'payment_name_required' => __( 'Please enter your name.', 'sureforms' ),
492 'payment_email_required' => __( 'Please enter your email.', 'sureforms' ),
493
494 // Payment processing messages.
495 'payment_failed' => __( 'Payment failed', 'sureforms' ),
496 'payment_successful' => __( 'Payment successful', 'sureforms' ),
497 'payment_could_not_be_completed' => __( 'Unable to complete payment. Please try again or contact support.', 'sureforms' ),
498
499 // Stripe decline codes - Card declined errors.
500 'generic_decline' => __( 'Your card was declined. Please try a different payment method or contact your bank.', 'sureforms' ),
501 'card_declined' => __( 'Your card was declined. Please try a different payment method or contact your bank.', 'sureforms' ),
502 'insufficient_funds' => __( 'Your card has insufficient funds. Please use a different payment method.', 'sureforms' ),
503 'lost_card' => __( 'Your card was declined because it has been reported as lost. Please contact your bank.', 'sureforms' ),
504 'stolen_card' => __( 'Your card was declined because it has been reported as stolen. Please contact your bank.', 'sureforms' ),
505 'expired_card' => __( 'Your card has expired. Please use a different payment method.', 'sureforms' ),
506 'pickup_card' => __( 'Your card was declined. Please contact your bank for more information.', 'sureforms' ),
507 'restricted_card' => __( 'Your card was declined due to restrictions. Please contact your bank.', 'sureforms' ),
508 'security_violation' => __( 'Your card was declined due to a security violation. Please contact your bank.', 'sureforms' ),
509 'service_not_allowed' => __( 'Your card does not support this type of purchase. Please use a different payment method.', 'sureforms' ),
510 'stop_payment_order' => __( 'A stop payment order has been placed on this card. Please contact your bank.', 'sureforms' ),
511 'testmode_decline' => __( 'A test card was used in a live environment. Please use a real card.', 'sureforms' ),
512 'withdrawal_count_limit_exceeded' => __( 'Your card has exceeded its withdrawal limit. Please contact your bank.', 'sureforms' ),
513 'incorrect_cvc' => __( 'Your card\'s security code is incorrect. Please check and try again.', 'sureforms' ),
514 'incorrect_number' => __( 'Your card number is incorrect. Please check and try again.', 'sureforms' ),
515 'invalid_cvc' => __( 'Your card\'s security code is invalid. Please check and try again.', 'sureforms' ),
516 'invalid_expiry_month' => __( 'Your card\'s expiration month is invalid. Please check and try again.', 'sureforms' ),
517 'invalid_expiry_year' => __( 'Your card\'s expiration year is invalid. Please check and try again.', 'sureforms' ),
518 'invalid_number' => __( 'Your card number is invalid. Please check and try again.', 'sureforms' ),
519 'processing_error' => __( 'Unable to process card. Please try again.', 'sureforms' ),
520 'reenter_transaction' => __( 'Unable to process transaction. Please try again.', 'sureforms' ),
521 'card_not_supported' => __( 'Your card is not supported for this transaction. Please use a different payment method.', 'sureforms' ),
522 'currency_not_supported' => __( 'Your card does not support the currency used for this transaction. Please use a different payment method.', 'sureforms' ),
523 'duplicate_transaction' => __( 'A transaction with identical details was submitted recently. Please wait a moment and try again.', 'sureforms' ),
524 'invalid_account' => __( 'The account associated with your card is invalid. Please contact your bank.', 'sureforms' ),
525 'invalid_amount' => __( 'The payment amount is invalid. Please contact the site administrator.', 'sureforms' ),
526 'issuer_not_available' => __( 'Unable to reach card issuer. Please try again later.', 'sureforms' ),
527 'merchant_blacklist' => __( 'Your card was declined. Please contact your bank for more information.', 'sureforms' ),
528 'new_account_information_available' => __( 'Your card information needs to be updated. Please contact your bank.', 'sureforms' ),
529 'no_action_taken' => __( 'The card cannot be used for this transaction. Please contact your bank.', 'sureforms' ),
530 'not_permitted' => __( 'The transaction is not permitted. Please contact your bank.', 'sureforms' ),
531 'offline_pin_required' => __( 'Your card requires offline PIN authentication. Please try again.', 'sureforms' ),
532 'online_or_offline_pin_required' => __( 'Your card requires PIN authentication. Please try again.', 'sureforms' ),
533 'pin_try_exceeded' => __( 'You have exceeded the maximum number of PIN attempts. Please contact your bank.', 'sureforms' ),
534 'revocation_of_all_authorizations' => __( 'All authorizations for this card have been revoked. Please contact your bank.', 'sureforms' ),
535 'revocation_of_authorization' => __( 'The authorization for this transaction has been revoked. Please try again.', 'sureforms' ),
536 'transaction_not_allowed' => __( 'This transaction is not allowed. Please contact your bank.', 'sureforms' ),
537 'try_again_later' => __( 'Unable to process transaction. Please try again later.', 'sureforms' ),
538 'live_mode_test_card' => __( 'Your card was declined. Your request was in live mode, but used a known test card.', 'sureforms' ),
539 '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' ),
540
541 // Default values and placeholders.
542 'sureforms_subscription' => __( 'SureForms Subscription', 'sureforms' ),
543 'sureforms_payment' => __( 'SureForms Payment', 'sureforms' ),
544 'subscription_plan' => __( 'Subscription Plan', 'sureforms' ),
545 'sureforms_customer' => __( 'SureForms Customer', 'sureforms' ),
546 'customer_example_email' => 'customer@example.com', // Not translatable - example email.
547 'amount_placeholder' => __( 'Complete the form to view the amount.', 'sureforms' ),
548 'failed_to_create_payment' => __( 'Unable to create payment. Please contact support.', 'sureforms' ),
549 ];
550 }
551
552 /**
553 * Retrieve a user-friendly payment error message by error key.
554 *
555 * @param string $key Error key received from payment processing/Stripe.
556 *
557 * @since 2.0.0
558 * @return string Localized error message or a generic "Unknown error" message if not found.
559 */
560 public static function get_error_message_by_key( $key ) {
561 $messages = self::get_payment_strings();
562 if ( isset( $messages[ $key ] ) ) {
563 return $messages[ $key ];
564 }
565 return __( 'Unknown error', 'sureforms' );
566 }
567
568 /**
569 * Validate payment amount against stored form configuration.
570 *
571 * This function verifies that the payment amount and currency submitted
572 * match the configured values in the form's payment block settings.
573 * It handles both fixed and minimum amount validations for single and subscription payments.
574 *
575 * @since 2.2.2
576 * @param int|float $amount Amount in smallest currency unit (e.g., cents for USD).
577 * @param string $currency Currency code (e.g., 'usd', 'eur').
578 * @param int $form_id WordPress post ID of the form.
579 * @param string $block_id Block identifier for the payment block.
580 * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
581 * @return array {
582 * Validation result.
583 *
584 * @type bool $valid Whether the validation passed.
585 * @type string $message Error message if validation failed, empty if valid.
586 * }
587 */
588 public static function validate_payment_amount( $amount, $currency, $form_id, $block_id, $active_type = '' ) {
589 // Retrieve block configuration from post meta.
590 $block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
591
592 // Check if block config exists.
593 if ( empty( $block_config ) || ! is_array( $block_config ) ) {
594 return [
595 'valid' => false,
596 'message' => __( 'Invalid form configuration.', 'sureforms' ),
597 ];
598 }
599
600 // Check if payment block exists in configuration.
601 if ( ! isset( $block_config[ $block_id ] ) || ! is_array( $block_config[ $block_id ] ) ) {
602 return [
603 'valid' => false,
604 'message' => __( 'Payment configuration not found for this form.', 'sureforms' ),
605 ];
606 }
607
608 $payment_config = $block_config[ $block_id ];
609 $global_currency = strtolower( self::get_currency() );
610 $submitted_currency = strtolower( $currency );
611 if ( $global_currency !== $submitted_currency ) {
612 return [
613 'valid' => false,
614 /* translators: 1: expected currency, 2: received currency */
615 'message' => sprintf( __( 'Currency mismatch: expected %1$s, received %2$s.', 'sureforms' ), strtoupper( $global_currency ), strtoupper( $submitted_currency ) ),
616 ];
617 }
618
619 // Reject when the requested flow (one-time vs subscription) is not allowed by
620 // the form's stored payment_type. "both" mode allows either flow; pure modes
621 // allow only their matching flow. Without this guard, an attacker could call
622 // the wrong intent-creation route on a pure-subscription form and pay once for
623 // what should be a recurring charge (or vice versa).
624 $payment_type = isset( $payment_config['payment_type'] ) && is_string( $payment_config['payment_type'] ) ? $payment_config['payment_type'] : 'one-time';
625 if ( ! empty( $active_type ) && 'both' !== $payment_type && $active_type !== $payment_type ) {
626 return [
627 'valid' => false,
628 'message' => __( 'Payment type does not match the form configuration.', 'sureforms' ),
629 ];
630 }
631
632 // BOTH MODE: when payment_type is 'both', resolve the correct per-type
633 // config (amount_type, fixed_amount, minimum_amount, variable_amount_field)
634 // based on which flow the user actually chose (one-time vs subscription).
635 $resolved_config = self::resolve_payment_config_for_active_type( $payment_config, $active_type );
636
637 // Get amount type (fixed or minimum).
638 $amount_type = $resolved_config['amount_type'] ?? 'fixed';
639
640 // Validate based on amount type.
641 if ( 'fixed' === $amount_type ) {
642 // Fixed amount validation - must match exactly.
643 $configured_amount = isset( $resolved_config['fixed_amount'] ) ? floatval( $resolved_config['fixed_amount'] ) : 10.00;
644
645 // Allow small floating point difference (0.01) due to rounding.
646 if ( abs( $amount - $configured_amount ) > 0.01 ) {
647 return [
648 'valid' => false,
649 /* translators: 1: expected amount with currency */
650 'message' => sprintf( __( 'Payment amount must be exactly %1$s.', 'sureforms' ), $configured_amount . ' ' . strtoupper( $currency ) ),
651 ];
652 }
653 } elseif ( 'variable' === $amount_type ) {
654 // Minimum amount validation - must be >= minimum.
655 $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
656
657 if ( $amount < $minimum_amount ) {
658 return [
659 'valid' => false,
660 /* translators: 1: minimum amount with currency */
661 'message' => sprintf( __( 'Payment amount must be at least %1$s.', 'sureforms' ), $minimum_amount . ' ' . strtoupper( $currency ) ),
662 ];
663 }
664
665 // Validate dynamic amount from dropdown/multi-choice field.
666 $dynamic_amount_validation = self::validate_dynamic_amount_field(
667 $resolved_config,
668 $block_config,
669 $amount,
670 $currency
671 );
672
673 if ( null !== $dynamic_amount_validation ) {
674 return $dynamic_amount_validation;
675 }
676 }
677
678 // Validation passed.
679 return [
680 'valid' => true,
681 'message' => '',
682 ];
683 }
684
685 /**
686 * Store payment intent metadata in transient for verification.
687 *
688 * Stores payment intent details temporarily to verify that the payment intent
689 * was created through our system and hasn't been tampered with.
690 *
691 * @since 2.2.2
692 * @param string $block_id Block identifier.
693 * @param string $payment_intent_id Payment intent ID from Stripe.
694 * @param array<string, mixed> $metadata Payment metadata to store.
695 * @return bool True on success, false on failure.
696 */
697 public static function store_payment_intent_metadata( $block_id, $payment_intent_id, $metadata ) {
698 if ( empty( $block_id ) || empty( $payment_intent_id ) ) {
699 return false;
700 }
701
702 // Create transient key: srfm_pi_{block_id}_{payment_intent_id}.
703 $transient_key = 'srfm_pi_' . sanitize_key( $block_id ) . '_' . sanitize_key( $payment_intent_id );
704
705 // Add timestamp to metadata.
706 $metadata['created_at'] = time();
707
708 // Store for 1 hour (3600 seconds).
709 return set_transient( $transient_key, $metadata, 3600 );
710 }
711
712 /**
713 * Verify payment intent and validate amount.
714 *
715 * Verifies that the payment intent was created through our system and validates
716 * the payment amount matches the expected amount based on form configuration.
717 *
718 * @since 2.3.0
719 * @param string $block_id Block identifier.
720 * @param string $payment_intent_id Payment intent ID from Stripe.
721 * @param array<string, mixed> $form_data Submitted form data.
722 * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
723 * @return array {
724 * Verification result.
725 *
726 * @type bool $valid Whether verification passed.
727 * @type string $message Error message if verification failed, empty if valid.
728 * }
729 */
730 public static function verify_payment_intent( $block_id, $payment_intent_id, $form_data, $active_type = '' ) {
731 // Get form ID from form data for verification.
732 $form_id = isset( $form_data['form-id'] ) && ! empty( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? intval( $form_data['form-id'] ) : 0;
733
734 // Validate required parameters.
735 if ( empty( $block_id ) || empty( $payment_intent_id ) || empty( $form_id ) ) {
736 return [
737 'valid' => false,
738 'message' => __( 'Invalid payment verification parameters.', 'sureforms' ),
739 ];
740 }
741
742 // Verify payment intent was created through our system.
743 $transient_key = 'srfm_pi_' . sanitize_key( $block_id ) . '_' . sanitize_key( $payment_intent_id );
744 $metadata = get_transient( $transient_key );
745
746 if ( empty( $metadata ) || ! is_array( $metadata ) ) {
747 return [
748 'valid' => false,
749 'message' => __( 'Payment verification failed. Invalid payment intent.', 'sureforms' ),
750 ];
751 }
752
753 // Reject when the submit path's active_type does not match the type that
754 // was validated at intent-creation time. Prevents an attacker from passing
755 // a one-time intent_id through the subscription submit path (or vice versa)
756 // to replay a small one-time charge in place of a recurring subscription.
757 $stored_active_type = isset( $metadata['active_type'] ) && is_string( $metadata['active_type'] ) ? $metadata['active_type'] : '';
758 if ( ! empty( $active_type ) && ! empty( $stored_active_type ) && $active_type !== $stored_active_type ) {
759 return [
760 'valid' => false,
761 'message' => __( 'Payment verification failed. Payment type mismatch.', 'sureforms' ),
762 ];
763 }
764
765 $payment_amount = isset( $metadata['amount'] ) && ! empty( $metadata['amount'] ) && is_numeric( $metadata['amount'] ) ? floatval( $metadata['amount'] ) : 0;
766
767 // Validate payment amount matches configuration.
768 $amount_validation = self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount, $active_type );
769
770 if ( false === $amount_validation['valid'] ) {
771 return $amount_validation;
772 }
773
774 // Verification passed.
775 return [
776 'valid' => true,
777 'message' => '',
778 ];
779 }
780
781 /**
782 * Validate an arbitrary amount against the form's server-side payment configuration.
783 *
784 * Public wrapper around the amount validator so the submission flow can re-check the amount
785 * Stripe actually charged (defense-in-depth) — not only the amount recorded when the intent was
786 * created.
787 *
788 * @param string $block_id Block identifier.
789 * @param int $form_id Form post ID.
790 * @param array<string, mixed> $form_data Submitted form data.
791 * @param float $amount Amount to validate (decimal, in the form currency).
792 * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
793 * @since 2.11.1
794 * @return array<string, mixed> Validation result with 'valid' (bool) and 'message' (string) keys.
795 */
796 public static function validate_amount_against_config( $block_id, $form_id, $form_data, $amount, $active_type = '' ) {
797 return self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $amount, $active_type );
798 }
799
800 /**
801 * Delete payment intent metadata from transient.
802 *
803 * Cleans up stored metadata after successful payment verification.
804 *
805 * @since 2.2.2
806 * @param string $block_id Block identifier.
807 * @param string $payment_intent_id Payment intent ID from Stripe.
808 * @return bool True on success, false on failure.
809 */
810 public static function delete_payment_intent_metadata( $block_id, $payment_intent_id ) {
811 if ( empty( $block_id ) || empty( $payment_intent_id ) ) {
812 return false;
813 }
814
815 // Create transient key: srfm_pi_{block_id}_{payment_intent_id}.
816 $transient_key = 'srfm_pi_' . sanitize_key( $block_id ) . '_' . sanitize_key( $payment_intent_id );
817
818 return delete_transient( $transient_key );
819 }
820
821 /**
822 * Get currency sign position.
823 *
824 * @since 2.5.1
825 * @return string Currency sign position ('left', 'right', 'left_space', 'right_space').
826 */
827 public static function get_currency_sign_position() {
828 $result = self::get_global_setting( 'currency_sign_position', 'left' );
829
830 return ! empty( $result ) && is_string( $result ) ? $result : 'left';
831 }
832
833 /**
834 * Get a submitted form value by field slug.
835 *
836 * Matches the SureForms field-name convention `{block}-{block_id}-lbl-{label}-{slug}` by
837 * suffix, regardless of block type. Used to resolve `{form:slug}` tokens when recomputing a
838 * calculation server-side. Returns null when the slug is not present in the submission.
839 *
840 * @param string $slug The field slug to look up.
841 * @param array<mixed> $form_data Submitted form data.
842 * @since 2.11.1
843 * @return mixed|null The submitted value, or null when not found.
844 */
845 public static function get_submitted_value_by_slug( $slug, $form_data ) {
846 if ( empty( $slug ) || ! is_string( $slug ) || ! is_array( $form_data ) ) {
847 return null;
848 }
849
850 $suffix = '-' . $slug;
851 foreach ( $form_data as $field_key => $field_value ) {
852 if ( ! is_string( $field_key ) || false === strpos( $field_key, '-lbl-' ) ) {
853 continue;
854 }
855
856 if ( substr( $field_key, -strlen( $suffix ) ) === $suffix ) {
857 return $field_value;
858 }
859 }
860
861 return null;
862 }
863
864 /**
865 * Resolve the WordPress user associated with a payment record.
866 *
867 * Resolution order:
868 * 1. The linked entry's `user_id` (set when a logged-in user submitted the form).
869 * 2. A user matching the payment's `customer_email`.
870 * 3. `0` for guest checkouts where no WordPress user can be resolved.
871 *
872 * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
873 * @return int Resolved WordPress user ID, or 0 when none can be determined.
874 * @since 2.12.0
875 */
876 public static function resolve_payment_user( $payment ) {
877 if ( ! is_array( $payment ) ) {
878 return 0;
879 }
880
881 // 1. Prefer the user_id stored on the linked entry.
882 $entry_id = ! empty( $payment['entry_id'] ) && is_numeric( $payment['entry_id'] ) ? intval( $payment['entry_id'] ) : 0;
883 if ( $entry_id > 0 ) {
884 $entry = Entries::get( $entry_id );
885 if ( is_array( $entry ) && ! empty( $entry['user_id'] ) && is_numeric( $entry['user_id'] ) ) {
886 $user_id = intval( $entry['user_id'] );
887 if ( $user_id > 0 ) {
888 return $user_id;
889 }
890 }
891 }
892
893 // 2. Fall back to a user matching the customer email.
894 $customer_email = ! empty( $payment['customer_email'] ) && is_string( $payment['customer_email'] ) ? sanitize_email( $payment['customer_email'] ) : '';
895 if ( ! empty( $customer_email ) ) {
896 $user = get_user_by( 'email', $customer_email );
897 if ( $user instanceof \WP_User ) {
898 return intval( $user->ID );
899 }
900 }
901
902 // 3. Guest checkout — no resolvable WordPress user.
903 return 0;
904 }
905
906 /**
907 * Build the standard context array passed alongside payment-lifecycle actions.
908 *
909 * Gives consumers (membership, LMS and other plugins) a consistent, resolved
910 * snapshot of who paid and through which form/gateway, without each consumer
911 * having to re-derive it from the raw payment row.
912 *
913 * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
914 * @return array{form_id:int, entry_id:int, user_id:int, customer_email:string, type:string, gateway:string, mode:string} Resolved payment context.
915 * @since 2.12.0
916 */
917 public static function build_payment_context( $payment ) {
918 $payment = is_array( $payment ) ? $payment : [];
919
920 return [
921 'form_id' => ! empty( $payment['form_id'] ) && is_numeric( $payment['form_id'] ) ? intval( $payment['form_id'] ) : 0,
922 'entry_id' => ! empty( $payment['entry_id'] ) && is_numeric( $payment['entry_id'] ) ? intval( $payment['entry_id'] ) : 0,
923 'user_id' => self::resolve_payment_user( $payment ),
924 'customer_email' => ! empty( $payment['customer_email'] ) && is_string( $payment['customer_email'] ) ? sanitize_email( $payment['customer_email'] ) : '',
925 'type' => ! empty( $payment['type'] ) && is_string( $payment['type'] ) ? sanitize_text_field( $payment['type'] ) : '',
926 'gateway' => ! empty( $payment['gateway'] ) && is_string( $payment['gateway'] ) ? sanitize_text_field( $payment['gateway'] ) : '',
927 'mode' => ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? sanitize_text_field( $payment['mode'] ) : '',
928 ];
929 }
930
931 /**
932 * Validate dynamic amount field from dropdown or multi-choice.
933 *
934 * @param array<string, mixed> $payment_config Payment block configuration.
935 * @param array<string, mixed> $block_config All block configurations.
936 * @param float $submitted_amount_decimal Submitted amount in decimal.
937 * @param string $currency Currency code.
938 * @return array|null Validation result array or null if validation passes.
939 * @since 2.3.0
940 */
941 /**
942 * BOTH MODE: resolve the correct amount config keys from the payment block
943 * config based on which flow (one-time or subscription) the user chose.
944 *
945 * For pure one-time / subscription blocks, the config already has the correct
946 * scalar keys (amount_type, fixed_amount, minimum_amount, etc.) so this method
947 * returns them unchanged. For "both" blocks, it remaps the per-type keys
948 * (one_time_* or subscription_*) into the scalar positions the validation
949 * functions expect.
950 *
951 * @param array<mixed> $payment_config Full block config from _srfm_block_config.
952 * @param string $active_type 'one-time' or 'subscription' — which flow is active.
953 * @return array<mixed> Config array with amount_type, fixed_amount, minimum_amount,
954 * variable_amount_field, variable_amount_field_block_name resolved
955 * for the active type.
956 * @since 2.8.2
957 */
958 private static function resolve_payment_config_for_active_type( $payment_config, $active_type ) {
959 // Only remap when the block is in "both" mode and the caller told us the active type.
960 if ( 'both' !== ( $payment_config['payment_type'] ?? '' ) || empty( $active_type ) ) {
961 return $payment_config;
962 }
963
964 $prefix = 'subscription' === $active_type ? 'subscription_' : 'one_time_';
965
966 $resolved = $payment_config; // Keep all original keys as fallback.
967
968 if ( isset( $payment_config[ $prefix . 'amount_type' ] ) ) {
969 $resolved['amount_type'] = $payment_config[ $prefix . 'amount_type' ];
970 }
971 if ( isset( $payment_config[ $prefix . 'fixed_amount' ] ) ) {
972 $resolved['fixed_amount'] = (float) $payment_config[ $prefix . 'fixed_amount' ];
973 }
974 if ( isset( $payment_config[ $prefix . 'minimum_amount' ] ) ) {
975 $resolved['minimum_amount'] = (float) $payment_config[ $prefix . 'minimum_amount' ];
976 }
977 if ( isset( $payment_config[ $prefix . 'variable_amount_field' ] ) ) {
978 $resolved['variable_amount_field'] = $payment_config[ $prefix . 'variable_amount_field' ];
979 }
980 if ( isset( $payment_config[ $prefix . 'variable_amount_field_block_name' ] ) ) {
981 $resolved['variable_amount_field_block_name'] = $payment_config[ $prefix . 'variable_amount_field_block_name' ];
982 }
983
984 return $resolved;
985 }
986
987 /**
988 * Validate that a submitted dynamic amount matches one of the options configured
989 * on a linked dropdown/multi-choice block (when single-selection is enabled).
990 *
991 * @since 2.8.2
992 * @param array<string, mixed> $payment_config Resolved payment block config (active for current mode).
993 * @param array<string, mixed> $block_config All form block configs keyed by block_id.
994 * @param float $submitted_amount_decimal Submitted amount as a decimal (not smallest unit).
995 * @param string $currency ISO currency code.
996 * @return array<string, mixed>|null Validation result array with 'valid' + 'message', or null when no validation is required.
997 */
998 private static function validate_dynamic_amount_field( $payment_config, $block_config, $submitted_amount_decimal, $currency ) {
999 // Check if variable amount field is from dropdown or multi-choice block.
1000 $dynamic_amount_field_block_name = $payment_config['variable_amount_field_block_name'] ?? '';
1001
1002 if ( empty( $dynamic_amount_field_block_name ) ) {
1003 // Return null because it can be old form configuration.
1004 return null;
1005 }
1006
1007 if ( 'srfm/dropdown' !== $dynamic_amount_field_block_name && 'srfm/multi-choice' !== $dynamic_amount_field_block_name ) {
1008 return null; // Not a dropdown/multi-choice, skip validation.
1009 }
1010
1011 // Get the slug of the variable amount field.
1012 $variable_amount_field_slug = ! empty( $payment_config['variable_amount_field'] ) && is_string( $payment_config['variable_amount_field'] ) ? $payment_config['variable_amount_field'] : '';
1013
1014 // Find the block config for the variable amount field by matching slug and block name.
1015 $variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );
1016
1017 // Verify the variable amount block config was found.
1018 if ( empty( $variable_amount_block_config ) || ! is_array( $variable_amount_block_config ) ) {
1019 return [
1020 'valid' => false,
1021 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
1022 ];
1023 }
1024
1025 // Check if single selection is enabled (only validate for single selection).
1026 $is_single_selection = false;
1027 if ( 'srfm/dropdown' === $dynamic_amount_field_block_name ) {
1028 // For dropdown, check if multi_select is disabled (single selection).
1029 $is_single_selection = empty( $variable_amount_block_config['multi_select'] );
1030 } elseif ( 'srfm/multi-choice' === $dynamic_amount_field_block_name ) {
1031 // For multi-choice, check if single_selection is enabled.
1032 $is_single_selection = ! empty( $variable_amount_block_config['single_selection'] );
1033 }
1034
1035 // Only validate amount matches options if single selection is enabled.
1036 if ( $is_single_selection ) {
1037 // Validate that submitted amount matches one of the allowed option values.
1038 $allowed_options = $variable_amount_block_config['options'] ?? [];
1039 if ( empty( $allowed_options ) || ! is_array( $allowed_options ) ) {
1040 return [
1041 'valid' => false,
1042 'message' => __( 'No payment options are configured for this field.', 'sureforms' ),
1043 ];
1044 }
1045
1046 // Extract allowed values from options.
1047 $allowed_values = [];
1048 foreach ( $allowed_options as $option ) {
1049 if ( isset( $option['value'] ) && ! empty( $option['value'] ) ) {
1050 $allowed_values[] = floatval( $option['value'] );
1051 }
1052 }
1053
1054 // Check if submitted amount matches any allowed value.
1055 $amount_is_valid = false;
1056 foreach ( $allowed_values as $allowed_value ) {
1057 // Allow small floating point difference (0.01) due to rounding.
1058 if ( abs( $submitted_amount_decimal - $allowed_value ) <= 0.01 ) {
1059 $amount_is_valid = true;
1060 break;
1061 }
1062 }
1063
1064 if ( ! $amount_is_valid ) {
1065 return [
1066 'valid' => false,
1067 /* translators: %s: currency code */
1068 'message' => sprintf( __( 'Invalid payment amount. Please select a valid amount from the available options.', 'sureforms' ), strtoupper( $currency ) ),
1069 ];
1070 }
1071 }
1072
1073 // Validation passed for dynamic amount field.
1074 return null;
1075 }
1076
1077 /**
1078 * Validate payment intent amount matches form configuration.
1079 *
1080 * Validates that the payment amount from Stripe matches the expected amount
1081 * based on form configuration, including dynamic amounts from dropdown/multi-choice fields.
1082 *
1083 * @since 2.3.0
1084 * @param string $block_id Block identifier.
1085 * @param int $form_id Form post ID.
1086 * @param array<string, mixed> $form_data Submitted form data.
1087 * @param int|float $payment_amount Payment amount from Stripe (in smallest currency unit).
1088 * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
1089 * @return array {
1090 * Validation result.
1091 *
1092 * @type bool $valid Whether validation passed.
1093 * @type string $message Error message if validation failed, empty if valid.
1094 * }
1095 */
1096 private static function validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount, $active_type = '' ) {
1097 // Get block configuration.
1098 $block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
1099
1100 if ( empty( $block_config ) || ! isset( $block_config[ $block_id ] ) ) {
1101 return [
1102 'valid' => false,
1103 /* translators: %1$s: expected amount, %2$s: payment amount */
1104 'message' => __( 'Payment configuration not found.', 'sureforms' ),
1105 ];
1106 }
1107
1108 $payment_config = $block_config[ $block_id ];
1109 $resolved_config = self::resolve_payment_config_for_active_type( $payment_config, $active_type );
1110 $amount_type = $resolved_config['amount_type'] ?? 'fixed';
1111
1112 // For fixed amounts, validate against configured amount.
1113 if ( 'fixed' === $amount_type ) {
1114 $configured_amount = isset( $resolved_config['fixed_amount'] ) ? floatval( $resolved_config['fixed_amount'] ) : 0;
1115
1116 // Allow small floating point difference (0.01) due to rounding.
1117 if ( abs( $payment_amount - $configured_amount ) > 0.01 ) {
1118 return [
1119 'valid' => false,
1120 /* translators: %1$s: expected amount, %2$s: payment amount */
1121 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $configured_amount, $payment_amount ),
1122 ];
1123 }
1124
1125 return [
1126 'valid' => true,
1127 'message' => '',
1128 ];
1129 }
1130
1131 // For variable amounts, validate based on source field.
1132 if ( 'variable' === $amount_type ) {
1133 // Check if variable amount comes from dropdown/multi-choice.
1134 $dynamic_amount_field_block_name = $resolved_config['variable_amount_field_block_name'] ?? '';
1135 $variable_amount_field_slug = $resolved_config['variable_amount_field'] ?? '';
1136
1137 // "Variant B": legacy/stale config may not have recorded the amount-source field
1138 // (empty source reference). Without it we cannot derive a server-side expected
1139 // amount. First try to recover it by refreshing the block config from the form's
1140 // current content — forms saved with current code record the source — which
1141 // self-heals legacy forms whose source field still exists.
1142 if ( empty( $dynamic_amount_field_block_name ) || empty( $variable_amount_field_slug ) ) {
1143 $refreshed_config = self::refresh_block_config( $form_id );
1144 if ( is_array( $refreshed_config ) && isset( $refreshed_config[ $block_id ] ) && is_array( $refreshed_config[ $block_id ] ) ) {
1145 $block_config = $refreshed_config;
1146 $resolved_config = self::resolve_payment_config_for_active_type( $block_config[ $block_id ], $active_type );
1147 $dynamic_amount_field_block_name = $resolved_config['variable_amount_field_block_name'] ?? '';
1148 $variable_amount_field_slug = $resolved_config['variable_amount_field'] ?? '';
1149 }
1150 }
1151
1152 // The source still cannot be identified (e.g. the amount-source field was deleted from
1153 // the form while the payment amount type is still "variable", so there is no field-level
1154 // config left to derive an expected amount from).
1155 //
1156 // Security: this branch previously returned a valid result unconditionally for such
1157 // forms, which allowed an unauthenticated attacker to pay any amount (down to 1 cent
1158 // when the form had no minimum-amount floor).
1159 //
1160 // If the admin configured a positive minimum amount we enforce it as the authoritative
1161 // lower bound — the only server-side guarantee available for such a form — instead of
1162 // rejecting outright. This keeps legacy forms (whose source field still has a floor)
1163 // working without requiring a re-save. With no positive floor there is nothing safe to
1164 // validate against, so we MUST fail safe and reject to avoid reopening the bypass.
1165 if ( empty( $dynamic_amount_field_block_name ) || empty( $variable_amount_field_slug ) ) {
1166 $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
1167
1168 if ( $minimum_amount > 0 ) {
1169 if ( $payment_amount < $minimum_amount ) {
1170 return [
1171 'valid' => false,
1172 /* translators: %1$s: minimum amount, %2$s: payment amount */
1173 'message' => sprintf( __( 'Payment amount below minimum. Minimum: %1$s, received %2$s.', 'sureforms' ), $minimum_amount, $payment_amount ),
1174 ];
1175 }
1176
1177 return [
1178 'valid' => true,
1179 'message' => '',
1180 ];
1181 }
1182
1183 return [
1184 'valid' => false,
1185 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
1186 ];
1187 }
1188
1189 // The amount source is identified: validate the charged amount against the
1190 // server-derived expected amount for that source. The configured minimum-amount
1191 // floor below is always enforced as an additional lower bound.
1192 $submitted_field_value = self::get_form_submitted_value_by_slug_and_block_name( $variable_amount_field_slug, $dynamic_amount_field_block_name, $form_data );
1193
1194 if ( empty( $submitted_field_value ) ) {
1195 return [
1196 'valid' => false,
1197 'message' => __( 'Variable amount field value is required.', 'sureforms' ),
1198 ];
1199 }
1200
1201 if ( 'srfm/dropdown' === $dynamic_amount_field_block_name || 'srfm/multi-choice' === $dynamic_amount_field_block_name ) {
1202 // Get the block config for the variable amount field by matching slug and block name.
1203 $variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );
1204
1205 if ( empty( $variable_amount_block_config ) || ! is_string( $submitted_field_value ) ) {
1206 return [
1207 'valid' => false,
1208 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
1209 ];
1210 }
1211
1212 // The expected amount is read from the server-side option config keyed by the
1213 // submitted selection — the attacker chooses the option, never its price.
1214 $get_expected_amount = self::get_amount_by_the_config_options( $submitted_field_value, $variable_amount_block_config );
1215
1216 // Fail safe when the submitted selection doesn't map to a configured
1217 // option value: get_amount_by_the_config_options() returns null, and
1218 // abs( $payment_amount - null ) would coerce null to 0 — reject explicitly
1219 // so the comparison can never be silently weakened by that coercion.
1220 if ( ! is_numeric( $get_expected_amount ) ) {
1221 return [
1222 'valid' => false,
1223 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
1224 ];
1225 }
1226
1227 // Validate payment amount matches expected amount.
1228 if ( abs( $payment_amount - $get_expected_amount ) > 0.01 ) {
1229 return [
1230 'valid' => false,
1231 /* translators: %1$s: expected amount, %2$s: payment amount */
1232 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $get_expected_amount, $payment_amount ),
1233 ];
1234 }
1235 } else {
1236 // Number and hidden fields. Their value may be server-determined — a
1237 // configured default value, or a calculation computed from other fields.
1238 // In those cases the expected amount MUST be derived server-side and the
1239 // value submitted with the request must never be trusted as the price.
1240 $variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );
1241
1242 if ( empty( $variable_amount_block_config ) ) {
1243 return [
1244 'valid' => false,
1245 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
1246 ];
1247 }
1248
1249 $expected_amount = self::resolve_server_side_variable_amount( $variable_amount_block_config, $block_config, $form_data );
1250
1251 if ( null !== $expected_amount ) {
1252 // Authoritative server-side amount (static default value or a
1253 // server-recomputed calculation). Reject any mismatch.
1254 if ( abs( $payment_amount - floatval( $expected_amount ) ) > 0.01 ) {
1255 return [
1256 'valid' => false,
1257 /* translators: %1$s: expected amount, %2$s: payment amount */
1258 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), floatval( $expected_amount ), $payment_amount ),
1259 ];
1260 }
1261 } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
1262 // Calculation-driven number: a null server amount means the formula
1263 // could NOT be recomputed server-side (a referenced field was
1264 // non-numeric, or the formula used something the parser can't
1265 // evaluate). This is NOT "name your price" — we must fail safe and
1266 // reject, never fall back to the client-submitted amount, which
1267 // would reopen the unauthenticated underpayment bypass.
1268 if ( ! empty( $variable_amount_block_config['enableCalculation'] ) ) {
1269 return [
1270 'valid' => false,
1271 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
1272 ];
1273 }
1274
1275 // Plain user-entered number ("name your price"): the amount is the
1276 // customer's own choice, so confirm the charge matches what they entered.
1277 // The minimum-amount floor below guards the lower bound.
1278 $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';
1279 $submitted_field_value = Helper::get_string_value( $submitted_field_value );
1280 $converted_payment_amount = self::normalize_amount_by_format( $submitted_field_value, $number_format_type );
1281
1282 if ( ! is_numeric( $converted_payment_amount ) || $converted_payment_amount <= 0 ) {
1283 return [
1284 'valid' => false,
1285 'message' => __( 'Variable amount field value is required.', 'sureforms' ),
1286 ];
1287 }
1288
1289 if ( abs( $payment_amount - $converted_payment_amount ) > 0.01 ) {
1290 return [
1291 'valid' => false,
1292 /* translators: %1$s: expected amount, %2$s: payment amount */
1293 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $converted_payment_amount, $payment_amount ),
1294 ];
1295 }
1296 } else {
1297 // Unresolved hidden / dynamic source: resolve_server_side_variable_amount()
1298 // returned null (e.g. a hidden field whose default is a smart tag like
1299 // {get_input:amount}, stored raw and therefore non-numeric), so the submitted
1300 // value cannot be trusted as the price and there is no server-authoritative
1301 // amount to compare against. The configured minimum-amount floor is then the
1302 // ONLY server-side guarantee, so it must be a positive authoritative value.
1303 //
1304 // This mirrors the "amount source not identified" handling above: with a
1305 // positive minimum we fall through to the floor check below (the documented
1306 // dynamic-prefill case keeps working); with no positive minimum there is
1307 // nothing safe to validate against, so we MUST fail safe and reject rather than
1308 // letting the floor default to 0 and accept any amount down to the gateway cent
1309 // floor — which would reopen the unauthenticated underpayment bypass. Merchants
1310 // doing custom JS-driven dynamic pricing must supply a server-authoritative
1311 // amount via the `srfm_server_side_variable_amount` filter or a
1312 // calculation-enabled field rather than relying on the submitted value.
1313 $unresolved_minimum = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
1314
1315 if ( $unresolved_minimum <= 0 ) {
1316 return [
1317 'valid' => false,
1318 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
1319 ];
1320 }
1321 }
1322 }
1323
1324 // All variable amount sources are subject to the configured minimum amount floor.
1325 // Use resolved_config so 'both'-mode forms read the active type's per-type minimum
1326 // (oneTimeMinimumAmount / subscriptionMinimumAmount) instead of the unset legacy scalar.
1327 $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
1328
1329 if ( $payment_amount < $minimum_amount ) {
1330 return [
1331 'valid' => false,
1332 /* translators: %1$s: minimum amount, %2$s: payment amount */
1333 'message' => sprintf( __( 'Payment amount below minimum. Minimum: %1$s, received %2$s.', 'sureforms' ), $minimum_amount, $payment_amount ),
1334 ];
1335 }
1336 }
1337
1338 // Validation passed.
1339 return [
1340 'valid' => true,
1341 'message' => '',
1342 ];
1343 }
1344
1345 /**
1346 * Force a refresh of the form's stored block configuration from its current content.
1347 *
1348 * Recovers the amount-source field reference for legacy forms whose cached
1349 * _srfm_block_config predates server-side source tracking (an empty
1350 * variable_amount_field_block_name). Re-parses the form blocks and rebuilds the config —
1351 * forms saved with current code record the source — then returns the refreshed config.
1352 *
1353 * @param int $form_id Form post ID.
1354 * @since 2.11.1
1355 * @return array<mixed>|null Refreshed block configuration, or null if it cannot be rebuilt.
1356 */
1357 private static function refresh_block_config( $form_id ) {
1358 if ( ! is_int( $form_id ) || $form_id <= 0 ) {
1359 return null;
1360 }
1361
1362 $post = get_post( $form_id );
1363 if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) || ! function_exists( 'parse_blocks' ) ) {
1364 return null;
1365 }
1366
1367 $blocks = parse_blocks( $post->post_content );
1368 if ( is_array( $blocks ) && ! empty( $blocks ) ) {
1369 Field_Validation::add_block_config( $blocks, $form_id );
1370 }
1371
1372 return Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
1373 }
1374
1375 /**
1376 * Resolve the authoritative server-side expected amount for a variable amount source.
1377 *
1378 * The expected amount is ALWAYS derived from server-side configuration — the field's
1379 * configured default value, or (for calculation-enabled fields) a value recomputed by
1380 * SureForms Pro from the submitted inputs. It is NEVER taken from the value submitted with
1381 * the request. Returns null when no authoritative amount can be determined server-side, in
1382 * which case the caller falls back to the configured minimum-amount floor.
1383 *
1384 * @param array<mixed> $source_config The amount-source field block config (block_name, slug, enableCalculation, defaultValue, calculationFormula, ...).
1385 * @param array<mixed> $block_config All block configurations for the form.
1386 * @param array<mixed> $form_data Submitted form data.
1387 * @since 2.11.1
1388 * @return float|null Expected amount, or null if it cannot be determined server-side.
1389 */
1390 private static function resolve_server_side_variable_amount( $source_config, $block_config, $form_data ) {
1391 if ( empty( $source_config ) || ! is_array( $source_config ) ) {
1392 return null;
1393 }
1394
1395 /**
1396 * Compute the authoritative server-side amount for a variable payment source.
1397 *
1398 * SureForms Pro hooks this to recompute a field's calculation formula from the
1399 * submitted field values. Handlers MUST return a numeric value derived only from
1400 * server-side configuration and other submitted inputs — never the raw value of the
1401 * amount field submitted with the request — or null if it cannot be computed.
1402 *
1403 * @since 2.11.1
1404 * @param float|null $amount The resolved amount. Default null.
1405 * @param array<string, mixed> $context Context: source_config, block_config, form_data.
1406 */
1407 $expected = apply_filters(
1408 'srfm_server_side_variable_amount',
1409 null,
1410 [
1411 'source_config' => $source_config,
1412 'block_config' => $block_config,
1413 'form_data' => $form_data,
1414 ]
1415 );
1416
1417 if ( is_numeric( $expected ) ) {
1418 return floatval( $expected );
1419 }
1420
1421 // Static hidden field: a *literal numeric* configured default value is the server-side
1422 // source of truth and is authoritative. A non-numeric default (e.g. a smart tag such as
1423 // {get_input:amount} stored raw, resolved to a runtime value only at render time) is NOT
1424 // treated as authoritative here — it returns null below so the caller validates against the
1425 // minimum-amount floor instead, preserving the documented dynamic-prefill behavior.
1426 $block_name = $source_config['block_name'] ?? ( $source_config['blockName'] ?? '' );
1427 if ( 'srfm/hidden' === $block_name && empty( $source_config['enableCalculation'] ) && isset( $source_config['defaultValue'] ) && is_numeric( $source_config['defaultValue'] ) ) {
1428 return floatval( $source_config['defaultValue'] );
1429 }
1430
1431 return null;
1432 }
1433
1434 /**
1435 * Get amount by matching submitted value with config options.
1436 *
1437 * @param string $submitted_field_value The submitted value (string, can be "value1 | value2" for multi-select).
1438 * @param array<mixed> $block_config Block configuration containing options.
1439 * @return float|null Expected amount if found, null otherwise.
1440 * @since 2.3.0
1441 */
1442 private static function get_amount_by_the_config_options( $submitted_field_value, $block_config ) {
1443 if ( empty( $submitted_field_value ) || ! is_string( $submitted_field_value ) ) {
1444 return null;
1445 }
1446
1447 // Get options from block config.
1448 $options = $block_config['options'] ?? [];
1449
1450 if ( empty( $options ) || ! is_array( $options ) ) {
1451 return null;
1452 }
1453
1454 // Check if multi-select is enabled.
1455 $is_multi_select = false;
1456 $block_name = $block_config['block_name'] ?? '';
1457
1458 if ( 'srfm/dropdown' === $block_name ) {
1459 $is_multi_select = ! empty( $block_config['multi_select'] );
1460 } elseif ( 'srfm/multi-choice' === $block_name ) {
1461 // For multi-choice, multi-select is when single_selection is disabled.
1462 $is_multi_select = empty( $block_config['single_selection'] );
1463 }
1464
1465 $expected_amount = null;
1466
1467 // Handle multi-select case (submitted value format: "value1 | value2").
1468 if ( $is_multi_select && false !== strpos( $submitted_field_value, ' | ' ) ) {
1469 // Explode the submitted value by " | " delimiter.
1470 $submitted_values = explode( ' | ', $submitted_field_value );
1471
1472 $combine_amount = 0;
1473
1474 foreach ( $options as $option ) {
1475 $option_label = isset( $option['label'] ) ? trim( $option['label'] ) : '';
1476
1477 foreach ( $submitted_values as $submitted_value ) {
1478 if ( trim( $submitted_value ) === $option_label ) {
1479 $combine_amount += floatval( $option['value'] );
1480 break;
1481 }
1482 }
1483 }
1484
1485 $expected_amount = $combine_amount;
1486
1487 } else {
1488 // Handle single select case (submitted value is a simple string).
1489 foreach ( $options as $option ) {
1490 $option_label = isset( $option['label'] ) ? trim( $option['label'] ) : '';
1491 if ( trim( $submitted_field_value ) === $option_label ) {
1492 $expected_amount = floatval( $option['value'] );
1493 break;
1494 }
1495 }
1496 }
1497
1498 return $expected_amount;
1499 }
1500
1501 /**
1502 * Get block configuration by block name and slug.
1503 *
1504 * @param array<mixed> $block_config All block configurations.
1505 * @param string $block_name Block name to search for.
1506 * @param string $slug Slug to match.
1507 * @return array|null Block configuration if found, null otherwise.
1508 * @since 2.3.0
1509 */
1510 private static function get_block_config_by_name_and_slug( $block_config, $block_name, $slug ) {
1511 foreach ( $block_config as $config ) {
1512 if ( empty( $config ) || ! is_array( $config ) ) {
1513 continue;
1514 }
1515
1516 // Core blocks store the block name under 'block_name'; Pro blocks (e.g. the hidden
1517 // field, registered via the srfm_block_config filter) store it under 'blockName'.
1518 // Accept either so Pro-sourced amount fields resolve correctly.
1519 $config_block_name = $config['block_name'] ?? ( $config['blockName'] ?? '' );
1520
1521 if ( isset( $config['slug'] ) && $config['slug'] === $slug && $config_block_name === $block_name ) {
1522 return $config;
1523 }
1524 }
1525 return null;
1526 }
1527
1528 /**
1529 * Normalize amount based on number format type (EU-style or US-style).
1530 *
1531 * @param string|float $amount The amount to normalize.
1532 * @param string $format_type The format type: 'eu-style' or 'us-style'.
1533 * @return float The normalized amount as a float.
1534 * @since 2.4.0
1535 */
1536 private static function normalize_amount_by_format( $amount, $format_type = 'us-style' ) {
1537 // If already a number, return it.
1538 if ( is_numeric( $amount ) && ! is_string( $amount ) ) {
1539 return floatval( $amount );
1540 }
1541
1542 // Convert to string and trim.
1543 $amount_str = trim( strval( $amount ) );
1544
1545 if ( 'eu-style' === $format_type ) {
1546 // EU-style: 1.234,56 (period = thousands, comma = decimal).
1547 // Remove periods (thousands separator) and replace comma with period (decimal).
1548 $amount_str = str_replace( '.', '', $amount_str );
1549 $amount_str = str_replace( ',', '.', $amount_str );
1550 } else {
1551 // US-style (default): 1,234.56 (comma = thousands, period = decimal).
1552 // Remove commas (thousands separator).
1553 $amount_str = str_replace( ',', '', $amount_str );
1554 }
1555
1556 return floatval( $amount_str );
1557 }
1558
1559 /**
1560 * Get form submitted value for a specific field by slug and block name.
1561 *
1562 * @param string $variable_amount_field_slug Slug of the field to find.
1563 * @param string $dynamic_amount_field_block_name Block name of the field.
1564 * @param array<mixed> $form_data Form submission data.
1565 * @return mixed|null Field value if found, null otherwise.
1566 * @since 2.3.0
1567 */
1568 private static function get_form_submitted_value_by_slug_and_block_name( $variable_amount_field_slug, $dynamic_amount_field_block_name, $form_data ) {
1569 $block_name = null;
1570 if ( 'srfm/dropdown' === $dynamic_amount_field_block_name ) {
1571 $block_name = 'srfm-dropdown';
1572 } elseif ( 'srfm/multi-choice' === $dynamic_amount_field_block_name ) {
1573 $block_name = 'srfm-input-multi-choice';
1574 } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
1575 $block_name = 'srfm-number';
1576 } elseif ( 'srfm/hidden' === $dynamic_amount_field_block_name ) {
1577 $block_name = 'srfm-hidden';
1578 }
1579
1580 // Now we need to get the submitted value.
1581 // Here is the structure of the form data name.
1582 // srfm-input-multi-choice-398dbcfe-lbl-UGxlYXNlIGNob29zZSBvcHRpb24-multi-choice
1583 // {block_name}-{block_id}-lbl-{combined-id}-{slug}.
1584 $submitted_field_value = null;
1585 foreach ( $form_data as $field_key => $field_value ) {
1586 // Check if field key starts with block_name- and ends with -slug.
1587 $is_start_with_block_name = strpos( $field_key, $block_name . '-' ) === 0;
1588 $is_last_with_slug = substr( $field_key, -strlen( '-' . $variable_amount_field_slug ) ) === '-' . $variable_amount_field_slug;
1589
1590 if ( $is_start_with_block_name && $is_last_with_slug ) {
1591 $submitted_field_value = $field_value;
1592 break;
1593 }
1594 }
1595
1596 return $submitted_field_value;
1597 }
1598
1599 /**
1600 * Get default payment settings (global + all gateways).
1601 *
1602 * @since 2.0.0
1603 * @return array<string, mixed> Default payment settings structure.
1604 */
1605 private static function get_default_payment_settings() {
1606 return [
1607 'currency' => 'USD',
1608 'payment_mode' => 'test',
1609 'currency_sign_position' => 'left',
1610 'stripe' => Stripe_Helper::get_default_stripe_settings(),
1611 ];
1612 }
1613 }
1614