Uname:Linux webm009.cluster131.gra.hosting.ovh.net 6.18.42-ovh-vps-grsec-zfs+ #1 SMP PREEMPT_DYNAMIC Wed Aug 5 15:59:48 CEST 2026 x86_64

403WebShell
403Webshell
Server IP : 146.59.209.152  /  Your IP : 216.73.216.152
Web Server : Apache
System : Linux webm009.cluster131.gra.hosting.ovh.net 6.18.42-ovh-vps-grsec-zfs+ #1 SMP PREEMPT_DYNAMIC Wed Aug 5 15:59:48 CEST 2026 x86_64
User : monpetu ( 144298)
PHP Version : 7.4.33
Disable Function : _dyuweyrj4,_dyuweyrj4r,dl
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : ON  |  Sudo : OFF  |  Pkexec : OFF
Directory :  /home/monpetu/www/ipprint/wp-content/plugins/sureforms/inc/payments/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /home/monpetu/www/ipprint/wp-content/plugins/sureforms/inc/payments/payment-helper.php
<?php
/**
 * Global Payment Helper functions for SureForms Payments.
 *
 * This class handles payment settings and operations that are common across
 * all payment gateways (Stripe, PayPal, etc.). Gateway-specific logic should
 * be in their respective helper classes.
 *
 * @package sureforms
 * @since 2.0.0
 */

namespace SRFM\Inc\Payments;

use SRFM\Inc\Database\Tables\Entries;
use SRFM\Inc\Field_Validation;
use SRFM\Inc\Helper;
use SRFM\Inc\Payments\Stripe\Stripe_Helper;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Global Payment Helper class for multi-gateway support.
 *
 * @since 2.0.0
 */
class Payment_Helper {
	/**
	 * Get all payment settings (global settings + all gateways).
	 *
	 * Retrieves the complete payment settings structure:
	 * payment_settings -> [currency, payment_mode, stripe, paypal, etc]
	 *
	 * @since 2.0.0
	 * @return array<string, mixed> The complete payment settings array.
	 */
	public static function get_all_payment_settings() {
		$payment_settings = Helper::get_srfm_option( 'payment_settings', [] );

		if ( ! is_array( $payment_settings ) || empty( $payment_settings ) ) {
			return self::get_default_payment_settings();
		}

		// Ensure required keys exist.
		if ( ! isset( $payment_settings['currency'] ) ) {
			$payment_settings['currency'] = 'USD';
		}

		if ( ! isset( $payment_settings['payment_mode'] ) ) {
			$payment_settings['payment_mode'] = 'test';
		}

		if ( ! isset( $payment_settings['stripe'] ) ) {
			$payment_settings['stripe'] = Stripe_Helper::get_default_stripe_settings();
		}

		return $payment_settings;
	}

	/**
	 * Update all payment settings.
	 *
	 * Stores the complete payment settings array in:
	 * srfm_options -> payment_settings
	 *
	 * @param array<string, mixed> $settings The complete payment settings array.
	 * @since 2.0.0
	 * @return bool True on success, false on failure.
	 */
	public static function update_payment_settings( $settings ) {
		if ( ! is_array( $settings ) ) {
			return false;
		}

		Helper::update_srfm_option( 'payment_settings', $settings );
		return true;
	}

	/**
	 * Get settings for a specific payment gateway.
	 *
	 * @param string $gateway Gateway identifier (e.g., 'stripe', 'paypal').
	 * @since 2.0.0
	 * @return array<string, mixed> Gateway settings array, or empty array if not found.
	 */
	public static function get_gateway_settings( $gateway ) {
		if ( ! is_string( $gateway ) || empty( $gateway ) ) {
			return [];
		}

		$payment_settings = self::get_all_payment_settings();

		return isset( $payment_settings[ $gateway ] ) && is_array( $payment_settings[ $gateway ] )
			? $payment_settings[ $gateway ]
			: [];
	}

	/**
	 * Update settings for a specific payment gateway.
	 *
	 * @param string               $gateway  Gateway identifier (e.g., 'stripe', 'paypal').
	 * @param array<string, mixed> $settings Gateway settings to save.
	 * @since 2.0.0
	 * @return bool True on success, false on failure.
	 */
	public static function update_gateway_settings( $gateway, $settings ) {
		if ( ! is_string( $gateway ) || empty( $gateway ) || ! is_array( $settings ) ) {
			return false;
		}

		$payment_settings             = self::get_all_payment_settings();
		$payment_settings[ $gateway ] = $settings;

		return self::update_payment_settings( $payment_settings );
	}

	/**
	 * Get a global payment setting (currency or payment_mode).
	 *
	 * @param string $key     Setting key (e.g., 'currency', 'payment_mode').
	 * @param mixed  $default Default value if setting not found.
	 * @since 2.0.0
	 * @return mixed Setting value or default.
	 */
	public static function get_global_setting( $key, $default = '' ) {
		if ( ! is_string( $key ) || empty( $key ) ) {
			return $default;
		}

		$payment_settings = self::get_all_payment_settings();

		return $payment_settings[ $key ] ?? $default;
	}

	/**
	 * Update a global payment setting (currency or payment_mode).
	 *
	 * @param string $key   Setting key to update.
	 * @param mixed  $value Value to set.
	 * @since 2.0.0
	 * @return bool True on success, false on failure.
	 */
	public static function update_global_setting( $key, $value ) {
		if ( ! is_string( $key ) || empty( $key ) ) {
			return false;
		}

		$payment_settings         = self::get_all_payment_settings();
		$payment_settings[ $key ] = $value;

		return self::update_payment_settings( $payment_settings );
	}

	/**
	 * Get the default currency.
	 *
	 * @since 2.0.0
	 * @return string The currency code (e.g., 'USD').
	 */
	public static function get_currency() {
		$response = self::get_global_setting( 'currency', 'USD' );
		return ! empty( $response ) && is_string( $response ) ? $response : 'USD';
	}

	/**
	 * Get the current payment mode (test or live).
	 *
	 * @since 2.0.0
	 * @return string The current payment mode ('test' or 'live').
	 */
	public static function get_payment_mode() {
		$response = self::get_global_setting( 'payment_mode', 'test' );
		return ! empty( $response ) && is_string( $response ) ? $response : 'test';
	}

	/**
	 * Get comprehensive currency data for all supported currencies.
	 *
	 * This is the single source of truth for all currency-related data.
	 * Contains currency name, symbol, and decimal places.
	 *
	 * @since 2.0.0
	 * @return array<string, array<string, mixed>> Array of currency data keyed by currency code.
	 */
	public static function get_all_currencies_data() {
		return [
			'USD' => [
				'name'           => __( 'US Dollar', 'sureforms' ),
				'symbol'         => '$',
				'decimal_places' => 2,
			],
			'EUR' => [
				'name'           => __( 'Euro', 'sureforms' ),
				'symbol'         => '€',
				'decimal_places' => 2,
			],
			'GBP' => [
				'name'           => __( 'British Pound', 'sureforms' ),
				'symbol'         => '£',
				'decimal_places' => 2,
			],
			'JPY' => [
				'name'           => __( 'Japanese Yen', 'sureforms' ),
				'symbol'         => '¥',
				'decimal_places' => 0,
			],
			'AUD' => [
				'name'           => __( 'Australian Dollar', 'sureforms' ),
				'symbol'         => 'A$',
				'decimal_places' => 2,
			],
			'CAD' => [
				'name'           => __( 'Canadian Dollar', 'sureforms' ),
				'symbol'         => 'C$',
				'decimal_places' => 2,
			],
			'CHF' => [
				'name'           => __( 'Swiss Franc', 'sureforms' ),
				'symbol'         => 'CHF',
				'decimal_places' => 2,
			],
			'CNY' => [
				'name'           => __( 'Chinese Yuan', 'sureforms' ),
				'symbol'         => '¥',
				'decimal_places' => 2,
			],
			'SEK' => [
				'name'           => __( 'Swedish Krona', 'sureforms' ),
				'symbol'         => 'kr',
				'decimal_places' => 2,
			],
			'NZD' => [
				'name'           => __( 'New Zealand Dollar', 'sureforms' ),
				'symbol'         => 'NZ$',
				'decimal_places' => 2,
			],
			'MXN' => [
				'name'           => __( 'Mexican Peso', 'sureforms' ),
				'symbol'         => 'MX$',
				'decimal_places' => 2,
			],
			'SGD' => [
				'name'           => __( 'Singapore Dollar', 'sureforms' ),
				'symbol'         => 'S$',
				'decimal_places' => 2,
			],
			'HKD' => [
				'name'           => __( 'Hong Kong Dollar', 'sureforms' ),
				'symbol'         => 'HK$',
				'decimal_places' => 2,
			],
			'NOK' => [
				'name'           => __( 'Norwegian Krone', 'sureforms' ),
				'symbol'         => 'kr',
				'decimal_places' => 2,
			],
			'PLN' => [
				'name'           => __( 'Polish Złoty', 'sureforms' ),
				'symbol'         => 'zł',
				'decimal_places' => 2,
			],
			'KRW' => [
				'name'           => __( 'South Korean Won', 'sureforms' ),
				'symbol'         => '₩',
				'decimal_places' => 0,
			],
			'TRY' => [
				'name'           => __( 'Turkish Lira', 'sureforms' ),
				'symbol'         => '₺',
				'decimal_places' => 2,
			],
			'RUB' => [
				'name'           => __( 'Russian Ruble', 'sureforms' ),
				'symbol'         => '₽',
				'decimal_places' => 2,
			],
			'INR' => [
				'name'           => __( 'Indian Rupee', 'sureforms' ),
				'symbol'         => '₹',
				'decimal_places' => 2,
			],
			'BRL' => [
				'name'           => __( 'Brazilian Real', 'sureforms' ),
				'symbol'         => 'R$',
				'decimal_places' => 2,
			],
			'ZAR' => [
				'name'           => __( 'South African Rand', 'sureforms' ),
				'symbol'         => 'R',
				'decimal_places' => 2,
			],
			'AED' => [
				'name'           => __( 'UAE Dirham', 'sureforms' ),
				'symbol'         => 'د.إ',
				'decimal_places' => 2,
			],
			'PHP' => [
				'name'           => __( 'Philippine Peso', 'sureforms' ),
				'symbol'         => '₱',
				'decimal_places' => 2,
			],
			'IDR' => [
				'name'           => __( 'Indonesian Rupiah', 'sureforms' ),
				'symbol'         => 'Rp',
				'decimal_places' => 2,
			],
			'MYR' => [
				'name'           => __( 'Malaysian Ringgit', 'sureforms' ),
				'symbol'         => 'RM',
				'decimal_places' => 2,
			],
			'THB' => [
				'name'           => __( 'Thai Baht', 'sureforms' ),
				'symbol'         => '฿',
				'decimal_places' => 2,
			],
			'BIF' => [
				'name'           => __( 'Burundian Franc', 'sureforms' ),
				'symbol'         => 'FBu',
				'decimal_places' => 0,
			],
			'CLP' => [
				'name'           => __( 'Chilean Peso', 'sureforms' ),
				'symbol'         => '$',
				'decimal_places' => 0,
			],
			'DJF' => [
				'name'           => __( 'Djiboutian Franc', 'sureforms' ),
				'symbol'         => 'Fdj',
				'decimal_places' => 0,
			],
			'GNF' => [
				'name'           => __( 'Guinean Franc', 'sureforms' ),
				'symbol'         => 'FG',
				'decimal_places' => 0,
			],
			'KMF' => [
				'name'           => __( 'Comorian Franc', 'sureforms' ),
				'symbol'         => 'CF',
				'decimal_places' => 0,
			],
			'MGA' => [
				'name'           => __( 'Malagasy Ariary', 'sureforms' ),
				'symbol'         => 'Ar',
				'decimal_places' => 0,
			],
			'PYG' => [
				'name'           => __( 'Paraguayan Guaraní', 'sureforms' ),
				'symbol'         => '₲',
				'decimal_places' => 0,
			],
			'RWF' => [
				'name'           => __( 'Rwandan Franc', 'sureforms' ),
				'symbol'         => 'FRw',
				'decimal_places' => 0,
			],
			'UGX' => [
				'name'           => __( 'Ugandan Shilling', 'sureforms' ),
				'symbol'         => 'USh',
				'decimal_places' => 0,
			],
			'VND' => [
				'name'           => __( 'Vietnamese Đồng', 'sureforms' ),
				'symbol'         => '₫',
				'decimal_places' => 0,
			],
			'VUV' => [
				'name'           => __( 'Vanuatu Vatu', 'sureforms' ),
				'symbol'         => 'VT',
				'decimal_places' => 0,
			],
			'XAF' => [
				'name'           => __( 'Central African CFA Franc', 'sureforms' ),
				'symbol'         => 'FCFA',
				'decimal_places' => 0,
			],
			'XOF' => [
				'name'           => __( 'West African CFA Franc', 'sureforms' ),
				'symbol'         => 'CFA',
				'decimal_places' => 0,
			],
			'XPF' => [
				'name'           => __( 'CFP Franc', 'sureforms' ),
				'symbol'         => '₣',
				'decimal_places' => 0,
			],
		];
	}

	/**
	 * Get currency names for all supported currencies.
	 *
	 * @since 2.0.0
	 * @return array<string, mixed> Array of currency names keyed by currency code.
	 */
	public static function get_currency_names() {
		$currencies = self::get_all_currencies_data();
		$names      = [];

		foreach ( $currencies as $code => $data ) {
			$names[ $code ] = $data['name'];
		}

		return $names;
	}

	/**
	 * Get currency symbol.
	 *
	 * @param string $currency Currency code.
	 * @since 2.0.0
	 * @return string Currency symbol or empty string.
	 */
	public static function get_currency_symbol( $currency ) {
		if ( empty( $currency ) || ! is_string( $currency ) ) {
			return '';
		}

		$currency      = strtoupper( $currency );
		$currencies    = self::get_all_currencies_data();
		$currency_data = $currencies[ $currency ] ?? null;

		$symbol = ! empty( $currency_data ) ? $currency_data['symbol'] : '';
		return is_string( $symbol ) ? $symbol : '';
	}

	/**
	 * Get list of zero-decimal currencies.
	 *
	 * Zero-decimal currencies don't use decimal points in payment APIs.
	 * For these currencies, amounts are passed as-is without multiplying/dividing by 100.
	 *
	 * @since 2.0.0
	 * @return array<string> Array of zero-decimal currency codes.
	 */
	public static function get_zero_decimal_currencies() {
		$currencies         = self::get_all_currencies_data();
		$zero_decimal_codes = [];

		foreach ( $currencies as $code => $data ) {
			if ( 0 === $data['decimal_places'] ) {
				$zero_decimal_codes[] = $code;
			}
		}

		return $zero_decimal_codes;
	}

	/**
	 * Check if currency is zero-decimal.
	 *
	 * @param string $currency Currency code.
	 * @since 2.0.0
	 * @return bool True if zero-decimal currency.
	 */
	public static function is_zero_decimal_currency( $currency ) {
		if ( empty( $currency ) || ! is_string( $currency ) ) {
			return false;
		}

		$currency      = strtoupper( $currency );
		$currencies    = self::get_all_currencies_data();
		$currency_data = $currencies[ $currency ] ?? null;

		return $currency_data && 0 === $currency_data['decimal_places'];
	}

	/**
	 * Get all payment-related translatable strings for frontend use.
	 *
	 * This is the single source of truth for all payment UI strings.
	 * Each string has a unique key (slug) for easy reference in JavaScript.
	 *
	 * @since 2.0.0
	 * @return array<string, string> Array of translatable strings keyed by slug.
	 */
	public static function get_payment_strings() {
		return [
			'unknown_error'                     => __( 'An unknown error occurred. Please try again or contact the site administrator.', 'sureforms' ),
			// Payment validation messages.
			'payment_unavailable'               => __( 'Payment is currently unavailable. Please contact the site administrator.', 'sureforms' ),
			'payment_amount_not_configured'     => __( 'Payment is currently unavailable. Please contact the site administrator to configure the payment amount.', 'sureforms' ),
			'invalid_variable_amount'           => __( 'Invalid payment amount', 'sureforms' ),
			'amount_below_minimum'              => __( 'Payment amount must be at least {symbol}{amount}.', 'sureforms' ),

			// Field mapping validation.
			'payment_name_not_mapped'           => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer name field.', 'sureforms' ),
			'payment_email_not_mapped'          => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer email field.', 'sureforms' ),
			'payment_name_required'             => __( 'Please enter your name.', 'sureforms' ),
			'payment_email_required'            => __( 'Please enter your email.', 'sureforms' ),

			// Payment processing messages.
			'payment_failed'                    => __( 'Payment failed', 'sureforms' ),
			'payment_successful'                => __( 'Payment successful', 'sureforms' ),
			'payment_could_not_be_completed'    => __( 'Unable to complete payment. Please try again or contact support.', 'sureforms' ),

			// Stripe decline codes - Card declined errors.
			'generic_decline'                   => __( 'Your card was declined. Please try a different payment method or contact your bank.', 'sureforms' ),
			'card_declined'                     => __( 'Your card was declined. Please try a different payment method or contact your bank.', 'sureforms' ),
			'insufficient_funds'                => __( 'Your card has insufficient funds. Please use a different payment method.', 'sureforms' ),
			'lost_card'                         => __( 'Your card was declined because it has been reported as lost. Please contact your bank.', 'sureforms' ),
			'stolen_card'                       => __( 'Your card was declined because it has been reported as stolen. Please contact your bank.', 'sureforms' ),
			'expired_card'                      => __( 'Your card has expired. Please use a different payment method.', 'sureforms' ),
			'pickup_card'                       => __( 'Your card was declined. Please contact your bank for more information.', 'sureforms' ),
			'restricted_card'                   => __( 'Your card was declined due to restrictions. Please contact your bank.', 'sureforms' ),
			'security_violation'                => __( 'Your card was declined due to a security violation. Please contact your bank.', 'sureforms' ),
			'service_not_allowed'               => __( 'Your card does not support this type of purchase. Please use a different payment method.', 'sureforms' ),
			'stop_payment_order'                => __( 'A stop payment order has been placed on this card. Please contact your bank.', 'sureforms' ),
			'testmode_decline'                  => __( 'A test card was used in a live environment. Please use a real card.', 'sureforms' ),
			'withdrawal_count_limit_exceeded'   => __( 'Your card has exceeded its withdrawal limit. Please contact your bank.', 'sureforms' ),
			'incorrect_cvc'                     => __( 'Your card\'s security code is incorrect. Please check and try again.', 'sureforms' ),
			'incorrect_number'                  => __( 'Your card number is incorrect. Please check and try again.', 'sureforms' ),
			'invalid_cvc'                       => __( 'Your card\'s security code is invalid. Please check and try again.', 'sureforms' ),
			'invalid_expiry_month'              => __( 'Your card\'s expiration month is invalid. Please check and try again.', 'sureforms' ),
			'invalid_expiry_year'               => __( 'Your card\'s expiration year is invalid. Please check and try again.', 'sureforms' ),
			'invalid_number'                    => __( 'Your card number is invalid. Please check and try again.', 'sureforms' ),
			'processing_error'                  => __( 'Unable to process card. Please try again.', 'sureforms' ),
			'reenter_transaction'               => __( 'Unable to process transaction. Please try again.', 'sureforms' ),
			'card_not_supported'                => __( 'Your card is not supported for this transaction. Please use a different payment method.', 'sureforms' ),
			'currency_not_supported'            => __( 'Your card does not support the currency used for this transaction. Please use a different payment method.', 'sureforms' ),
			'duplicate_transaction'             => __( 'A transaction with identical details was submitted recently. Please wait a moment and try again.', 'sureforms' ),
			'invalid_account'                   => __( 'The account associated with your card is invalid. Please contact your bank.', 'sureforms' ),
			'invalid_amount'                    => __( 'The payment amount is invalid. Please contact the site administrator.', 'sureforms' ),
			'issuer_not_available'              => __( 'Unable to reach card issuer. Please try again later.', 'sureforms' ),
			'merchant_blacklist'                => __( 'Your card was declined. Please contact your bank for more information.', 'sureforms' ),
			'new_account_information_available' => __( 'Your card information needs to be updated. Please contact your bank.', 'sureforms' ),
			'no_action_taken'                   => __( 'The card cannot be used for this transaction. Please contact your bank.', 'sureforms' ),
			'not_permitted'                     => __( 'The transaction is not permitted. Please contact your bank.', 'sureforms' ),
			'offline_pin_required'              => __( 'Your card requires offline PIN authentication. Please try again.', 'sureforms' ),
			'online_or_offline_pin_required'    => __( 'Your card requires PIN authentication. Please try again.', 'sureforms' ),
			'pin_try_exceeded'                  => __( 'You have exceeded the maximum number of PIN attempts. Please contact your bank.', 'sureforms' ),
			'revocation_of_all_authorizations'  => __( 'All authorizations for this card have been revoked. Please contact your bank.', 'sureforms' ),
			'revocation_of_authorization'       => __( 'The authorization for this transaction has been revoked. Please try again.', 'sureforms' ),
			'transaction_not_allowed'           => __( 'This transaction is not allowed. Please contact your bank.', 'sureforms' ),
			'try_again_later'                   => __( 'Unable to process transaction. Please try again later.', 'sureforms' ),
			'live_mode_test_card'               => __( 'Your card was declined. Your request was in live mode, but used a known test card.', 'sureforms' ),
			'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' ),

			// Default values and placeholders.
			'sureforms_subscription'            => __( 'SureForms Subscription', 'sureforms' ),
			'sureforms_payment'                 => __( 'SureForms Payment', 'sureforms' ),
			'subscription_plan'                 => __( 'Subscription Plan', 'sureforms' ),
			'sureforms_customer'                => __( 'SureForms Customer', 'sureforms' ),
			'customer_example_email'            => 'customer@example.com', // Not translatable - example email.
			'amount_placeholder'                => __( 'Complete the form to view the amount.', 'sureforms' ),
			'failed_to_create_payment'          => __( 'Unable to create payment. Please contact support.', 'sureforms' ),
		];
	}

	/**
	 * Retrieve a user-friendly payment error message by error key.
	 *
	 * @param string $key Error key received from payment processing/Stripe.
	 *
	 * @since 2.0.0
	 * @return string Localized error message or a generic "Unknown error" message if not found.
	 */
	public static function get_error_message_by_key( $key ) {
		$messages = self::get_payment_strings();
		if ( isset( $messages[ $key ] ) ) {
			return $messages[ $key ];
		}
		return __( 'Unknown error', 'sureforms' );
	}

	/**
	 * Validate payment amount against stored form configuration.
	 *
	 * This function verifies that the payment amount and currency submitted
	 * match the configured values in the form's payment block settings.
	 * It handles both fixed and minimum amount validations for single and subscription payments.
	 *
	 * @since 2.2.2
	 * @param int|float $amount   Amount in smallest currency unit (e.g., cents for USD).
	 * @param string    $currency Currency code (e.g., 'usd', 'eur').
	 * @param int       $form_id  WordPress post ID of the form.
	 * @param string    $block_id    Block identifier for the payment block.
	 * @param string    $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
	 * @return array {
	 *     Validation result.
	 *
	 *     @type bool   $valid   Whether the validation passed.
	 *     @type string $message Error message if validation failed, empty if valid.
	 * }
	 */
	public static function validate_payment_amount( $amount, $currency, $form_id, $block_id, $active_type = '' ) {
		// Retrieve block configuration from post meta.
		$block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );

		// Check if block config exists.
		if ( empty( $block_config ) || ! is_array( $block_config ) ) {
			return [
				'valid'   => false,
				'message' => __( 'Invalid form configuration.', 'sureforms' ),
			];
		}

		// Check if payment block exists in configuration.
		if ( ! isset( $block_config[ $block_id ] ) || ! is_array( $block_config[ $block_id ] ) ) {
			return [
				'valid'   => false,
				'message' => __( 'Payment configuration not found for this form.', 'sureforms' ),
			];
		}

		$payment_config     = $block_config[ $block_id ];
		$global_currency    = strtolower( self::get_currency() );
		$submitted_currency = strtolower( $currency );
		if ( $global_currency !== $submitted_currency ) {
			return [
				'valid'   => false,
				/* translators: 1: expected currency, 2: received currency */
				'message' => sprintf( __( 'Currency mismatch: expected %1$s, received %2$s.', 'sureforms' ), strtoupper( $global_currency ), strtoupper( $submitted_currency ) ),
			];
		}

		// Reject when the requested flow (one-time vs subscription) is not allowed by
		// the form's stored payment_type. "both" mode allows either flow; pure modes
		// allow only their matching flow. Without this guard, an attacker could call
		// the wrong intent-creation route on a pure-subscription form and pay once for
		// what should be a recurring charge (or vice versa).
		$payment_type = isset( $payment_config['payment_type'] ) && is_string( $payment_config['payment_type'] ) ? $payment_config['payment_type'] : 'one-time';
		if ( ! empty( $active_type ) && 'both' !== $payment_type && $active_type !== $payment_type ) {
			return [
				'valid'   => false,
				'message' => __( 'Payment type does not match the form configuration.', 'sureforms' ),
			];
		}

		// BOTH MODE: when payment_type is 'both', resolve the correct per-type
		// config (amount_type, fixed_amount, minimum_amount, variable_amount_field)
		// based on which flow the user actually chose (one-time vs subscription).
		$resolved_config = self::resolve_payment_config_for_active_type( $payment_config, $active_type );

		// Get amount type (fixed or minimum).
		$amount_type = $resolved_config['amount_type'] ?? 'fixed';

		// Validate based on amount type.
		if ( 'fixed' === $amount_type ) {
			// Fixed amount validation - must match exactly.
			$configured_amount = isset( $resolved_config['fixed_amount'] ) ? floatval( $resolved_config['fixed_amount'] ) : 10.00;

			// Allow small floating point difference (0.01) due to rounding.
			if ( abs( $amount - $configured_amount ) > 0.01 ) {
				return [
					'valid'   => false,
					/* translators: 1: expected amount with currency */
					'message' => sprintf( __( 'Payment amount must be exactly %1$s.', 'sureforms' ), $configured_amount . ' ' . strtoupper( $currency ) ),
				];
			}
		} elseif ( 'variable' === $amount_type ) {
			// Minimum amount validation - must be >= minimum.
			$minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;

			if ( $amount < $minimum_amount ) {
				return [
					'valid'   => false,
					/* translators: 1: minimum amount with currency */
					'message' => sprintf( __( 'Payment amount must be at least %1$s.', 'sureforms' ), $minimum_amount . ' ' . strtoupper( $currency ) ),
				];
			}

			// Validate dynamic amount from dropdown/multi-choice field.
			$dynamic_amount_validation = self::validate_dynamic_amount_field(
				$resolved_config,
				$block_config,
				$amount,
				$currency
			);

			if ( null !== $dynamic_amount_validation ) {
				return $dynamic_amount_validation;
			}
		}

		// Validation passed.
		return [
			'valid'   => true,
			'message' => '',
		];
	}

	/**
	 * Store payment intent metadata in transient for verification.
	 *
	 * Stores payment intent details temporarily to verify that the payment intent
	 * was created through our system and hasn't been tampered with.
	 *
	 * @since 2.2.2
	 * @param string               $block_id          Block identifier.
	 * @param string               $payment_intent_id Payment intent ID from Stripe.
	 * @param array<string, mixed> $metadata          Payment metadata to store.
	 * @return bool True on success, false on failure.
	 */
	public static function store_payment_intent_metadata( $block_id, $payment_intent_id, $metadata ) {
		if ( empty( $block_id ) || empty( $payment_intent_id ) ) {
			return false;
		}

		// Create transient key: srfm_pi_{block_id}_{payment_intent_id}.
		$transient_key = 'srfm_pi_' . sanitize_key( $block_id ) . '_' . sanitize_key( $payment_intent_id );

		// Add timestamp to metadata.
		$metadata['created_at'] = time();

		// Store for 1 hour (3600 seconds).
		return set_transient( $transient_key, $metadata, 3600 );
	}

	/**
	 * Verify payment intent and validate amount.
	 *
	 * Verifies that the payment intent was created through our system and validates
	 * the payment amount matches the expected amount based on form configuration.
	 *
	 * @since 2.3.0
	 * @param string               $block_id          Block identifier.
	 * @param string               $payment_intent_id Payment intent ID from Stripe.
	 * @param array<string, mixed> $form_data         Submitted form data.
	 * @param string               $active_type       Optional. 'one-time' or 'subscription' for "both" mode resolution.
	 * @return array {
	 *     Verification result.
	 *
	 *     @type bool   $valid   Whether verification passed.
	 *     @type string $message Error message if verification failed, empty if valid.
	 * }
	 */
	public static function verify_payment_intent( $block_id, $payment_intent_id, $form_data, $active_type = '' ) {
		// Get form ID from form data for verification.
		$form_id = isset( $form_data['form-id'] ) && ! empty( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? intval( $form_data['form-id'] ) : 0;

		// Validate required parameters.
		if ( empty( $block_id ) || empty( $payment_intent_id ) || empty( $form_id ) ) {
			return [
				'valid'   => false,
				'message' => __( 'Invalid payment verification parameters.', 'sureforms' ),
			];
		}

		// Verify payment intent was created through our system.
		$transient_key = 'srfm_pi_' . sanitize_key( $block_id ) . '_' . sanitize_key( $payment_intent_id );
		$metadata      = get_transient( $transient_key );

		if ( empty( $metadata ) || ! is_array( $metadata ) ) {
			return [
				'valid'   => false,
				'message' => __( 'Payment verification failed. Invalid payment intent.', 'sureforms' ),
			];
		}

		// Reject when the submit path's active_type does not match the type that
		// was validated at intent-creation time. Prevents an attacker from passing
		// a one-time intent_id through the subscription submit path (or vice versa)
		// to replay a small one-time charge in place of a recurring subscription.
		$stored_active_type = isset( $metadata['active_type'] ) && is_string( $metadata['active_type'] ) ? $metadata['active_type'] : '';
		if ( ! empty( $active_type ) && ! empty( $stored_active_type ) && $active_type !== $stored_active_type ) {
			return [
				'valid'   => false,
				'message' => __( 'Payment verification failed. Payment type mismatch.', 'sureforms' ),
			];
		}

		$payment_amount = isset( $metadata['amount'] ) && ! empty( $metadata['amount'] ) && is_numeric( $metadata['amount'] ) ? floatval( $metadata['amount'] ) : 0;

		// Validate payment amount matches configuration.
		$amount_validation = self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount, $active_type );

		if ( false === $amount_validation['valid'] ) {
			return $amount_validation;
		}

		// Verification passed.
		return [
			'valid'   => true,
			'message' => '',
		];
	}

	/**
	 * Validate an arbitrary amount against the form's server-side payment configuration.
	 *
	 * Public wrapper around the amount validator so the submission flow can re-check the amount
	 * Stripe actually charged (defense-in-depth) — not only the amount recorded when the intent was
	 * created.
	 *
	 * @param string               $block_id    Block identifier.
	 * @param int                  $form_id     Form post ID.
	 * @param array<string, mixed> $form_data   Submitted form data.
	 * @param float                $amount      Amount to validate (decimal, in the form currency).
	 * @param string               $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
	 * @since 2.11.1
	 * @return array<string, mixed> Validation result with 'valid' (bool) and 'message' (string) keys.
	 */
	public static function validate_amount_against_config( $block_id, $form_id, $form_data, $amount, $active_type = '' ) {
		return self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $amount, $active_type );
	}

	/**
	 * Delete payment intent metadata from transient.
	 *
	 * Cleans up stored metadata after successful payment verification.
	 *
	 * @since 2.2.2
	 * @param string $block_id          Block identifier.
	 * @param string $payment_intent_id Payment intent ID from Stripe.
	 * @return bool True on success, false on failure.
	 */
	public static function delete_payment_intent_metadata( $block_id, $payment_intent_id ) {
		if ( empty( $block_id ) || empty( $payment_intent_id ) ) {
			return false;
		}

		// Create transient key: srfm_pi_{block_id}_{payment_intent_id}.
		$transient_key = 'srfm_pi_' . sanitize_key( $block_id ) . '_' . sanitize_key( $payment_intent_id );

		return delete_transient( $transient_key );
	}

	/**
	 * Get currency sign position.
	 *
	 * @since 2.5.1
	 * @return string Currency sign position ('left', 'right', 'left_space', 'right_space').
	 */
	public static function get_currency_sign_position() {
		$result = self::get_global_setting( 'currency_sign_position', 'left' );

		return ! empty( $result ) && is_string( $result ) ? $result : 'left';
	}

	/**
	 * Get a submitted form value by field slug.
	 *
	 * Matches the SureForms field-name convention `{block}-{block_id}-lbl-{label}-{slug}` by
	 * suffix, regardless of block type. Used to resolve `{form:slug}` tokens when recomputing a
	 * calculation server-side. Returns null when the slug is not present in the submission.
	 *
	 * @param string       $slug      The field slug to look up.
	 * @param array<mixed> $form_data Submitted form data.
	 * @since 2.11.1
	 * @return mixed|null The submitted value, or null when not found.
	 */
	public static function get_submitted_value_by_slug( $slug, $form_data ) {
		if ( empty( $slug ) || ! is_string( $slug ) || ! is_array( $form_data ) ) {
			return null;
		}

		$suffix = '-' . $slug;
		foreach ( $form_data as $field_key => $field_value ) {
			if ( ! is_string( $field_key ) || false === strpos( $field_key, '-lbl-' ) ) {
				continue;
			}

			if ( substr( $field_key, -strlen( $suffix ) ) === $suffix ) {
				return $field_value;
			}
		}

		return null;
	}

	/**
	 * Resolve the WordPress user associated with a payment record.
	 *
	 * Resolution order:
	 *  1. The linked entry's `user_id` (set when a logged-in user submitted the form).
	 *  2. A user matching the payment's `customer_email`.
	 *  3. `0` for guest checkouts where no WordPress user can be resolved.
	 *
	 * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
	 * @return int Resolved WordPress user ID, or 0 when none can be determined.
	 * @since 2.12.0
	 */
	public static function resolve_payment_user( $payment ) {
		if ( ! is_array( $payment ) ) {
			return 0;
		}

		// 1. Prefer the user_id stored on the linked entry.
		$entry_id = ! empty( $payment['entry_id'] ) && is_numeric( $payment['entry_id'] ) ? intval( $payment['entry_id'] ) : 0;
		if ( $entry_id > 0 ) {
			$entry = Entries::get( $entry_id );
			if ( is_array( $entry ) && ! empty( $entry['user_id'] ) && is_numeric( $entry['user_id'] ) ) {
				$user_id = intval( $entry['user_id'] );
				if ( $user_id > 0 ) {
					return $user_id;
				}
			}
		}

		// 2. Fall back to a user matching the customer email.
		$customer_email = ! empty( $payment['customer_email'] ) && is_string( $payment['customer_email'] ) ? sanitize_email( $payment['customer_email'] ) : '';
		if ( ! empty( $customer_email ) ) {
			$user = get_user_by( 'email', $customer_email );
			if ( $user instanceof \WP_User ) {
				return intval( $user->ID );
			}
		}

		// 3. Guest checkout — no resolvable WordPress user.
		return 0;
	}

	/**
	 * Build the standard context array passed alongside payment-lifecycle actions.
	 *
	 * Gives consumers (membership, LMS and other plugins) a consistent, resolved
	 * snapshot of who paid and through which form/gateway, without each consumer
	 * having to re-derive it from the raw payment row.
	 *
	 * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
	 * @return array{form_id:int, entry_id:int, user_id:int, customer_email:string, type:string, gateway:string, mode:string} Resolved payment context.
	 * @since 2.12.0
	 */
	public static function build_payment_context( $payment ) {
		$payment = is_array( $payment ) ? $payment : [];

		return [
			'form_id'        => ! empty( $payment['form_id'] ) && is_numeric( $payment['form_id'] ) ? intval( $payment['form_id'] ) : 0,
			'entry_id'       => ! empty( $payment['entry_id'] ) && is_numeric( $payment['entry_id'] ) ? intval( $payment['entry_id'] ) : 0,
			'user_id'        => self::resolve_payment_user( $payment ),
			'customer_email' => ! empty( $payment['customer_email'] ) && is_string( $payment['customer_email'] ) ? sanitize_email( $payment['customer_email'] ) : '',
			'type'           => ! empty( $payment['type'] ) && is_string( $payment['type'] ) ? sanitize_text_field( $payment['type'] ) : '',
			'gateway'        => ! empty( $payment['gateway'] ) && is_string( $payment['gateway'] ) ? sanitize_text_field( $payment['gateway'] ) : '',
			'mode'           => ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? sanitize_text_field( $payment['mode'] ) : '',
		];
	}

	/**
	 * Validate dynamic amount field from dropdown or multi-choice.
	 *
	 * @param array<string, mixed> $payment_config Payment block configuration.
	 * @param array<string, mixed> $block_config  All block configurations.
	 * @param float                $submitted_amount_decimal Submitted amount in decimal.
	 * @param string               $currency Currency code.
	 * @return array|null Validation result array or null if validation passes.
	 * @since 2.3.0
	 */
	/**
	 * BOTH MODE: resolve the correct amount config keys from the payment block
	 * config based on which flow (one-time or subscription) the user chose.
	 *
	 * For pure one-time / subscription blocks, the config already has the correct
	 * scalar keys (amount_type, fixed_amount, minimum_amount, etc.) so this method
	 * returns them unchanged. For "both" blocks, it remaps the per-type keys
	 * (one_time_* or subscription_*) into the scalar positions the validation
	 * functions expect.
	 *
	 * @param array<mixed> $payment_config Full block config from _srfm_block_config.
	 * @param string       $active_type    'one-time' or 'subscription' — which flow is active.
	 * @return array<mixed> Config array with amount_type, fixed_amount, minimum_amount,
	 *                      variable_amount_field, variable_amount_field_block_name resolved
	 *                      for the active type.
	 * @since 2.8.2
	 */
	private static function resolve_payment_config_for_active_type( $payment_config, $active_type ) {
		// Only remap when the block is in "both" mode and the caller told us the active type.
		if ( 'both' !== ( $payment_config['payment_type'] ?? '' ) || empty( $active_type ) ) {
			return $payment_config;
		}

		$prefix = 'subscription' === $active_type ? 'subscription_' : 'one_time_';

		$resolved = $payment_config; // Keep all original keys as fallback.

		if ( isset( $payment_config[ $prefix . 'amount_type' ] ) ) {
			$resolved['amount_type'] = $payment_config[ $prefix . 'amount_type' ];
		}
		if ( isset( $payment_config[ $prefix . 'fixed_amount' ] ) ) {
			$resolved['fixed_amount'] = (float) $payment_config[ $prefix . 'fixed_amount' ];
		}
		if ( isset( $payment_config[ $prefix . 'minimum_amount' ] ) ) {
			$resolved['minimum_amount'] = (float) $payment_config[ $prefix . 'minimum_amount' ];
		}
		if ( isset( $payment_config[ $prefix . 'variable_amount_field' ] ) ) {
			$resolved['variable_amount_field'] = $payment_config[ $prefix . 'variable_amount_field' ];
		}
		if ( isset( $payment_config[ $prefix . 'variable_amount_field_block_name' ] ) ) {
			$resolved['variable_amount_field_block_name'] = $payment_config[ $prefix . 'variable_amount_field_block_name' ];
		}

		return $resolved;
	}

	/**
	 * Validate that a submitted dynamic amount matches one of the options configured
	 * on a linked dropdown/multi-choice block (when single-selection is enabled).
	 *
	 * @since 2.8.2
	 * @param array<string, mixed> $payment_config           Resolved payment block config (active for current mode).
	 * @param array<string, mixed> $block_config             All form block configs keyed by block_id.
	 * @param float                $submitted_amount_decimal Submitted amount as a decimal (not smallest unit).
	 * @param string               $currency                 ISO currency code.
	 * @return array<string, mixed>|null Validation result array with 'valid' + 'message', or null when no validation is required.
	 */
	private static function validate_dynamic_amount_field( $payment_config, $block_config, $submitted_amount_decimal, $currency ) {
		// Check if variable amount field is from dropdown or multi-choice block.
		$dynamic_amount_field_block_name = $payment_config['variable_amount_field_block_name'] ?? '';

		if ( empty( $dynamic_amount_field_block_name ) ) {
			// Return null because it can be old form configuration.
			return null;
		}

		if ( 'srfm/dropdown' !== $dynamic_amount_field_block_name && 'srfm/multi-choice' !== $dynamic_amount_field_block_name ) {
			return null; // Not a dropdown/multi-choice, skip validation.
		}

		// Get the slug of the variable amount field.
		$variable_amount_field_slug = ! empty( $payment_config['variable_amount_field'] ) && is_string( $payment_config['variable_amount_field'] ) ? $payment_config['variable_amount_field'] : '';

		// Find the block config for the variable amount field by matching slug and block name.
		$variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );

		// Verify the variable amount block config was found.
		if ( empty( $variable_amount_block_config ) || ! is_array( $variable_amount_block_config ) ) {
			return [
				'valid'   => false,
				'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
			];
		}

		// Check if single selection is enabled (only validate for single selection).
		$is_single_selection = false;
		if ( 'srfm/dropdown' === $dynamic_amount_field_block_name ) {
			// For dropdown, check if multi_select is disabled (single selection).
			$is_single_selection = empty( $variable_amount_block_config['multi_select'] );
		} elseif ( 'srfm/multi-choice' === $dynamic_amount_field_block_name ) {
			// For multi-choice, check if single_selection is enabled.
			$is_single_selection = ! empty( $variable_amount_block_config['single_selection'] );
		}

		// Only validate amount matches options if single selection is enabled.
		if ( $is_single_selection ) {
			// Validate that submitted amount matches one of the allowed option values.
			$allowed_options = $variable_amount_block_config['options'] ?? [];
			if ( empty( $allowed_options ) || ! is_array( $allowed_options ) ) {
				return [
					'valid'   => false,
					'message' => __( 'No payment options are configured for this field.', 'sureforms' ),
				];
			}

			// Extract allowed values from options.
			$allowed_values = [];
			foreach ( $allowed_options as $option ) {
				if ( isset( $option['value'] ) && ! empty( $option['value'] ) ) {
					$allowed_values[] = floatval( $option['value'] );
				}
			}

			// Check if submitted amount matches any allowed value.
			$amount_is_valid = false;
			foreach ( $allowed_values as $allowed_value ) {
				// Allow small floating point difference (0.01) due to rounding.
				if ( abs( $submitted_amount_decimal - $allowed_value ) <= 0.01 ) {
					$amount_is_valid = true;
					break;
				}
			}

			if ( ! $amount_is_valid ) {
				return [
					'valid'   => false,
					/* translators: %s: currency code */
					'message' => sprintf( __( 'Invalid payment amount. Please select a valid amount from the available options.', 'sureforms' ), strtoupper( $currency ) ),
				];
			}
		}

		// Validation passed for dynamic amount field.
		return null;
	}

	/**
	 * Validate payment intent amount matches form configuration.
	 *
	 * Validates that the payment amount from Stripe matches the expected amount
	 * based on form configuration, including dynamic amounts from dropdown/multi-choice fields.
	 *
	 * @since 2.3.0
	 * @param string               $block_id       Block identifier.
	 * @param int                  $form_id        Form post ID.
	 * @param array<string, mixed> $form_data      Submitted form data.
	 * @param int|float            $payment_amount Payment amount from Stripe (in smallest currency unit).
	 * @param string               $active_type    Optional. 'one-time' or 'subscription' for "both" mode resolution.
	 * @return array {
	 *     Validation result.
	 *
	 *     @type bool   $valid   Whether validation passed.
	 *     @type string $message Error message if validation failed, empty if valid.
	 * }
	 */
	private static function validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount, $active_type = '' ) {
		// Get block configuration.
		$block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );

		if ( empty( $block_config ) || ! isset( $block_config[ $block_id ] ) ) {
			return [
				'valid'   => false,
				/* translators: %1$s: expected amount, %2$s: payment amount */
				'message' => __( 'Payment configuration not found.', 'sureforms' ),
			];
		}

		$payment_config  = $block_config[ $block_id ];
		$resolved_config = self::resolve_payment_config_for_active_type( $payment_config, $active_type );
		$amount_type     = $resolved_config['amount_type'] ?? 'fixed';

		// For fixed amounts, validate against configured amount.
		if ( 'fixed' === $amount_type ) {
			$configured_amount = isset( $resolved_config['fixed_amount'] ) ? floatval( $resolved_config['fixed_amount'] ) : 0;

			// Allow small floating point difference (0.01) due to rounding.
			if ( abs( $payment_amount - $configured_amount ) > 0.01 ) {
				return [
					'valid'   => false,
					/* translators: %1$s: expected amount, %2$s: payment amount */
					'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $configured_amount, $payment_amount ),
				];
			}

			return [
				'valid'   => true,
				'message' => '',
			];
		}

		// For variable amounts, validate based on source field.
		if ( 'variable' === $amount_type ) {
			// Check if variable amount comes from dropdown/multi-choice.
			$dynamic_amount_field_block_name = $resolved_config['variable_amount_field_block_name'] ?? '';
			$variable_amount_field_slug      = $resolved_config['variable_amount_field'] ?? '';

			// "Variant B": legacy/stale config may not have recorded the amount-source field
			// (empty source reference). Without it we cannot derive a server-side expected
			// amount. First try to recover it by refreshing the block config from the form's
			// current content — forms saved with current code record the source — which
			// self-heals legacy forms whose source field still exists.
			if ( empty( $dynamic_amount_field_block_name ) || empty( $variable_amount_field_slug ) ) {
				$refreshed_config = self::refresh_block_config( $form_id );
				if ( is_array( $refreshed_config ) && isset( $refreshed_config[ $block_id ] ) && is_array( $refreshed_config[ $block_id ] ) ) {
					$block_config                    = $refreshed_config;
					$resolved_config                 = self::resolve_payment_config_for_active_type( $block_config[ $block_id ], $active_type );
					$dynamic_amount_field_block_name = $resolved_config['variable_amount_field_block_name'] ?? '';
					$variable_amount_field_slug      = $resolved_config['variable_amount_field'] ?? '';
				}
			}

			// The source still cannot be identified (e.g. the amount-source field was deleted from
			// the form while the payment amount type is still "variable", so there is no field-level
			// config left to derive an expected amount from).
			//
			// Security: this branch previously returned a valid result unconditionally for such
			// forms, which allowed an unauthenticated attacker to pay any amount (down to 1 cent
			// when the form had no minimum-amount floor).
			//
			// If the admin configured a positive minimum amount we enforce it as the authoritative
			// lower bound — the only server-side guarantee available for such a form — instead of
			// rejecting outright. This keeps legacy forms (whose source field still has a floor)
			// working without requiring a re-save. With no positive floor there is nothing safe to
			// validate against, so we MUST fail safe and reject to avoid reopening the bypass.
			if ( empty( $dynamic_amount_field_block_name ) || empty( $variable_amount_field_slug ) ) {
				$minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;

				if ( $minimum_amount > 0 ) {
					if ( $payment_amount < $minimum_amount ) {
						return [
							'valid'   => false,
							/* translators: %1$s: minimum amount, %2$s: payment amount */
							'message' => sprintf( __( 'Payment amount below minimum. Minimum: %1$s, received %2$s.', 'sureforms' ), $minimum_amount, $payment_amount ),
						];
					}

					return [
						'valid'   => true,
						'message' => '',
					];
				}

				return [
					'valid'   => false,
					'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
				];
			}

			// The amount source is identified: validate the charged amount against the
			// server-derived expected amount for that source. The configured minimum-amount
			// floor below is always enforced as an additional lower bound.
			$submitted_field_value = self::get_form_submitted_value_by_slug_and_block_name( $variable_amount_field_slug, $dynamic_amount_field_block_name, $form_data );

			if ( empty( $submitted_field_value ) ) {
				return [
					'valid'   => false,
					'message' => __( 'Variable amount field value is required.', 'sureforms' ),
				];
			}

			if ( 'srfm/dropdown' === $dynamic_amount_field_block_name || 'srfm/multi-choice' === $dynamic_amount_field_block_name ) {
				// Get the block config for the variable amount field by matching slug and block name.
				$variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );

				if ( empty( $variable_amount_block_config ) || ! is_string( $submitted_field_value ) ) {
					return [
						'valid'   => false,
						'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
					];
				}

				// The expected amount is read from the server-side option config keyed by the
				// submitted selection — the attacker chooses the option, never its price.
				$get_expected_amount = self::get_amount_by_the_config_options( $submitted_field_value, $variable_amount_block_config );

				// Fail safe when the submitted selection doesn't map to a configured
				// option value: get_amount_by_the_config_options() returns null, and
				// abs( $payment_amount - null ) would coerce null to 0 — reject explicitly
				// so the comparison can never be silently weakened by that coercion.
				if ( ! is_numeric( $get_expected_amount ) ) {
					return [
						'valid'   => false,
						'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
					];
				}

				// Validate payment amount matches expected amount.
				if ( abs( $payment_amount - $get_expected_amount ) > 0.01 ) {
					return [
						'valid'   => false,
						/* translators: %1$s: expected amount, %2$s: payment amount */
						'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $get_expected_amount, $payment_amount ),
					];
				}
			} else {
				// Number and hidden fields. Their value may be server-determined — a
				// configured default value, or a calculation computed from other fields.
				// In those cases the expected amount MUST be derived server-side and the
				// value submitted with the request must never be trusted as the price.
				$variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );

				if ( empty( $variable_amount_block_config ) ) {
					return [
						'valid'   => false,
						'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
					];
				}

				$expected_amount = self::resolve_server_side_variable_amount( $variable_amount_block_config, $block_config, $form_data );

				if ( null !== $expected_amount ) {
					// Authoritative server-side amount (static default value or a
					// server-recomputed calculation). Reject any mismatch.
					if ( abs( $payment_amount - floatval( $expected_amount ) ) > 0.01 ) {
						return [
							'valid'   => false,
							/* translators: %1$s: expected amount, %2$s: payment amount */
							'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), floatval( $expected_amount ), $payment_amount ),
						];
					}
				} elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
					// Calculation-driven number: a null server amount means the formula
					// could NOT be recomputed server-side (a referenced field was
					// non-numeric, or the formula used something the parser can't
					// evaluate). This is NOT "name your price" — we must fail safe and
					// reject, never fall back to the client-submitted amount, which
					// would reopen the unauthenticated underpayment bypass.
					if ( ! empty( $variable_amount_block_config['enableCalculation'] ) ) {
						return [
							'valid'   => false,
							'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
						];
					}

					// Plain user-entered number ("name your price"): the amount is the
					// customer's own choice, so confirm the charge matches what they entered.
					// The minimum-amount floor below guards the lower bound.
					$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';
					$submitted_field_value    = Helper::get_string_value( $submitted_field_value );
					$converted_payment_amount = self::normalize_amount_by_format( $submitted_field_value, $number_format_type );

					if ( ! is_numeric( $converted_payment_amount ) || $converted_payment_amount <= 0 ) {
						return [
							'valid'   => false,
							'message' => __( 'Variable amount field value is required.', 'sureforms' ),
						];
					}

					if ( abs( $payment_amount - $converted_payment_amount ) > 0.01 ) {
						return [
							'valid'   => false,
							/* translators: %1$s: expected amount, %2$s: payment amount */
							'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $converted_payment_amount, $payment_amount ),
						];
					}
				} else {
					// Unresolved hidden / dynamic source: resolve_server_side_variable_amount()
					// returned null (e.g. a hidden field whose default is a smart tag like
					// {get_input:amount}, stored raw and therefore non-numeric), so the submitted
					// value cannot be trusted as the price and there is no server-authoritative
					// amount to compare against. The configured minimum-amount floor is then the
					// ONLY server-side guarantee, so it must be a positive authoritative value.
					//
					// This mirrors the "amount source not identified" handling above: with a
					// positive minimum we fall through to the floor check below (the documented
					// dynamic-prefill case keeps working); with no positive minimum there is
					// nothing safe to validate against, so we MUST fail safe and reject rather than
					// letting the floor default to 0 and accept any amount down to the gateway cent
					// floor — which would reopen the unauthenticated underpayment bypass. Merchants
					// doing custom JS-driven dynamic pricing must supply a server-authoritative
					// amount via the `srfm_server_side_variable_amount` filter or a
					// calculation-enabled field rather than relying on the submitted value.
					$unresolved_minimum = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;

					if ( $unresolved_minimum <= 0 ) {
						return [
							'valid'   => false,
							'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
						];
					}
				}
			}

			// All variable amount sources are subject to the configured minimum amount floor.
			// Use resolved_config so 'both'-mode forms read the active type's per-type minimum
			// (oneTimeMinimumAmount / subscriptionMinimumAmount) instead of the unset legacy scalar.
			$minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;

			if ( $payment_amount < $minimum_amount ) {
				return [
					'valid'   => false,
					/* translators: %1$s: minimum amount, %2$s: payment amount */
					'message' => sprintf( __( 'Payment amount below minimum. Minimum: %1$s, received %2$s.', 'sureforms' ), $minimum_amount, $payment_amount ),
				];
			}
		}

		// Validation passed.
		return [
			'valid'   => true,
			'message' => '',
		];
	}

	/**
	 * Force a refresh of the form's stored block configuration from its current content.
	 *
	 * Recovers the amount-source field reference for legacy forms whose cached
	 * _srfm_block_config predates server-side source tracking (an empty
	 * variable_amount_field_block_name). Re-parses the form blocks and rebuilds the config —
	 * forms saved with current code record the source — then returns the refreshed config.
	 *
	 * @param int $form_id Form post ID.
	 * @since 2.11.1
	 * @return array<mixed>|null Refreshed block configuration, or null if it cannot be rebuilt.
	 */
	private static function refresh_block_config( $form_id ) {
		if ( ! is_int( $form_id ) || $form_id <= 0 ) {
			return null;
		}

		$post = get_post( $form_id );
		if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) || ! function_exists( 'parse_blocks' ) ) {
			return null;
		}

		$blocks = parse_blocks( $post->post_content );
		if ( is_array( $blocks ) && ! empty( $blocks ) ) {
			Field_Validation::add_block_config( $blocks, $form_id );
		}

		return Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
	}

	/**
	 * Resolve the authoritative server-side expected amount for a variable amount source.
	 *
	 * The expected amount is ALWAYS derived from server-side configuration — the field's
	 * configured default value, or (for calculation-enabled fields) a value recomputed by
	 * SureForms Pro from the submitted inputs. It is NEVER taken from the value submitted with
	 * the request. Returns null when no authoritative amount can be determined server-side, in
	 * which case the caller falls back to the configured minimum-amount floor.
	 *
	 * @param array<mixed> $source_config The amount-source field block config (block_name, slug, enableCalculation, defaultValue, calculationFormula, ...).
	 * @param array<mixed> $block_config  All block configurations for the form.
	 * @param array<mixed> $form_data     Submitted form data.
	 * @since 2.11.1
	 * @return float|null Expected amount, or null if it cannot be determined server-side.
	 */
	private static function resolve_server_side_variable_amount( $source_config, $block_config, $form_data ) {
		if ( empty( $source_config ) || ! is_array( $source_config ) ) {
			return null;
		}

		/**
		 * Compute the authoritative server-side amount for a variable payment source.
		 *
		 * SureForms Pro hooks this to recompute a field's calculation formula from the
		 * submitted field values. Handlers MUST return a numeric value derived only from
		 * server-side configuration and other submitted inputs — never the raw value of the
		 * amount field submitted with the request — or null if it cannot be computed.
		 *
		 * @since 2.11.1
		 * @param float|null           $amount  The resolved amount. Default null.
		 * @param array<string, mixed> $context Context: source_config, block_config, form_data.
		 */
		$expected = apply_filters(
			'srfm_server_side_variable_amount',
			null,
			[
				'source_config' => $source_config,
				'block_config'  => $block_config,
				'form_data'     => $form_data,
			]
		);

		if ( is_numeric( $expected ) ) {
			return floatval( $expected );
		}

		// Static hidden field: a *literal numeric* configured default value is the server-side
		// source of truth and is authoritative. A non-numeric default (e.g. a smart tag such as
		// {get_input:amount} stored raw, resolved to a runtime value only at render time) is NOT
		// treated as authoritative here — it returns null below so the caller validates against the
		// minimum-amount floor instead, preserving the documented dynamic-prefill behavior.
		$block_name = $source_config['block_name'] ?? ( $source_config['blockName'] ?? '' );
		if ( 'srfm/hidden' === $block_name && empty( $source_config['enableCalculation'] ) && isset( $source_config['defaultValue'] ) && is_numeric( $source_config['defaultValue'] ) ) {
			return floatval( $source_config['defaultValue'] );
		}

		return null;
	}

	/**
	 * Get amount by matching submitted value with config options.
	 *
	 * @param string       $submitted_field_value The submitted value (string, can be "value1 | value2" for multi-select).
	 * @param array<mixed> $block_config          Block configuration containing options.
	 * @return float|null Expected amount if found, null otherwise.
	 * @since 2.3.0
	 */
	private static function get_amount_by_the_config_options( $submitted_field_value, $block_config ) {
		if ( empty( $submitted_field_value ) || ! is_string( $submitted_field_value ) ) {
			return null;
		}

		// Get options from block config.
		$options = $block_config['options'] ?? [];

		if ( empty( $options ) || ! is_array( $options ) ) {
			return null;
		}

		// Check if multi-select is enabled.
		$is_multi_select = false;
		$block_name      = $block_config['block_name'] ?? '';

		if ( 'srfm/dropdown' === $block_name ) {
			$is_multi_select = ! empty( $block_config['multi_select'] );
		} elseif ( 'srfm/multi-choice' === $block_name ) {
			// For multi-choice, multi-select is when single_selection is disabled.
			$is_multi_select = empty( $block_config['single_selection'] );
		}

		$expected_amount = null;

		// Handle multi-select case (submitted value format: "value1 | value2").
		if ( $is_multi_select && false !== strpos( $submitted_field_value, ' | ' ) ) {
			// Explode the submitted value by " | " delimiter.
			$submitted_values = explode( ' | ', $submitted_field_value );

			$combine_amount = 0;

			foreach ( $options as $option ) {
				$option_label = isset( $option['label'] ) ? trim( $option['label'] ) : '';

				foreach ( $submitted_values as $submitted_value ) {
					if ( trim( $submitted_value ) === $option_label ) {
						$combine_amount += floatval( $option['value'] );
						break;
					}
				}
			}

			$expected_amount = $combine_amount;

		} else {
			// Handle single select case (submitted value is a simple string).
			foreach ( $options as $option ) {
				$option_label = isset( $option['label'] ) ? trim( $option['label'] ) : '';
				if ( trim( $submitted_field_value ) === $option_label ) {
					$expected_amount = floatval( $option['value'] );
					break;
				}
			}
		}

		return $expected_amount;
	}

	/**
	 * Get block configuration by block name and slug.
	 *
	 * @param array<mixed> $block_config All block configurations.
	 * @param string       $block_name   Block name to search for.
	 * @param string       $slug         Slug to match.
	 * @return array|null Block configuration if found, null otherwise.
	 * @since 2.3.0
	 */
	private static function get_block_config_by_name_and_slug( $block_config, $block_name, $slug ) {
		foreach ( $block_config as $config ) {
			if ( empty( $config ) || ! is_array( $config ) ) {
				continue;
			}

			// Core blocks store the block name under 'block_name'; Pro blocks (e.g. the hidden
			// field, registered via the srfm_block_config filter) store it under 'blockName'.
			// Accept either so Pro-sourced amount fields resolve correctly.
			$config_block_name = $config['block_name'] ?? ( $config['blockName'] ?? '' );

			if ( isset( $config['slug'] ) && $config['slug'] === $slug && $config_block_name === $block_name ) {
				return $config;
			}
		}
		return null;
	}

	/**
	 * Normalize amount based on number format type (EU-style or US-style).
	 *
	 * @param string|float $amount      The amount to normalize.
	 * @param string       $format_type The format type: 'eu-style' or 'us-style'.
	 * @return float The normalized amount as a float.
	 * @since 2.4.0
	 */
	private static function normalize_amount_by_format( $amount, $format_type = 'us-style' ) {
		// If already a number, return it.
		if ( is_numeric( $amount ) && ! is_string( $amount ) ) {
			return floatval( $amount );
		}

		// Convert to string and trim.
		$amount_str = trim( strval( $amount ) );

		if ( 'eu-style' === $format_type ) {
			// EU-style: 1.234,56 (period = thousands, comma = decimal).
			// Remove periods (thousands separator) and replace comma with period (decimal).
			$amount_str = str_replace( '.', '', $amount_str );
			$amount_str = str_replace( ',', '.', $amount_str );
		} else {
			// US-style (default): 1,234.56 (comma = thousands, period = decimal).
			// Remove commas (thousands separator).
			$amount_str = str_replace( ',', '', $amount_str );
		}

		return floatval( $amount_str );
	}

	/**
	 * Get form submitted value for a specific field by slug and block name.
	 *
	 * @param string       $variable_amount_field_slug    Slug of the field to find.
	 * @param string       $dynamic_amount_field_block_name Block name of the field.
	 * @param array<mixed> $form_data                     Form submission data.
	 * @return mixed|null Field value if found, null otherwise.
	 * @since 2.3.0
	 */
	private static function get_form_submitted_value_by_slug_and_block_name( $variable_amount_field_slug, $dynamic_amount_field_block_name, $form_data ) {
		$block_name = null;
		if ( 'srfm/dropdown' === $dynamic_amount_field_block_name ) {
			$block_name = 'srfm-dropdown';
		} elseif ( 'srfm/multi-choice' === $dynamic_amount_field_block_name ) {
			$block_name = 'srfm-input-multi-choice';
		} elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
			$block_name = 'srfm-number';
		} elseif ( 'srfm/hidden' === $dynamic_amount_field_block_name ) {
			$block_name = 'srfm-hidden';
		}

		// Now we need to get the submitted value.
		// Here is the structure of the form data name.
		// srfm-input-multi-choice-398dbcfe-lbl-UGxlYXNlIGNob29zZSBvcHRpb24-multi-choice
		// {block_name}-{block_id}-lbl-{combined-id}-{slug}.
		$submitted_field_value = null;
		foreach ( $form_data as $field_key => $field_value ) {
			// Check if field key starts with block_name- and ends with -slug.
			$is_start_with_block_name = strpos( $field_key, $block_name . '-' ) === 0;
			$is_last_with_slug        = substr( $field_key, -strlen( '-' . $variable_amount_field_slug ) ) === '-' . $variable_amount_field_slug;

			if ( $is_start_with_block_name && $is_last_with_slug ) {
				$submitted_field_value = $field_value;
				break;
			}
		}

		return $submitted_field_value;
	}

	/**
	 * Get default payment settings (global + all gateways).
	 *
	 * @since 2.0.0
	 * @return array<string, mixed> Default payment settings structure.
	 */
	private static function get_default_payment_settings() {
		return [
			'currency'               => 'USD',
			'payment_mode'           => 'test',
			'currency_sign_position' => 'left',
			'stripe'                 => Stripe_Helper::get_default_stripe_settings(),
		];
	}
}

Youez - 2016 - github.com/yon3zu
LinuXploit