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 +741 -46 2.8.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
@@ -482,8 +483,9 @@
482 483 'payment_unavailable' => __( 'Payment is currently unavailable. Please contact the site administrator.', 'sureforms' ),
483 484 'payment_amount_not_configured' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the payment amount.', 'sureforms' ),
484 485 'invalid_variable_amount' => __( 'Invalid payment amount', 'sureforms' ),
485 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' ),
486 488
487 489 // Field mapping validation.
488 490 'payment_name_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer name field.', 'sureforms' ),
489 491 'payment_email_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer email field.', 'sureforms' ),
@@ -574,9 +576,10 @@
574 576 * @since 2.2.2
575 577 * @param int|float $amount Amount in smallest currency unit (e.g., cents for USD).
576 578 * @param string $currency Currency code (e.g., 'usd', 'eur').
577 579 * @param int $form_id WordPress post ID of the form.
578 - * @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.
579 582 * @return array {
580 583 * Validation result.
581 584 *
582 585 * @type bool $valid Whether the validation passed.
@@ -582,9 +585,9 @@
582 585 * @type bool $valid Whether the validation passed.
583 586 * @type string $message Error message if validation failed, empty if valid.
584 587 * }
585 588 */
586 - 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 = '' ) {
587 590 // Retrieve block configuration from post meta.
588 591 $block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
589 592
590 593 // Check if block config exists.
@@ -613,15 +616,33 @@
613 616 'message' => sprintf( __( 'Currency mismatch: expected %1$s, received %2$s.', 'sureforms' ), strtoupper( $global_currency ), strtoupper( $submitted_currency ) ),
614 617 ];
615 618 }
616 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 +
617 638 // Get amount type (fixed or minimum).
618 - $amount_type = $payment_config['amount_type'] ?? 'fixed';
639 + $amount_type = $resolved_config['amount_type'] ?? 'fixed';
619 640
620 641 // Validate based on amount type.
621 642 if ( 'fixed' === $amount_type ) {
622 643 // Fixed amount validation - must match exactly.
623 - $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;
624 645
625 646 // Allow small floating point difference (0.01) due to rounding.
626 647 if ( abs( $amount - $configured_amount ) > 0.01 ) {
627 648 return [
@@ -631,9 +652,9 @@
631 652 ];
632 653 }
633 654 } elseif ( 'variable' === $amount_type ) {
634 655 // Minimum amount validation - must be >= minimum.
635 - $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;
636 657
637 658 if ( $amount < $minimum_amount ) {
638 659 return [
639 660 'valid' => false,
@@ -643,9 +664,9 @@
643 664 }
644 665
645 666 // Validate dynamic amount from dropdown/multi-choice field.
646 667 $dynamic_amount_validation = self::validate_dynamic_amount_field(
647 - $payment_config,
668 + $resolved_config,
648 669 $block_config,
649 670 $amount,
650 671 $currency
651 672 );
@@ -698,8 +719,9 @@
698 719 * @since 2.3.0
699 720 * @param string $block_id Block identifier.
700 721 * @param string $payment_intent_id Payment intent ID from Stripe.
701 722 * @param array<string, mixed> $form_data Submitted form data.
723 + * @param string $active_type Optional. 'one-time' or 'subscription' for "both" mode resolution.
702 724 * @return array {
703 725 * Verification result.
704 726 *
705 727 * @type bool $valid Whether verification passed.
@@ -705,9 +727,9 @@
705 727 * @type bool $valid Whether verification passed.
706 728 * @type string $message Error message if verification failed, empty if valid.
707 729 * }
708 730 */
709 - 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 = '' ) {
710 732 // Get form ID from form data for verification.
711 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;
712 734
713 735 // Validate required parameters.
@@ -728,12 +750,24 @@
728 750 'message' => __( 'Payment verification failed. Invalid payment intent.', 'sureforms' ),
729 751 ];
730 752 }
731 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 +
732 766 $payment_amount = isset( $metadata['amount'] ) && ! empty( $metadata['amount'] ) && is_numeric( $metadata['amount'] ) ? floatval( $metadata['amount'] ) : 0;
733 767
734 768 // Validate payment amount matches configuration.
735 - $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 );
736 770
737 771 if ( false === $amount_validation['valid'] ) {
738 772 return $amount_validation;
739 773 }
@@ -745,8 +779,27 @@
745 779 ];
746 780 }
747 781
748 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 + /**
749 802 * Delete payment intent metadata from transient.
750 803 *
751 804 * Cleans up stored metadata after successful payment verification.
752 805 *
@@ -778,8 +831,244 @@
778 831 return ! empty( $result ) && is_string( $result ) ? $result : 'left';
779 832 }
780 833
781 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 + /**
782 1071 * Validate dynamic amount field from dropdown or multi-choice.
783 1072 *
784 1073 * @param array<string, mixed> $payment_config Payment block configuration.
785 1074 * @param array<string, mixed> $block_config All block configurations.
@@ -787,8 +1076,204 @@
787 1076 * @param string $currency Currency code.
788 1077 * @return array|null Validation result array or null if validation passes.
789 1078 * @since 2.3.0
790 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 + */
791 1276 private static function validate_dynamic_amount_field( $payment_config, $block_config, $submitted_amount_decimal, $currency ) {
792 1277 // Check if variable amount field is from dropdown or multi-choice block.
793 1278 $dynamic_amount_field_block_name = $payment_config['variable_amount_field_block_name'] ?? '';
794 1279
@@ -877,8 +1362,9 @@
877 1362 * @param string $block_id Block identifier.
878 1363 * @param int $form_id Form post ID.
879 1364 * @param array<string, mixed> $form_data Submitted form data.
880 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.
881 1367 * @return array {
882 1368 * Validation result.
883 1369 *
884 1370 * @type bool $valid Whether validation passed.
@@ -884,9 +1370,9 @@
884 1370 * @type bool $valid Whether validation passed.
885 1371 * @type string $message Error message if validation failed, empty if valid.
886 1372 * }
887 1373 */
888 - 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 = '' ) {
889 1375 // Get block configuration.
890 1376 $block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
891 1377
892 1378 if ( empty( $block_config ) || ! isset( $block_config[ $block_id ] ) ) {
@@ -896,14 +1382,15 @@
896 1382 'message' => __( 'Payment configuration not found.', 'sureforms' ),
897 1383 ];
898 1384 }
899 1385
900 - $payment_config = $block_config[ $block_id ];
901 - $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';
902 1389
903 1390 // For fixed amounts, validate against configured amount.
904 1391 if ( 'fixed' === $amount_type ) {
905 - $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;
906 1393
907 1394 // Allow small floating point difference (0.01) due to rounding.
908 1395 if ( abs( $payment_amount - $configured_amount ) > 0.01 ) {
909 1396 return [
@@ -921,19 +1408,66 @@
921 1408
922 1409 // For variable amounts, validate based on source field.
923 1410 if ( 'variable' === $amount_type ) {
924 1411 // Check if variable amount comes from dropdown/multi-choice.
925 - $dynamic_amount_field_block_name = $payment_config['variable_amount_field_block_name'] ?? '';
926 - $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'] ?? '';
927 1414
928 - // 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.
929 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 +
930 1461 return [
931 - 'valid' => true,
932 - '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' ),
933 1464 ];
934 1465 }
935 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.
936 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 );
937 1471
938 1472 if ( empty( $submitted_field_value ) ) {
939 1473 return [
@@ -952,11 +1486,23 @@
952 1486 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
953 1487 ];
954 1488 }
955 1489
956 - // 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.
957 1492 $get_expected_amount = self::get_amount_by_the_config_options( $submitted_field_value, $variable_amount_block_config );
958 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 +
959 1505 // Validate payment amount matches expected amount.
960 1506 if ( abs( $payment_amount - $get_expected_amount ) > 0.01 ) {
961 1507 return [
962 1508 'valid' => false,
@@ -963,48 +1509,101 @@
963 1509 /* translators: %1$s: expected amount, %2$s: payment amount */
964 1510 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $get_expected_amount, $payment_amount ),
965 1511 ];
966 1512 }
967 - } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
968 - // Get the block config for the number field to retrieve the format type.
969 - $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 );
970 1519
971 - if ( empty( $number_block_config ) ) {
1520 + if ( empty( $variable_amount_block_config ) ) {
972 1521 return [
973 1522 'valid' => false,
974 - 'message' => __( 'Number field configuration not found.', 'sureforms' ),
1523 + 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
975 1524 ];
976 1525 }
977 1526
978 - // Get the number format type from the block config (default to 'us-style').
979 - $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 );
980 1528
981 - // If submitted_field_value is not string then convert the value to string.
982 - $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 + }
983 1552
984 - // Normalize the submitted amount based on the format type.
985 - $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 );
986 1559
987 - // Validate that the normalized amount is valid.
988 - if ( ! is_numeric( $converted_payment_amount ) || $converted_payment_amount <= 0 ) {
989 - return [
990 - 'valid' => false,
991 - 'message' => __( 'Variable amount field value is required.', 'sureforms' ),
992 - ];
993 - }
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 + }
994 1566
995 - // Validate payment amount matches expected amount.
996 - if ( abs( $payment_amount - $converted_payment_amount ) > 0.01 ) {
997 - return [
998 - 'valid' => false,
999 - /* translators: %1$s: expected amount, %2$s: payment amount */
1000 - 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $converted_payment_amount, $payment_amount ),
1001 - ];
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 + }
1002 1599 }
1003 1600 }
1004 1601
1005 - // For other variable amount sources (e.g., number field), validate minimum amount.
1006 - $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;
1007 1606
1008 1607 if ( $payment_amount < $minimum_amount ) {
1009 1608 return [
1010 1609 'valid' => false,
@@ -1021,8 +1620,97 @@
1021 1620 ];
1022 1621 }
1023 1622
1024 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 + /**
1025 1713 * Get amount by matching submitted value with config options.
1026 1714 *
1027 1715 * @param string $submitted_field_value The submitted value (string, can be "value1 | value2" for multi-select).
1028 1716 * @param array<mixed> $block_config Block configuration containing options.
@@ -1102,9 +1790,14 @@
1102 1790 if ( empty( $config ) || ! is_array( $config ) ) {
1103 1791 continue;
1104 1792 }
1105 1793
1106 - 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 ) {
1107 1800 return $config;
1108 1801 }
1109 1802 }
1110 1803 return null;
@@ -1157,8 +1850,10 @@
1157 1850 } elseif ( 'srfm/multi-choice' === $dynamic_amount_field_block_name ) {
1158 1851 $block_name = 'srfm-input-multi-choice';
1159 1852 } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
1160 1853 $block_name = 'srfm-number';
1854 + } elseif ( 'srfm/hidden' === $dynamic_amount_field_block_name ) {
1855 + $block_name = 'srfm-hidden';
1161 1856 }
1162 1857
1163 1858 // Now we need to get the submitted value.
1164 1859 // Here is the structure of the form data name.