| @@ -483,8 +483,9 @@ | ||
| 483 | 483 | 'payment_unavailable' => __( 'Payment is currently unavailable. Please contact the site administrator.', 'sureforms' ), |
| 484 | 484 | 'payment_amount_not_configured' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the payment amount.', 'sureforms' ), |
| 485 | 485 | 'invalid_variable_amount' => __( 'Invalid payment amount', 'sureforms' ), |
| 486 | 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' ), | |
| 487 | 488 | |
| 488 | 489 | // Field mapping validation. |
| 489 | 490 | 'payment_name_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer name field.', 'sureforms' ), |
| 490 | 491 | 'payment_email_not_mapped' => __( 'Payment is currently unavailable. Please contact the site administrator to configure the customer email field.', 'sureforms' ), |
| @@ -861,8 +862,146 @@ | ||
| 861 | 862 | return null; |
| 862 | 863 | } |
| 863 | 864 | |
| 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 | + /** | |
| 865 | 1004 | * Resolve the WordPress user associated with a payment record. |
| 866 | 1005 | * |
| 867 | 1006 | * Resolution order: |
| 868 | 1007 | * 1. The linked entry's `user_id` (set when a logged-in user submitted the form). |
| @@ -937,8 +1076,147 @@ | ||
| 937 | 1076 | * @param string $currency Currency code. |
| 938 | 1077 | * @return array|null Validation result array or null if validation passes. |
| 939 | 1078 | * @since 2.3.0 |
| 940 | 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 | + | |
| 941 | 1219 | /** |
| 942 | 1220 | * BOTH MODE: resolve the correct amount config keys from the payment block |
| 943 | 1221 | * config based on which flow (one-time or subscription) the user chose. |
| 944 | 1222 | * |