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 +154 -18 2.10.1 → 2.12.8 View file →
@@ -9,8 +9,9 @@
9 9 namespace SRFM\Inc\Payments;
10 10
11 11 use SRFM\Inc\Database\Tables\Payments;
12 12 use SRFM\Inc\Field_Validation;
13 +use SRFM\Inc\Helper;
13 14 use SRFM\Inc\Payments\Stripe\Stripe_Helper;
14 15 use SRFM\Inc\Submit_Token;
15 16 use SRFM\Inc\Traits\Get_Instance;
16 17
@@ -131,24 +132,26 @@
131 132 ]
132 133 );
133 134 }
134 135
135 - $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 );
136 138
137 139 // Create payment intent with confirm: true for immediate processing.
138 140 $payment_intent_data = [
139 - 'secret_key' => $secret_key,
140 - 'amount' => $amount,
141 - 'currency' => strtolower( $currency ),
142 - 'description' => $description,
143 - 'confirm' => false, // Will be confirmed by frontend.
144 - 'receipt_email' => $customer_email,
145 - 'license_key' => $license_key,
146 - 'automatic_payment_methods' => [
147 - 'enabled' => true,
148 - 'allow_redirects' => 'never',
149 - ],
150 - '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' => [
151 154 'source' => 'SureForms',
152 155 'block_id' => $block_id,
153 156 'original_amount' => $amount,
154 157 'receipt_email' => $customer_email,
@@ -356,9 +359,10 @@
356 359 if ( ! $customer_id ) {
357 360 throw new \Exception( __( 'Failed to create customer for subscription.', 'sureforms' ) );
358 361 }
359 362
360 - $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 );
361 365 // Prepare subscription data for middleware.
362 366 $subscription_data = apply_filters(
363 367 'srfm_create_subscription_data',
364 368 [
@@ -479,8 +483,20 @@
479 483 }
480 484
481 485 $payment_response = [];
482 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 +
483 499 // Loop through form data to find payment fields.
484 500 foreach ( $form_data as $field_name => $field_value ) {
485 501 // Check if field name contains "-lbl-" pattern.
486 502 if ( strpos( $field_name, '-lbl-' ) === false ) {
@@ -499,8 +515,10 @@
499 515 if ( ! ( strpos( $name_parts[0], 'srfm-payment-' ) === 0 ) ) {
500 516 continue;
501 517 }
502 518
519 + $payment_field_names[] = $field_name;
520 +
503 521 // Value will be in the form of the json string.
504 522 $payment_value = json_decode( $field_value, true );
505 523
506 524 if ( empty( $payment_value ) || ! is_array( $payment_value ) ) {
@@ -544,16 +562,28 @@
544 562
545 563 if ( ! empty( $payment_response ) && isset( $payment_response['payment_id'] ) ) {
546 564 // Modify the form data with the payment ID.
547 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;
548 570 }
549 571 }
550 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 +
551 581 if ( ! empty( $payment_response ) && isset( $payment_response['error'] ) ) {
552 - $form_data = array_merge( $form_data, $payment_response );
582 + return array_merge( $form_data, $payment_response );
553 583 }
554 584
555 - return $form_data;
585 + return $this->require_verified_payments( $form_data, $verified_block_ids );
556 586 }
557 587
558 588 /**
559 589 * Verify Stripe payment
@@ -797,8 +827,19 @@
797 827 $currency = isset( $paid_invoice['currency'] ) && ! empty( $paid_invoice['currency'] ) ? $paid_invoice['currency'] : 'usd';
798 828 $form_id = isset( $form_data['form-id'] ) && ! empty( $form_data['form-id'] ) ? $form_data['form-id'] : 0;
799 829 $subscription_status = isset( $subscription['status'] ) && ! empty( $subscription['status'] ) && is_string( $subscription['status'] ) ? $subscription['status'] : '';
800 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 +
801 842 $invoice_status = isset( $paid_invoice['status'] ) && ! empty( $paid_invoice['status'] ) && is_string( $paid_invoice['status'] ) ? $paid_invoice['status'] : '';
802 843
803 844 // Extract customer data.
804 845 $customer_data = $this->extract_customer_data( $subscription_value );
@@ -1121,8 +1162,44 @@
1121 1162 return $default_value;
1122 1163 }
1123 1164
1124 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 + /**
1125 1202 * Verify payment intent status
1126 1203 *
1127 1204 * @param array<mixed> $payment_value Payment value.
1128 1205 * @param string $payment_id Payment ID.
@@ -1224,8 +1301,18 @@
1224 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;
1225 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';
1226 1303 $confirm_payment_id = is_array( $confirmed_payment_intent ) && isset( $confirmed_payment_intent['id'] ) && ! empty( $confirmed_payment_intent['id'] ) ? (string) $confirmed_payment_intent['id'] : '';
1227 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 +
1228 1315 // Extract customer data.
1229 1316 $customer_data = $this->extract_customer_data( $payment_value );
1230 1317
1231 1318 // update payment status and save to the payment entries table.
@@ -1528,9 +1615,15 @@
1528 1615 $payment_entry_id = intval( $payment_entries[0]['id'] );
1529 1616
1530 1617 // Update the payment entry with entry_id using Payments class.
1531 1618 $updated = Payments::update( $payment_entry_id, [ 'entry_id' => $entry_id ] );
1532 - 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;
1533 1626 }
1534 1627
1535 1628 return false;
1536 1629 }
@@ -1535,8 +1628,45 @@
1535 1628 return false;
1536 1629 }
1537 1630
1538 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 + /**
1539 1669 * Update payment entry with entry_id by subscription_id.
1540 1670 *
1541 1671 * Similar to update_payment_entry_id but looks up payment records by subscription_id
1542 1672 * instead of transaction_id. This is useful for subscription payments (PayPal, Stripe)
@@ -1558,9 +1688,15 @@
1558 1688 $payment_entry_id = intval( $payment_entries[0]['id'] );
1559 1689
1560 1690 // Update the payment entry with entry_id using Payments class.
1561 1691 $updated = Payments::update( $payment_entry_id, [ 'entry_id' => $entry_id ] );
1562 - 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;
1563 1699 }
1564 1700
1565 1701 return false;
1566 1702 }