PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | inc/payments/payment-helper.php +746 -46 2.7.1 → 2.12.8 View file →
@@ -11,8 +11,9 @@
11 11 */
12 12
13 13 namespace SRFM\Inc\Payments;
14 14
15 +use SRFM\Inc\Database\Tables\Entries;
15 16 use SRFM\Inc\Field_Validation;
16 17 use SRFM\Inc\Helper;
17 18 use SRFM\Inc\Payments\Stripe\Stripe_Helper;
18 19
@@ -254,8 +255,13 @@
254 255 'name' => __( 'Norwegian Krone', 'sureforms' ),
255 256 'symbol' => 'kr',
256 257 'decimal_places' => 2,
257 258 ],
259 + 'PLN' => [
260 + 'name' => __( 'Polish Złoty', 'sureforms' ),
261 + 'symbol' => 'zł',
262 + 'decimal_places' => 2,
263 + ],
258 264 'KRW' => [
259 265 'name' => __( 'South Korean Won', 'sureforms' ),
260 266 'symbol' => '₩',
261 267 'decimal_places' => 0,
@@ -477,8 +483,9 @@
477 483 'payment_unavailable' => __( 'Payment is currently unavailable. Please contact the site administrator.', 'sureforms' ),
478 484 'payment_amount_not_configured' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the payment amount.', 'sureforms' ),
479 485 'invalid_variable_amount' => __( 'Invalid payment amount', 'sureforms' ),
480 486 'amount_below_minimum' => __( 'Payment amount must be at least {symbol}{amount}.', 'sureforms' ),
487 + 'payment_required' => __( 'This form requires a payment. Please complete the payment and submit again.', 'sureforms' ),
481 488
482 489 // Field mapping validation.
483 490 'payment_name_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer name field.', 'sureforms' ),
484 491 'payment_email_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer email field.', 'sureforms' ),
@@ -569,9 +576,10 @@
569 576 * @since 2.2.2
570 577 * @param int|float $amount Amount in smallest currency unit (e.g., cents for USD).
571 578 * @param string $currency Currency code (e.g., 'usd', 'eur').
572 579 * @param int $form_id WordPress post ID of the form.
573 - * @param string $block_id Block identifier for the payment block.
580 + * @param string $block_id Block identifier for the payment block.
581 + * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
574 582 * @return array {
575 583 * Validation result.
576 584 *
577 585 * @type bool $valid Whether the validation passed.
@@ -577,9 +585,9 @@
577 585 * @type bool $valid Whether the validation passed.
578 586 * @type string $message Error message if validation failed, empty if valid.
579 587 * }
580 588 */
581 - public static function validate_payment_amount( $amount, $currency, $form_id, $block_id ) {
589 + public static function validate_payment_amount( $amount, $currency, $form_id, $block_id, $active_type = '' ) {
582 590 // Retrieve block configuration from post meta.
583 591 $block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
584 592
585 593 // Check if block config exists.
@@ -608,15 +616,33 @@
608 616 'message' => sprintf( __( 'Currency mismatch: expected %1$s, received %2$s.', 'sureforms' ), strtoupper( $global_currency ), strtoupper( $submitted_currency ) ),
609 617 ];
610 618 }
611 619
620 + // Reject when the requested flow (one-time vs subscription) is not allowed by
621 + // the form's stored payment_type. "both" mode allows either flow; pure modes
622 + // allow only their matching flow. Without this guard, an attacker could call
623 + // the wrong intent-creation route on a pure-subscription form and pay once for
624 + // what should be a recurring charge (or vice versa).
625 + $payment_type = isset( $payment_config['payment_type'] ) && is_string( $payment_config['payment_type'] ) ? $payment_config['payment_type'] : 'one-time';
626 + if ( ! empty( $active_type ) && 'both' !== $payment_type && $active_type !== $payment_type ) {
627 + return [
628 + 'valid' => false,
629 + 'message' => __( 'Payment type does not match the form configuration.', 'sureforms' ),
630 + ];
631 + }
632 +
633 + // BOTH MODE: when payment_type is 'both', resolve the correct per-type
634 + // config (amount_type, fixed_amount, minimum_amount, variable_amount_field)
635 + // based on which flow the user actually chose (one-time vs subscription).
636 + $resolved_config = self::resolve_payment_config_for_active_type( $payment_config, $active_type );
637 +
612 638 // Get amount type (fixed or minimum).
613 - $amount_type = $payment_config['amount_type'] ?? 'fixed';
639 + $amount_type = $resolved_config['amount_type'] ?? 'fixed';
614 640
615 641 // Validate based on amount type.
616 642 if ( 'fixed' === $amount_type ) {
617 643 // Fixed amount validation - must match exactly.
618 - $configured_amount = isset( $payment_config['fixed_amount'] ) ? floatval( $payment_config['fixed_amount'] ) : 10.00;
644 + $configured_amount = isset( $resolved_config['fixed_amount'] ) ? floatval( $resolved_config['fixed_amount'] ) : 10.00;
619 645
620 646 // Allow small floating point difference (0.01) due to rounding.
621 647 if ( abs( $amount - $configured_amount ) > 0.01 ) {
622 648 return [
@@ -626,9 +652,9 @@
626 652 ];
627 653 }
628 654 } elseif ( 'variable' === $amount_type ) {
629 655 // Minimum amount validation - must be >= minimum.
630 - $minimum_amount = isset( $payment_config['minimum_amount'] ) ? floatval( $payment_config['minimum_amount'] ) : 0;
656 + $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
631 657
632 658 if ( $amount < $minimum_amount ) {
633 659 return [
634 660 'valid' => false,
@@ -638,9 +664,9 @@
638 664 }
639 665
640 666 // Validate dynamic amount from dropdown/multi-choice field.
641 667 $dynamic_amount_validation = self::validate_dynamic_amount_field(
642 - $payment_config,
668 + $resolved_config,
643 669 $block_config,
644 670 $amount,
645 671 $currency
646 672 );
@@ -693,8 +719,9 @@
693 719 * @since 2.3.0
694 720 * @param string $block_id Block identifier.
695 721 * @param string $payment_intent_id Payment intent ID from Stripe.
696 722 * @param array<string, mixed> $form_data Submitted form data.
723 + * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
697 724 * @return array {
698 725 * Verification result.
699 726 *
700 727 * @type bool $valid Whether verification passed.
@@ -700,9 +727,9 @@
700 727 * @type bool $valid Whether verification passed.
701 728 * @type string $message Error message if verification failed, empty if valid.
702 729 * }
703 730 */
704 - public static function verify_payment_intent( $block_id, $payment_intent_id, $form_data ) {
731 + public static function verify_payment_intent( $block_id, $payment_intent_id, $form_data, $active_type = '' ) {
705 732 // Get form ID from form data for verification.
706 733 $form_id = isset( $form_data['form-id'] ) && ! empty( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? intval( $form_data['form-id'] ) : 0;
707 734
708 735 // Validate required parameters.
@@ -723,12 +750,24 @@
723 750 'message' => __( 'Payment verification failed. Invalid payment intent.', 'sureforms' ),
724 751 ];
725 752 }
726 753
754 + // Reject when the submit path's active_type does not match the type that
755 + // was validated at intent-creation time. Prevents an attacker from passing
756 + // a one-time intent_id through the subscription submit path (or vice versa)
757 + // to replay a small one-time charge in place of a recurring subscription.
758 + $stored_active_type = isset( $metadata['active_type'] ) && is_string( $metadata['active_type'] ) ? $metadata['active_type'] : '';
759 + if ( ! empty( $active_type ) && ! empty( $stored_active_type ) && $active_type !== $stored_active_type ) {
760 + return [
761 + 'valid' => false,
762 + 'message' => __( 'Payment verification failed. Payment type mismatch.', 'sureforms' ),
763 + ];
764 + }
765 +
727 766 $payment_amount = isset( $metadata['amount'] ) && ! empty( $metadata['amount'] ) && is_numeric( $metadata['amount'] ) ? floatval( $metadata['amount'] ) : 0;
728 767
729 768 // Validate payment amount matches configuration.
730 - $amount_validation = self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount );
769 + $amount_validation = self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount, $active_type );
731 770
732 771 if ( false === $amount_validation['valid'] ) {
733 772 return $amount_validation;
734 773 }
@@ -740,8 +779,27 @@
740 779 ];
741 780 }
742 781
743 782 /**
783 + * Validate an arbitrary amount against the form's server-side payment configuration.
784 + *
785 + * Public wrapper around the amount validator so the submission flow can re-check the amount
786 + * Stripe actually charged (defense-in-depth) — not only the amount recorded when the intent was
787 + * created.
788 + *
789 + * @param string $block_id Block identifier.
790 + * @param int $form_id Form post ID.
791 + * @param array<string, mixed> $form_data Submitted form data.
792 + * @param float $amount Amount to validate (decimal, in the form currency).
793 + * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
794 + * @since 2.11.1
795 + * @return array<string, mixed> Validation result with 'valid' (bool) and 'message' (string) keys.
796 + */
797 + public static function validate_amount_against_config( $block_id, $form_id, $form_data, $amount, $active_type = '' ) {
798 + return self::validate_payment_intent_amount( $block_id, $form_id, $form_data, $amount, $active_type );
799 + }
800 +
801 + /**
744 802 * Delete payment intent metadata from transient.
745 803 *
746 804 * Cleans up stored metadata after successful payment verification.
747 805 *
@@ -773,8 +831,244 @@
773 831 return ! empty( $result ) && is_string( $result ) ? $result : 'left';
774 832 }
775 833
776 834 /**
835 + * Get a submitted form value by field slug.
836 + *
837 + * Matches the SureForms field-name convention `{block}-{block_id}-lbl-{label}-{slug}` by
838 + * suffix, regardless of block type. Used to resolve `{form:slug}` tokens when recomputing a
839 + * calculation server-side. Returns null when the slug is not present in the submission.
840 + *
841 + * @param string $slug The field slug to look up.
842 + * @param array<mixed> $form_data Submitted form data.
843 + * @since 2.11.1
844 + * @return mixed|null The submitted value, or null when not found.
845 + */
846 + public static function get_submitted_value_by_slug( $slug, $form_data ) {
847 + if ( empty( $slug ) || ! is_string( $slug ) || ! is_array( $form_data ) ) {
848 + return null;
849 + }
850 +
851 + $suffix = '-' . $slug;
852 + foreach ( $form_data as $field_key => $field_value ) {
853 + if ( ! is_string( $field_key ) || false === strpos( $field_key, '-lbl-' ) ) {
854 + continue;
855 + }
856 +
857 + if ( substr( $field_key, -strlen( $suffix ) ) === $suffix ) {
858 + return $field_value;
859 + }
860 + }
861 +
862 + return null;
863 + }
864 +
865 + /**
866 + * Get the payment methods a payment block can actually offer.
867 + *
868 + * The block's enabled methods intersected with the methods that are registered
869 + * and connected. Shared with Payment_Markup so the renderer and the submission
870 + * guard can never disagree about whether a payment field is usable.
871 + *
872 + * @param array<mixed> $attrs Payment block attributes.
873 + *
874 + * @since 2.12.3
875 + * @return array<string, mixed> Usable payment methods, keyed by method ID.
876 + */
877 + public static function get_registered_payment_methods( $attrs ) {
878 + $methods = [];
879 + $attrs = is_array( $attrs ) ? $attrs : [];
880 + $enabled_methods = isset( $attrs['paymentMethods'] ) && is_array( $attrs['paymentMethods'] ) ? $attrs['paymentMethods'] : [ 'stripe' ];
881 +
882 + // Filter to get method configurations - start with Stripe as default.
883 + $available_methods = apply_filters(
884 + 'srfm_payment_methods_registry',
885 + [
886 + 'stripe' => [
887 + 'id' => 'stripe',
888 + 'label' => __( 'Stripe', 'sureforms' ),
889 + 'description' => __( 'Pay with credit or debit card', 'sureforms' ),
890 + 'icon' => 'credit-card',
891 + 'enabled' => Stripe_Helper::is_stripe_connected(),
892 + 'container_class' => 'srfm-stripe-payment-element',
893 + ],
894 + ]
895 + );
896 +
897 + // Filter enabled methods.
898 + foreach ( $enabled_methods as $method_id ) {
899 + if ( is_array( $available_methods ) && isset( $available_methods[ $method_id ] ) && ! empty( $available_methods[ $method_id ]['enabled'] ) ) {
900 + $methods[ $method_id ] = $available_methods[ $method_id ];
901 + }
902 + }
903 +
904 + return $methods;
905 + }
906 +
907 + /**
908 + * Whether a payment block's configuration produces a usable payment field.
909 + *
910 + * Mirrors the conditions under which Payment_Markup::markup() returns early with
911 + * no markup: no usable payment method, or the customer field mappings the gateway
912 + * needs are missing. A block that renders nothing cannot be required on submit.
913 + *
914 + * @param array<mixed> $attrs Payment block attributes.
915 + *
916 + * @since 2.12.3
917 + * @return bool True when the block renders a payment field.
918 + */
919 + public static function is_payment_field_active( $attrs ) {
920 + if ( ! is_array( $attrs ) ) {
921 + return false;
922 + }
923 +
924 + if ( empty( self::get_registered_payment_methods( $attrs ) ) ) {
925 + return false;
926 + }
927 +
928 + // Customer field mappings, including the legacy subscriptionPlan fallbacks.
929 + $subscription_plan = isset( $attrs['subscriptionPlan'] ) && is_array( $attrs['subscriptionPlan'] ) ? $attrs['subscriptionPlan'] : [];
930 + $email_field = ! empty( $attrs['customerEmailField'] ) ? $attrs['customerEmailField'] : ( $subscription_plan['customer_email'] ?? '' );
931 +
932 + if ( empty( $email_field ) ) {
933 + return false;
934 + }
935 +
936 + $payment_type = ! empty( $attrs['paymentType'] ) && is_string( $attrs['paymentType'] ) ? $attrs['paymentType'] : 'one-time';
937 +
938 + // A subscription path also needs the customer name mapping.
939 + if ( in_array( $payment_type, [ 'subscription', 'both' ], true ) ) {
940 + $name_field = ! empty( $attrs['customerNameField'] ) ? $attrs['customerNameField'] : ( $subscription_plan['customer_name'] ?? '' );
941 +
942 + if ( empty( $name_field ) ) {
943 + return false;
944 + }
945 + }
946 +
947 + return true;
948 + }
949 +
950 + /**
951 + * Get the block IDs of payment fields a submission of this form must pay for.
952 + *
953 + * Derived from the stored form, never from what the client submitted. Blocks that
954 + * render nothing (see is_payment_field_active()) and blocks under conditional logic
955 + * are excluded: conditional logic is evaluated on the client, so a hidden payment
956 + * field legitimately submits no payment value and must not be required here.
957 + *
958 + * @param int $form_id Form ID.
959 + *
960 + * @since 2.12.3
961 + * @return array<string> Block IDs that require a verified payment.
962 + */
963 + public static function get_required_payment_block_ids( $form_id ) {
964 + // absint(), matching Submit_Token::verify()'s normalisation in
965 + // Form_Submit::submit_form_permissions_check(). get_integer_value() would keep a
966 + // negative id and bail below, so the guard would resolve a different form from
967 + // the one the submit token authorised.
968 + $form_id = absint( $form_id );
969 + $form = $form_id > 0 ? get_post( $form_id ) : null;
970 +
971 + if ( ! $form instanceof \WP_Post || '' === $form->post_content ) {
972 + return [];
973 + }
974 +
975 + $block_ids = self::collect_active_payment_block_ids( parse_blocks( $form->post_content ) );
976 +
977 + if ( empty( $block_ids ) ) {
978 + return [];
979 + }
980 +
981 + // Drop blocks that conditional logic can hide.
982 + foreach ( self::get_conditional_logic_block_ids( $form_id ) as $conditional_id ) {
983 + unset( $block_ids[ $conditional_id ] );
984 + }
985 +
986 + /**
987 + * Filters the payment block IDs that require a verified payment on submit.
988 + *
989 + * Lets add-ons that can evaluate their own visibility rules server-side add or
990 + * remove blocks — e.g. re-adding a conditionally shown payment field once the
991 + * rule is known to have matched.
992 + *
993 + * @since 2.12.3
994 + *
995 + * @param array<string> $block_ids Block IDs requiring a verified payment.
996 + * @param int $form_id Form ID.
997 + */
998 + $block_ids = apply_filters( 'srfm_required_payment_block_ids', array_keys( $block_ids ), $form_id );
999 +
1000 + return is_array( $block_ids ) ? $block_ids : [];
1001 + }
1002 +
1003 + /**
1004 + * Resolve the WordPress user associated with a payment record.
1005 + *
1006 + * Resolution order:
1007 + * 1. The linked entry's `user_id` (set when a logged-in user submitted the form).
1008 + * 2. A user matching the payment's `customer_email`.
1009 + * 3. `0` for guest checkouts where no WordPress user can be resolved.
1010 + *
1011 + * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
1012 + * @return int Resolved WordPress user ID, or 0 when none can be determined.
1013 + * @since 2.12.0
1014 + */
1015 + public static function resolve_payment_user( $payment ) {
1016 + if ( ! is_array( $payment ) ) {
1017 + return 0;
1018 + }
1019 +
1020 + // 1. Prefer the user_id stored on the linked entry.
1021 + $entry_id = ! empty( $payment['entry_id'] ) && is_numeric( $payment['entry_id'] ) ? intval( $payment['entry_id'] ) : 0;
1022 + if ( $entry_id > 0 ) {
1023 + $entry = Entries::get( $entry_id );
1024 + if ( is_array( $entry ) && ! empty( $entry['user_id'] ) && is_numeric( $entry['user_id'] ) ) {
1025 + $user_id = intval( $entry['user_id'] );
1026 + if ( $user_id > 0 ) {
1027 + return $user_id;
1028 + }
1029 + }
1030 + }
1031 +
1032 + // 2. Fall back to a user matching the customer email.
1033 + $customer_email = ! empty( $payment['customer_email'] ) && is_string( $payment['customer_email'] ) ? sanitize_email( $payment['customer_email'] ) : '';
1034 + if ( ! empty( $customer_email ) ) {
1035 + $user = get_user_by( 'email', $customer_email );
1036 + if ( $user instanceof \WP_User ) {
1037 + return intval( $user->ID );
1038 + }
1039 + }
1040 +
1041 + // 3. Guest checkout — no resolvable WordPress user.
1042 + return 0;
1043 + }
1044 +
1045 + /**
1046 + * Build the standard context array passed alongside payment-lifecycle actions.
1047 + *
1048 + * Gives consumers (membership, LMS and other plugins) a consistent, resolved
1049 + * snapshot of who paid and through which form/gateway, without each consumer
1050 + * having to re-derive it from the raw payment row.
1051 + *
1052 + * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
1053 + * @return array{form_id:int, entry_id:int, user_id:int, customer_email:string, type:string, gateway:string, mode:string} Resolved payment context.
1054 + * @since 2.12.0
1055 + */
1056 + public static function build_payment_context( $payment ) {
1057 + $payment = is_array( $payment ) ? $payment : [];
1058 +
1059 + return [
1060 + 'form_id' => ! empty( $payment['form_id'] ) && is_numeric( $payment['form_id'] ) ? intval( $payment['form_id'] ) : 0,
1061 + 'entry_id' => ! empty( $payment['entry_id'] ) && is_numeric( $payment['entry_id'] ) ? intval( $payment['entry_id'] ) : 0,
1062 + 'user_id' => self::resolve_payment_user( $payment ),
1063 + 'customer_email' => ! empty( $payment['customer_email'] ) && is_string( $payment['customer_email'] ) ? sanitize_email( $payment['customer_email'] ) : '',
1064 + 'type' => ! empty( $payment['type'] ) && is_string( $payment['type'] ) ? sanitize_text_field( $payment['type'] ) : '',
1065 + 'gateway' => ! empty( $payment['gateway'] ) && is_string( $payment['gateway'] ) ? sanitize_text_field( $payment['gateway'] ) : '',
1066 + 'mode' => ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? sanitize_text_field( $payment['mode'] ) : '',
1067 + ];
1068 + }
1069 +
1070 + /**
777 1071 * Validate dynamic amount field from dropdown or multi-choice.
778 1072 *
779 1073 * @param array<string, mixed> $payment_config Payment block configuration.
780 1074 * @param array<string, mixed> $block_config All block configurations.
@@ -782,8 +1076,204 @@
782 1076 * @param string $currency Currency code.
783 1077 * @return array|null Validation result array or null if validation passes.
784 1078 * @since 2.3.0
785 1079 */
1080 + /**
1081 + * Recursively collect the block IDs of payment blocks that render a payment field.
1082 + *
1083 + * Recurses into innerBlocks and expands core/block reusable/synced patterns, so a
1084 + * payment block that renders from inside a pattern is still required. The payment
1085 + * block sets "reusable": false, so reaching that state needs imported or
1086 + * hand-authored post_content rather than the editor — but a payment field that
1087 + * renders and is not required is exactly the hole this guard exists to close.
1088 + *
1089 + * @param array<mixed> $blocks Parsed blocks from parse_blocks().
1090 + * @param array<int, true> $visited_refs Reusable-block post IDs already expanded,
1091 + * keyed by ID — guards against reference cycles.
1092 + *
1093 + * @since 2.12.3
1094 + * @return array<string,true> Active payment block IDs, keyed by block ID.
1095 + */
1096 + private static function collect_active_payment_block_ids( $blocks, &$visited_refs = [] ) {
1097 + $block_ids = [];
1098 +
1099 + foreach ( $blocks as $block ) {
1100 + if ( ! is_array( $block ) ) {
1101 + continue;
1102 + }
1103 +
1104 + $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
1105 +
1106 + if ( 'srfm/payment' === ( $block['blockName'] ?? '' )
1107 + && ! empty( $attrs['block_id'] )
1108 + && is_scalar( $attrs['block_id'] )
1109 + && self::is_payment_field_active( $attrs )
1110 + ) {
1111 + $block_ids[ Helper::get_string_value( $attrs['block_id'] ) ] = true;
1112 + }
1113 +
1114 + if ( isset( $block['blockName'] ) && 'core/block' === $block['blockName'] && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) {
1115 + $ref = absint( $attrs['ref'] );
1116 +
1117 + if ( $ref && ! isset( $visited_refs[ $ref ] ) ) {
1118 + $visited_refs[ $ref ] = true;
1119 + $ref_post = get_post( $ref );
1120 +
1121 + if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && '' !== $ref_post->post_content ) {
1122 + $block_ids += self::collect_active_payment_block_ids( parse_blocks( $ref_post->post_content ), $visited_refs );
1123 + }
1124 + }
1125 + }
1126 +
1127 + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
1128 + $block_ids += self::collect_active_payment_block_ids( $block['innerBlocks'], $visited_refs );
1129 + }
1130 + }
1131 +
1132 + return $block_ids;
1133 + }
1134 +
1135 + /**
1136 + * Get the block IDs a form has conditional logic rules for.
1137 + *
1138 + * Field visibility rules live in the `_srfm_conditional_logic` post meta, keyed by
1139 + * block ID, and are evaluated on the client. A payment field under such a rule may
1140 + * legitimately be hidden at submit time.
1141 + *
1142 + * @param int $form_id Form ID.
1143 + *
1144 + * @since 2.12.3
1145 + * @return array<string> Block IDs carrying conditional logic rules.
1146 + */
1147 + private static function get_conditional_logic_block_ids( $form_id ) {
1148 + // Only exempt when conditional logic can actually hide anything. The rules are
1149 + // evaluated client-side by the add-on that registers this meta; without it
1150 + // nothing hides, so a stale rule (e.g. written by a form importer on a site that
1151 + // never had the add-on) must not buy a payment block an exemption.
1152 + if ( ! registered_meta_key_exists( 'post', '_srfm_conditional_logic', SRFM_FORMS_POST_TYPE ) ) {
1153 + return [];
1154 + }
1155 +
1156 + $conditional_logic = get_post_meta( $form_id, '_srfm_conditional_logic', true );
1157 +
1158 + if ( ! is_array( $conditional_logic ) ) {
1159 + return [];
1160 + }
1161 +
1162 + $block_ids = [];
1163 +
1164 + foreach ( $conditional_logic as $item ) {
1165 + if ( ! is_array( $item ) ) {
1166 + continue;
1167 + }
1168 +
1169 + foreach ( $item as $block_id => $rule ) {
1170 + if ( ! is_string( $block_id ) || '' === $block_id ) {
1171 + continue;
1172 + }
1173 +
1174 + // An actionable rule only — a rule with no conditions can never match, so
1175 + // the field's visibility is never altered and payment stays required.
1176 + if ( self::has_actionable_conditional_rule( $rule ) ) {
1177 + $block_ids[] = $block_id;
1178 + }
1179 + }
1180 + }
1181 +
1182 + return $block_ids;
1183 + }
1184 +
1185 + /**
1186 + * Whether a stored conditional-logic rule can actually change a field's visibility.
1187 + *
1188 + * Both `show` and `hide` actions can leave the field hidden for a given submission
1189 + * (show = hidden until the conditions match, hide = visible until they match), so the
1190 + * action itself is not the discriminator — the presence of at least one real condition
1191 + * is. Empty or malformed rules are left behind by editor cleanup and must not exempt
1192 + * a payment block.
1193 + *
1194 + * @param mixed $rule Stored rule for a single block.
1195 + *
1196 + * @since 2.12.3
1197 + * @return bool True when the rule carries at least one condition.
1198 + */
1199 + private static function has_actionable_conditional_rule( $rule ) {
1200 + if ( ! is_array( $rule ) || empty( $rule['action'] ) || empty( $rule['logic'] ) || ! is_array( $rule['logic'] ) ) {
1201 + return false;
1202 + }
1203 +
1204 + foreach ( $rule['logic'] as $conditions ) {
1205 + if ( ! is_array( $conditions ) ) {
1206 + continue;
1207 + }
1208 +
1209 + foreach ( $conditions as $condition ) {
1210 + if ( is_array( $condition ) && ! empty( $condition['field'] ) ) {
1211 + return true;
1212 + }
1213 + }
1214 + }
1215 +
1216 + return false;
1217 + }
1218 +
1219 + /**
1220 + * BOTH MODE: resolve the correct amount config keys from the payment block
1221 + * config based on which flow (one-time or subscription) the user chose.
1222 + *
1223 + * For pure one-time / subscription blocks, the config already has the correct
1224 + * scalar keys (amount_type, fixed_amount, minimum_amount, etc.) so this method
1225 + * returns them unchanged. For "both" blocks, it remaps the per-type keys
1226 + * (one_time_* or subscription_*) into the scalar positions the validation
1227 + * functions expect.
1228 + *
1229 + * @param array<mixed> $payment_config Full block config from _srfm_block_config.
1230 + * @param string $active_type 'one-time' or 'subscription' — which flow is active.
1231 + * @return array<mixed> Config array with amount_type, fixed_amount, minimum_amount,
1232 + * variable_amount_field, variable_amount_field_block_name resolved
1233 + * for the active type.
1234 + * @since 2.8.2
1235 + */
1236 + private static function resolve_payment_config_for_active_type( $payment_config, $active_type ) {
1237 + // Only remap when the block is in "both" mode and the caller told us the active type.
1238 + if ( 'both' !== ( $payment_config['payment_type'] ?? '' ) || empty( $active_type ) ) {
1239 + return $payment_config;
1240 + }
1241 +
1242 + $prefix = 'subscription' === $active_type ? 'subscription_' : 'one_time_';
1243 +
1244 + $resolved = $payment_config; // Keep all original keys as fallback.
1245 +
1246 + if ( isset( $payment_config[ $prefix . 'amount_type' ] ) ) {
1247 + $resolved['amount_type'] = $payment_config[ $prefix . 'amount_type' ];
1248 + }
1249 + if ( isset( $payment_config[ $prefix . 'fixed_amount' ] ) ) {
1250 + $resolved['fixed_amount'] = (float) $payment_config[ $prefix . 'fixed_amount' ];
1251 + }
1252 + if ( isset( $payment_config[ $prefix . 'minimum_amount' ] ) ) {
1253 + $resolved['minimum_amount'] = (float) $payment_config[ $prefix . 'minimum_amount' ];
1254 + }
1255 + if ( isset( $payment_config[ $prefix . 'variable_amount_field' ] ) ) {
1256 + $resolved['variable_amount_field'] = $payment_config[ $prefix . 'variable_amount_field' ];
1257 + }
1258 + if ( isset( $payment_config[ $prefix . 'variable_amount_field_block_name' ] ) ) {
1259 + $resolved['variable_amount_field_block_name'] = $payment_config[ $prefix . 'variable_amount_field_block_name' ];
1260 + }
1261 +
1262 + return $resolved;
1263 + }
1264 +
1265 + /**
1266 + * Validate that a submitted dynamic amount matches one of the options configured
1267 + * on a linked dropdown/multi-choice block (when single-selection is enabled).
1268 + *
1269 + * @since 2.8.2
1270 + * @param array<string, mixed> $payment_config Resolved payment block config (active for current mode).
1271 + * @param array<string, mixed> $block_config All form block configs keyed by block_id.
1272 + * @param float $submitted_amount_decimal Submitted amount as a decimal (not smallest unit).
1273 + * @param string $currency ISO currency code.
1274 + * @return array<string, mixed>|null Validation result array with 'valid' + 'message', or null when no validation is required.
1275 + */
786 1276 private static function validate_dynamic_amount_field( $payment_config, $block_config, $submitted_amount_decimal, $currency ) {
787 1277 // Check if variable amount field is from dropdown or multi-choice block.
788 1278 $dynamic_amount_field_block_name = $payment_config['variable_amount_field_block_name'] ?? '';
789 1279
@@ -872,8 +1362,9 @@
872 1362 * @param string $block_id Block identifier.
873 1363 * @param int $form_id Form post ID.
874 1364 * @param array<string, mixed> $form_data Submitted form data.
875 1365 * @param int|float $payment_amount Payment amount from Stripe (in smallest currency unit).
1366 + * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
876 1367 * @return array {
877 1368 * Validation result.
878 1369 *
879 1370 * @type bool $valid Whether validation passed.
@@ -879,9 +1370,9 @@
879 1370 * @type bool $valid Whether validation passed.
880 1371 * @type string $message Error message if validation failed, empty if valid.
881 1372 * }
882 1373 */
883 - private static function validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount ) {
1374 + private static function validate_payment_intent_amount( $block_id, $form_id, $form_data, $payment_amount, $active_type = '' ) {
884 1375 // Get block configuration.
885 1376 $block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
886 1377
887 1378 if ( empty( $block_config ) || ! isset( $block_config[ $block_id ] ) ) {
@@ -891,14 +1382,15 @@
891 1382 'message' => __( 'Payment configuration not found.', 'sureforms' ),
892 1383 ];
893 1384 }
894 1385
895 - $payment_config = $block_config[ $block_id ];
896 - $amount_type = $payment_config['amount_type'] ?? 'fixed';
1386 + $payment_config = $block_config[ $block_id ];
1387 + $resolved_config = self::resolve_payment_config_for_active_type( $payment_config, $active_type );
1388 + $amount_type = $resolved_config['amount_type'] ?? 'fixed';
897 1389
898 1390 // For fixed amounts, validate against configured amount.
899 1391 if ( 'fixed' === $amount_type ) {
900 - $configured_amount = isset( $payment_config['fixed_amount'] ) ? floatval( $payment_config['fixed_amount'] ) : 0;
1392 + $configured_amount = isset( $resolved_config['fixed_amount'] ) ? floatval( $resolved_config['fixed_amount'] ) : 0;
901 1393
902 1394 // Allow small floating point difference (0.01) due to rounding.
903 1395 if ( abs( $payment_amount - $configured_amount ) > 0.01 ) {
904 1396 return [
@@ -916,19 +1408,66 @@
916 1408
917 1409 // For variable amounts, validate based on source field.
918 1410 if ( 'variable' === $amount_type ) {
919 1411 // Check if variable amount comes from dropdown/multi-choice.
920 - $dynamic_amount_field_block_name = $payment_config['variable_amount_field_block_name'] ?? '';
921 - $variable_amount_field_slug = $payment_config['variable_amount_field'] ?? '';
1412 + $dynamic_amount_field_block_name = $resolved_config['variable_amount_field_block_name'] ?? '';
1413 + $variable_amount_field_slug = $resolved_config['variable_amount_field'] ?? '';
922 1414
923 - // Skipping if it is old form configuration.
1415 + // "Variant B": legacy/stale config may not have recorded the amount-source field
1416 + // (empty source reference). Without it we cannot derive a server-side expected
1417 + // amount. First try to recover it by refreshing the block config from the form's
1418 + // current content — forms saved with current code record the source — which
1419 + // self-heals legacy forms whose source field still exists.
924 1420 if ( empty( $dynamic_amount_field_block_name ) || empty( $variable_amount_field_slug ) ) {
1421 + $refreshed_config = self::refresh_block_config( $form_id );
1422 + if ( is_array( $refreshed_config ) && isset( $refreshed_config[ $block_id ] ) && is_array( $refreshed_config[ $block_id ] ) ) {
1423 + $block_config = $refreshed_config;
1424 + $resolved_config = self::resolve_payment_config_for_active_type( $block_config[ $block_id ], $active_type );
1425 + $dynamic_amount_field_block_name = $resolved_config['variable_amount_field_block_name'] ?? '';
1426 + $variable_amount_field_slug = $resolved_config['variable_amount_field'] ?? '';
1427 + }
1428 + }
1429 +
1430 + // The source still cannot be identified (e.g. the amount-source field was deleted from
1431 + // the form while the payment amount type is still "variable", so there is no field-level
1432 + // config left to derive an expected amount from).
1433 + //
1434 + // Security: this branch previously returned a valid result unconditionally for such
1435 + // forms, which allowed an unauthenticated attacker to pay any amount (down to 1 cent
1436 + // when the form had no minimum-amount floor).
1437 + //
1438 + // If the admin configured a positive minimum amount we enforce it as the authoritative
1439 + // lower bound — the only server-side guarantee available for such a form — instead of
1440 + // rejecting outright. This keeps legacy forms (whose source field still has a floor)
1441 + // working without requiring a re-save. With no positive floor there is nothing safe to
1442 + // validate against, so we MUST fail safe and reject to avoid reopening the bypass.
1443 + if ( empty( $dynamic_amount_field_block_name ) || empty( $variable_amount_field_slug ) ) {
1444 + $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
1445 +
1446 + if ( $minimum_amount > 0 ) {
1447 + if ( $payment_amount < $minimum_amount ) {
1448 + return [
1449 + 'valid' => false,
1450 + /* translators: %1$s: minimum amount, %2$s: payment amount */
1451 + 'message' => sprintf( __( 'Payment amount below minimum. Minimum: %1$s, received %2$s.', 'sureforms' ), $minimum_amount, $payment_amount ),
1452 + ];
1453 + }
1454 +
1455 + return [
1456 + 'valid' => true,
1457 + 'message' => '',
1458 + ];
1459 + }
1460 +
925 1461 return [
926 - 'valid' => true,
927 - 'message' => '',
1462 + 'valid' => false,
1463 + 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
928 1464 ];
929 1465 }
930 1466
1467 + // The amount source is identified: validate the charged amount against the
1468 + // server-derived expected amount for that source. The configured minimum-amount
1469 + // floor below is always enforced as an additional lower bound.
931 1470 $submitted_field_value = self::get_form_submitted_value_by_slug_and_block_name( $variable_amount_field_slug, $dynamic_amount_field_block_name, $form_data );
932 1471
933 1472 if ( empty( $submitted_field_value ) ) {
934 1473 return [
@@ -947,11 +1486,23 @@
947 1486 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
948 1487 ];
949 1488 }
950 1489
951 - // To get the expected amount we need to check by the value of the submitted field. we will have the values now we need to check the expected amount in the block config. because block config dropdown/multi-choice has the expected amount in the options.
1490 + // The expected amount is read from the server-side option config keyed by the
1491 + // submitted selection — the attacker chooses the option, never its price.
952 1492 $get_expected_amount = self::get_amount_by_the_config_options( $submitted_field_value, $variable_amount_block_config );
953 1493
1494 + // Fail safe when the submitted selection doesn't map to a configured
1495 + // option value: get_amount_by_the_config_options() returns null, and
1496 + // abs( $payment_amount - null ) would coerce null to 0 — reject explicitly
1497 + // so the comparison can never be silently weakened by that coercion.
1498 + if ( ! is_numeric( $get_expected_amount ) ) {
1499 + return [
1500 + 'valid' => false,
1501 + 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
1502 + ];
1503 + }
1504 +
954 1505 // Validate payment amount matches expected amount.
955 1506 if ( abs( $payment_amount - $get_expected_amount ) > 0.01 ) {
956 1507 return [
957 1508 'valid' => false,
@@ -958,48 +1509,101 @@
958 1509 /* translators: %1$s: expected amount, %2$s: payment amount */
959 1510 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $get_expected_amount, $payment_amount ),
960 1511 ];
961 1512 }
962 - } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
963 - // Get the block config for the number field to retrieve the format type.
964 - $number_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );
1513 + } else {
1514 + // Number and hidden fields. Their value may be server-determined — a
1515 + // configured default value, or a calculation computed from other fields.
1516 + // In those cases the expected amount MUST be derived server-side and the
1517 + // value submitted with the request must never be trusted as the price.
1518 + $variable_amount_block_config = self::get_block_config_by_name_and_slug( $block_config, $dynamic_amount_field_block_name, $variable_amount_field_slug );
965 1519
966 - if ( empty( $number_block_config ) ) {
1520 + if ( empty( $variable_amount_block_config ) ) {
967 1521 return [
968 1522 'valid' => false,
969 - 'message' => __( 'Number field configuration not found.', 'sureforms' ),
1523 + 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
970 1524 ];
971 1525 }
972 1526
973 - // Get the number format type from the block config (default to 'us-style').
974 - $number_format_type = isset( $number_block_config['format_type'] ) && ! empty( $number_block_config['format_type'] ) ? $number_block_config['format_type'] : 'us-style';
1527 + $expected_amount = self::resolve_server_side_variable_amount( $variable_amount_block_config, $block_config, $form_data );
975 1528
976 - // If submitted_field_value is not string then convert the value to string.
977 - $submitted_field_value = Helper::get_string_value( $submitted_field_value );
1529 + if ( null !== $expected_amount ) {
1530 + // Authoritative server-side amount (static default value or a
1531 + // server-recomputed calculation). Reject any mismatch.
1532 + if ( abs( $payment_amount - floatval( $expected_amount ) ) > 0.01 ) {
1533 + return [
1534 + 'valid' => false,
1535 + /* translators: %1$s: expected amount, %2$s: payment amount */
1536 + 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), floatval( $expected_amount ), $payment_amount ),
1537 + ];
1538 + }
1539 + } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
1540 + // Calculation-driven number: a null server amount means the formula
1541 + // could NOT be recomputed server-side (a referenced field was
1542 + // non-numeric, or the formula used something the parser can't
1543 + // evaluate). This is NOT "name your price" — we must fail safe and
1544 + // reject, never fall back to the client-submitted amount, which
1545 + // would reopen the unauthenticated underpayment bypass.
1546 + if ( ! empty( $variable_amount_block_config['enableCalculation'] ) ) {
1547 + return [
1548 + 'valid' => false,
1549 + 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
1550 + ];
1551 + }
978 1552
979 - // Normalize the submitted amount based on the format type.
980 - $converted_payment_amount = self::normalize_amount_by_format( $submitted_field_value, $number_format_type );
1553 + // Plain user-entered number ("name your price"): the amount is the
1554 + // customer's own choice, so confirm the charge matches what they entered.
1555 + // The minimum-amount floor below guards the lower bound.
1556 + $number_format_type = isset( $variable_amount_block_config['format_type'] ) && ! empty( $variable_amount_block_config['format_type'] ) ? $variable_amount_block_config['format_type'] : 'us-style';
1557 + $submitted_field_value = Helper::get_string_value( $submitted_field_value );
1558 + $converted_payment_amount = self::normalize_amount_by_format( $submitted_field_value, $number_format_type );
981 1559
982 - // Validate that the normalized amount is valid.
983 - if ( ! is_numeric( $converted_payment_amount ) || $converted_payment_amount <= 0 ) {
984 - return [
985 - 'valid' => false,
986 - 'message' => __( 'Variable amount field value is required.', 'sureforms' ),
987 - ];
988 - }
1560 + if ( ! is_numeric( $converted_payment_amount ) || $converted_payment_amount <= 0 ) {
1561 + return [
1562 + 'valid' => false,
1563 + 'message' => __( 'Variable amount field value is required.', 'sureforms' ),
1564 + ];
1565 + }
989 1566
990 - // Validate payment amount matches expected amount.
991 - if ( abs( $payment_amount - $converted_payment_amount ) > 0.01 ) {
992 - return [
993 - 'valid' => false,
994 - /* translators: %1$s: expected amount, %2$s: payment amount */
995 - 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $converted_payment_amount, $payment_amount ),
996 - ];
1567 + if ( abs( $payment_amount - $converted_payment_amount ) > 0.01 ) {
1568 + return [
1569 + 'valid' => false,
1570 + /* translators: %1$s: expected amount, %2$s: payment amount */
1571 + 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $converted_payment_amount, $payment_amount ),
1572 + ];
1573 + }
1574 + } else {
1575 + // Unresolved hidden / dynamic source: resolve_server_side_variable_amount()
1576 + // returned null (e.g. a hidden field whose default is a smart tag like
1577 + // {get_input:amount}, stored raw and therefore non-numeric), so the submitted
1578 + // value cannot be trusted as the price and there is no server-authoritative
1579 + // amount to compare against. The configured minimum-amount floor is then the
1580 + // ONLY server-side guarantee, so it must be a positive authoritative value.
1581 + //
1582 + // This mirrors the "amount source not identified" handling above: with a
1583 + // positive minimum we fall through to the floor check below (the documented
1584 + // dynamic-prefill case keeps working); with no positive minimum there is
1585 + // nothing safe to validate against, so we MUST fail safe and reject rather than
1586 + // letting the floor default to 0 and accept any amount down to the gateway cent
1587 + // floor — which would reopen the unauthenticated underpayment bypass. Merchants
1588 + // doing custom JS-driven dynamic pricing must supply a server-authoritative
1589 + // amount via the `srfm_server_side_variable_amount` filter or a
1590 + // calculation-enabled field rather than relying on the submitted value.
1591 + $unresolved_minimum = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
1592 +
1593 + if ( $unresolved_minimum <= 0 ) {
1594 + return [
1595 + 'valid' => false,
1596 + 'message' => __( 'Payment amount could not be verified for this form. Please edit and re-save the form, then try again.', 'sureforms' ),
1597 + ];
1598 + }
997 1599 }
998 1600 }
999 1601
1000 - // For other variable amount sources (e.g., number field), validate minimum amount.
1001 - $minimum_amount = isset( $payment_config['minimum_amount'] ) ? floatval( $payment_config['minimum_amount'] ) : 0;
1602 + // All variable amount sources are subject to the configured minimum amount floor.
1603 + // Use resolved_config so 'both'-mode forms read the active type's per-type minimum
1604 + // (oneTimeMinimumAmount / subscriptionMinimumAmount) instead of the unset legacy scalar.
1605 + $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
1002 1606
1003 1607 if ( $payment_amount < $minimum_amount ) {
1004 1608 return [
1005 1609 'valid' => false,
@@ -1016,8 +1620,97 @@
1016 1620 ];
1017 1621 }
1018 1622
1019 1623 /**
1624 + * Force a refresh of the form's stored block configuration from its current content.
1625 + *
1626 + * Recovers the amount-source field reference for legacy forms whose cached
1627 + * _srfm_block_config predates server-side source tracking (an empty
1628 + * variable_amount_field_block_name). Re-parses the form blocks and rebuilds the config —
1629 + * forms saved with current code record the source — then returns the refreshed config.
1630 + *
1631 + * @param int $form_id Form post ID.
1632 + * @since 2.11.1
1633 + * @return array<mixed>|null Refreshed block configuration, or null if it cannot be rebuilt.
1634 + */
1635 + private static function refresh_block_config( $form_id ) {
1636 + if ( ! is_int( $form_id ) || $form_id <= 0 ) {
1637 + return null;
1638 + }
1639 +
1640 + $post = get_post( $form_id );
1641 + if ( ! ( $post instanceof \WP_Post ) || empty( $post->post_content ) || ! function_exists( 'parse_blocks' ) ) {
1642 + return null;
1643 + }
1644 +
1645 + $blocks = parse_blocks( $post->post_content );
1646 + if ( is_array( $blocks ) && ! empty( $blocks ) ) {
1647 + Field_Validation::add_block_config( $blocks, $form_id );
1648 + }
1649 +
1650 + return Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
1651 + }
1652 +
1653 + /**
1654 + * Resolve the authoritative server-side expected amount for a variable amount source.
1655 + *
1656 + * The expected amount is ALWAYS derived from server-side configuration — the field's
1657 + * configured default value, or (for calculation-enabled fields) a value recomputed by
1658 + * SureForms Pro from the submitted inputs. It is NEVER taken from the value submitted with
1659 + * the request. Returns null when no authoritative amount can be determined server-side, in
1660 + * which case the caller falls back to the configured minimum-amount floor.
1661 + *
1662 + * @param array<mixed> $source_config The amount-source field block config (block_name, slug, enableCalculation, defaultValue, calculationFormula, ...).
1663 + * @param array<mixed> $block_config All block configurations for the form.
1664 + * @param array<mixed> $form_data Submitted form data.
1665 + * @since 2.11.1
1666 + * @return float|null Expected amount, or null if it cannot be determined server-side.
1667 + */
1668 + private static function resolve_server_side_variable_amount( $source_config, $block_config, $form_data ) {
1669 + if ( empty( $source_config ) || ! is_array( $source_config ) ) {
1670 + return null;
1671 + }
1672 +
1673 + /**
1674 + * Compute the authoritative server-side amount for a variable payment source.
1675 + *
1676 + * SureForms Pro hooks this to recompute a field's calculation formula from the
1677 + * submitted field values. Handlers MUST return a numeric value derived only from
1678 + * server-side configuration and other submitted inputs — never the raw value of the
1679 + * amount field submitted with the request — or null if it cannot be computed.
1680 + *
1681 + * @since 2.11.1
1682 + * @param float|null $amount The resolved amount. Default null.
1683 + * @param array<string, mixed> $context Context: source_config, block_config, form_data.
1684 + */
1685 + $expected = apply_filters(
1686 + 'srfm_server_side_variable_amount',
1687 + null,
1688 + [
1689 + 'source_config' => $source_config,
1690 + 'block_config' => $block_config,
1691 + 'form_data' => $form_data,
1692 + ]
1693 + );
1694 +
1695 + if ( is_numeric( $expected ) ) {
1696 + return floatval( $expected );
1697 + }
1698 +
1699 + // Static hidden field: a *literal numeric* configured default value is the server-side
1700 + // source of truth and is authoritative. A non-numeric default (e.g. a smart tag such as
1701 + // {get_input:amount} stored raw, resolved to a runtime value only at render time) is NOT
1702 + // treated as authoritative here — it returns null below so the caller validates against the
1703 + // minimum-amount floor instead, preserving the documented dynamic-prefill behavior.
1704 + $block_name = $source_config['block_name'] ?? ( $source_config['blockName'] ?? '' );
1705 + if ( 'srfm/hidden' === $block_name && empty( $source_config['enableCalculation'] ) && isset( $source_config['defaultValue'] ) && is_numeric( $source_config['defaultValue'] ) ) {
1706 + return floatval( $source_config['defaultValue'] );
1707 + }
1708 +
1709 + return null;
1710 + }
1711 +
1712 + /**
1020 1713 * Get amount by matching submitted value with config options.
1021 1714 *
1022 1715 * @param string $submitted_field_value The submitted value (string, can be "value1 | value2" for multi-select).
1023 1716 * @param array<mixed> $block_config Block configuration containing options.
@@ -1097,9 +1790,14 @@
1097 1790 if ( empty( $config ) || ! is_array( $config ) ) {
1098 1791 continue;
1099 1792 }
1100 1793
1101 - if ( isset( $config['slug'] ) && $config['slug'] === $slug && isset( $config['block_name'] ) && $config['block_name'] === $block_name ) {
1794 + // Core blocks store the block name under 'block_name'; Pro blocks (e.g. the hidden
1795 + // field, registered via the srfm_block_config filter) store it under 'blockName'.
1796 + // Accept either so Pro-sourced amount fields resolve correctly.
1797 + $config_block_name = $config['block_name'] ?? ( $config['blockName'] ?? '' );
1798 +
1799 + if ( isset( $config['slug'] ) && $config['slug'] === $slug && $config_block_name === $block_name ) {
1102 1800 return $config;
1103 1801 }
1104 1802 }
1105 1803 return null;
@@ -1152,8 +1850,10 @@
1152 1850 } elseif ( 'srfm/multi-choice' === $dynamic_amount_field_block_name ) {
1153 1851 $block_name = 'srfm-input-multi-choice';
1154 1852 } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
1155 1853 $block_name = 'srfm-number';
1854 + } elseif ( 'srfm/hidden' === $dynamic_amount_field_block_name ) {
1855 + $block_name = 'srfm-hidden';
1156 1856 }
1157 1857
1158 1858 // Now we need to get the submitted value.
1159 1859 // Here is the structure of the form data name.