| @@ -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' ), |
| @@ -860,8 +862,213 @@ | ||
| 860 | 862 | return null; |
| 861 | 863 | } |
| 862 | 864 | |
| 863 | 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 | + /** | |
| 864 | 1071 | * Validate dynamic amount field from dropdown or multi-choice. |
| 865 | 1072 | * |
| 866 | 1073 | * @param array<string, mixed> $payment_config Payment block configuration. |
| 867 | 1074 | * @param array<string, mixed> $block_config All block configurations. |
| @@ -870,8 +1077,147 @@ | ||
| 870 | 1077 | * @return array|null Validation result array or null if validation passes. |
| 871 | 1078 | * @since 2.3.0 |
| 872 | 1079 | */ |
| 873 | 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 | + /** | |
| 874 | 1220 | * BOTH MODE: resolve the correct amount config keys from the payment block |
| 875 | 1221 | * config based on which flow (one-time or subscription) the user chose. |
| 876 | 1222 | * |
| 877 | 1223 | * For pure one-time / subscription blocks, the config already has the correct |
| @@ -1224,21 +1570,34 @@ | ||
| 1224 | 1570 | /* translators: %1$s: expected amount, %2$s: payment amount */ |
| 1225 | 1571 | 'message' => sprintf( __( 'Payment amount mismatch. Expected %1$s, received %2$s.', 'sureforms' ), $converted_payment_amount, $payment_amount ), |
| 1226 | 1572 | ]; |
| 1227 | 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 | + } | |
| 1228 | 1599 | } |
| 1229 | - // Otherwise (e.g. a hidden field whose value could not be resolved | |
| 1230 | - // server-side): do NOT trust the submitted value — fall through to the | |
| 1231 | - // minimum-amount floor below as the only safe guarantee. | |
| 1232 | - // | |
| 1233 | - // In practice this covers the documented dynamic-prefill case: a hidden field | |
| 1234 | - // whose default value is a smart tag (e.g. {get_input:amount}) is stored raw and | |
| 1235 | - // is therefore non-numeric, so resolve_server_side_variable_amount() returns null | |
| 1236 | - // and the charge is validated against the floor only — dynamic prefill keeps | |
| 1237 | - // working. A hidden field with a literal numeric default, by contrast, is treated | |
| 1238 | - // as authoritative above; merchants doing custom JS-driven dynamic pricing must | |
| 1239 | - // supply a server-authoritative amount via the `srfm_server_side_variable_amount` | |
| 1240 | - // filter or a calculation-enabled field rather than relying on the submitted value. | |
| 1241 | 1600 | } |
| 1242 | 1601 | |
| 1243 | 1602 | // All variable amount sources are subject to the configured minimum amount floor. |
| 1244 | 1603 | // Use resolved_config so 'both'-mode forms read the active type's per-type minimum |