query_vars, get_the_permalink() . '?better_payment_paypal_status=success&better_payment_widget_id=' . $widget_id );
$cancel_url = add_query_arg( $wp->query_vars, get_the_permalink() . '?better_payment_error_status=error&better_payment_widget_id=' . $widget_id );
ob_start();
$error_info = [
'business_email' => !empty($settings['better_payment_paypal_business_email'])
];
?>
bp_allowed_tags() );
}
/**
* Paystack button
*
* @since 0.0.1
*/
public static function paystack_button( $widget_id = '' , $settings = [], $args = [] ) {
$args['extra_classes'] = ! empty( $args['extra_classes'] ) ? $args['extra_classes'] : '';
$args['extra_classes'] = is_array($args['extra_classes']) ? implode(' ', $args['extra_classes']) : $args['extra_classes'];
$args['button_text_default'] = ! empty( $args['button_text_default'] ) ? sanitize_text_field( $args['button_text_default'] ) : 'Proceed to Payment';
$paystack_button_text = ! empty( $settings["better_payment_form_form_buttons_paystack_button_text"] ) ? sanitize_text_field( $settings["better_payment_form_form_buttons_paystack_button_text"] ) : __( $args['button_text_default'], 'better-payment' );
ob_start();
?>
bp_allowed_tags() );
}
public static function hide_show_payment_button_class( $settings, $type = 'paypal' ){
$button_hidden_class = 'is-hidden';
$types = ['paypal', 'stripe', 'paystack'];
if( ! in_array($type, $types) ){
return $button_hidden_class;
}
$is_type_enable = ! empty( $settings[ "better_payment_form_{$type}_enable" ] ) && 'yes' === $settings[ "better_payment_form_{$type}_enable" ];
if( ! $is_type_enable ){
return $button_hidden_class;
}
$is_paypal_enable = ! empty( $settings[ "better_payment_form_paypal_enable" ] ) && 'yes' === $settings[ "better_payment_form_paypal_enable" ];
$is_stripe_enable = ! empty( $settings[ "better_payment_form_stripe_enable" ] ) && 'yes' === $settings[ "better_payment_form_stripe_enable" ];
$is_paystack_enable = ! empty( $settings[ "better_payment_form_paystack_enable" ] ) && 'yes' === $settings[ "better_payment_form_paystack_enable" ];
switch( $type ){
case 'paypal':
$button_hidden_class = '';
break;
case 'stripe':
if( ( ! $is_paypal_enable ) ){
$button_hidden_class = '';
}
break;
case 'paystack':
if( ( ! $is_paypal_enable ) && ( ! $is_stripe_enable ) ){
$button_hidden_class = '';
}
break;
default:
break;
}
return $button_hidden_class;
}
/**
* Payment create in db
*
* @since 0.0.1
*/
public static function payment_create( $data ) {
global $wpdb;
$table = "{$wpdb->prefix}better_payment";
$wpdb->insert( $table, self::normalize_pending_stripe_status( $data ) );
if ( $wpdb->insert_id ) {
return $wpdb->insert_id;
} else {
return false;
}
}
/**
* A Stripe Checkout Session order is always created pending.
*
* Every Stripe surface stores the session's `payment_status` at creation. For a session with
* nothing to charge right now — a subscription trial, a 100%-off promotion code, setup mode —
* Stripe already reports `no_payment_required` before anyone has completed the checkout. Stored
* as is, the order could never be confirmed (confirmation claims it from `unpaid`), so the trial
* or free donation never reached the thank-you page, its receipts or the campaign's donors.
* Only a Checkout Session row (`obj_id` `cs_…`) is touched: a renewal PaymentIntent row is
* written after its charge and keeps its status.
*
* @since 2.4.0
*
* @param mixed $data Row data for payment_create().
* @return mixed
*/
public static function normalize_pending_stripe_status( $data ) {
if ( ! is_array( $data ) || ! isset( $data['status'], $data['obj_id'] ) || ! is_string( $data['obj_id'] ) ) {
return $data;
}
if ( 'no_payment_required' === $data['status'] && 0 === strpos( $data['obj_id'], 'cs_' ) ) {
$data['status'] = self::STRIPE_PENDING_STATUS;
}
return $data;
}
/**
* A new, unguessable order id: `{gateway}_{24 hex chars}`.
*
* The order id is the only key the public return pages and the PayPal status poll look
* an order up by. uniqid() is the current time in microseconds, so ids created around a
* known moment could be enumerated to read other visitors' order status and receipts.
* 96 random bits cannot be. Fits the 50-char `order_id` column with room to spare.
*
* @since 2.3.4
*
* @param string $gateway 'paypal' | 'stripe' | 'paystack'.
* @return string
*/
public static function new_order_id( $gateway ) {
try {
$random = bin2hex( random_bytes( 12 ) );
} catch ( \Exception $e ) {
$random = strtolower( wp_generate_password( 24, false ) );
}
return sanitize_key( $gateway ) . '_' . $random;
}
/**
* Mamage response
*
* @since 0.0.1
*/
public static function manage_response( $settings = [], $widget_id = '' ) {
$status = false;
//if widget id is different then return false
if ( !empty( $_REQUEST[ 'better_payment_widget_id' ] ) && $_REQUEST[ 'better_payment_widget_id' ] !== $widget_id ) {
return $status;
}
if ( !empty( $_REQUEST[ 'better_payment_error_status' ] ) ) {
// Determine order_id based on payment method
$order_id = '';
if ( !empty( $_REQUEST['better_payment_stripe_id'] ) ) {
$order_id = sanitize_text_field( $_REQUEST['better_payment_stripe_id'] );
} elseif ( !empty( $_REQUEST['better_payment_paystack_id'] ) ) {
$order_id = sanitize_text_field( $_REQUEST['better_payment_paystack_id'] );
} elseif ( !empty( $_REQUEST['better_payment_paypal_id'] ) ) {
$order_id = sanitize_text_field( $_REQUEST['better_payment_paypal_id'] );
}
self::render_failed_response( $settings, $order_id );
return $status;
}
if ( !empty( $_REQUEST[ 'better_payment_paypal_status' ] ) && $_REQUEST[ 'better_payment_paypal_status' ] == 'success' ) {
$status = self::paypal_payment_success( $settings );
} elseif ( !empty( $_REQUEST[ 'better_payment_stripe_status' ] ) && $_REQUEST[ 'better_payment_stripe_status' ] == 'success' ) {
$status = self::stripe_payment_success( $settings );
} elseif ( !empty( $_REQUEST[ 'better_payment_paystack_status' ] ) && $_REQUEST[ 'better_payment_paystack_status' ] == 'success' ) {
$status = self::paystack_payment_success( $settings );
}
if ( is_wp_error( $status ) ) {
// A verification that confirmed no payment never renders the success page or
// redirects to the success URL (a WP_Error is truthy, and used to do both).
if ( 'bp_stripe_payment_processing' === $status->get_error_code() ) {
self::stripe_processing_message_template();
// The return URL keeps its query args, so reloading it verifies the order again.
return $status;
}
if ( 'bp_stripe_payment_incomplete' === $status->get_error_code() ) {
$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'] ) ) : '';
self::render_failed_response( $settings, $stripe_order_id );
// Like a cancelled checkout: the form renders again under the notice.
$status = false;
}
} elseif ( ! empty( $status['pending'] ) ) {
// Use home_url() instead of HTTP_HOST to avoid open-redirect via forged Host headers.
$return_url = home_url( $_SERVER['REQUEST_URI'] );
// PayPal uses rm=2 (POST redirect), so item_number arrives in $_POST and is absent from the URL.
// Add it explicitly so the poll-complete redirect (a plain GET) still carries item_number.
if ( ! empty( $status['order_id'] ) && strpos( $return_url, 'item_number=' ) === false ) {
$return_url = add_query_arg( 'item_number', $status['order_id'], $return_url );
}
?>
prefix}better_payment";
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT transaction_id, status, email, amount, currency FROM $table WHERE order_id = %s LIMIT 1",
$item_number
)
);
// No matching order — reject (prevents fake success display with forged item_number).
if ( empty( $row ) ) {
return false;
}
// IPN confirmed — return verified DB data. The payer's e-mail and the PayPal transaction
// id only for an unguessable order id: before 2.4.0 ids were `paypal_` . uniqid(), which is
// time-based and can be enumerated, so anyone could read them off the return page.
if ( $row->status === 'Completed' ) {
$unguessable = self::paypal_order_id_is_unguessable( $item_number );
return [
'amount' => floatval( $row->amount ),
'email' => $unguessable ? sanitize_email( $row->email ) : '',
'transaction_id' => $unguessable ? sanitize_text_field( $row->transaction_id ) : '',
'currency' => sanitize_text_field( $row->currency ),
'method' => 'paypal',
];
}
// Order exists but IPN hasn't arrived yet — signal pending so JS can poll.
return [ 'pending' => true, 'order_id' => $item_number ];
}
/**
* Whether a PayPal order id came from new_order_id() (24 random characters), not the
* enumerable `paypal_` . uniqid() of releases before 2.4.0.
*
* @since 2.4.1
*
* @param mixed $order_id The order id.
* @return bool
*/
public static function paypal_order_id_is_unguessable( $order_id ) {
return is_string( $order_id ) && 1 === preg_match( '/^paypal_[0-9a-z]{24}$/', $order_id );
}
/**
* Whether an order id of any gateway came from new_order_id() (`{gateway}_` + 24 random
* characters), not the enumerable `{gateway}_` . uniqid() of releases before 2.4.0.
*
* @since 2.4.1
*
* @param mixed $order_id The order id.
* @return bool
*/
public static function order_id_is_unguessable( $order_id ) {
return is_string( $order_id ) && 1 === preg_match( '/^[a-z0-9-]+_[0-9a-z]{24}$/', $order_id );
}
/**
* AJAX: check whether a PayPal order has been confirmed by IPN.
*
* Called by the frontend polling loop after the user lands on the return URL
* before the IPN fires. Returns {status:'pending'} or {status:'completed'}.
*
* Lives on Handler (not Actions) so it can be registered unconditionally —
* PayPal payments flow through the block/campaign path without Elementor.
*/
public static function check_paypal_status() {
check_ajax_referer( 'better-payment', 'security' );
$order_id = ! empty( $_POST['order_id'] ) ? sanitize_text_field( $_POST['order_id'] ) : '';
if ( empty( $order_id ) ) {
wp_send_json_error( [ 'message' => 'Missing order_id' ] );
}
global $wpdb;
$table = "{$wpdb->prefix}better_payment";
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT status FROM $table WHERE order_id = %s LIMIT 1",
$order_id
)
);
if ( empty( $row ) ) {
wp_send_json_error( [ 'message' => 'Order not found' ] );
}
if ( $row->status === 'Completed' ) {
wp_send_json_success( [ 'status' => 'completed' ] );
}
wp_send_json_success( [ 'status' => 'pending' ] );
}
/**
* PayPal IPN listener.
*
* PayPal POSTs the raw IPN body here server-to-server. We echo it back with
* cmd=_notify-validate; only a VERIFIED response from PayPal's servers reaches
* apply_paypal_ipn(), the only writer. The browser return URL
* (paypal_payment_success) never writes.
*
* @since 2.1.3
*/
public static function handle_paypal_ipn() {
$raw_post = file_get_contents( 'php://input' );
if ( empty( $raw_post ) ) {
status_header( 400 );
exit;
}
$data = [];
wp_parse_str( $raw_post, $data );
// PayPal includes test_ipn=1 for sandbox notifications.
$verify_url = ! empty( $data['test_ipn'] )
? 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'
: 'https://ipnpb.paypal.com/cgi-bin/webscr';
$response = wp_remote_post(
$verify_url,
[
'body' => 'cmd=_notify-validate&' . $raw_post,
'timeout' => 30,
'sslverify' => true,
'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded' ],
]
);
// Always respond 200 to PayPal to prevent retries, but only act on VERIFIED.
if ( is_wp_error( $response ) || wp_remote_retrieve_body( $response ) !== 'VERIFIED' ) {
status_header( 200 );
exit;
}
self::apply_paypal_ipn( $data );
status_header( 200 );
exit;
}
/**
* Whether a notification's PayPal environment matches the order's.
*
* Orders since 2.4.1 record `paypal_mode` (`live` | `sandbox`) when created, and must match
* exactly. An older order has no record, so a live notification is accepted (a real payment
* must never be stranded) and a sandbox one only while the site's PayPal setting is not live.
*
* @since 2.4.1
*
* @param mixed $form_info The order's `form_fields_info`, unserialized.
* @param string $ipn_mode `live` or `sandbox` (from the notification's `test_ipn`).
* @return bool
*/
public static function paypal_ipn_mode_matches( $form_info, $ipn_mode ) {
$order_mode = is_array( $form_info ) && isset( $form_info['paypal_mode'] ) ? $form_info['paypal_mode'] : '';
if ( 'live' === $order_mode || 'sandbox' === $order_mode ) {
return $order_mode === $ipn_mode;
}
if ( 'live' === $ipn_mode ) {
return true;
}
return 'yes' !== DB::get_settings( 'better_payment_settings_payment_paypal_live_mode' );
}
/**
* Apply a PayPal IPN to the order it names.
*
* Call ONLY with a notification handle_paypal_ipn() posted back and PayPal answered VERIFIED —
* nothing here authenticates the data. Changes nothing unless the notification is a
* `Completed` payment for a pending order (`status IS NULL`) paid to the merchant's address,
* for at least the order's amount, in its currency, under a transaction id no order holds.
* Then the row is claimed with one update (`WHERE id = … AND status IS NULL`): PayPal redelivers
* a notification it saw no 200 for and can deliver one twice at once, and only the delivery that
* wins the claim sends the receipts.
*
* @since 2.4.0
*
* @param array $data The notification's fields.
* @return string 'confirmed' | 'ignored' | 'unmatched' | 'refused' | 'duplicate'
*/
public static function apply_paypal_ipn( array $data ) {
$data = array_map( 'sanitize_text_field', $data );
if ( empty( $data['payment_status'] ) || empty( $data['item_number'] ) || $data['payment_status'] !== 'Completed' ) {
return 'ignored';
}
global $wpdb;
$table = "{$wpdb->prefix}better_payment";
$results = $wpdb->get_row(
$wpdb->prepare(
"SELECT id, amount, currency, form_fields_info, referer FROM $table WHERE order_id = %s AND status IS NULL LIMIT 1",
$data['item_number']
)
);
if ( empty( $results->id ) ) {
return 'unmatched';
}
// Validate receiver_email matches the merchant's configured PayPal email.
$form_info = maybe_unserialize( $results->form_fields_info );
$expected_email = ! empty( $form_info['paypal_business_email'] ) ? strtolower( $form_info['paypal_business_email'] ) : '';
$receiver = ! empty( $data['receiver_email'] ) ? strtolower( $data['receiver_email'] ) : ( ! empty( $data['business'] ) ? strtolower( $data['business'] ) : '' );
if ( $expected_email && $receiver && $expected_email !== $receiver ) {
return 'refused';
}
// A sandbox notification is VERIFIED by PayPal's sandbox, which pays in play money to an
// account anyone can create under any address. It must never confirm a live order.
$ipn_mode = ! empty( $data['test_ipn'] ) ? 'sandbox' : 'live';
if ( ! self::paypal_ipn_mode_matches( $form_info, $ipn_mode ) ) {
return 'refused';
}
// Validate the paid amount is not less than the expected order amount.
$paid_amount = ! empty( $data['mc_gross'] ) ? floatval( $data['mc_gross'] ) : 0;
if ( $paid_amount < floatval( $results->amount ) ) {
return 'refused';
}
// Validate currency matches.
$paid_currency = ! empty( $data['mc_currency'] ) ? strtoupper( $data['mc_currency'] ) : '';
if ( $paid_currency && $results->currency && strtoupper( $results->currency ) !== $paid_currency ) {
return 'refused';
}
$txn_id = ! empty( $data['txn_id'] ) ? $data['txn_id'] : '';
// Prevent txn_id replay: reject if this transaction ID was already recorded.
if ( $txn_id ) {
$existing = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM $table WHERE transaction_id = %s LIMIT 1",
$txn_id
)
);
if ( $existing ) {
return 'duplicate';
}
}
$payer_email = ! empty( $data['payer_email'] ) ? sanitize_email( $data['payer_email'] ) : '';
$claimed = $wpdb->update(
$table,
[
'transaction_id' => $txn_id,
'status' => $data['payment_status'],
'email' => $payer_email,
'customer_info' => maybe_serialize( $data ),
],
[
'id' => (int) $results->id,
'status' => null,
]
);
if ( 1 !== (int) $claimed ) {
// A concurrent delivery of this notification confirmed the order first.
return 'duplicate';
}
$is_elementor_form = ! empty( $results->referer ) && $results->referer === 'elementor-form' ? 1 : 0;
// Rebuild the e-mail settings persisted at payment-create time. IPN runs
// server-to-server with no live widget context, so without this the body
// would collapse to the bare transaction id (every section flag defaults
// to 0). Legacy / pending rows created before this fix fall back to [].
$email_settings = ! empty( $form_info['email_settings'] ) && is_array( $form_info['email_settings'] )
? $form_info['email_settings']
: [];
// Mirror the Stripe / Paystack gate: honour the form's "Send Email" toggle,
// and always e-mail for Elementor Pro Form submissions.
if (
( isset( $email_settings['better_payment_form_email_enable'] ) && 'yes' === $email_settings['better_payment_form_email_enable'] )
|| $is_elementor_form
) {
self::better_email_notification( $txn_id, $payer_email, $email_settings, 'PayPal', $results->form_fields_info, $is_elementor_form );
}
return 'confirmed';
}
/**
* Stripe payment success — the return page.
*
* Reads the transaction id from the request, EXCEPT when the caller
* supplies `better_payment_stripe_id` in $settings. A caller that has
* already authorized the id against server-side state (the WooCommerce
* return handler matches it to the order's own `_bp_payment_id`) must
* pass it explicitly: a caller-side guard that reads $_GET cannot bind
* this lookup, because PHP's default request_order=GP lets a POST body
* overwrite the query string and the two would then see different ids.
*
* A pending order is verified against Stripe and confirmed through
* confirm_stripe_session(). An order an earlier return (a reload, a second
* tab) already confirmed renders the success page again without asking
* Stripe or re-running anything. See stripe_settled_return().
*
* @since 0.0.1
*
* @param array $settings Form settings; `better_payment_stripe_secret_key` is required to verify.
* @return array|false|\WP_Error Success data, false for no order to show, or why nothing was confirmed.
*/
public static function stripe_payment_success( $settings = [] ) {
$data = $_REQUEST;
if ( ! empty( $settings[ 'better_payment_stripe_id' ] ) ) {
$data[ 'better_payment_stripe_id' ] = $settings[ 'better_payment_stripe_id' ];
}
if ( empty( $data['better_payment_stripe_id'] ) || ! is_scalar( $data['better_payment_stripe_id'] ) ) {
return false;
}
$order_id = sanitize_text_field( (string) $data['better_payment_stripe_id'] );
$row = self::get_stripe_row( 'order_id', $order_id );
if ( empty( $row->id ) ) {
return false;
}
if ( self::STRIPE_PENDING_STATUS !== $row->status ) {
return self::stripe_settled_return( $row, $order_id );
}
if ( empty( $row->obj_id ) || empty( $settings[ 'better_payment_stripe_secret_key' ] ) ) {
return false;
}
// Subscription and split-payment orders are Pro's: Pro (or, for an older Pro, Lite's
// transitional copy) adds the subscription expansion and the recurring / split flags.
$verification = self::stripe_verification_for( $row );
$response = wp_safe_remote_post(
'https://api.stripe.com/v1/checkout/sessions/' . rawurlencode( (string) $row->obj_id ),
array(
'method' => 'GET',
'headers' => array(
'Authorization' => 'Basic ' . base64_encode( sanitize_text_field( $settings[ 'better_payment_stripe_secret_key' ] ) . ':' ),
'Stripe-Version' => '2019-05-16',
),
'timeout' => 70,
'body' => array(
'expand' => $verification['expand'],
),
)
);
if ( is_wp_error( $response ) || empty( $response[ 'body' ] ) ) {
return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
}
$session = json_decode( $response[ 'body' ] );
if ( ! is_object( $session ) || isset( $session->error ) ) {
return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
}
return self::confirm_stripe_session( $row, $session, $settings, $verification );
}
/**
* Confirm a pending Stripe order from its verified Checkout Session.
*
* The one place a Stripe payment is confirmed: the return page. In order:
*
* 1. The session must be this row's (when it carries an id), must not be a setup-mode session
* (the WooCommerce free-trial checkout, which OrderHandler confirms by its SetupIntent), and
* must be paid (stripe_session_is_paid()). Nothing below runs for a session that is not: an
* abandoned checkout, or a completed one whose delayed payment has not cleared (a later
* return confirms it once it has).
* 2. Pro's verified-session step, the customer e-mail and the charged amount are computed. The
* amount is `amount_total / 100` whenever Stripe reports one — 0 for a 100%-off promotion
* code or a trial — and the form's amount is kept as `original_amount` when they differ.
* Pro's step must stay free of side effects: a request that loses the claim has asked it too.
* 3. The row is claimed with ONE update, `WHERE id = … AND status = 'unpaid'`, which writes the
* confirmed status, amount, e-mail, session and Pro's details together. However many returns
* race for a session (a reload, a second tab), exactly one update matches.
* 4. Only that winner fires `better_payment/stripe_payment/success` and
* `better_payment/payment_confirmed` and sends the receipts. A caller that lost the claim to a
* confirmation gets the same success data back, so the visitor still sees the thank-you page.
*
* The confirmed status is always `paid`, including for a session Stripe reports as
* `no_payment_required`: campaign totals and donor counts, Pro's Donors Wall and the WooCommerce
* bridge count `paid`, and a $0 donation is still a donation. Stripe's own value stays in
* `customer_info`.
*
* @since 2.4.0
*
* @param object|null $row Pending row from get_stripe_row() (null is refused).
* @param mixed $session Decoded Checkout Session, with $verification['expand'] expanded where Pro needs it (anything but an object is refused).
* @param array|null $settings Form settings for the receipts; null or [] for none.
* @param array|null $verification stripe_verification_for( $row ); computed when null.
* @return array|\WP_Error Success data for the thank-you page, or why nothing was confirmed.
*/
public static function confirm_stripe_session( $row, $session, $settings = null, $verification = null ) {
global $wpdb;
$table = "{$wpdb->prefix}better_payment";
$settings = is_array( $settings ) ? $settings : [];
if ( ! is_object( $row ) || empty( $row->id ) || ! is_object( $session ) ) {
return new \WP_Error( 'bp_stripe_confirm_invalid', __( 'There is no Stripe payment to confirm.', 'better-payment' ) );
}
if ( isset( $session->id ) && ( empty( $row->obj_id ) || (string) $session->id !== (string) $row->obj_id ) ) {
return new \WP_Error( 'bp_stripe_session_mismatch', __( 'Stripe did not confirm a payment for this checkout.', 'better-payment' ) );
}
if ( isset( $session->mode ) && 'setup' === $session->mode ) {
return new \WP_Error( 'bp_stripe_setup_session', __( 'This checkout saves a payment method and is confirmed by its order.', 'better-payment' ) );
}
$stored_fields = isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
$form_fields_info = is_array( $stored_fields ) ? $stored_fields : [];
if ( ! is_array( $verification ) || ! isset( $verification['expand'] ) ) {
$verification = self::stripe_verification_for( $row );
}
// Stripe returns the session whether or not anyone paid for it. Nothing below —
// Pro's verified-session step, the row update, the confirmation hooks, the e-mails,
// the success page — may run for a session that is not complete and paid. The row
// stays 'unpaid', so a later return can still confirm it.
if ( ! self::stripe_session_is_paid( $session ) ) {
return self::stripe_unpaid_session_error( $session );
}
$action_data = [
'form_fields_info' => $form_fields_info,
'is_payment_recurring' => $verification['is_payment_recurring'],
'is_payment_split_payment' => $verification['is_payment_split_payment'],
'checkout_session_id' => $row->obj_id,
];
// A subscription's id, customer e-mail and details, and the thank-you page's
// recurring / split flags, are Pro's to add.
$verified = PaymentEntitlement::stripe_verified_session(
[
'form_fields_info' => $form_fields_info,
'transaction_id' => ! empty( $row->transaction_id ) ? sanitize_text_field( $row->transaction_id ) : '',
'email' => self::stripe_session_customer_email( $session ),
'frontend_data' => [],
],
$session,
$verification
);
$confirmed_fields = is_array( $verified['form_fields_info'] ) ? $verified['form_fields_info'] : $form_fields_info;
$transaction_id = $verified['transaction_id'];
$customer_email = $verified['email'];
$amounts = self::stripe_paid_amounts( $session, isset( $row->amount ) ? $row->amount : 0, $confirmed_fields );
$paid_amount = $amounts['amount'];
$confirmed_fields = $amounts['form_fields_info'];
$claimed = $wpdb->update(
$table,
array(
'amount' => $paid_amount,
'status' => self::STRIPE_CONFIRMED_STATUS,
'email' => $customer_email,
'customer_info' => maybe_serialize( $session ),
'form_fields_info' => maybe_serialize( $confirmed_fields ),
'transaction_id' => $transaction_id,
),
array(
'id' => (int) $row->id,
'status' => self::STRIPE_PENDING_STATUS,
)
);
if ( 1 !== (int) $claimed ) {
// Another return (a reload, a second tab) got here first (or the write failed).
// Whatever it recorded is the outcome; nothing is fired or sent twice.
$current = self::get_stripe_row( 'id', (int) $row->id );
if ( ! empty( $current->id ) && self::STRIPE_CONFIRMED_STATUS === $current->status ) {
return self::stripe_frontend_data( $current, $verified['frontend_data'] );
}
return new \WP_Error( 'bp_stripe_confirm_failed', __( 'The payment could not be recorded. Please reload this page.', 'better-payment' ) );
}
do_action('better_payment/stripe_payment/success', $action_data);
do_action( 'better_payment/payment_confirmed', (int) $row->id );
$frontend_data = [
'amount' => $paid_amount,
'email' => $customer_email,
'transaction_id' => $transaction_id,
'currency' => ! empty( $row->currency ) ? $row->currency : __( 'USD', 'better-payment' ),
'method' => 'stripe',
] + $verified['frontend_data'];
//Send email notification
if (
( isset( $settings[ 'better_payment_form_email_enable' ] ) && 'yes' === $settings[ 'better_payment_form_email_enable' ] )
|| ( isset( $row->referer ) && 'elementor-form' === $row->referer )
) {
$is_elementor_form = isset( $row->referer ) && 'elementor-form' === $row->referer ? 1 : 0;
self::better_email_notification( $transaction_id, $customer_email, $settings, 'Stripe', $row->form_fields_info, $is_elementor_form );
}
return $frontend_data;
}
/**
* A Stripe transaction row, looked up by `id`, `order_id` or `obj_id` (the Checkout Session id).
*
* @since 2.4.0
*
* @param string $field 'id' | 'order_id' | 'obj_id'.
* @param mixed $value Value to match; anything but a non-empty scalar matches nothing.
* @return object|null
*/
public static function get_stripe_row( $field, $value ) {
global $wpdb;
if ( ! in_array( $field, [ 'id', 'order_id', 'obj_id' ], true ) || ! is_scalar( $value ) || '' === (string) $value ) {
return null;
}
$table = "{$wpdb->prefix}better_payment";
$format = 'id' === $field ? '%d' : '%s';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from $wpdb->prefix, column and format from the whitelist above.
$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 ) );
return is_object( $row ) ? $row : null;
}
/**
* How a row's Checkout Session is retrieved and whether it is a subscription — Pro's answer.
*
* @since 2.4.0
*
* @param object|null $row Transaction row; null reads as a row with no stored fields.
* @return array `expand`, `is_payment_recurring`, `is_payment_split_payment`.
*/
public static function stripe_verification_for( $row ) {
$form_fields_info = is_object( $row ) && isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
return PaymentEntitlement::stripe_verification_args(
[
'expand' => [ 'payment_intent' ],
'is_payment_recurring' => false,
'is_payment_split_payment' => false,
],
is_array( $form_fields_info ) ? $form_fields_info : []
);
}
/**
* What a verified Checkout Session charged, and what the transaction's fields record about it.
*
* The charged amount is `amount_total / 100` whenever Stripe reports a numeric total — 0
* included: a 100%-off promotion code or a trial charges nothing, and a row claiming otherwise
* would add money nobody paid to a campaign's total. It used to fall back to the form's amount
* whenever the total was 0. Only a session with no total at all keeps the row's amount.
*
* `exact_paid_amount` is always recorded. When the charge differs from the amount the order was
* created for, that amount is kept as `original_amount`; `is_coupon_applied` and
* `paid_amount_diff` (read by the transaction details and the receipt) are set only when a
* discount explains the difference — a trial is not a coupon.
*
* @since 2.4.0
*
* @param mixed $session Decoded Checkout Session.
* @param mixed $row_amount The row's amount (what the order was created for).
* @param array $form_fields_info The fields to record into.
* @return array `amount` (float) and `form_fields_info`.
*/
public static function stripe_paid_amounts( $session, $row_amount, array $form_fields_info ) {
$original = is_numeric( $row_amount ) ? (float) $row_amount : 0.0;
$total = is_object( $session ) && isset( $session->amount_total ) && is_numeric( $session->amount_total ) ? (float) $session->amount_total : null;
$paid = null === $total ? $original : $total / 100;
$form_fields_info['is_coupon_applied'] = 0;
$form_fields_info['exact_paid_amount'] = $paid;
unset( $form_fields_info['original_amount'], $form_fields_info['paid_amount_diff'] );
if ( (int) round( $paid * 100 ) !== (int) round( $original * 100 ) ) {
$form_fields_info['original_amount'] = $original;
$discounted = ( is_object( $session ) && isset( $session->total_details->amount_discount ) && is_numeric( $session->total_details->amount_discount ) && (float) $session->total_details->amount_discount > 0 )
|| ( isset( $form_fields_info['stripe_coupon_code'] ) && is_scalar( $form_fields_info['stripe_coupon_code'] ) && '' !== trim( (string) $form_fields_info['stripe_coupon_code'] ) );
if ( $discounted ) {
$form_fields_info['paid_amount_diff'] = $original - $paid;
$form_fields_info['is_coupon_applied'] = 1;
}
}
return [
'amount' => $paid,
'form_fields_info' => $form_fields_info,
];
}
/**
* The customer's e-mail on a Checkout Session: the charge's billing e-mail when the PaymentIntent
* is expanded, else the session's `customer_details` / `customer_email`.
*
* @since 2.4.0
*
* @param mixed $session Decoded Checkout Session.
* @return string
*/
public static function stripe_session_customer_email( $session ) {
if ( ! is_object( $session ) ) {
return '';
}
if ( isset( $session->payment_intent->charges->data ) && is_array( $session->payment_intent->charges->data ) && ! empty( $session->payment_intent->charges->data ) ) {
$charge = current( $session->payment_intent->charges->data );
if ( isset( $charge->billing_details->email ) && is_string( $charge->billing_details->email ) && '' !== $charge->billing_details->email ) {
return sanitize_email( $charge->billing_details->email );
}
}
if ( isset( $session->customer_details->email ) && is_string( $session->customer_details->email ) && '' !== $session->customer_details->email ) {
return sanitize_email( $session->customer_details->email );
}
if ( isset( $session->customer_email ) && is_string( $session->customer_email ) ) {
return sanitize_email( $session->customer_email );
}
return '';
}
/**
* Whether an order id is one Handler::new_order_id() minted for Stripe.
*
* A return for an order that is already confirmed shows its receipt from the row, by id alone.
* Orders from before 2.3.4 carry a time-based id that could be enumerated, so only an
* unguessable one gets that page.
*
* @since 2.4.0
*
* @param mixed $order_id Order id.
* @return bool
*/
public static function stripe_order_id_is_unguessable( $order_id ) {
return is_string( $order_id ) && 1 === preg_match( '/^stripe_[0-9a-f]{24}$/', $order_id );
}
/**
* The return page for an order that is no longer pending.
*
* - Confirmed (`paid`): the success data, rebuilt from the row — no Stripe call, no hook, no
* e-mail — with Pro's thank-you page flags. Only for an unguessable order id.
* - Anything else (refunded, an older release's statuses): nothing, as before.
*
* @param object $row The row.
* @param string $order_id The order id the return names.
* @return array|false
*/
private static function stripe_settled_return( $row, $order_id ) {
if ( self::STRIPE_CONFIRMED_STATUS !== $row->status || ! self::stripe_order_id_is_unguessable( $order_id ) ) {
return false;
}
$form_fields_info = isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
// Only the thank-you page flags are used, and Pro derives them from the stored fields.
$details = PaymentEntitlement::stripe_verified_session(
[
'form_fields_info' => is_array( $form_fields_info ) ? $form_fields_info : [],
'transaction_id' => (string) $row->transaction_id,
'email' => (string) $row->email,
'frontend_data' => [],
],
new \stdClass(),
self::stripe_verification_for( $row )
);
return self::stripe_frontend_data( $row, $details['frontend_data'] );
}
/**
* Success data for a confirmed Stripe row.
*
* @param object $row Confirmed row.
* @param array $extra Pro's thank-you page flags.
* @return array
*/
private static function stripe_frontend_data( $row, array $extra = [] ) {
return [
'amount' => (float) $row->amount,
'email' => sanitize_email( (string) $row->email ),
'transaction_id' => sanitize_text_field( (string) $row->transaction_id ),
'currency' => ! empty( $row->currency ) ? sanitize_text_field( $row->currency ) : __( 'USD', 'better-payment' ),
'method' => 'stripe',
] + $extra;
}
/**
* Whether a retrieved Stripe Checkout Session has really been paid for.
*
* Retrieving a session proves only that it exists. A visitor can come back to the return
* URL from a checkout they abandoned (`status` is `open`, or `expired`), and a completed
* checkout paid with a delayed method (bank debits, vouchers) is `complete` with
* `payment_status: unpaid` until the money arrives — sometimes days later, sometimes never.
* Both used to confirm the order: the row, `better_payment/payment_confirmed`, Pro's
* payment types, the receipts and the success page.
*
* Paid means `payment_status` is `paid` (the funds are there), or `no_payment_required`
* (a 100%-off promotion code, a subscription trial) on a `complete` session: Stripe also
* reports `no_payment_required` for such a session before it is completed. A present
* `status` other than `complete` is never paid. A missing `status` is tolerated only for
* `paid` — requests pin `Stripe-Version: 2019-05-16`, and `paid` is only ever reported for
* a completed checkout — so a response without the field cannot stall every payment.
* Anything else missing or unrecognised is unpaid.
*
* @since 2.4.0
*
* @param mixed $session Decoded Checkout Session.
* @return bool
*/
public static function stripe_session_is_paid( $session ) {
if ( ! is_object( $session ) ) {
return false;
}
$status = isset( $session->status ) && is_string( $session->status ) ? $session->status : '';
$payment_status = isset( $session->payment_status ) && is_string( $session->payment_status ) ? $session->payment_status : '';
if ( 'paid' === $payment_status ) {
return '' === $status || 'complete' === $status;
}
return 'no_payment_required' === $payment_status && 'complete' === $status;
}
/**
* Why a Stripe return did not confirm its order.
*
* `bp_stripe_payment_processing`: the checkout was completed but Stripe has not been paid
* yet (a delayed payment method). `bp_stripe_payment_incomplete`: anything else — the
* checkout was never completed. The message is shown in WooCommerce order notes.
*
* @since 2.4.0
*
* @param mixed $session Decoded Checkout Session.
* @return \WP_Error
*/
public static function stripe_unpaid_session_error( $session ) {
$status = is_object( $session ) && isset( $session->status ) && is_string( $session->status ) ? $session->status : '';
$payment_status = is_object( $session ) && isset( $session->payment_status ) && is_string( $session->payment_status ) ? $session->payment_status : '';
$data = [
'status' => sanitize_text_field( $status ),
'payment_status' => sanitize_text_field( $payment_status ),
];
if ( 'complete' === $status && 'unpaid' === $payment_status ) {
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 );
}
return new \WP_Error( 'bp_stripe_payment_incomplete', __( 'Stripe did not confirm a payment for this checkout.', 'better-payment' ), $data );
}
/**
* The return page for a completed Stripe checkout whose payment has not cleared yet.
*
* Deliberately neither the success page (nothing has been paid) nor the failure page (the
* payment may still succeed).
*
* @since 2.4.0
*
* @return void
*/
private static function stripe_processing_message_template() {
?>
prefix}better_payment";
$order_id = sanitize_text_field( (string) $data[ 'better_payment_paystack_id' ] );
$reference = sanitize_text_field( (string) $data['reference'] );
$results = $wpdb->get_row(
$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 )
);
if ( empty( $results->id ) ) {
return false;
}
if ( 'unpaid' !== $results->status ) {
return self::paystack_settled_return( $results, $reference );
}
$header_info = array(
'Authorization' => 'Bearer ' . sanitize_text_field( isset( $settings[ 'better_payment_paystack_secret_key' ] ) ? $settings[ 'better_payment_paystack_secret_key' ] : '' ),
"Cache-Control: no-cache",
);
$response = wp_safe_remote_get(
'https://api.paystack.co/transaction/verify/' . rawurlencode( $reference ),
array(
'headers' => $header_info,
'timeout' => 70,
)
);
$response = json_decode(wp_remote_retrieve_body($response));
if ( empty( $response->status ) ) {
return false;
}
$verified = isset( $response->data ) ? $response->data : null;
if ( ! self::paystack_verification_matches( $results, $verified )
|| self::paystack_reference_claimed_elsewhere( $verified->reference, (int) $results->id ) ) {
return false;
}
$claimed = $wpdb->update(
$table,
array(
'status' => ! empty( $response->data->status ) ? sanitize_text_field( $response->data->status ) : '',
'amount' => ! empty( $response->data->amount ) ? floatval( ( $response->data->amount ) / 100 ) : 0,
'obj_id' => ! empty( $response->data->id ) ? sanitize_text_field( $response->data->id ) : '',
'transaction_id' => ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '',
'customer_info' => ! empty( $response->data->customer ) ? maybe_serialize( $response->data->customer ) : '',
'email' => ! empty( $response->data->customer->email ) ? sanitize_email( $response->data->customer->email ) : '',
),
array(
'id' => (int) $results->id,
'status' => 'unpaid',
)
);
if ( 1 !== (int) $claimed ) {
// Another return confirmed this order first (or the write failed): nothing is fired or sent twice.
$current = $wpdb->get_row(
$wpdb->prepare( "SELECT id,transaction_id,amount,currency,email,status FROM $table WHERE id=%d limit 1", (int) $results->id )
);
return ! empty( $current->id ) ? self::paystack_settled_return( $current, $reference ) : false;
}
do_action( 'better_payment/payment_confirmed', (int) $results->id );
$better_customer_email = ! empty( $response->data->customer->email ) ? maybe_serialize( $response->data->customer->email ) : '';
$transaction_id = ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '';
$frontend_data = [
'amount' => ! empty( $response->data->amount ) ? floatval( ( $response->data->amount ) / 100 ) : 0,
'email' => ! empty( $better_customer_email ) ? $better_customer_email : '',
'transaction_id' => $transaction_id,
'currency' => ! empty( $response->data->currency ) ? sanitize_text_field( $response->data->currency ) : __( 'USD', 'better-payment' ),
'method' => 'paystack',
];
//Send email notification
if (
$better_customer_email
&& (
( isset( $settings[ 'better_payment_form_email_enable' ] ) && $settings[ 'better_payment_form_email_enable' ] == 'yes' )
|| ( $results->referer === 'elementor-form' )
)
) {
$is_elementor_form = ! empty( $results->referer ) && $results->referer === 'elementor-form' ? 1 : 0;
self::better_email_notification($transaction_id, $better_customer_email, $settings, 'Paystack', $results->form_fields_info, $is_elementor_form);
}
do_action( 'better_payment/payment/success', $order_id, (int) $results->id, 'paystack' );
return $frontend_data;
}
/**
* The return page for a Paystack order that is no longer pending.
*
* A confirmed order (`success`) shows its success page again, from the row, when the return
* carries the reference the order holds — the order id alone is not enough to see a receipt.
* Anything else shows nothing, as before.
*
* @param object $row The row.
* @param string $reference The return's `reference`.
* @return array|false
*/
private static function paystack_settled_return( $row, $reference ) {
$stored = isset( $row->transaction_id ) ? (string) $row->transaction_id : '';
if ( ! isset( $row->status ) || 'success' !== $row->status || '' === $stored || ! hash_equals( $stored, (string) $reference ) ) {
return false;
}
return [
'amount' => (float) $row->amount,
'email' => sanitize_email( (string) $row->email ),
'transaction_id' => sanitize_text_field( $stored ),
'currency' => ! empty( $row->currency ) ? sanitize_text_field( $row->currency ) : __( 'USD', 'better-payment' ),
'method' => 'paystack',
];
}
/**
* Whether a verified Paystack transaction really pays for the pending order.
*
* The verify call looks up whatever `reference` the return URL carries, and the return
* URL is the visitor's to edit. Without this, a cheap paid transaction could be replayed
* against an expensive pending order and mark it paid. Checked, in order:
* - the transaction itself succeeded. Paystack's top-level `status: true` only says the
* reference resolved — it is just as true for an abandoned or failed checkout, which is
* how a visitor who paid nothing used to reach the success page;
* - the reference is the one Paystack issued for this order at initialize (rows created
* before references were stored have none, and fall through to the amount check —
* paystack_reference_claimed_elsewhere() keeps that fallback from being replayable);
* - the amount paid covers the amount the order was created for — which
* PaymentRequestGuard has already held to what the form allows;
* - the currency is the order's.
*
* @param object $row Pending row: transaction_id, amount, currency.
* @param mixed $verified `data` object from Paystack's /transaction/verify response.
* @return bool
*/
private static function paystack_verification_matches( $row, $verified ) {
if ( ! is_object( $verified ) ) {
return false;
}
if ( ! isset( $verified->status ) || 'success' !== $verified->status ) {
return false;
}
$expected_reference = isset( $row->transaction_id ) ? (string) $row->transaction_id : '';
$verified_reference = isset( $verified->reference ) ? (string) $verified->reference : '';
if ( '' !== $expected_reference && $verified_reference !== $expected_reference ) {
return false;
}
$paid = isset( $verified->amount ) && is_numeric( $verified->amount ) ? floatval( $verified->amount ) / 100 : 0.0;
if ( $paid + 0.005 < floatval( isset( $row->amount ) ? $row->amount : 0 ) ) {
return false;
}
$paid_currency = isset( $verified->currency ) ? strtoupper( (string) $verified->currency ) : '';
$order_currency = isset( $row->currency ) ? strtoupper( (string) $row->currency ) : '';
return '' === $paid_currency || '' === $order_currency || $paid_currency === $order_currency;
}
/**
* Whether a Paystack reference already belongs to a different order.
*
* Every order created since references were stored holds its own reference from
* initialize, so this only ever decides the legacy fallback — an order with no stored
* reference. Without it, one genuine payment could be replayed to mark every such
* order paid. A reference no order has claimed can still settle exactly one of them.
*
* @param mixed $reference Verified reference.
* @param int $row_id The order being verified.
* @return bool True when the reference must not settle this order.
*/
private static function paystack_reference_claimed_elsewhere( $reference, $row_id ) {
global $wpdb;
$reference = is_scalar( $reference ) ? sanitize_text_field( (string) $reference ) : '';
if ( '' === $reference ) {
return true;
}
$table = "{$wpdb->prefix}better_payment";
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
$other = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM $table WHERE transaction_id = %s AND id <> %d LIMIT 1", $reference, (int) $row_id ) );
return ! empty( $other );
}
/**
* Success message template
*
* @since 0.0.1
*/
public static function success_message_template( $settings = [], $tr_id = '' ) {
if ( empty( $tr_id ) || is_wp_error($tr_id)) {
return false;
}
wp_enqueue_script('better-payment-admin-script');
$image_url = BETTER_PAYMENT_ASSETS . '/img/success.svg';
$show_icon = 0;
$default_icon = 1;
$helper_obj = new Helper();
$store_name = ( isset( $settings['better_payment_form_payment_source'] ) && $settings['better_payment_form_payment_source'] === 'manual' && !empty( $settings['better_payment_form_title'] ) )
? esc_html($settings['better_payment_form_title'])
: esc_html( get_bloginfo('name') );
$payment_desc_text = !empty( $settings['better_payment_form_success_message_heading'] )
? esc_html($settings['better_payment_form_success_message_heading'])
: esc_html( __('You paid', 'better-payment') . ' [currency_symbol][amount] ' . __('to', 'better-payment') . ' [store_name]');
$bp__currency_symbol = esc_html( ! empty( $tr_id['currency'] ) ? $helper_obj->get_currency_symbol( $tr_id['currency'] ) : '' );
$payment_desc_text = str_replace( '[currency_symbol]', '' . $bp__currency_symbol . '', $payment_desc_text );
$payment_desc_text = str_replace( '[amount]', '' . esc_html( ! empty( $tr_id['amount'] ) ? $tr_id['amount'] : 0 ) . '', $payment_desc_text );
$payment_desc_text = str_replace( '[store_name]', '' . esc_html( $store_name ) . '', $payment_desc_text );
$payment_mail_text = !empty( $settings['better_payment_form_success_message_sub_heading'] ) ? esc_html($settings['better_payment_form_success_message_sub_heading']) : esc_html( __('Payment Confirmation email will be sent to ', 'better-payment') . '[customer_email]');
$payment_mail_text = str_replace( '[customer_email]', '' . esc_html( ! empty( $tr_id['email'] ) ? $tr_id['email'] : '' ) . '', $payment_mail_text );
if ( !empty( $settings[ 'better_payment_form_success_message_icon' ][ 'library' ] ) ) {
if ( $settings[ 'better_payment_form_success_message_icon' ][ 'library' ] == 'svg' ) {
$image_url = $settings[ 'better_payment_form_success_message_icon' ][ 'value' ][ 'url' ];
$default_icon = 0;
} else {
$show_icon = 1;
$image_url = $settings[ 'better_payment_form_success_message_icon' ][ 'value' ];
$default_icon = 0;
}
}
$allowed_payment_methods = [
'paypal' => BETTER_PAYMENT_ASSETS . '/img/paypal-2.svg',
'stripe' => BETTER_PAYMENT_ASSETS . '/img/stripe-2.svg',
'paystack' => BETTER_PAYMENT_ASSETS . '/img/paystack-2.svg',
];
$payment_method_logo = isset( $tr_id['method'] ) && isset( $allowed_payment_methods[ $tr_id['method'] ] ) ? $allowed_payment_methods[ $tr_id['method'] ] : '';
$better_payment_form_success_message_thanks = !empty( $settings['better_payment_form_success_message_thanks'] )
? esc_html( $settings['better_payment_form_success_message_thanks'] )
: esc_html__('Thank You!', 'better-payment');
$better_payment_form_success_message_transaction = !empty( $settings['better_payment_form_success_message_transaction'] )
? esc_html__( $settings['better_payment_form_success_message_transaction'], 'better-payment' )
: esc_html__('Transaction ID', 'better-payment');
$better_payment_form_success_message_amount_text = !empty( $settings['better_payment_form_success_message_amount_text'] )
? esc_html( $settings['better_payment_form_success_message_amount_text'] )
: esc_html__( 'Amount', 'better-payment' );
$better_payment_form_success_message_currency_text = !empty( $settings['better_payment_form_success_message_currency_text'] )
? esc_html( $settings['better_payment_form_success_message_currency_text'] )
: esc_html__( 'Currency', 'better-payment' );
$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' );
// Rows only Pro's payment types add: Payment Type, and a split payment's "Paid Amount … out of …".
$pro_rows_after_payment_method = PaymentEntitlement::success_rows( 'after_payment_method', $settings, $tr_id, [ 'currency_symbol' => $bp__currency_symbol ] );
$pro_rows_after_merchant_details = PaymentEntitlement::success_rows( 'after_merchant_details', $settings, $tr_id, [ 'currency_symbol' => $bp__currency_symbol ] );
$better_payment_form_success_message_merchant_details_text = !empty( $settings['better_payment_form_success_message_merchant_details_text'] )
? esc_html( $settings['better_payment_form_success_message_merchant_details_text'] )
: esc_html__( 'Merchant Details', 'better-payment' );
$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'] )
? esc_html($settings['better_payment_form_title'])
: esc_html( get_bloginfo( 'name' ) );
$better_payment_form_success_message_purchase_details_text = !empty( $settings['better_payment_form_success_message_purchase_details_text'] )
? esc_html( $settings['better_payment_form_success_message_purchase_details_text'] )
: esc_html__( 'Purchase Details', 'better-payment' );
$better_payment_form_success_message_print_text = !empty( $settings['better_payment_form_success_message_print_text'] )
? esc_html( $settings['better_payment_form_success_message_print_text'] )
: esc_html__( 'Print', 'better-payment' );
$better_payment_form_success_message_view_details_btn_text = !empty( $settings['better_payment_form_success_message_view_details_btn_text'] )
? esc_html( $settings['better_payment_form_success_message_view_details_btn_text'] )
: esc_html__( 'View Details', 'better-payment' );
$better_payment_form_success_page_view_details_url = !empty( $settings['better_payment_form_success_page_view_details_url']['url'] ) ? esc_url( $settings['better_payment_form_success_page_view_details_url']['url'] ) : 'javascript:void(0)';
?>