| @@ -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 | } |