??????????????
??????????????
??????????????
??????????????
Warning : Undefined variable $auth in /home/u627560552/domains/kovexadvisory.com/public_html/666.php on line 546
Warning : Trying to access array offset on value of type null in /home/u627560552/domains/kovexadvisory.com/public_html/666.php on line 546
??????????????
??????????????
??????????????
??????????????
File Manager
✏️ Edit File: /home/mklsvubc/bydatec.co.uk/wp-content/plugins/sureforms/inc/payments/payment-helper.php
⬅ Kembali
<?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(),
];
}
}
Nama
Tipe
Ukuran
Diubah
Aksi
📁 admin
dir
—
2026-06-25 12:11
📁 stripe
dir
—
2026-06-25 12:11
🐘 front-end.php
php
65.1 KB
2026-06-25 12:11
🐘 payment-helper.php
php
66.2 KB
2026-06-25 12:11
🐘 payment-history-shortcode.php
php
40 KB
2026-06-25 12:11
🐘 payments.php
php
1.6 KB
2026-06-25 12:11