| @@ -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 |
| @@ -1132,8 +1162,44 @@ | ||
| 1132 | 1162 | return $default_value; |
| 1133 | 1163 | } |
| 1134 | 1164 | |
| 1135 | 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 | + /** | |
| 1136 | 1202 | * Verify payment intent status |
| 1137 | 1203 | * |
| 1138 | 1204 | * @param array<mixed> $payment_value Payment value. |
| 1139 | 1205 | * @param string $payment_id Payment ID. |
| @@ -1549,9 +1615,15 @@ | ||
| 1549 | 1615 | $payment_entry_id = intval( $payment_entries[0]['id'] ); |
| 1550 | 1616 | |
| 1551 | 1617 | // Update the payment entry with entry_id using Payments class. |
| 1552 | 1618 | $updated = Payments::update( $payment_entry_id, [ 'entry_id' => $entry_id ] ); |
| 1553 | - 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; | |
| 1554 | 1626 | } |
| 1555 | 1627 | |
| 1556 | 1628 | return false; |
| 1557 | 1629 | } |
| @@ -1556,8 +1628,45 @@ | ||
| 1556 | 1628 | return false; |
| 1557 | 1629 | } |
| 1558 | 1630 | |
| 1559 | 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 | + /** | |
| 1560 | 1669 | * Update payment entry with entry_id by subscription_id. |
| 1561 | 1670 | * |
| 1562 | 1671 | * Similar to update_payment_entry_id but looks up payment records by subscription_id |
| 1563 | 1672 | * instead of transaction_id. This is useful for subscription payments (PayPal, Stripe) |
| @@ -1579,9 +1688,15 @@ | ||
| 1579 | 1688 | $payment_entry_id = intval( $payment_entries[0]['id'] ); |
| 1580 | 1689 | |
| 1581 | 1690 | // Update the payment entry with entry_id using Payments class. |
| 1582 | 1691 | $updated = Payments::update( $payment_entry_id, [ 'entry_id' => $entry_id ] ); |
| 1583 | - 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; | |
| 1584 | 1699 | } |
| 1585 | 1700 | |
| 1586 | 1701 | return false; |
| 1587 | 1702 | } |