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/front-end.php +207 -27 2.8.0 → 2.12.8 View file →
@@ -8,8 +8,10 @@
8 8
9 9 namespace SRFM\Inc\Payments;
10 10
11 11 use SRFM\Inc\Database\Tables\Payments;
12 +use SRFM\Inc\Field_Validation;
13 +use SRFM\Inc\Helper;
12 14 use SRFM\Inc\Payments\Stripe\Stripe_Helper;
13 15 use SRFM\Inc\Submit_Token;
14 16 use SRFM\Inc\Traits\Get_Instance;
15 17
@@ -96,9 +98,10 @@
96 98 if ( $form_id <= 0 || empty( $block_id ) ) {
97 99 wp_send_json_error( __( 'Invalid form configuration.', 'sureforms' ) );
98 100 }
99 101
100 - $validation_result = Payment_Helper::validate_payment_amount( $amount_processed_with_currency, $currency, $form_id, $block_id );
102 + // BOTH MODE: pass 'one-time' so the validator uses the correct per-type amount config.
103 + $validation_result = Payment_Helper::validate_payment_amount( $amount_processed_with_currency, $currency, $form_id, $block_id, 'one-time' );
101 104 if ( ! $validation_result['valid'] ) {
102 105 wp_send_json_error( $validation_result['message'] );
103 106 }
104 107
@@ -129,24 +132,26 @@
129 132 ]
130 133 );
131 134 }
132 135
133 - $license_key = Stripe_Helper::get_license_key();
136 + // Public checkout request - never block the visitor on a SureCart license call.
137 + $license_key = Stripe_Helper::get_license_key( false );
134 138
135 139 // Create payment intent with confirm: true for immediate processing.
136 140 $payment_intent_data = [
137 - 'secret_key' => $secret_key,
138 - 'amount' => $amount,
139 - 'currency' => strtolower( $currency ),
140 - 'description' => $description,
141 - 'confirm' => false, // Will be confirmed by frontend.
142 - 'receipt_email' => $customer_email,
143 - 'license_key' => $license_key,
144 - 'automatic_payment_methods' => [
145 - 'enabled' => true,
146 - 'allow_redirects' => 'never',
147 - ],
148 - 'metadata' => [
141 + 'secret_key' => $secret_key,
142 + 'amount' => $amount,
143 + 'currency' => strtolower( $currency ),
144 + 'description' => $description,
145 + 'confirm' => false, // Will be confirmed by frontend.
146 + 'receipt_email' => $customer_email,
147 + 'license_key' => $license_key,
148 + // One-time payments use manual capture; methods that don't support it (Bacs, Link, Cash App, BNPL) make
149 + // Stripe reject the deferred Elements session in live mode, and an automatic-payment-methods intent can't
150 + // be confirmed by the card-scoped client Element. Pin to card so the client Element, this payload, and the
151 + // middleware intent all agree (Apple/Google Pay are still surfaced through 'card').
152 + 'payment_method_types' => [ 'card' ],
153 + 'metadata' => [
149 154 'source' => 'SureForms',
150 155 'block_id' => $block_id,
151 156 'original_amount' => $amount,
152 157 'receipt_email' => $customer_email,
@@ -217,16 +222,19 @@
217 222 throw new \Exception( Payment_Helper::get_error_message_by_key( 'failed_to_create_payment' ) );
218 223 }
219 224
220 225 // Store payment intent metadata in transient for verification.
226 + // active_type binds this intent to the one-time flow so a tampered
227 + // submission cannot replay it through the subscription submit path.
221 228 Payment_Helper::store_payment_intent_metadata(
222 229 $block_id,
223 230 $payment_intent['id'],
224 231 [
225 - 'form_id' => $form_id,
226 - 'block_id' => $block_id,
227 - 'amount' => $amount_processed_with_currency,
228 - 'currency' => strtolower( $currency ),
232 + 'form_id' => $form_id,
233 + 'block_id' => $block_id,
234 + 'amount' => $amount_processed_with_currency,
235 + 'currency' => strtolower( $currency ),
236 + 'active_type' => 'one-time',
229 237 ]
230 238 );
231 239
232 240 wp_send_json_success(
@@ -297,9 +305,10 @@
297 305 if ( $form_id <= 0 || empty( $block_id ) ) {
298 306 wp_send_json_error( __( 'Invalid form configuration.', 'sureforms' ) );
299 307 }
300 308
301 - $validation_result = Payment_Helper::validate_payment_amount( $amount_processed_with_currency, $currency, $form_id, $block_id );
309 + // BOTH MODE: pass 'subscription' so the validator uses the correct per-type amount config.
310 + $validation_result = Payment_Helper::validate_payment_amount( $amount_processed_with_currency, $currency, $form_id, $block_id, 'subscription' );
302 311 if ( ! $validation_result['valid'] ) {
303 312 wp_send_json_error( $validation_result['message'] );
304 313 }
305 314
@@ -308,13 +317,27 @@
308 317 wp_send_json_error( __( 'Amount must be greater than 0', 'sureforms' ) );
309 318 }
310 319
311 320 // Validate interval like simple-stripe-subscriptions.
312 - $valid_intervals = [ 'day', 'week', 'month', 'year' ];
321 + // BOTH MODE: 'quarter' is a valid editor option but was missing from the allow-list,
322 + // causing Quarterly subscriptions to be rejected at submit time.
323 + $valid_intervals = [ 'day', 'week', 'month', 'quarter', 'year' ];
313 324 if ( ! in_array( $subscription_interval, $valid_intervals, true ) ) {
314 325 wp_send_json_error( __( 'Invalid billing interval', 'sureforms' ) );
315 326 }
316 327
328 + // Reject when the submitted interval does not match what the admin saved in
329 + // the form's stored block config. Admin picks a single interval in the editor;
330 + // the end user has no chooser. So a divergence here is always tampering — the
331 + // data attribute the server itself rendered has been altered before submit.
332 + $stored_block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id );
333 + if ( is_array( $stored_block_config ) && isset( $stored_block_config[ $block_id ] ) && is_array( $stored_block_config[ $block_id ] ) ) {
334 + $stored_interval = $stored_block_config[ $block_id ]['subscription_interval'] ?? '';
335 + if ( ! empty( $stored_interval ) && $stored_interval !== $subscription_interval ) {
336 + wp_send_json_error( __( 'Billing interval does not match the form configuration.', 'sureforms' ) );
337 + }
338 + }
339 +
317 340 try {
318 341 // Validate Stripe connection.
319 342 if ( ! Stripe_Helper::is_stripe_connected() ) {
320 343 throw new \Exception( __( 'Stripe is not connected.', 'sureforms' ) );
@@ -336,9 +359,10 @@
336 359 if ( ! $customer_id ) {
337 360 throw new \Exception( __( 'Failed to create customer for subscription.', 'sureforms' ) );
338 361 }
339 362
340 - $license_key = Stripe_Helper::get_license_key();
363 + // Public checkout request - never block the visitor on a SureCart license call.
364 + $license_key = Stripe_Helper::get_license_key( false );
341 365 // Prepare subscription data for middleware.
342 366 $subscription_data = apply_filters(
343 367 'srfm_create_subscription_data',
344 368 [
@@ -411,8 +435,10 @@
411 435 throw new \Exception( __( 'Failed to create subscription.', 'sureforms' ) );
412 436 }
413 437
414 438 // Store subscription metadata in transient for verification.
439 + // active_type binds this intent to the subscription flow so a tampered
440 + // submission cannot replay it through the one-time submit path.
415 441 Payment_Helper::store_payment_intent_metadata(
416 442 $block_id,
417 443 $payment_intent_id,
418 444 [
@@ -420,8 +446,9 @@
420 446 'block_id' => $block_id,
421 447 'amount' => $amount_processed_with_currency,
422 448 'currency' => strtolower( $currency ),
423 449 'subscription_id' => $subscription_id,
450 + 'active_type' => 'subscription',
424 451 ]
425 452 );
426 453
427 454 $response = [
@@ -456,8 +483,20 @@
456 483 }
457 484
458 485 $payment_response = [];
459 486
487 + // Block IDs that produced a verified payment on this submission.
488 + $verified_block_ids = [];
489 +
490 + // Field keys of every payment field seen in this submission, and the
491 + // subset whose payment we actually verified below. A payment field's
492 + // value is only a trustworthy payment-record id once verified here; any
493 + // field left unverified is cleared before it reaches the submission data,
494 + // so the {form-payment} smart tag can never resolve a client-supplied id
495 + // to an arbitrary payment row.
496 + $payment_field_names = [];
497 + $verified_field_names = [];
498 +
460 499 // Loop through form data to find payment fields.
461 500 foreach ( $form_data as $field_name => $field_value ) {
462 501 // Check if field name contains "-lbl-" pattern.
463 502 if ( strpos( $field_name, '-lbl-' ) === false ) {
@@ -476,8 +515,10 @@
476 515 if ( ! ( strpos( $name_parts[0], 'srfm-payment-' ) === 0 ) ) {
477 516 continue;
478 517 }
479 518
519 + $payment_field_names[] = $field_name;
520 +
480 521 // Value will be in the form of the json string.
481 522 $payment_value = json_decode( $field_value, true );
482 523
483 524 if ( empty( $payment_value ) || ! is_array( $payment_value ) ) {
@@ -521,16 +562,28 @@
521 562
522 563 if ( ! empty( $payment_response ) && isset( $payment_response['payment_id'] ) ) {
523 564 // Modify the form data with the payment ID.
524 565 $form_data[ $field_name ] = $payment_response['payment_id'];
566 +
567 + $verified_block_ids[ Helper::get_string_value( $block_id ) ] = true;
568 +
569 + $verified_field_names[ $field_name ] = true;
525 570 }
526 571 }
527 572
573 + // Deny-by-default: drop any payment field we did not verify this request,
574 + // so its raw client value cannot later be read back as a payment-record id.
575 + foreach ( $payment_field_names as $payment_field_name ) {
576 + if ( ! isset( $verified_field_names[ $payment_field_name ] ) ) {
577 + $form_data[ $payment_field_name ] = '';
578 + }
579 + }
580 +
528 581 if ( ! empty( $payment_response ) && isset( $payment_response['error'] ) ) {
529 - $form_data = array_merge( $form_data, $payment_response );
582 + return array_merge( $form_data, $payment_response );
530 583 }
531 584
532 - return $form_data;
585 + return $this->require_verified_payments( $form_data, $verified_block_ids );
533 586 }
534 587
535 588 /**
536 589 * Verify Stripe payment
@@ -585,9 +638,10 @@
585 638 $customer_id = ! empty( $subscription_value['customerId'] ) ? $subscription_value['customerId'] : '';
586 639 $setup_intent_id = ! empty( $subscription_value['setupIntent'] ) && is_string( $subscription_value['setupIntent'] ) ? $subscription_value['setupIntent'] : '';
587 640
588 641 // Verify payment intent with comprehensive validation including form data.
589 - $verification_result = Payment_Helper::verify_payment_intent( $block_id, $setup_intent_id, $form_data );
642 + // BOTH MODE: pass 'subscription' so per-type amount config is used for verification.
643 + $verification_result = Payment_Helper::verify_payment_intent( $block_id, $setup_intent_id, $form_data, 'subscription' );
590 644
591 645 if ( false === $verification_result['valid'] ) {
592 646 return [
593 647 'error' => $verification_result['message'],
@@ -637,8 +691,27 @@
637 691 'default_payment_method' => $setup_intent['payment_method'],
638 692 'collection_method' => 'charge_automatically',
639 693 ];
640 694
695 + // Override interval + billing cycles with the values stored in the
696 + // form's block config. These come from the data attributes the
697 + // server itself rendered, so they cannot legitimately diverge from
698 + // the admin's saved subscriptionPlan. Trusting the submitted values
699 + // would let an attacker DevTools-flip cancel_at to 'ongoing'.
700 + $form_id_for_config = isset( $form_data['form-id'] ) && is_numeric( $form_data['form-id'] ) ? intval( $form_data['form-id'] ) : 0;
701 + if ( $form_id_for_config > 0 && ! empty( $block_id ) ) {
702 + $stored_block_config = Field_Validation::get_or_migrate_block_config_for_legacy_form( $form_id_for_config );
703 + if ( is_array( $stored_block_config ) && isset( $stored_block_config[ $block_id ] ) && is_array( $stored_block_config[ $block_id ] ) ) {
704 + $stored_payment_config = $stored_block_config[ $block_id ];
705 + if ( isset( $stored_payment_config['subscription_interval'] ) ) {
706 + $subscription_value['subscriptionInterval'] = $stored_payment_config['subscription_interval'];
707 + }
708 + if ( isset( $stored_payment_config['subscription_billing_cycles'] ) ) {
709 + $subscription_value['subscriptionBillingCycles'] = $stored_payment_config['subscription_billing_cycles'];
710 + }
711 + }
712 + }
713 +
641 714 // Calculate cancel_at timestamp based on billing cycles and interval.
642 715 $cancel_at = $this->prepare_cancel_at( $subscription_value );
643 716 if ( ! empty( $cancel_at ) ) {
644 717 $subscription_update_data['cancel_at'] = $cancel_at;
@@ -754,8 +827,19 @@
754 827 $currency = isset( $paid_invoice['currency'] ) && ! empty( $paid_invoice['currency'] ) ? $paid_invoice['currency'] : 'usd';
755 828 $form_id = isset( $form_data['form-id'] ) && ! empty( $form_data['form-id'] ) ? $form_data['form-id'] : 0;
756 829 $subscription_status = isset( $subscription['status'] ) && ! empty( $subscription['status'] ) && is_string( $subscription['status'] ) ? $subscription['status'] : '';
757 830
831 + // Defense-in-depth: re-validate the amount Stripe actually invoiced against the form's
832 + // server-side configuration. The recurring price is the invoiced amount, so an
833 + // underpayment here would otherwise repeat every billing cycle.
834 + $charged_amount = Stripe_Helper::amount_from_stripe_format( is_numeric( $amount ) ? (int) $amount : 0, is_string( $currency ) ? $currency : 'usd' );
835 + $charge_validation = Payment_Helper::validate_amount_against_config( $block_id, is_numeric( $form_id ) ? (int) $form_id : 0, $form_data, $charged_amount, 'subscription' );
836 + if ( false === $charge_validation['valid'] ) {
837 + return [
838 + 'error' => $charge_validation['message'],
839 + ];
840 + }
841 +
758 842 $invoice_status = isset( $paid_invoice['status'] ) && ! empty( $paid_invoice['status'] ) && is_string( $paid_invoice['status'] ) ? $paid_invoice['status'] : '';
759 843
760 844 // Extract customer data.
761 845 $customer_data = $this->extract_customer_data( $subscription_value );
@@ -1078,8 +1162,44 @@
1078 1162 return $default_value;
1079 1163 }
1080 1164
1081 1165 /**
1166 + * Fail closed when a form's payment field carries no verified payment.
1167 + *
1168 + * SECURITY INVARIANT — the payment requirement must come from the stored form
1169 + * config, never from the submitted payload. Verification driven by what the client
1170 + * sent can only confirm the payments it was given; it cannot know about one that
1171 + * was never presented. Deriving the requirement from the saved form keeps a
1172 + * submission that carries no payment field from being treated as complete.
1173 + *
1174 + * @param array<mixed> $form_data Form data.
1175 + * @param array<string,true> $verified_block_ids Payment block IDs verified on this submission.
1176 + *
1177 + * @since 2.12.3
1178 + * @return array<mixed> Form data, carrying an `error` key when a payment is missing.
1179 + */
1180 + private function require_verified_payments( $form_data, $verified_block_ids ) {
1181 + // absint() to match the normalisation the submit token was verified against.
1182 + $form_id = isset( $form_data['form-id'] ) ? absint( Helper::get_string_value( $form_data['form-id'] ) ) : 0;
1183 +
1184 + if ( 0 === $form_id ) {
1185 + return $form_data;
1186 + }
1187 +
1188 + foreach ( Payment_Helper::get_required_payment_block_ids( $form_id ) as $block_id ) {
1189 + if ( isset( $verified_block_ids[ Helper::get_string_value( $block_id ) ] ) ) {
1190 + continue;
1191 + }
1192 +
1193 + $form_data['error'] = Payment_Helper::get_error_message_by_key( 'payment_required' );
1194 +
1195 + break;
1196 + }
1197 +
1198 + return $form_data;
1199 + }
1200 +
1201 + /**
1082 1202 * Verify payment intent status
1083 1203 *
1084 1204 * @param array<mixed> $payment_value Payment value.
1085 1205 * @param string $payment_id Payment ID.
@@ -1100,9 +1220,10 @@
1100 1220 ];
1101 1221 }
1102 1222
1103 1223 // Verify payment intent with comprehensive validation including form data.
1104 - $verification_result = Payment_Helper::verify_payment_intent( $block_id, $payment_id, $form_data );
1224 + // BOTH MODE: pass 'one-time' so per-type amount config is used for verification.
1225 + $verification_result = Payment_Helper::verify_payment_intent( $block_id, $payment_id, $form_data, 'one-time' );
1105 1226
1106 1227 if ( false === $verification_result['valid'] ) {
1107 1228 return [
1108 1229 'error' => $verification_result['message'],
@@ -1180,8 +1301,18 @@
1180 1301 $confirm_payment_amount = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['amount'] ) && ! empty( $confirmed_payment_intent['amount'] ) ? intval( $confirmed_payment_intent['amount'] ) : 0;
1181 1302 $confirm_payment_currency = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['currency'] ) && ! empty( $confirmed_payment_intent['currency'] ) ? (string) $confirmed_payment_intent['currency'] : 'usd';
1182 1303 $confirm_payment_id = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['id'] ) && ! empty( $confirmed_payment_intent['id'] ) ? (string) $confirmed_payment_intent['id'] : '';
1183 1304
1305 + // Defense-in-depth: re-validate the amount Stripe actually charged against the form's
1306 + // server-side configuration — not only the amount recorded when the intent was created.
1307 + $charged_amount = Stripe_Helper::amount_from_stripe_format( $confirm_payment_amount, $confirm_payment_currency );
1308 + $charge_validation = Payment_Helper::validate_amount_against_config( $block_id, $form_id, $form_data, $charged_amount, 'one-time' );
1309 + if ( false === $charge_validation['valid'] ) {
1310 + return [
1311 + 'error' => $charge_validation['message'],
1312 + ];
1313 + }
1314 +
1184 1315 // Extract customer data.
1185 1316 $customer_data = $this->extract_customer_data( $payment_value );
1186 1317
1187 1318 // update payment status and save to the payment entries table.
@@ -1484,9 +1615,15 @@
1484 1615 $payment_entry_id = intval( $payment_entries[0]['id'] );
1485 1616
1486 1617 // Update the payment entry with entry_id using Payments class.
1487 1618 $updated = Payments::update( $payment_entry_id, [ 'entry_id' => $entry_id ] );
1488 - return $updated ? true : false;
1619 +
1620 + if ( $updated ) {
1621 + $this->maybe_fire_payment_completed( $payment_entry_id );
1622 + return true;
1623 + }
1624 +
1625 + return false;
1489 1626 }
1490 1627
1491 1628 return false;
1492 1629 }
@@ -1491,8 +1628,45 @@
1491 1628 return false;
1492 1629 }
1493 1630
1494 1631 /**
1632 + * Fire the `srfm_payment_completed` action for a freshly linked payment.
1633 + *
1634 + * Called right after a payment row is linked to its form entry, so `entry_id`
1635 + * (and therefore the submitting user) is resolvable. Gated on the `succeeded`
1636 + * status so consumers never grant access for pending, failed or refunded
1637 + * payments.
1638 + *
1639 + * @param int $payment_entry_id Primary key of the linked `sureforms_payments` row.
1640 + * @since 2.12.0
1641 + * @return void
1642 + */
1643 + private function maybe_fire_payment_completed( $payment_entry_id ) {
1644 + $payment = Payments::get( $payment_entry_id );
1645 + if ( ! is_array( $payment ) ) {
1646 + return;
1647 + }
1648 +
1649 + $status = ! empty( $payment['status'] ) && is_string( $payment['status'] ) ? $payment['status'] : '';
1650 + if ( 'succeeded' !== $status ) {
1651 + return;
1652 + }
1653 +
1654 + /**
1655 + * Fires when a SureForms payment reaches the `succeeded` state and has been
1656 + * linked to its form entry — a one-time payment, or the initial charge of a
1657 + * subscription.
1658 + *
1659 + * @param array<string, mixed> $payment Payment record (a `sureforms_payments` row).
1660 + * @param array<string, mixed> $context Resolved context: form_id, entry_id,
1661 + * user_id (0 for guests), customer_email,
1662 + * type, gateway, mode.
1663 + * @since 2.12.0
1664 + */
1665 + do_action( 'srfm_payment_completed', $payment, Payment_Helper::build_payment_context( $payment ) );
1666 + }
1667 +
1668 + /**
1495 1669 * Update payment entry with entry_id by subscription_id.
1496 1670 *
1497 1671 * Similar to update_payment_entry_id but looks up payment records by subscription_id
1498 1672 * instead of transaction_id. This is useful for subscription payments (PayPal, Stripe)
@@ -1514,9 +1688,15 @@
1514 1688 $payment_entry_id = intval( $payment_entries[0]['id'] );
1515 1689
1516 1690 // Update the payment entry with entry_id using Payments class.
1517 1691 $updated = Payments::update( $payment_entry_id, [ 'entry_id' => $entry_id ] );
1518 - return $updated ? true : false;
1692 +
1693 + if ( $updated ) {
1694 + $this->maybe_fire_payment_completed( $payment_entry_id );
1695 + return true;
1696 + }
1697 +
1698 + return false;
1519 1699 }
1520 1700
1521 1701 return false;
1522 1702 }