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 +371 -12 2.11.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' ),
@@ -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