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 +630 -50 2.8.2 → 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' ),
@@ -777,8 +779,27 @@
777 779 ];
778 780 }
779 781
780 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 + /**
781 802 * Delete payment intent metadata from transient.
782 803 *
783 804 * Cleans up stored metadata after successful payment verification.
784 805 *
@@ -810,8 +831,244 @@
810 831 return ! empty( $result ) && is_string( $result ) ? $result : 'left';
811 832 }
812 833
813 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 + /**
814 1071 * Validate dynamic amount field from dropdown or multi-choice.
815 1072 *
816 1073 * @param array<string, mixed> $payment_config Payment block configuration.
817 1074 * @param array<string, mixed> $block_config All block configurations.
@@ -820,8 +1077,147 @@
820 1077 * @return array|null Validation result array or null if validation passes.
821 1078 * @since 2.3.0
822 1079 */
823 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 + /**
824 1220 * BOTH MODE: resolve the correct amount config keys from the payment block
825 1221 * config based on which flow (one-time or subscription) the user chose.
826 1222 *
827 1223 * For pure one-time / subscription blocks, the config already has the correct
@@ -1015,16 +1411,63 @@
1015 1411 // Check if variable amount comes from dropdown/multi-choice.
1016 1412 $dynamic_amount_field_block_name = $resolved_config['variable_amount_field_block_name'] ?? '';
1017 1413 $variable_amount_field_slug = $resolved_config['variable_amount_field'] ?? '';
1018 1414
1019 - // 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.
1020 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 +
1021 1461 return [
1022 - 'valid' => true,
1023 - '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' ),
1024 1464 ];
1025 1465 }
1026 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.
1027 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 );
1028 1471
1029 1472 if ( empty( $submitted_field_value ) ) {
1030 1473 return [
@@ -1043,11 +1486,23 @@
1043 1486 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
1044 1487 ];
1045 1488 }
1046 1489
1047 - // 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.
1048 1492 $get_expected_amount = self::get_amount_by_the_config_options( $submitted_field_value, $variable_amount_block_config );
1049 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 +
1050 1505 // Validate payment amount matches expected amount.
1051 1506 if ( abs( $payment_amount - $get_expected_amount ) > 0.01 ) {
1052 1507 return [
1053 1508 'valid' => false,
@@ -1054,67 +1509,98 @@
1054 1509 /* translators: %1$s: expected amount, %2$s: payment amount */
1055 1510 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $get_expected_amount, $payment_amount ),
1056 1511 ];
1057 1512 }
1058 - } elseif ( 'srfm/number' === $dynamic_amount_field_block_name ) {
1059 - // Get the block config for the number field to retrieve the format type.
1060 - $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 );
1061 1519
1062 - if ( empty( $number_block_config ) ) {
1520 + if ( empty( $variable_amount_block_config ) ) {
1063 1521 return [
1064 1522 'valid' => false,
1065 - 'message' => __( 'Number field configuration not found.', 'sureforms' ),
1523 + 'message' => __( 'Variable amount field configuration not found.', 'sureforms' ),
1066 1524 ];
1067 1525 }
1068 1526
1069 - // Get the number format type from the block config (default to 'us-style').
1070 - $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 );
1071 1528
1072 - // If submitted_field_value is not string then convert the value to string.
1073 - $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 + }
1074 1552
1075 - // Normalize the submitted amount based on the format type.
1076 - $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 );
1077 1559
1078 - // Validate that the normalized amount is valid.
1079 - if ( ! is_numeric( $converted_payment_amount ) || $converted_payment_amount <= 0 ) {
1080 - return [
1081 - 'valid' => false,
1082 - 'message' => __( 'Variable amount field value is required.', 'sureforms' ),
1083 - ];
1084 - }
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 + }
1085 1566
1086 - // Validate payment amount matches expected amount.
1087 - if ( abs( $payment_amount - $converted_payment_amount ) > 0.01 ) {
1088 - return [
1089 - 'valid' => false,
1090 - /* translators: %1$s: expected amount, %2$s: payment amount */
1091 - 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $converted_payment_amount, $payment_amount ),
1092 - ];
1093 - }
1094 - } elseif ( 'srfm/hidden' === $dynamic_amount_field_block_name ) {
1095 - // Hidden field values are dynamic — they may be set at runtime via
1096 - // URL params, cookies, or JS. Trust the value submitted with the
1097 - // form and verify the Stripe-charged amount matches it.
1098 - $expected_amount = is_numeric( $submitted_field_value ) ? floatval( $submitted_field_value ) : 0;
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;
1099 1592
1100 - if ( $expected_amount <= 0 ) {
1101 - return [
1102 - 'valid' => false,
1103 - 'message' => __( 'Variable amount field value is required.', 'sureforms' ),
1104 - ];
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 + }
1105 1599 }
1106 -
1107 - if ( abs( $payment_amount - $expected_amount ) > 0.01 ) {
1108 - return [
1109 - 'valid' => false,
1110 - /* translators: %1$s: expected amount, %2$s: payment amount */
1111 - 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $expected_amount, $payment_amount ),
1112 - ];
1113 - }
1114 1600 }
1115 1601
1116 - // All variable amount sources (number, hidden) are subject to the configured minimum amount floor.
1602 + // All variable amount sources are subject to the configured minimum amount floor.
1117 1603 // Use resolved_config so 'both'-mode forms read the active type's per-type minimum
1118 1604 // (oneTimeMinimumAmount / subscriptionMinimumAmount) instead of the unset legacy scalar.
1119 1605 $minimum_amount = isset( $resolved_config['minimum_amount'] ) ? floatval( $resolved_config['minimum_amount'] ) : 0;
1120 1606
@@ -1134,8 +1620,97 @@
1134 1620 ];
1135 1621 }
1136 1622
1137 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 + /**
1138 1713 * Get amount by matching submitted value with config options.
1139 1714 *
1140 1715 * @param string $submitted_field_value The submitted value (string, can be "value1 | value2" for multi-select).
1141 1716 * @param array<mixed> $block_config Block configuration containing options.
@@ -1215,9 +1790,14 @@
1215 1790 if ( empty( $config ) || ! is_array( $config ) ) {
1216 1791 continue;
1217 1792 }
1218 1793
1219 - 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 ) {
1220 1800 return $config;
1221 1801 }
1222 1802 }
1223 1803 return null;