PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
← All changes | includes/Classes/Handler.php +1019 -342 2.3.3 → 2.4.1 View file →
@@ -22,8 +22,18 @@
22 22 class Handler extends Controller{
23 23 use TraitsHelper;
24 24
25 25 /**
26 + * Status a Stripe Checkout Session order waits in until it is confirmed.
27 + */
28 + const STRIPE_PENDING_STATUS = 'unpaid';
29 +
30 + /**
31 + * Status a confirmed Checkout Session order is stored with. See confirm_stripe_session().
32 + */
33 + const STRIPE_CONFIRMED_STATUS = 'paid';
34 +
35 + /**
26 36 * PayPal button
27 37 *
28 38 * @since 0.0.1
29 39 */
@@ -145,15 +155,15 @@
145 155 }
146 156
147 157 /**
148 158 * Payment create in db
149 - *
159 + *
150 160 * @since 0.0.1
151 161 */
152 162 public static function payment_create( $data ) {
153 163 global $wpdb;
154 164 $table = "{$wpdb->prefix}better_payment";
155 - $wpdb->insert( $table, $data );
165 + $wpdb->insert( $table, self::normalize_pending_stripe_status( $data ) );
156 166 if ( $wpdb->insert_id ) {
157 167 return $wpdb->insert_id;
158 168 } else {
159 169 return false;
@@ -160,8 +170,59 @@
160 170 }
161 171 }
162 172
163 173 /**
174 + * A Stripe Checkout Session order is always created pending.
175 + *
176 + * Every Stripe surface stores the session's `payment_status` at creation. For a session with
177 + * nothing to charge right now — a subscription trial, a 100%-off promotion code, setup mode —
178 + * Stripe already reports `no_payment_required` before anyone has completed the checkout. Stored
179 + * as is, the order could never be confirmed (confirmation claims it from `unpaid`), so the trial
180 + * or free donation never reached the thank-you page, its receipts or the campaign's donors.
181 + * Only a Checkout Session row (`obj_id` `cs_…`) is touched: a renewal PaymentIntent row is
182 + * written after its charge and keeps its status.
183 + *
184 + * @since 2.4.0
185 + *
186 + * @param mixed $data Row data for payment_create().
187 + * @return mixed
188 + */
189 + public static function normalize_pending_stripe_status( $data ) {
190 + if ( ! is_array( $data ) || ! isset( $data['status'], $data['obj_id'] ) || ! is_string( $data['obj_id'] ) ) {
191 + return $data;
192 + }
193 +
194 + if ( 'no_payment_required' === $data['status'] && 0 === strpos( $data['obj_id'], 'cs_' ) ) {
195 + $data['status'] = self::STRIPE_PENDING_STATUS;
196 + }
197 +
198 + return $data;
199 + }
200 +
201 + /**
202 + * A new, unguessable order id: `{gateway}_{24 hex chars}`.
203 + *
204 + * The order id is the only key the public return pages and the PayPal status poll look
205 + * an order up by. uniqid() is the current time in microseconds, so ids created around a
206 + * known moment could be enumerated to read other visitors' order status and receipts.
207 + * 96 random bits cannot be. Fits the 50-char `order_id` column with room to spare.
208 + *
209 + * @since 2.3.4
210 + *
211 + * @param string $gateway 'paypal' | 'stripe' | 'paystack'.
212 + * @return string
213 + */
214 + public static function new_order_id( $gateway ) {
215 + try {
216 + $random = bin2hex( random_bytes( 12 ) );
217 + } catch ( \Exception $e ) {
218 + $random = strtolower( wp_generate_password( 24, false ) );
219 + }
220 +
221 + return sanitize_key( $gateway ) . '_' . $random;
222 + }
223 +
224 + /**
164 225 * Mamage response
165 226 *
166 227 * @since 0.0.1
167 228 */
@@ -183,23 +244,9 @@
183 244 } elseif ( !empty( $_REQUEST['better_payment_paypal_id'] ) ) {
184 245 $order_id = sanitize_text_field( $_REQUEST['better_payment_paypal_id'] );
185 246 }
186 247
187 - // Fetch transaction data from database
188 - $transaction_data = self::get_failed_transaction_data( $order_id );
189 -
190 - self::failed_message_template( $settings, $transaction_data );
191 - $redirection_url_error = ! empty( $settings['better_payment_form_error_page_url']['url'] ) ? esc_url( $settings['better_payment_form_error_page_url']['url'] ) : '';
192 -
193 - if( $redirection_url_error ){
194 - ?>
195 - <script>
196 - setTimeout(function(){
197 - window.location.replace("<?php echo esc_url( $redirection_url_error ); ?>");
198 - }, 2000);
199 - </script>
200 - <?php
201 - }
248 + self::render_failed_response( $settings, $order_id );
202 249
203 250 return $status;
204 251 }
205 252
@@ -210,9 +257,27 @@
210 257 } elseif ( !empty( $_REQUEST[ 'better_payment_paystack_status' ] ) && $_REQUEST[ 'better_payment_paystack_status' ] == 'success' ) {
211 258 $status = self::paystack_payment_success( $settings );
212 259 }
213 260
214 - if ( ! empty( $status['pending'] ) ) {
261 + if ( is_wp_error( $status ) ) {
262 + // A verification that confirmed no payment never renders the success page or
263 + // redirects to the success URL (a WP_Error is truthy, and used to do both).
264 + if ( 'bp_stripe_payment_processing' === $status->get_error_code() ) {
265 + self::stripe_processing_message_template();
266 +
267 + // The return URL keeps its query args, so reloading it verifies the order again.
268 + return $status;
269 + }
270 +
271 + if ( 'bp_stripe_payment_incomplete' === $status->get_error_code() ) {
272 + $stripe_order_id = ! empty( $_REQUEST['better_payment_stripe_id'] ) && is_scalar( $_REQUEST['better_payment_stripe_id'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['better_payment_stripe_id'] ) ) : '';
273 +
274 + self::render_failed_response( $settings, $stripe_order_id );
275 +
276 + // Like a cancelled checkout: the form renders again under the notice.
277 + $status = false;
278 + }
279 + } elseif ( ! empty( $status['pending'] ) ) {
215 280 // Use home_url() instead of HTTP_HOST to avoid open-redirect via forged Host headers.
216 281 $return_url = home_url( $_SERVER['REQUEST_URI'] );
217 282 // PayPal uses rm=2 (POST redirect), so item_number arrives in $_POST and is absent from the URL.
218 283 // Add it explicitly so the poll-complete redirect (a plain GET) still carries item_number.
@@ -305,14 +370,18 @@
305 370 if ( empty( $row ) ) {
306 371 return false;
307 372 }
308 373
309 - // IPN confirmed — return verified DB data.
374 + // IPN confirmed — return verified DB data. The payer's e-mail and the PayPal transaction
375 + // id only for an unguessable order id: before 2.4.0 ids were `paypal_` . uniqid(), which is
376 + // time-based and can be enumerated, so anyone could read them off the return page.
310 377 if ( $row->status === 'Completed' ) {
378 + $unguessable = self::paypal_order_id_is_unguessable( $item_number );
379 +
311 380 return [
312 381 'amount' => floatval( $row->amount ),
313 - 'email' => sanitize_email( $row->email ),
314 - 'transaction_id' => sanitize_text_field( $row->transaction_id ),
382 + 'email' => $unguessable ? sanitize_email( $row->email ) : '',
383 + 'transaction_id' => $unguessable ? sanitize_text_field( $row->transaction_id ) : '',
315 384 'currency' => sanitize_text_field( $row->currency ),
316 385 'method' => 'paypal',
317 386 ];
318 387 }
@@ -321,8 +390,34 @@
321 390 return [ 'pending' => true, 'order_id' => $item_number ];
322 391 }
323 392
324 393 /**
394 + * Whether a PayPal order id came from new_order_id() (24 random characters), not the
395 + * enumerable `paypal_` . uniqid() of releases before 2.4.0.
396 + *
397 + * @since 2.4.1
398 + *
399 + * @param mixed $order_id The order id.
400 + * @return bool
401 + */
402 + public static function paypal_order_id_is_unguessable( $order_id ) {
403 + return is_string( $order_id ) && 1 === preg_match( '/^paypal_[0-9a-z]{24}$/', $order_id );
404 + }
405 +
406 + /**
407 + * Whether an order id of any gateway came from new_order_id() (`{gateway}_` + 24 random
408 + * characters), not the enumerable `{gateway}_` . uniqid() of releases before 2.4.0.
409 + *
410 + * @since 2.4.1
411 + *
412 + * @param mixed $order_id The order id.
413 + * @return bool
414 + */
415 + public static function order_id_is_unguessable( $order_id ) {
416 + return is_string( $order_id ) && 1 === preg_match( '/^[a-z0-9-]+_[0-9a-z]{24}$/', $order_id );
417 + }
418 +
419 + /**
325 420 * AJAX: check whether a PayPal order has been confirmed by IPN.
326 421 *
327 422 * Called by the frontend polling loop after the user lands on the return URL
328 423 * before the IPN fires. Returns {status:'pending'} or {status:'completed'}.
@@ -362,10 +457,11 @@
362 457 /**
363 458 * PayPal IPN listener.
364 459 *
365 460 * PayPal POSTs the raw IPN body here server-to-server. We echo it back with
366 - * cmd=_notify-validate; only a VERIFIED response from PayPal's servers triggers
367 - * the DB write. The browser return URL (paypal_payment_success) never writes.
461 + * cmd=_notify-validate; only a VERIFIED response from PayPal's servers reaches
462 + * apply_paypal_ipn(), the only writer. The browser return URL
463 + * (paypal_payment_success) never writes.
368 464 *
369 465 * @since 2.1.3
370 466 */
371 467 public static function handle_paypal_ipn() {
@@ -399,13 +495,62 @@
399 495 status_header( 200 );
400 496 exit;
401 497 }
402 498
499 + self::apply_paypal_ipn( $data );
500 +
501 + status_header( 200 );
502 + exit;
503 + }
504 +
505 + /**
506 + * Whether a notification's PayPal environment matches the order's.
507 + *
508 + * Orders since 2.4.1 record `paypal_mode` (`live` | `sandbox`) when created, and must match
509 + * exactly. An older order has no record, so a live notification is accepted (a real payment
510 + * must never be stranded) and a sandbox one only while the site's PayPal setting is not live.
511 + *
512 + * @since 2.4.1
513 + *
514 + * @param mixed $form_info The order's `form_fields_info`, unserialized.
515 + * @param string $ipn_mode `live` or `sandbox` (from the notification's `test_ipn`).
516 + * @return bool
517 + */
518 + public static function paypal_ipn_mode_matches( $form_info, $ipn_mode ) {
519 + $order_mode = is_array( $form_info ) && isset( $form_info['paypal_mode'] ) ? $form_info['paypal_mode'] : '';
520 +
521 + if ( 'live' === $order_mode || 'sandbox' === $order_mode ) {
522 + return $order_mode === $ipn_mode;
523 + }
524 +
525 + if ( 'live' === $ipn_mode ) {
526 + return true;
527 + }
528 +
529 + return 'yes' !== DB::get_settings( 'better_payment_settings_payment_paypal_live_mode' );
530 + }
531 +
532 + /**
533 + * Apply a PayPal IPN to the order it names.
534 + *
535 + * Call ONLY with a notification handle_paypal_ipn() posted back and PayPal answered VERIFIED —
536 + * nothing here authenticates the data. Changes nothing unless the notification is a
537 + * `Completed` payment for a pending order (`status IS NULL`) paid to the merchant's address,
538 + * for at least the order's amount, in its currency, under a transaction id no order holds.
539 + * Then the row is claimed with one update (`WHERE id = … AND status IS NULL`): PayPal redelivers
540 + * a notification it saw no 200 for and can deliver one twice at once, and only the delivery that
541 + * wins the claim sends the receipts.
542 + *
543 + * @since 2.4.0
544 + *
545 + * @param array $data The notification's fields.
546 + * @return string 'confirmed' | 'ignored' | 'unmatched' | 'refused' | 'duplicate'
547 + */
548 + public static function apply_paypal_ipn( array $data ) {
403 549 $data = array_map( 'sanitize_text_field', $data );
404 550
405 551 if ( empty( $data['payment_status'] ) || empty( $data['item_number'] ) || $data['payment_status'] !== 'Completed' ) {
406 - status_header( 200 );
407 - exit;
552 + return 'ignored';
408 553 }
409 554
410 555 global $wpdb;
411 556 $table = "{$wpdb->prefix}better_payment";
@@ -416,10 +561,9 @@
416 561 )
417 562 );
418 563
419 564 if ( empty( $results->id ) ) {
420 - status_header( 200 );
421 - exit;
565 + return 'unmatched';
422 566 }
423 567
424 568 // Validate receiver_email matches the merchant's configured PayPal email.
425 569 $form_info = maybe_unserialize( $results->form_fields_info );
@@ -426,24 +570,29 @@
426 570 $expected_email = ! empty( $form_info['paypal_business_email'] ) ? strtolower( $form_info['paypal_business_email'] ) : '';
427 571 $receiver = ! empty( $data['receiver_email'] ) ? strtolower( $data['receiver_email'] ) : ( ! empty( $data['business'] ) ? strtolower( $data['business'] ) : '' );
428 572
429 573 if ( $expected_email && $receiver && $expected_email !== $receiver ) {
430 - status_header( 200 );
431 - exit;
574 + return 'refused';
432 575 }
433 576
577 + // A sandbox notification is VERIFIED by PayPal's sandbox, which pays in play money to an
578 + // account anyone can create under any address. It must never confirm a live order.
579 + $ipn_mode = ! empty( $data['test_ipn'] ) ? 'sandbox' : 'live';
580 +
581 + if ( ! self::paypal_ipn_mode_matches( $form_info, $ipn_mode ) ) {
582 + return 'refused';
583 + }
584 +
434 585 // Validate the paid amount is not less than the expected order amount.
435 586 $paid_amount = ! empty( $data['mc_gross'] ) ? floatval( $data['mc_gross'] ) : 0;
436 587 if ( $paid_amount < floatval( $results->amount ) ) {
437 - status_header( 200 );
438 - exit;
588 + return 'refused';
439 589 }
440 590
441 591 // Validate currency matches.
442 592 $paid_currency = ! empty( $data['mc_currency'] ) ? strtoupper( $data['mc_currency'] ) : '';
443 593 if ( $paid_currency && $results->currency && strtoupper( $results->currency ) !== $paid_currency ) {
444 - status_header( 200 );
445 - exit;
594 + return 'refused';
446 595 }
447 596
448 597 $txn_id = ! empty( $data['txn_id'] ) ? $data['txn_id'] : '';
449 598
@@ -455,16 +604,15 @@
455 604 $txn_id
456 605 )
457 606 );
458 607 if ( $existing ) {
459 - status_header( 200 );
460 - exit;
608 + return 'duplicate';
461 609 }
462 610 }
463 611
464 612 $payer_email = ! empty( $data['payer_email'] ) ? sanitize_email( $data['payer_email'] ) : '';
465 613
466 - $updated = $wpdb->update(
614 + $claimed = $wpdb->update(
467 615 $table,
468 616 [
469 617 'transaction_id' => $txn_id,
470 618 'status' => $data['payment_status'],
@@ -470,38 +618,43 @@
470 618 'status' => $data['payment_status'],
471 619 'email' => $payer_email,
472 620 'customer_info' => maybe_serialize( $data ),
473 621 ],
474 - [ 'ID' => $results->id ]
622 + [
623 + 'id' => (int) $results->id,
624 + 'status' => null,
625 + ]
475 626 );
476 627
477 - if ( false !== $updated ) {
478 - $is_elementor_form = ! empty( $results->referer ) && $results->referer === 'elementor-form' ? 1 : 0;
628 + if ( 1 !== (int) $claimed ) {
629 + // A concurrent delivery of this notification confirmed the order first.
630 + return 'duplicate';
631 + }
479 632
480 - // Rebuild the e-mail settings persisted at payment-create time. IPN runs
481 - // server-to-server with no live widget context, so without this the body
482 - // would collapse to the bare transaction id (every section flag defaults
483 - // to 0). Legacy / pending rows created before this fix fall back to [].
484 - $email_settings = ! empty( $form_info['email_settings'] ) && is_array( $form_info['email_settings'] )
485 - ? $form_info['email_settings']
486 - : [];
633 + $is_elementor_form = ! empty( $results->referer ) && $results->referer === 'elementor-form' ? 1 : 0;
487 634
488 - // Mirror the Stripe / Paystack gate: honour the form's "Send Email" toggle,
489 - // and always e-mail for Elementor Pro Form submissions.
490 - if (
491 - ( isset( $email_settings['better_payment_form_email_enable'] ) && 'yes' === $email_settings['better_payment_form_email_enable'] )
492 - || $is_elementor_form
493 - ) {
494 - self::better_email_notification( $txn_id, $payer_email, $email_settings, 'PayPal', $results->form_fields_info, $is_elementor_form );
495 - }
635 + // Rebuild the e-mail settings persisted at payment-create time. IPN runs
636 + // server-to-server with no live widget context, so without this the body
637 + // would collapse to the bare transaction id (every section flag defaults
638 + // to 0). Legacy / pending rows created before this fix fall back to [].
639 + $email_settings = ! empty( $form_info['email_settings'] ) && is_array( $form_info['email_settings'] )
640 + ? $form_info['email_settings']
641 + : [];
642 +
643 + // Mirror the Stripe / Paystack gate: honour the form's "Send Email" toggle,
644 + // and always e-mail for Elementor Pro Form submissions.
645 + if (
646 + ( isset( $email_settings['better_payment_form_email_enable'] ) && 'yes' === $email_settings['better_payment_form_email_enable'] )
647 + || $is_elementor_form
648 + ) {
649 + self::better_email_notification( $txn_id, $payer_email, $email_settings, 'PayPal', $results->form_fields_info, $is_elementor_form );
496 650 }
497 651
498 - status_header( 200 );
499 - exit;
652 + return 'confirmed';
500 653 }
501 654
502 655 /**
503 - * Stripe payment success
656 + * Stripe payment success — the return page.
504 657 *
505 658 * Reads the transaction id from the request, EXCEPT when the caller
506 659 * supplies `better_payment_stripe_id` in $settings. A caller that has
507 660 * already authorized the id against server-side state (the WooCommerce
@@ -509,9 +662,17 @@
509 662 * pass it explicitly: a caller-side guard that reads $_GET cannot bind
510 663 * this lookup, because PHP's default request_order=GP lets a POST body
511 664 * overwrite the query string and the two would then see different ids.
512 665 *
666 + * A pending order is verified against Stripe and confirmed through
667 + * confirm_stripe_session(). An order an earlier return (a reload, a second
668 + * tab) already confirmed renders the success page again without asking
669 + * Stripe or re-running anything. See stripe_settled_return().
670 + *
513 671 * @since 0.0.1
672 + *
673 + * @param array $settings Form settings; `better_payment_stripe_secret_key` is required to verify.
674 + * @return array|false|\WP_Error Success data, false for no order to show, or why nothing was confirmed.
514 675 */
515 676 public static function stripe_payment_success( $settings = [] ) {
516 677
517 678 $data = $_REQUEST;
@@ -519,236 +680,733 @@
519 680 if ( ! empty( $settings[ 'better_payment_stripe_id' ] ) ) {
520 681 $data[ 'better_payment_stripe_id' ] = $settings[ 'better_payment_stripe_id' ];
521 682 }
522 683
523 - if ( !empty( $data[ 'better_payment_stripe_id' ] ) ) {
524 - global $wpdb;
525 - $table = "{$wpdb->prefix}better_payment";
526 - $results = $wpdb->get_row(
527 - $wpdb->prepare( "SELECT id, obj_id, amount, transaction_id, currency, form_fields_info, referer FROM $table WHERE order_id=%s and status = 'unpaid' limit 1", sanitize_text_field( $data[ 'better_payment_stripe_id' ] ) )
528 - );
529 -
530 - if ( !empty( $results->obj_id ) && !empty( $settings[ 'better_payment_stripe_secret_key' ] ) ) {
531 - $header = array(
684 + if ( empty( $data['better_payment_stripe_id'] ) || ! is_scalar( $data['better_payment_stripe_id'] ) ) {
685 + return false;
686 + }
687 +
688 + $order_id = sanitize_text_field( (string) $data['better_payment_stripe_id'] );
689 + $row = self::get_stripe_row( 'order_id', $order_id );
690 +
691 + if ( empty( $row->id ) ) {
692 + return false;
693 + }
694 +
695 + if ( self::STRIPE_PENDING_STATUS !== $row->status ) {
696 + return self::stripe_settled_return( $row, $order_id );
697 + }
698 +
699 + if ( empty( $row->obj_id ) || empty( $settings[ 'better_payment_stripe_secret_key' ] ) ) {
700 + return false;
701 + }
702 +
703 + // Subscription and split-payment orders are Pro's: Pro (or, for an older Pro, Lite's
704 + // transitional copy) adds the subscription expansion and the recurring / split flags.
705 + $verification = self::stripe_verification_for( $row );
706 +
707 + $response = wp_safe_remote_post(
708 + 'https://api.stripe.com/v1/checkout/sessions/' . rawurlencode( (string) $row->obj_id ),
709 + array(
710 + 'method' => 'GET',
711 + 'headers' => array(
532 712 'Authorization' => 'Basic ' . base64_encode( sanitize_text_field( $settings[ 'better_payment_stripe_secret_key' ] ) . ':' ),
533 713 'Stripe-Version' => '2019-05-16',
534 - );
714 + ),
715 + 'timeout' => 70,
716 + 'body' => array(
717 + 'expand' => $verification['expand'],
718 + ),
719 + )
720 + );
535 721
536 - $request = [
537 - 'expand' => [
538 - 'subscription.latest_invoice.payment_intent',
539 - 'payment_intent'
540 - ]
541 - ];
542 - $form_fields_info = maybe_unserialize($results->form_fields_info);
543 - $is_payment_recurring = ! empty( $form_fields_info['mode'] ) && 'subscription' === $form_fields_info['mode'];
544 - $is_payment_split_payment = ( $is_payment_recurring ) && ( ! empty( $form_fields_info['is_payment_split_payment'] ) && 1 === intval( $form_fields_info['is_payment_split_payment'] ) );
545 -
546 - $action_data = [
547 - 'form_fields_info' => $form_fields_info,
548 - 'is_payment_recurring' => $is_payment_recurring,
549 - 'is_payment_split_payment' => $is_payment_split_payment,
550 - 'checkout_session_id' => $results->obj_id,
551 - ];
722 + if ( is_wp_error( $response ) || empty( $response[ 'body' ] ) ) {
723 + return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
724 + }
552 725
553 - $response = wp_safe_remote_post(
554 - 'https://api.stripe.com/v1/checkout/sessions/' . $results->obj_id,
555 - array(
556 - 'method' => 'GET',
557 - 'headers' => $header,
558 - 'timeout' => 70,
559 - 'body' => $request
560 - )
561 - );
726 + $session = json_decode( $response[ 'body' ] );
562 727
563 - if ( is_wp_error( $response ) || empty( $response[ 'body' ] ) ) {
564 - return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
565 - }
728 + if ( ! is_object( $session ) || isset( $session->error ) ) {
729 + return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
730 + }
566 731
567 - $response = json_decode( $response[ 'body' ] );
568 - $transaction_id = ! empty( $results->transaction_id ) ? sanitize_text_field( $results->transaction_id ) : '';
569 -
570 - if ( $is_payment_recurring && ! empty( $response->subscription->id ) ) {
571 - $transaction_id = sanitize_text_field( $response->subscription->id );
572 - $subscription_obj = $response->subscription;
573 - $form_fields_info['subscription_id'] = sanitize_text_field( $transaction_id );
574 - $form_fields_info['subscription_customer_id'] = ! empty( $response->customer ) ? sanitize_text_field( $response->customer ) : '';
575 - $form_fields_info['subscription_plan_id'] = ! empty( $subscription_obj->items->data[0]->plan->id ) ? sanitize_text_field( $subscription_obj->items->data[0]->plan->id ) : '';
576 - $form_fields_info['subscription_interval'] = ! empty( $subscription_obj->items->data[0]->plan->interval_count ) ? intval( $subscription_obj->items->data[0]->plan->interval_count ) : '';
577 - $form_fields_info['subscription_interval'] .= ! empty( $subscription_obj->items->data[0]->plan->interval ) ? ' ' . sanitize_text_field( $subscription_obj->items->data[0]->plan->interval ) : $form_fields_info['subscription_interval'];
578 - $form_fields_info['subscription_current_period_start'] = ! empty( $subscription_obj->current_period_start ) ? intval( $subscription_obj->current_period_start ) : 0;
579 - $form_fields_info['subscription_current_period_end'] = ! empty( $subscription_obj->current_period_end ) ? intval( $subscription_obj->current_period_end ) : 0;
580 - $form_fields_info['subscription_status'] = sanitize_text_field( $response->status );
581 - $form_fields_info['subscription_created_date'] = sanitize_text_field( $subscription_obj->created );
582 - }
732 + return self::confirm_stripe_session( $row, $session, $settings, $verification );
733 + }
583 734
584 - if ( isset( $response->error->message ) ) {
585 - return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
586 - }
735 + /**
736 + * Confirm a pending Stripe order from its verified Checkout Session.
737 + *
738 + * The one place a Stripe payment is confirmed: the return page. In order:
739 + *
740 + * 1. The session must be this row's (when it carries an id), must not be a setup-mode session
741 + * (the WooCommerce free-trial checkout, which OrderHandler confirms by its SetupIntent), and
742 + * must be paid (stripe_session_is_paid()). Nothing below runs for a session that is not: an
743 + * abandoned checkout, or a completed one whose delayed payment has not cleared (a later
744 + * return confirms it once it has).
745 + * 2. Pro's verified-session step, the customer e-mail and the charged amount are computed. The
746 + * amount is `amount_total / 100` whenever Stripe reports one — 0 for a 100%-off promotion
747 + * code or a trial — and the form's amount is kept as `original_amount` when they differ.
748 + * Pro's step must stay free of side effects: a request that loses the claim has asked it too.
749 + * 3. The row is claimed with ONE update, `WHERE id = … AND status = 'unpaid'`, which writes the
750 + * confirmed status, amount, e-mail, session and Pro's details together. However many returns
751 + * race for a session (a reload, a second tab), exactly one update matches.
752 + * 4. Only that winner fires `better_payment/stripe_payment/success` and
753 + * `better_payment/payment_confirmed` and sends the receipts. A caller that lost the claim to a
754 + * confirmation gets the same success data back, so the visitor still sees the thank-you page.
755 + *
756 + * The confirmed status is always `paid`, including for a session Stripe reports as
757 + * `no_payment_required`: campaign totals and donor counts, Pro's Donors Wall and the WooCommerce
758 + * bridge count `paid`, and a $0 donation is still a donation. Stripe's own value stays in
759 + * `customer_info`.
760 + *
761 + * @since 2.4.0
762 + *
763 + * @param object|null $row Pending row from get_stripe_row() (null is refused).
764 + * @param mixed $session Decoded Checkout Session, with $verification['expand'] expanded where Pro needs it (anything but an object is refused).
765 + * @param array|null $settings Form settings for the receipts; null or [] for none.
766 + * @param array|null $verification stripe_verification_for( $row ); computed when null.
767 + * @return array|\WP_Error Success data for the thank-you page, or why nothing was confirmed.
768 + */
769 + public static function confirm_stripe_session( $row, $session, $settings = null, $verification = null ) {
770 + global $wpdb;
587 771
588 - $customer_email = $is_payment_recurring && ( ! empty( $response->customer_email ) ) ? sanitize_email( $response->customer_email ) : '';
589 - $customer_email_optional = '';
590 - if (
591 - isset( $response->payment_intent ) &&
592 - isset( $response->payment_intent->charges ) &&
593 - isset( $response->payment_intent->charges->data ) &&
594 - ! empty( $response->payment_intent->charges->data )
595 - ) {
596 - $charge = current( $response->payment_intent->charges->data );
597 -
598 - if (
599 - isset( $charge->billing_details ) &&
600 - isset( $charge->billing_details->email ) &&
601 - ! empty( $charge->billing_details->email )
602 - ) {
603 - $customer_email_optional = sanitize_email( $charge->billing_details->email );
604 - }
605 - }
772 + $table = "{$wpdb->prefix}better_payment";
773 + $settings = is_array( $settings ) ? $settings : [];
606 774
607 - $form_fields_info['is_coupon_applied'] = 0;
608 - $exact_paid_amount = ! empty( $response->amount_total ) ? floatval( $response->amount_total ) : $results->amount * 100;
609 - $exact_paid_amount = $exact_paid_amount / 100;
610 - $form_fields_info['exact_paid_amount'] = $exact_paid_amount;
611 - if( (int)$exact_paid_amount != (int)$results->amount ) {
612 - $form_fields_info['paid_amount_diff'] = $results->amount - $exact_paid_amount;
613 - $form_fields_info['is_coupon_applied'] = 1;
614 - }
615 -
616 - $updated = $wpdb->update(
617 - $table,
618 - array(
619 - 'amount' => $exact_paid_amount,
620 - 'status' => sanitize_text_field( $response->payment_status ),
621 - 'email' => $is_payment_recurring ? $customer_email : $customer_email_optional,
622 - 'customer_info' => maybe_serialize( $response ),
623 - 'form_fields_info' => maybe_serialize( $form_fields_info ),
624 - 'transaction_id' => $transaction_id,
625 - ),
626 - array( 'ID' => $results->id )
627 - );
775 + if ( ! is_object( $row ) || empty( $row->id ) || ! is_object( $session ) ) {
776 + return new \WP_Error( 'bp_stripe_confirm_invalid', __( 'There is no Stripe payment to confirm.', 'better-payment' ) );
777 + }
628 778
629 - do_action('better_payment/stripe_payment/success', $action_data);
630 - do_action( 'better_payment/payment_confirmed', (int) $results->id );
779 + if ( isset( $session->id ) && ( empty( $row->obj_id ) || (string) $session->id !== (string) $row->obj_id ) ) {
780 + return new \WP_Error( 'bp_stripe_session_mismatch', __( 'Stripe did not confirm a payment for this checkout.', 'better-payment' ) );
781 + }
631 782
632 - $frontend_data = [
633 - 'amount' => $exact_paid_amount,
634 - 'email' => $is_payment_recurring ? $customer_email : $customer_email_optional,
635 - 'transaction_id' => $transaction_id,
636 - 'currency' => ! empty( $results->currency ) ? $results->currency : __( 'USD', 'better-payment' ),
637 - 'method' => 'stripe',
638 - 'is_payment_recurring' => $is_payment_recurring,
639 - 'is_payment_split_payment' => $is_payment_split_payment,
640 - 'is_payment_mixed' => ! empty( $form_fields_info['is_payment_mixed'] ) ? intval( $form_fields_info['is_payment_mixed'] ) : 0,
641 - 'mixed_payment_selected_type' => ! empty( $form_fields_info['mixed_payment_selected_type'] ) ? sanitize_text_field( $form_fields_info['mixed_payment_selected_type'] ) : '',
642 - ];
783 + if ( isset( $session->mode ) && 'setup' === $session->mode ) {
784 + return new \WP_Error( 'bp_stripe_setup_session', __( 'This checkout saves a payment method and is confirmed by its order.', 'better-payment' ) );
785 + }
643 786
644 - if ( $is_payment_split_payment ) {
645 - $total_amount = ! empty( $form_fields_info['split_payment_total_amount'] )
646 - ? sanitize_text_field( $form_fields_info['split_payment_total_amount'] )
647 - : 0;
648 - $total_installment = ! empty( $form_fields_info['split_payment_installment_iteration'] )
649 - ? sanitize_text_field( $form_fields_info['split_payment_installment_iteration'] )
650 - : 1;
787 + $stored_fields = isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
788 + $form_fields_info = is_array( $stored_fields ) ? $stored_fields : [];
651 789
652 - // $frontend_data['amount'] = floatval( $total_amount / $total_installment );
653 - $frontend_data['total_amount'] = $total_amount;
654 - }
790 + if ( ! is_array( $verification ) || ! isset( $verification['expand'] ) ) {
791 + $verification = self::stripe_verification_for( $row );
792 + }
655 793
656 - if ( false !== $updated ) {
657 - //Send email notification
658 - $better_customer_email = $is_payment_recurring ? $customer_email : $customer_email_optional;
659 -
660 - if (
661 - (isset($settings[ 'better_payment_form_email_enable' ]) && $settings[ 'better_payment_form_email_enable' ] == 'yes' )
662 - || ( $results->referer === 'elementor-form' )
663 - ) {
664 - $is_elementor_form = ! empty( $results->referer ) && $results->referer === 'elementor-form' ? 1 : 0;
665 - self::better_email_notification($transaction_id, $better_customer_email, $settings, 'Stripe', $results->form_fields_info, $is_elementor_form);
666 - }
794 + // Stripe returns the session whether or not anyone paid for it. Nothing below —
795 + // Pro's verified-session step, the row update, the confirmation hooks, the e-mails,
796 + // the success page — may run for a session that is not complete and paid. The row
797 + // stays 'unpaid', so a later return can still confirm it.
798 + if ( ! self::stripe_session_is_paid( $session ) ) {
799 + return self::stripe_unpaid_session_error( $session );
800 + }
667 801
668 - return $frontend_data;
669 - }
802 + $action_data = [
803 + 'form_fields_info' => $form_fields_info,
804 + 'is_payment_recurring' => $verification['is_payment_recurring'],
805 + 'is_payment_split_payment' => $verification['is_payment_split_payment'],
806 + 'checkout_session_id' => $row->obj_id,
807 + ];
808 +
809 + // A subscription's id, customer e-mail and details, and the thank-you page's
810 + // recurring / split flags, are Pro's to add.
811 + $verified = PaymentEntitlement::stripe_verified_session(
812 + [
813 + 'form_fields_info' => $form_fields_info,
814 + 'transaction_id' => ! empty( $row->transaction_id ) ? sanitize_text_field( $row->transaction_id ) : '',
815 + 'email' => self::stripe_session_customer_email( $session ),
816 + 'frontend_data' => [],
817 + ],
818 + $session,
819 + $verification
820 + );
821 +
822 + $confirmed_fields = is_array( $verified['form_fields_info'] ) ? $verified['form_fields_info'] : $form_fields_info;
823 + $transaction_id = $verified['transaction_id'];
824 + $customer_email = $verified['email'];
825 + $amounts = self::stripe_paid_amounts( $session, isset( $row->amount ) ? $row->amount : 0, $confirmed_fields );
826 + $paid_amount = $amounts['amount'];
827 + $confirmed_fields = $amounts['form_fields_info'];
828 +
829 + $claimed = $wpdb->update(
830 + $table,
831 + array(
832 + 'amount' => $paid_amount,
833 + 'status' => self::STRIPE_CONFIRMED_STATUS,
834 + 'email' => $customer_email,
835 + 'customer_info' => maybe_serialize( $session ),
836 + 'form_fields_info' => maybe_serialize( $confirmed_fields ),
837 + 'transaction_id' => $transaction_id,
838 + ),
839 + array(
840 + 'id' => (int) $row->id,
841 + 'status' => self::STRIPE_PENDING_STATUS,
842 + )
843 + );
844 +
845 + if ( 1 !== (int) $claimed ) {
846 + // Another return (a reload, a second tab) got here first (or the write failed).
847 + // Whatever it recorded is the outcome; nothing is fired or sent twice.
848 + $current = self::get_stripe_row( 'id', (int) $row->id );
849 +
850 + if ( ! empty( $current->id ) && self::STRIPE_CONFIRMED_STATUS === $current->status ) {
851 + return self::stripe_frontend_data( $current, $verified['frontend_data'] );
670 852 }
853 +
854 + return new \WP_Error( 'bp_stripe_confirm_failed', __( 'The payment could not be recorded. Please reload this page.', 'better-payment' ) );
671 855 }
672 - return false;
856 +
857 + do_action('better_payment/stripe_payment/success', $action_data);
858 + do_action( 'better_payment/payment_confirmed', (int) $row->id );
859 +
860 + $frontend_data = [
861 + 'amount' => $paid_amount,
862 + 'email' => $customer_email,
863 + 'transaction_id' => $transaction_id,
864 + 'currency' => ! empty( $row->currency ) ? $row->currency : __( 'USD', 'better-payment' ),
865 + 'method' => 'stripe',
866 + ] + $verified['frontend_data'];
867 +
868 + //Send email notification
869 + if (
870 + ( isset( $settings[ 'better_payment_form_email_enable' ] ) && 'yes' === $settings[ 'better_payment_form_email_enable' ] )
871 + || ( isset( $row->referer ) && 'elementor-form' === $row->referer )
872 + ) {
873 + $is_elementor_form = isset( $row->referer ) && 'elementor-form' === $row->referer ? 1 : 0;
874 +
875 + self::better_email_notification( $transaction_id, $customer_email, $settings, 'Stripe', $row->form_fields_info, $is_elementor_form );
876 + }
877 +
878 + return $frontend_data;
673 879 }
674 -
880 +
675 881 /**
882 + * A Stripe transaction row, looked up by `id`, `order_id` or `obj_id` (the Checkout Session id).
883 + *
884 + * @since 2.4.0
885 + *
886 + * @param string $field 'id' | 'order_id' | 'obj_id'.
887 + * @param mixed $value Value to match; anything but a non-empty scalar matches nothing.
888 + * @return object|null
889 + */
890 + public static function get_stripe_row( $field, $value ) {
891 + global $wpdb;
892 +
893 + if ( ! in_array( $field, [ 'id', 'order_id', 'obj_id' ], true ) || ! is_scalar( $value ) || '' === (string) $value ) {
894 + return null;
895 + }
896 +
897 + $table = "{$wpdb->prefix}better_payment";
898 + $format = 'id' === $field ? '%d' : '%s';
899 +
900 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from $wpdb->prefix, column and format from the whitelist above.
901 + $row = $wpdb->get_row( $wpdb->prepare( "SELECT id, order_id, obj_id, amount, transaction_id, currency, email, status, source, referer, form_fields_info, payment_date FROM $table WHERE $field = $format LIMIT 1", $value ) );
902 +
903 + return is_object( $row ) ? $row : null;
904 + }
905 +
906 + /**
907 + * How a row's Checkout Session is retrieved and whether it is a subscription — Pro's answer.
908 + *
909 + * @since 2.4.0
910 + *
911 + * @param object|null $row Transaction row; null reads as a row with no stored fields.
912 + * @return array `expand`, `is_payment_recurring`, `is_payment_split_payment`.
913 + */
914 + public static function stripe_verification_for( $row ) {
915 + $form_fields_info = is_object( $row ) && isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
916 +
917 + return PaymentEntitlement::stripe_verification_args(
918 + [
919 + 'expand' => [ 'payment_intent' ],
920 + 'is_payment_recurring' => false,
921 + 'is_payment_split_payment' => false,
922 + ],
923 + is_array( $form_fields_info ) ? $form_fields_info : []
924 + );
925 + }
926 +
927 + /**
928 + * What a verified Checkout Session charged, and what the transaction's fields record about it.
929 + *
930 + * The charged amount is `amount_total / 100` whenever Stripe reports a numeric total — 0
931 + * included: a 100%-off promotion code or a trial charges nothing, and a row claiming otherwise
932 + * would add money nobody paid to a campaign's total. It used to fall back to the form's amount
933 + * whenever the total was 0. Only a session with no total at all keeps the row's amount.
934 + *
935 + * `exact_paid_amount` is always recorded. When the charge differs from the amount the order was
936 + * created for, that amount is kept as `original_amount`; `is_coupon_applied` and
937 + * `paid_amount_diff` (read by the transaction details and the receipt) are set only when a
938 + * discount explains the difference — a trial is not a coupon.
939 + *
940 + * @since 2.4.0
941 + *
942 + * @param mixed $session Decoded Checkout Session.
943 + * @param mixed $row_amount The row's amount (what the order was created for).
944 + * @param array $form_fields_info The fields to record into.
945 + * @return array `amount` (float) and `form_fields_info`.
946 + */
947 + public static function stripe_paid_amounts( $session, $row_amount, array $form_fields_info ) {
948 + $original = is_numeric( $row_amount ) ? (float) $row_amount : 0.0;
949 + $total = is_object( $session ) && isset( $session->amount_total ) && is_numeric( $session->amount_total ) ? (float) $session->amount_total : null;
950 + $paid = null === $total ? $original : $total / 100;
951 +
952 + $form_fields_info['is_coupon_applied'] = 0;
953 + $form_fields_info['exact_paid_amount'] = $paid;
954 + unset( $form_fields_info['original_amount'], $form_fields_info['paid_amount_diff'] );
955 +
956 + if ( (int) round( $paid * 100 ) !== (int) round( $original * 100 ) ) {
957 + $form_fields_info['original_amount'] = $original;
958 +
959 + $discounted = ( is_object( $session ) && isset( $session->total_details->amount_discount ) && is_numeric( $session->total_details->amount_discount ) && (float) $session->total_details->amount_discount > 0 )
960 + || ( isset( $form_fields_info['stripe_coupon_code'] ) && is_scalar( $form_fields_info['stripe_coupon_code'] ) && '' !== trim( (string) $form_fields_info['stripe_coupon_code'] ) );
961 +
962 + if ( $discounted ) {
963 + $form_fields_info['paid_amount_diff'] = $original - $paid;
964 + $form_fields_info['is_coupon_applied'] = 1;
965 + }
966 + }
967 +
968 + return [
969 + 'amount' => $paid,
970 + 'form_fields_info' => $form_fields_info,
971 + ];
972 + }
973 +
974 + /**
975 + * The customer's e-mail on a Checkout Session: the charge's billing e-mail when the PaymentIntent
976 + * is expanded, else the session's `customer_details` / `customer_email`.
977 + *
978 + * @since 2.4.0
979 + *
980 + * @param mixed $session Decoded Checkout Session.
981 + * @return string
982 + */
983 + public static function stripe_session_customer_email( $session ) {
984 + if ( ! is_object( $session ) ) {
985 + return '';
986 + }
987 +
988 + if ( isset( $session->payment_intent->charges->data ) && is_array( $session->payment_intent->charges->data ) && ! empty( $session->payment_intent->charges->data ) ) {
989 + $charge = current( $session->payment_intent->charges->data );
990 +
991 + if ( isset( $charge->billing_details->email ) && is_string( $charge->billing_details->email ) && '' !== $charge->billing_details->email ) {
992 + return sanitize_email( $charge->billing_details->email );
993 + }
994 + }
995 +
996 + if ( isset( $session->customer_details->email ) && is_string( $session->customer_details->email ) && '' !== $session->customer_details->email ) {
997 + return sanitize_email( $session->customer_details->email );
998 + }
999 +
1000 + if ( isset( $session->customer_email ) && is_string( $session->customer_email ) ) {
1001 + return sanitize_email( $session->customer_email );
1002 + }
1003 +
1004 + return '';
1005 + }
1006 +
1007 + /**
1008 + * Whether an order id is one Handler::new_order_id() minted for Stripe.
1009 + *
1010 + * A return for an order that is already confirmed shows its receipt from the row, by id alone.
1011 + * Orders from before 2.3.4 carry a time-based id that could be enumerated, so only an
1012 + * unguessable one gets that page.
1013 + *
1014 + * @since 2.4.0
1015 + *
1016 + * @param mixed $order_id Order id.
1017 + * @return bool
1018 + */
1019 + public static function stripe_order_id_is_unguessable( $order_id ) {
1020 + return is_string( $order_id ) && 1 === preg_match( '/^stripe_[0-9a-f]{24}$/', $order_id );
1021 + }
1022 +
1023 + /**
1024 + * The return page for an order that is no longer pending.
1025 + *
1026 + * - Confirmed (`paid`): the success data, rebuilt from the row — no Stripe call, no hook, no
1027 + * e-mail — with Pro's thank-you page flags. Only for an unguessable order id.
1028 + * - Anything else (refunded, an older release's statuses): nothing, as before.
1029 + *
1030 + * @param object $row The row.
1031 + * @param string $order_id The order id the return names.
1032 + * @return array|false
1033 + */
1034 + private static function stripe_settled_return( $row, $order_id ) {
1035 + if ( self::STRIPE_CONFIRMED_STATUS !== $row->status || ! self::stripe_order_id_is_unguessable( $order_id ) ) {
1036 + return false;
1037 + }
1038 +
1039 + $form_fields_info = isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
1040 +
1041 + // Only the thank-you page flags are used, and Pro derives them from the stored fields.
1042 + $details = PaymentEntitlement::stripe_verified_session(
1043 + [
1044 + 'form_fields_info' => is_array( $form_fields_info ) ? $form_fields_info : [],
1045 + 'transaction_id' => (string) $row->transaction_id,
1046 + 'email' => (string) $row->email,
1047 + 'frontend_data' => [],
1048 + ],
1049 + new \stdClass(),
1050 + self::stripe_verification_for( $row )
1051 + );
1052 +
1053 + return self::stripe_frontend_data( $row, $details['frontend_data'] );
1054 + }
1055 +
1056 + /**
1057 + * Success data for a confirmed Stripe row.
1058 + *
1059 + * @param object $row Confirmed row.
1060 + * @param array $extra Pro's thank-you page flags.
1061 + * @return array
1062 + */
1063 + private static function stripe_frontend_data( $row, array $extra = [] ) {
1064 + return [
1065 + 'amount' => (float) $row->amount,
1066 + 'email' => sanitize_email( (string) $row->email ),
1067 + 'transaction_id' => sanitize_text_field( (string) $row->transaction_id ),
1068 + 'currency' => ! empty( $row->currency ) ? sanitize_text_field( $row->currency ) : __( 'USD', 'better-payment' ),
1069 + 'method' => 'stripe',
1070 + ] + $extra;
1071 + }
1072 +
1073 + /**
1074 + * Whether a retrieved Stripe Checkout Session has really been paid for.
1075 + *
1076 + * Retrieving a session proves only that it exists. A visitor can come back to the return
1077 + * URL from a checkout they abandoned (`status` is `open`, or `expired`), and a completed
1078 + * checkout paid with a delayed method (bank debits, vouchers) is `complete` with
1079 + * `payment_status: unpaid` until the money arrives — sometimes days later, sometimes never.
1080 + * Both used to confirm the order: the row, `better_payment/payment_confirmed`, Pro's
1081 + * payment types, the receipts and the success page.
1082 + *
1083 + * Paid means `payment_status` is `paid` (the funds are there), or `no_payment_required`
1084 + * (a 100%-off promotion code, a subscription trial) on a `complete` session: Stripe also
1085 + * reports `no_payment_required` for such a session before it is completed. A present
1086 + * `status` other than `complete` is never paid. A missing `status` is tolerated only for
1087 + * `paid` — requests pin `Stripe-Version: 2019-05-16`, and `paid` is only ever reported for
1088 + * a completed checkout — so a response without the field cannot stall every payment.
1089 + * Anything else missing or unrecognised is unpaid.
1090 + *
1091 + * @since 2.4.0
1092 + *
1093 + * @param mixed $session Decoded Checkout Session.
1094 + * @return bool
1095 + */
1096 + public static function stripe_session_is_paid( $session ) {
1097 + if ( ! is_object( $session ) ) {
1098 + return false;
1099 + }
1100 +
1101 + $status = isset( $session->status ) && is_string( $session->status ) ? $session->status : '';
1102 + $payment_status = isset( $session->payment_status ) && is_string( $session->payment_status ) ? $session->payment_status : '';
1103 +
1104 + if ( 'paid' === $payment_status ) {
1105 + return '' === $status || 'complete' === $status;
1106 + }
1107 +
1108 + return 'no_payment_required' === $payment_status && 'complete' === $status;
1109 + }
1110 +
1111 + /**
1112 + * Why a Stripe return did not confirm its order.
1113 + *
1114 + * `bp_stripe_payment_processing`: the checkout was completed but Stripe has not been paid
1115 + * yet (a delayed payment method). `bp_stripe_payment_incomplete`: anything else — the
1116 + * checkout was never completed. The message is shown in WooCommerce order notes.
1117 + *
1118 + * @since 2.4.0
1119 + *
1120 + * @param mixed $session Decoded Checkout Session.
1121 + * @return \WP_Error
1122 + */
1123 + public static function stripe_unpaid_session_error( $session ) {
1124 + $status = is_object( $session ) && isset( $session->status ) && is_string( $session->status ) ? $session->status : '';
1125 + $payment_status = is_object( $session ) && isset( $session->payment_status ) && is_string( $session->payment_status ) ? $session->payment_status : '';
1126 + $data = [
1127 + 'status' => sanitize_text_field( $status ),
1128 + 'payment_status' => sanitize_text_field( $payment_status ),
1129 + ];
1130 +
1131 + if ( 'complete' === $status && 'unpaid' === $payment_status ) {
1132 + return new \WP_Error( 'bp_stripe_payment_processing', __( 'Stripe has not confirmed this payment yet. It is confirmed once the payment clears.', 'better-payment' ), $data );
1133 + }
1134 +
1135 + return new \WP_Error( 'bp_stripe_payment_incomplete', __( 'Stripe did not confirm a payment for this checkout.', 'better-payment' ), $data );
1136 + }
1137 +
1138 + /**
1139 + * The return page for a completed Stripe checkout whose payment has not cleared yet.
1140 + *
1141 + * Deliberately neither the success page (nothing has been paid) nor the failure page (the
1142 + * payment may still succeed).
1143 + *
1144 + * @since 2.4.0
1145 + *
1146 + * @return void
1147 + */
1148 + private static function stripe_processing_message_template() {
1149 + ?>
1150 + <section class="bp-thank_page">
1151 + <div class="bp-thank_page-wrapper bp-stripe-processing">
1152 + <div class="bp-thank_page-text">
1153 + <h2 class="bp-font_ibm bp-page_header"><?php esc_html_e( 'Your payment is processing', 'better-payment' ); ?></h2>
1154 + <p class="bp-font_ibm bp-payment_info"><?php esc_html_e( 'Stripe has not confirmed your payment yet. Some payment methods take a few days to clear. You do not need to pay again; reload this page later to see your confirmation.', 'better-payment' ); ?></p>
1155 + </div>
1156 + </div>
1157 + </section>
1158 + <?php
1159 + }
1160 +
1161 + /**
1162 + * The failed-payment page, with the optional redirect to the form's error page.
1163 + *
1164 + * @param array $settings Form settings.
1165 + * @param string $order_id The order the return is for.
1166 + * @return void
1167 + */
1168 + private static function render_failed_response( $settings, $order_id ) {
1169 + self::failed_message_template( $settings, self::get_failed_transaction_data( $order_id ) );
1170 +
1171 + $redirection_url_error = ! empty( $settings['better_payment_form_error_page_url']['url'] ) ? esc_url( $settings['better_payment_form_error_page_url']['url'] ) : '';
1172 +
1173 + if ( $redirection_url_error ) {
1174 + ?>
1175 + <script>
1176 + setTimeout(function(){
1177 + window.location.replace("<?php echo esc_url( $redirection_url_error ); ?>");
1178 + }, 2000);
1179 + </script>
1180 + <?php
1181 + }
1182 + }
1183 +
1184 + /**
676 1185 * Paystack payment success
677 - *
1186 + *
1187 + * Verifies a pending order against Paystack, then claims it with one update
1188 + * (`WHERE id = … AND status = 'unpaid'`). Only the request that wins the claim fires
1189 + * `better_payment/payment_confirmed`, sends the receipts and fires
1190 + * `better_payment/payment/success`, so a reload, a double-click or two tabs returning at once
1191 + * confirm the order exactly once. A request that loses the claim to a confirmation, and a later
1192 + * return for an order that is already confirmed, render the success page from the row — only
1193 + * when the return's `reference` is the one that order holds.
1194 + *
678 1195 * @since 0.0.1
679 1196 */
680 1197 public static function paystack_payment_success( $settings = [] ) {
681 1198 $data = $_REQUEST;
682 1199
683 - if ( ! empty( $data[ 'better_payment_paystack_id' ] ) ) {
684 - global $wpdb;
685 - $table = "{$wpdb->prefix}better_payment";
686 - $results = $wpdb->get_row(
687 - $wpdb->prepare( "SELECT id,obj_id,transaction_id,form_fields_info,referer FROM $table WHERE order_id=%s and status = 'unpaid' limit 1", sanitize_text_field( $data[ 'better_payment_paystack_id' ] ) )
1200 + if ( empty( $data[ 'better_payment_paystack_id' ] ) || ! is_scalar( $data[ 'better_payment_paystack_id' ] ) ) {
1201 + return false;
1202 + }
1203 +
1204 + if ( empty( $data['reference'] ) || ! is_scalar( $data['reference'] ) ) {
1205 + return false;
1206 + }
1207 +
1208 + global $wpdb;
1209 + $table = "{$wpdb->prefix}better_payment";
1210 + $order_id = sanitize_text_field( (string) $data[ 'better_payment_paystack_id' ] );
1211 + $reference = sanitize_text_field( (string) $data['reference'] );
1212 + $results = $wpdb->get_row(
1213 + $wpdb->prepare( "SELECT id,obj_id,transaction_id,amount,currency,email,status,form_fields_info,referer FROM $table WHERE order_id=%s limit 1", $order_id )
1214 + );
1215 +
1216 + if ( empty( $results->id ) ) {
1217 + return false;
1218 + }
1219 +
1220 + if ( 'unpaid' !== $results->status ) {
1221 + return self::paystack_settled_return( $results, $reference );
1222 + }
1223 +
1224 + $header_info = array(
1225 + 'Authorization' => 'Bearer ' . sanitize_text_field( isset( $settings[ 'better_payment_paystack_secret_key' ] ) ? $settings[ 'better_payment_paystack_secret_key' ] : '' ),
1226 + "Cache-Control: no-cache",
1227 + );
1228 +
1229 + $response = wp_safe_remote_get(
1230 + 'https://api.paystack.co/transaction/verify/' . rawurlencode( $reference ),
1231 + array(
1232 + 'headers' => $header_info,
1233 + 'timeout' => 70,
1234 + )
1235 + );
1236 +
1237 + $response = json_decode(wp_remote_retrieve_body($response));
1238 +
1239 + if ( empty( $response->status ) ) {
1240 + return false;
1241 + }
1242 +
1243 + $verified = isset( $response->data ) ? $response->data : null;
1244 +
1245 + if ( ! self::paystack_verification_matches( $results, $verified )
1246 + || self::paystack_reference_claimed_elsewhere( $verified->reference, (int) $results->id ) ) {
1247 + return false;
1248 + }
1249 +
1250 + $claimed = $wpdb->update(
1251 + $table,
1252 + array(
1253 + 'status' => ! empty( $response->data->status ) ? sanitize_text_field( $response->data->status ) : '',
1254 + 'amount' => ! empty( $response->data->amount ) ? floatval( ( $response->data->amount ) / 100 ) : 0,
1255 + 'obj_id' => ! empty( $response->data->id ) ? sanitize_text_field( $response->data->id ) : '',
1256 + 'transaction_id' => ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '',
1257 + 'customer_info' => ! empty( $response->data->customer ) ? maybe_serialize( $response->data->customer ) : '',
1258 + 'email' => ! empty( $response->data->customer->email ) ? sanitize_email( $response->data->customer->email ) : '',
1259 + ),
1260 + array(
1261 + 'id' => (int) $results->id,
1262 + 'status' => 'unpaid',
1263 + )
1264 + );
1265 +
1266 + if ( 1 !== (int) $claimed ) {
1267 + // Another return confirmed this order first (or the write failed): nothing is fired or sent twice.
1268 + $current = $wpdb->get_row(
1269 + $wpdb->prepare( "SELECT id,transaction_id,amount,currency,email,status FROM $table WHERE id=%d limit 1", (int) $results->id )
688 1270 );
689 -
690 - $header_info = array(
691 - 'Authorization' => 'Bearer ' . sanitize_text_field( $settings[ 'better_payment_paystack_secret_key' ] ),
692 - "Cache-Control: no-cache",
693 - );
694 -
695 - $response = wp_safe_remote_get(
696 - 'https://api.paystack.co/transaction/verify/'. sanitize_text_field( $data[ 'reference' ] ),
697 - array(
698 - 'headers' => $header_info,
699 - 'timeout' => 70,
700 - )
701 - );
702 1271
703 - $response = json_decode(wp_remote_retrieve_body($response));
704 -
705 - if ( ! empty( $response->status ) ) {
706 - $updated = $wpdb->update(
707 - $table,
708 - array(
709 - 'status' => ! empty( $response->data->status ) ? sanitize_text_field( $response->data->status ) : '',
710 - 'amount' => ! empty( $response->data->amount ) ? floatval( ( $response->data->amount ) / 100 ) : 0,
711 - 'obj_id' => ! empty( $response->data->id ) ? sanitize_text_field( $response->data->id ) : '',
712 - 'transaction_id' => ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '',
713 - 'customer_info' => ! empty( $response->data->customer ) ? maybe_serialize( $response->data->customer ) : '',
714 - 'email' => ! empty( $response->data->customer->email ) ? sanitize_email( $response->data->customer->email ) : '',
715 - ),
716 - array( 'ID' => $results->id )
717 - );
718 - do_action( 'better_payment/payment_confirmed', (int) $results->id );
719 - }
1272 + return ! empty( $current->id ) ? self::paystack_settled_return( $current, $reference ) : false;
1273 + }
720 1274
721 - $better_customer_email = ! empty( $response->data->customer->email ) ? maybe_serialize( $response->data->customer->email ) : '';
722 - $transaction_id = ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '';
1275 + do_action( 'better_payment/payment_confirmed', (int) $results->id );
723 1276
724 - $frontend_data = [
725 - 'amount' => ! empty( $response->data->amount ) ? floatval( ( $response->data->amount ) / 100 ) : 0,
726 - 'email' => ! empty( $better_customer_email ) ? $better_customer_email : '',
727 - 'transaction_id' => $transaction_id,
728 - 'currency' => ! empty( $response->data->currency ) ? sanitize_text_field( $response->data->currency ) : __( 'USD', 'better-payment' ),
729 - 'method' => 'paystack',
730 - ];
1277 + $better_customer_email = ! empty( $response->data->customer->email ) ? maybe_serialize( $response->data->customer->email ) : '';
1278 + $transaction_id = ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '';
731 1279
732 - if ( ! empty( $updated ) && $better_customer_email ) {
733 - //Send email notification
734 - if (
735 - (isset($settings[ 'better_payment_form_email_enable' ]) && $settings[ 'better_payment_form_email_enable' ] == 'yes' )
736 - || ( $results->referer === 'elementor-form' )
737 - ) {
738 - $is_elementor_form = ! empty( $results->referer ) && $results->referer === 'elementor-form' ? 1 : 0;
739 - self::better_email_notification($transaction_id, $better_customer_email, $settings, 'Paystack', $results->form_fields_info, $is_elementor_form);
740 - }
1280 + $frontend_data = [
1281 + 'amount' => ! empty( $response->data->amount ) ? floatval( ( $response->data->amount ) / 100 ) : 0,
1282 + 'email' => ! empty( $better_customer_email ) ? $better_customer_email : '',
1283 + 'transaction_id' => $transaction_id,
1284 + 'currency' => ! empty( $response->data->currency ) ? sanitize_text_field( $response->data->currency ) : __( 'USD', 'better-payment' ),
1285 + 'method' => 'paystack',
1286 + ];
741 1287
742 - do_action( 'better_payment/payment/success', sanitize_text_field( $data['better_payment_paystack_id'] ?? '' ), (int) $results->id, 'paystack' );
1288 + //Send email notification
1289 + if (
1290 + $better_customer_email
1291 + && (
1292 + ( isset( $settings[ 'better_payment_form_email_enable' ] ) && $settings[ 'better_payment_form_email_enable' ] == 'yes' )
1293 + || ( $results->referer === 'elementor-form' )
1294 + )
1295 + ) {
1296 + $is_elementor_form = ! empty( $results->referer ) && $results->referer === 'elementor-form' ? 1 : 0;
1297 + self::better_email_notification($transaction_id, $better_customer_email, $settings, 'Paystack', $results->form_fields_info, $is_elementor_form);
1298 + }
743 1299
744 - return $frontend_data;
745 - }
1300 + do_action( 'better_payment/payment/success', $order_id, (int) $results->id, 'paystack' );
1301 +
1302 + return $frontend_data;
1303 + }
1304 +
1305 + /**
1306 + * The return page for a Paystack order that is no longer pending.
1307 + *
1308 + * A confirmed order (`success`) shows its success page again, from the row, when the return
1309 + * carries the reference the order holds — the order id alone is not enough to see a receipt.
1310 + * Anything else shows nothing, as before.
1311 + *
1312 + * @param object $row The row.
1313 + * @param string $reference The return's `reference`.
1314 + * @return array|false
1315 + */
1316 + private static function paystack_settled_return( $row, $reference ) {
1317 + $stored = isset( $row->transaction_id ) ? (string) $row->transaction_id : '';
1318 +
1319 + if ( ! isset( $row->status ) || 'success' !== $row->status || '' === $stored || ! hash_equals( $stored, (string) $reference ) ) {
1320 + return false;
746 1321 }
747 - return false;
1322 +
1323 + return [
1324 + 'amount' => (float) $row->amount,
1325 + 'email' => sanitize_email( (string) $row->email ),
1326 + 'transaction_id' => sanitize_text_field( $stored ),
1327 + 'currency' => ! empty( $row->currency ) ? sanitize_text_field( $row->currency ) : __( 'USD', 'better-payment' ),
1328 + 'method' => 'paystack',
1329 + ];
748 1330 }
749 1331
750 1332 /**
1333 + * Whether a verified Paystack transaction really pays for the pending order.
1334 + *
1335 + * The verify call looks up whatever `reference` the return URL carries, and the return
1336 + * URL is the visitor's to edit. Without this, a cheap paid transaction could be replayed
1337 + * against an expensive pending order and mark it paid. Checked, in order:
1338 + * - the transaction itself succeeded. Paystack's top-level `status: true` only says the
1339 + * reference resolved — it is just as true for an abandoned or failed checkout, which is
1340 + * how a visitor who paid nothing used to reach the success page;
1341 + * - the reference is the one Paystack issued for this order at initialize (rows created
1342 + * before references were stored have none, and fall through to the amount check —
1343 + * paystack_reference_claimed_elsewhere() keeps that fallback from being replayable);
1344 + * - the amount paid covers the amount the order was created for — which
1345 + * PaymentRequestGuard has already held to what the form allows;
1346 + * - the currency is the order's.
1347 + *
1348 + * @param object $row Pending row: transaction_id, amount, currency.
1349 + * @param mixed $verified `data` object from Paystack's /transaction/verify response.
1350 + * @return bool
1351 + */
1352 + private static function paystack_verification_matches( $row, $verified ) {
1353 + if ( ! is_object( $verified ) ) {
1354 + return false;
1355 + }
1356 +
1357 + if ( ! isset( $verified->status ) || 'success' !== $verified->status ) {
1358 + return false;
1359 + }
1360 +
1361 + $expected_reference = isset( $row->transaction_id ) ? (string) $row->transaction_id : '';
1362 + $verified_reference = isset( $verified->reference ) ? (string) $verified->reference : '';
1363 +
1364 + if ( '' !== $expected_reference && $verified_reference !== $expected_reference ) {
1365 + return false;
1366 + }
1367 +
1368 + $paid = isset( $verified->amount ) && is_numeric( $verified->amount ) ? floatval( $verified->amount ) / 100 : 0.0;
1369 +
1370 + if ( $paid + 0.005 < floatval( isset( $row->amount ) ? $row->amount : 0 ) ) {
1371 + return false;
1372 + }
1373 +
1374 + $paid_currency = isset( $verified->currency ) ? strtoupper( (string) $verified->currency ) : '';
1375 + $order_currency = isset( $row->currency ) ? strtoupper( (string) $row->currency ) : '';
1376 +
1377 + return '' === $paid_currency || '' === $order_currency || $paid_currency === $order_currency;
1378 + }
1379 +
1380 + /**
1381 + * Whether a Paystack reference already belongs to a different order.
1382 + *
1383 + * Every order created since references were stored holds its own reference from
1384 + * initialize, so this only ever decides the legacy fallback — an order with no stored
1385 + * reference. Without it, one genuine payment could be replayed to mark every such
1386 + * order paid. A reference no order has claimed can still settle exactly one of them.
1387 + *
1388 + * @param mixed $reference Verified reference.
1389 + * @param int $row_id The order being verified.
1390 + * @return bool True when the reference must not settle this order.
1391 + */
1392 + private static function paystack_reference_claimed_elsewhere( $reference, $row_id ) {
1393 + global $wpdb;
1394 +
1395 + $reference = is_scalar( $reference ) ? sanitize_text_field( (string) $reference ) : '';
1396 +
1397 + if ( '' === $reference ) {
1398 + return true;
1399 + }
1400 +
1401 + $table = "{$wpdb->prefix}better_payment";
1402 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
1403 + $other = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM $table WHERE transaction_id = %s AND id <> %d LIMIT 1", $reference, (int) $row_id ) );
1404 +
1405 + return ! empty( $other );
1406 + }
1407 +
1408 + /**
751 1409 * Success message template
752 1410 *
753 1411 * @since 0.0.1
754 1412 */
@@ -813,34 +1471,11 @@
813 1471 $better_payment_form_success_message_currency_text = !empty( $settings['better_payment_form_success_message_currency_text'] )
814 1472 ? esc_html( $settings['better_payment_form_success_message_currency_text'] )
815 1473 : esc_html__( 'Currency', 'better-payment' );
816 1474 $better_payment_form_success_message_pay_method_text = !empty( $settings['better_payment_form_success_message_pay_method_text'] ) ? esc_html( $settings['better_payment_form_success_message_pay_method_text'] ) : esc_html__( 'Payment Method', 'better-payment' );
817 - $better_payment_form_success_message_pay_type_text = !empty( $settings['better_payment_form_success_message_pay_type_text'] )
818 - ? esc_html( $settings['better_payment_form_success_message_pay_type_text'] )
819 - : esc_html__( 'Payment Type', 'better-payment' );
820 - $better_payment_form_payment_type = !empty( $settings['better_payment_form_payment_type'] )
821 - ? esc_html( $settings['better_payment_form_payment_type'] )
822 - : '';
823 - if ( ! empty( $better_payment_form_payment_type ) ) {
824 - if ( 'one-time' === $better_payment_form_payment_type ) {
825 - $better_payment_form_payment_type_value = esc_html__( 'One Time Payment', 'better-payment' );
826 - } elseif ( 'recurring' === $better_payment_form_payment_type ) {
827 - $better_payment_form_payment_type_value = esc_html__( 'Recurring Payment', 'better-payment' );
828 - } elseif ( 'mixed' === $better_payment_form_payment_type ) {
829 - $mixed_payment_selected_type = ! empty( $tr_id['mixed_payment_selected_type'] ) ? sanitize_text_field( $tr_id['mixed_payment_selected_type'] ) : '';
830 - $is_mixed_recurring = 'recurring' === $mixed_payment_selected_type;
831 -
832 - if ( empty( $mixed_payment_selected_type ) ) {
833 - $is_mixed_recurring = ! empty( $tr_id['is_payment_recurring'] ) && empty( $tr_id['is_payment_split_payment'] );
834 - }
835 -
836 - $better_payment_form_payment_type_value = $is_mixed_recurring
837 - ? esc_html__( 'Recurring Payment', 'better-payment' )
838 - : esc_html__( 'One Time Payment', 'better-payment' );
839 - } else {
840 - $better_payment_form_payment_type_value = esc_html__( 'Split Payment', 'better-payment' );
841 - }
842 - }
1475 + // Rows only Pro's payment types add: Payment Type, and a split payment's "Paid Amount … out of …".
1476 + $pro_rows_after_payment_method = PaymentEntitlement::success_rows( 'after_payment_method', $settings, $tr_id, [ 'currency_symbol' => $bp__currency_symbol ] );
1477 + $pro_rows_after_merchant_details = PaymentEntitlement::success_rows( 'after_merchant_details', $settings, $tr_id, [ 'currency_symbol' => $bp__currency_symbol ] );
843 1478 $better_payment_form_success_message_merchant_details_text = !empty( $settings['better_payment_form_success_message_merchant_details_text'] )
844 1479 ? esc_html( $settings['better_payment_form_success_message_merchant_details_text'] )
845 1480 : esc_html__( 'Merchant Details', 'better-payment' );
846 1481 $better_payment_form_success_message_merchant_details_value = isset( $settings['better_payment_form_payment_source'] ) && $settings['better_payment_form_payment_source'] === 'manual' && ! empty( $settings['better_payment_form_title'] )
@@ -845,11 +1480,8 @@
845 1480 : esc_html__( 'Merchant Details', 'better-payment' );
846 1481 $better_payment_form_success_message_merchant_details_value = isset( $settings['better_payment_form_payment_source'] ) && $settings['better_payment_form_payment_source'] === 'manual' && ! empty( $settings['better_payment_form_title'] )
847 1482 ? esc_html($settings['better_payment_form_title'])
848 1483 : esc_html( get_bloginfo( 'name' ) );
849 - $better_payment_form_success_message_paid_amount_text = !empty( $settings['better_payment_form_success_message_paid_amount_text'] )
850 - ? esc_html( $settings['better_payment_form_success_message_paid_amount_text'] )
851 - : esc_html__( 'Paid Amount', 'better-payment' );
852 1484 $better_payment_form_success_message_purchase_details_text = !empty( $settings['better_payment_form_success_message_purchase_details_text'] )
853 1485 ? esc_html( $settings['better_payment_form_success_message_purchase_details_text'] )
854 1486 : esc_html__( 'Purchase Details', 'better-payment' );
855 1487 $better_payment_form_success_message_print_text = !empty( $settings['better_payment_form_success_message_print_text'] )
@@ -960,18 +1592,16 @@
960 1592 <img src="<?php echo esc_url( $payment_method_logo ); ?>" alt="<?php echo esc_attr( $tr_id['method'] ); ?>" class="bp-payment_method-logo">
961 1593 <?php endif; ?>
962 1594 </div>
963 1595 </li>
964 - <?php if( !empty( $better_payment_form_payment_type ) ): ?>
1596 + <?php foreach ( $pro_rows_after_payment_method as $pro_row ) : ?>
965 1597 <li class="bp-flex bp-font_ibm bp-page_info-list_item">
966 1598 <span class="bp-info_content">
967 - <?php
968 - echo wp_kses_post( $better_payment_form_success_message_pay_type_text );
969 - ?>
1599 + <?php echo wp_kses_post( $pro_row['label'] ); ?>
970 1600 </span>
971 - <span class="bp-info_content bp-bold"><?php echo wp_kses_post( $better_payment_form_payment_type_value ); ?></span>
1601 + <span class="bp-info_content bp-bold"><?php echo wp_kses_post( $pro_row['value'] ); ?></span>
972 1602 </li>
973 - <?php endif; ?>
1603 + <?php endforeach; ?>
974 1604 <li class="bp-flex bp-font_ibm bp-page_info-list_item">
975 1605 <span class="bp-info_content">
976 1606 <?php
977 1607 echo wp_kses_post( $better_payment_form_success_message_merchant_details_text );
@@ -980,20 +1610,16 @@
980 1610 <span class="bp-info_content bp-bold">
981 1611 <?php echo wp_kses_post( $better_payment_form_success_message_merchant_details_value ); ?>
982 1612 </span>
983 1613 </li>
984 - <?php if( isset( $tr_id['is_payment_split_payment'] ) && $tr_id['is_payment_split_payment'] ): ?>
1614 + <?php foreach ( $pro_rows_after_merchant_details as $pro_row ) : ?>
985 1615 <li class="bp-flex bp-font_ibm bp-page_info-list_item">
986 1616 <span class="bp-info_content">
987 - <?php
988 - echo wp_kses_post( $better_payment_form_success_message_paid_amount_text );
989 - ?>
1617 + <?php echo wp_kses_post( $pro_row['label'] ); ?>
990 1618 </span>
991 - <span class="bp-info_content bp-bold">
992 - <?php echo $bp__currency_symbol . esc_html($tr_id['amount']); ?> out of <?php echo $bp__currency_symbol . esc_html($tr_id['total_amount']); ?>
993 - </span>
1619 + <span class="bp-info_content bp-bold"><?php echo wp_kses_post( $pro_row['value'] ); ?></span>
994 1620 </li>
995 - <?php endif; ?>
1621 + <?php endforeach; ?>
996 1622 <?php
997 1623 $has_woo_products = !empty($settings['better_payment_form_woocommerce_product_id']) || !empty($settings['better_payment_form_woocommerce_product_ids']);
998 1624 $has_fluentcart_products = !empty($settings['better_payment_form_fluentcart_product_id']) || !empty($settings['better_payment_form_fluentcart_product_ids']);
999 1625
@@ -1268,9 +1894,11 @@
1268 1894 return [
1269 1895 'amount' => floatval( $result->amount ),
1270 1896 'currency' => sanitize_text_field( $result->currency ),
1271 1897 'currency_symbol' => $helper_obj->get_currency_symbol( $result->currency ),
1272 - 'transaction_id' => !empty( $result->transaction_id ) ? sanitize_text_field( $result->transaction_id ) : 'N/A',
1898 + // The order id is request data, and ids before 2.4.0 were `{gateway}_` . uniqid() —
1899 + // enumerable — so the gateway's transaction id is shown only for an unguessable id.
1900 + 'transaction_id' => ! empty( $result->transaction_id ) && self::order_id_is_unguessable( $order_id ) ? sanitize_text_field( $result->transaction_id ) : 'N/A',
1273 1901 'method' => sanitize_text_field( $result->source ),
1274 1902 ];
1275 1903 }
1276 1904
@@ -1588,43 +2216,30 @@
1588 2216 $currency_symbol = $helper_obj->get_currency_symbol( esc_html($currency) );
1589 2217 $payment_date = wp_date(get_option('date_format').' '.get_option('time_format'), strtotime($transaction_obj->payment_date));
1590 2218 }
1591 2219
1592 - $is_empty_email_body = empty($email_body);
1593 -
1594 - $email_body = do_shortcode( $email_body );
1595 - $bp_form_fields_html_content = $email_body;
1596 -
1597 - $bp_all_fields_shortcode = 'bp-all-fields';
1598 2220 $referer_content_page_link = '#';
1599 -
1600 - $field_text = __('Field', 'better-payment');
1601 - $value_text = __('Entry', 'better-payment');
1602 2221
1603 2222 $form_fields_info_arr = maybe_unserialize($form_fields_info);
2223 + $form_fields_info_arr = is_array( $form_fields_info_arr ) ? $form_fields_info_arr : [];
1604 2224
1605 2225 $email_logo_url = ! empty( $args['email_logo_url'] ) ? $args['email_logo_url'] : '';
1606 2226 $email_content_greeting = ! empty( $args['email_content_greeting'] ) ? 1 : 0;
1607 2227 $email_form_name = ! empty( $args['email_form_name'] ) ? $args['email_form_name'] : 'N/A';
1608 2228
1609 - $bp_form_fields_html_header = '<table style="margin-bottom: 20px; font-size: 14px; border-collapse: collapse; width: 100%;">';
2229 + $form_field_rows = [];
1610 2230
1611 - $bp_form_fields_html_footer = "</table>";
1612 -
1613 - $content = ''. $line_break;
1614 -
1615 2231 foreach ( $form_fields_info_arr as $key => $field ) {
1616 - if ( $key === 'is_payment_split_payment' ) {
1617 - $is_payment_split_payment = 1;
1618 - }
1619 - //Hide few fields
2232 + //Hide few fields: storage keys, not fields the visitor filled in (the From section hides the same ones)
1620 2233 if(
1621 2234 $key === 'referer_page_id' || $key === 'referer_widget_id' || $key === 'source' || $key === 'el_form_fields'
1622 - || $key === 'is_woo_layout'
1623 - || $key === 'is_payment_split_payment'
1624 - || $key === 'split_payment_total_amount'
1625 - || $key === 'split_payment_total_amount_price_id'
1626 - || $key === 'split_payment_installment_price_id'
2235 + || $key === 'amount_quantity'
2236 + || $key === 'woo_product_id'
2237 + || $key === 'woo_product_ids'
2238 + || $key === 'fluentcart_product_id'
2239 + || $key === 'fluentcart_product_ids'
2240 + || $key === 'detailed_product_info'
2241 + || $key === 'email_settings'
1627 2242 ) {
1628 2243 if($key === 'referer_page_id'){
1629 2244 $referer_content_page_link = !empty($field) ? get_permalink( $field ) : $referer_content_page_link;
1630 2245 }
@@ -1631,40 +2246,42 @@
1631 2246
1632 2247 continue;
1633 2248 }
1634 2249
2250 + $label_key = $key;
2251 +
1635 2252 if($key === 'primary_first_name'){
1636 - $key = 'first_name';
2253 + $label_key = 'first_name';
1637 2254 $customer_first_name = $field;
1638 2255 }
1639 2256 if($key === 'primary_last_name'){
1640 - $key = 'last_name';
2257 + $label_key = 'last_name';
1641 2258 $customer_last_name = $field;
1642 2259 }
1643 2260
1644 2261 if($key === 'primary_email'){
1645 - $key = 'email';
2262 + $label_key = 'email';
1646 2263 }
1647 2264
1648 2265 if($key === 'primary_payment_amount'){
1649 - $key = 'payment_amount';
2266 + $label_key = 'payment_amount';
1650 2267 }
1651 -
1652 - $key_formatted = self::better_title_case($key);
2268 +
2269 + $key_formatted = self::better_title_case($label_key);
1653 2270 if($key_formatted === 'Amount'){
1654 2271 $key_formatted = __('Paid', 'better-payment');
1655 -
1656 - if ( ! empty( $is_payment_split_payment ) ) {
1657 - $key_formatted = __('Product Price: ', 'better-payment');
1658 - }
1659 2272 }
1660 2273
1661 - $content .= "<tr>
1662 - <td style='padding: 7px; border: 1px solid #666;'> <strong>$key_formatted</strong> </td>
1663 - <td style='padding: 7px; border: 1px solid #666;'> $field </td>
1664 - </tr>";
2274 + $form_field_rows[] = [
2275 + 'key' => $key,
2276 + 'label' => $key_formatted,
2277 + 'value' => $field,
2278 + ];
1665 2279 }
1666 2280
2281 + // A Pro payment's own fields (split payments, layout 6) are hidden or relabelled by Pro.
2282 + $all_fields_rows = PaymentEntitlement::email_rows( $form_field_rows, 'all_fields', $form_fields_info_arr );
2283 +
1667 2284 if($customer_first_name) {
1668 2285 $customer_name = $customer_first_name . ' ';
1669 2286 }
1670 2287 if($customer_last_name) {
@@ -1765,25 +2382,17 @@
1765 2382 'email_content_from_section_show' => intval( $args['email_content_from_section_show'] ),
1766 2383 'email_content_to_section_show' => intval( $args['email_content_to_section_show'] ),
1767 2384 'email_content_transaction_summary_show' => intval( $args['email_content_transaction_summary_show'] ),
1768 2385 'email_content_footer_text_show' => intval( $args['email_content_footer_text_show'] ),
1769 - 'email_content_customer' => wp_kses_post( $args['email_content_customer'] ),
1770 - 'email_content_admin' => wp_kses_post( $args['email_content_admin'] ),
2386 + // The form's messages, with `[bp-all-fields]` replaced where the author placed it.
2387 + 'email_content_customer' => self::render_email_message( $args['email_content_customer'] ?? '', $all_fields_rows, 'plain' === ( $args['email_content_type_customer'] ?? 'html' ) ),
2388 + 'email_content_admin' => self::render_email_message( $args['email_content_admin'] ?? '', $all_fields_rows, 'plain' === ( $args['email_content_type'] ?? 'html' ) ),
1771 2389 'is_elementor_form' => intval( $args['is_elementor_form'] ),
1772 2390 'email_content_type' => $args['email_content_type'] ?? 'html',
1773 2391 'email_content_type_customer' => $args['email_content_type_customer'] ?? 'html',
1774 2392 ];
1775 2393
1776 - $bp_form_fields_html_body = $content;
1777 - $bp_form_fields_html_content = $bp_form_fields_html_header . $bp_form_fields_html_body . $bp_form_fields_html_footer ;
1778 -
1779 - //Replace shortcode with form fields info
1780 - $email_body = str_replace( "[$bp_all_fields_shortcode]", $bp_form_fields_html_content, $email_body );
1781 -
1782 - // Email V2
1783 - // General content
1784 - $form_name =
1785 -
2394 + // Email V2: the template is the whole body.
1786 2395 ob_start();
1787 2396
1788 2397 include BETTER_PAYMENT_ADMIN_VIEWS_PATH . "/template-email-notification.php";
1789 2398
@@ -1791,8 +2400,76 @@
1791 2400 ob_end_clean();
1792 2401
1793 2402 return $email_body;
1794 2403 }
2404 +
2405 + /**
2406 + * A form's e-mail message, with `[bp-all-fields]` replaced by the submitted fields.
2407 + *
2408 + * The template prints the message; the fields used to be built into a body the template's
2409 + * output then overwrote, so the token reached every e-mail as literal text. The message is
2410 + * the form's own setting and is kses-filtered as it always was. The fields are the
2411 + * visitor's, so they are escaped here and inserted after the filter — never passed through
2412 + * it or do_shortcode(), where a submitted value could add markup or run a shortcode.
2413 + *
2414 + * @since 2.4.0
2415 + *
2416 + * @param mixed $message The form's message setting.
2417 + * @param array[] $rows `label` and `value` of each field to list, in order.
2418 + * @param bool $is_plain Whether the e-mail is sent as plain text.
2419 + * @return string
2420 + */
2421 + public static function render_email_message( $message, array $rows, $is_plain = false ) {
2422 + $message = wp_kses_post( is_scalar( $message ) ? (string) $message : '' );
2423 +
2424 + if ( false === strpos( $message, '[bp-all-fields]' ) ) {
2425 + return $message;
2426 + }
2427 +
2428 + return str_replace( '[bp-all-fields]', self::email_all_fields_markup( $rows, $is_plain ), $message );
2429 + }
2430 +
2431 + /**
2432 + * The `[bp-all-fields]` block: a two-column table, or `Label: value` lines in a plain-text e-mail.
2433 + *
2434 + * @param array[] $rows `label` and `value` of each field.
2435 + * @param bool $is_plain Plain text instead of HTML.
2436 + * @return string
2437 + */
2438 + private static function email_all_fields_markup( array $rows, $is_plain ) {
2439 + $lines = [];
2440 + $cells = '';
2441 +
2442 + foreach ( $rows as $row ) {
2443 + if ( ! isset( $row['label'] ) || ! is_scalar( $row['label'] ) || ! array_key_exists( 'value', $row ) ) {
2444 + continue;
2445 + }
2446 +
2447 + // A nested value (stored settings, a product list) is not something a visitor typed.
2448 + if ( ! is_scalar( $row['value'] ) && null !== $row['value'] ) {
2449 + continue;
2450 + }
2451 +
2452 + $label = (string) $row['label'];
2453 + $value = (string) $row['value'];
2454 +
2455 + if ( $is_plain ) {
2456 + $lines[] = rtrim( wp_strip_all_tags( $label ), ': ' ) . ': ' . wp_strip_all_tags( $value );
2457 + continue;
2458 + }
2459 +
2460 + $cells .= '<tr>'
2461 + . '<td style="padding: 7px; border: 1px solid #666;"><strong>' . esc_html( $label ) . '</strong></td>'
2462 + . '<td style="padding: 7px; border: 1px solid #666;">' . esc_html( $value ) . '</td>'
2463 + . '</tr>';
2464 + }
2465 +
2466 + if ( $is_plain ) {
2467 + return implode( "\n", $lines );
2468 + }
2469 +
2470 + return '<table class="bp-all-fields" style="margin-bottom: 20px; font-size: 14px; border-collapse: collapse; width: 100%;">' . $cells . '</table>';
2471 + }
1795 2472
1796 2473 /**
1797 2474 * String helper method
1798 2475 * helps to convert string to title case