PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.0
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
better-payment / includes / Classes / Handler.php

Handler.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.4.0, at includes/Classes/Handler.php

2,540 lines 136.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\Classes;
4
5 use Better_Payment\Lite\Admin\DB;
6 use Better_Payment\Lite\Controller;
7 use Better_Payment\Lite\Classes\Helper;
8 use Better_Payment\Lite\Traits\Helper as TraitsHelper;
9
10 /**
11 * Exit if accessed directly
12 */
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 /**
18 * The plugin handler class
19 *
20 * @since 0.0.1
21 */
22 class Handler extends Controller{
23 use TraitsHelper;
24
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 /**
36 * PayPal button
37 *
38 * @since 0.0.1
39 */
40 public static function paypal_button( $widget_id = '' , $settings = [], $args = [] ) {
41 global $wp;
42
43 $args['extra_classes'] = ! empty( $args['extra_classes'] ) ? $args['extra_classes'] : '';
44 $args['extra_classes'] = is_array($args['extra_classes']) ? implode(' ', $args['extra_classes']) : $args['extra_classes'];
45 $args['button_text_default'] = ! empty( $args['button_text_default'] ) ? sanitize_text_field( $args['button_text_default'] ) : 'Proceed to Payment';
46
47 $paypal_button_text = ! empty( $settings["better_payment_form_form_buttons_paypal_button_text"] ) ? sanitize_text_field( $settings["better_payment_form_form_buttons_paypal_button_text"] ) : __( $args['button_text_default'], 'better-payment' );
48
49 $return_url = add_query_arg( $wp->query_vars, get_the_permalink() . '?better_payment_paypal_status=success&better_payment_widget_id=' . $widget_id );
50 $cancel_url = add_query_arg( $wp->query_vars, get_the_permalink() . '?better_payment_error_status=error&better_payment_widget_id=' . $widget_id );
51
52 ob_start();
53 $error_info = [
54 'business_email' => !empty($settings['better_payment_paypal_business_email'])
55 ];
56 ?>
57 <input type="hidden" name="return" value="<?php echo esc_url( $return_url ); ?>">
58 <input type="hidden" name="action" value="paypal_form_handle">
59 <input type="hidden" name="security" value="<?php echo esc_attr( wp_create_nonce('better-payment-paypal') ); ?>">
60 <input type="hidden" name="cancel_return" value="<?php echo esc_url( $cancel_url ); ?>">
61 <button data-paypal-info = <?php echo esc_attr(wp_json_encode( $error_info) ) ; ?> class="button is-medium is-fullwidth better-payment-paypal-bt <?php echo esc_attr( self::hide_show_payment_button_class($settings, 'paypal') ); ?> <?php echo esc_attr( $args['extra_classes'] ); ?>" ><?php echo esc_html( $paypal_button_text ); ?></button>
62
63 <?php
64 $paypal_button_html = ob_get_clean();
65 $paypal_button_html = apply_filters( 'better_payment/elementor/editor/paypal_button_html', $paypal_button_html, $widget_id, $settings );
66
67 echo $paypal_button_html;
68 }
69
70 /**
71 * Stripe button
72 *
73 * @since 0.0.1
74 */
75 public static function stripe_button( $widget_id = '' , $settings = [], $args = [] ) {
76 $args['extra_classes'] = ! empty( $args['extra_classes'] ) ? $args['extra_classes'] : '';
77 $args['extra_classes'] = is_array($args['extra_classes']) ? implode(' ', $args['extra_classes']) : $args['extra_classes'];
78 $args['button_text_default'] = ! empty( $args['button_text_default'] ) ? sanitize_text_field( $args['button_text_default'] ) : 'Proceed to Payment';
79
80 $stripe_button_text = ! empty( $settings["better_payment_form_form_buttons_stripe_button_text"] ) ? sanitize_text_field( $settings["better_payment_form_form_buttons_stripe_button_text"] ) : __( $args['button_text_default'], 'better-payment' );
81
82 ob_start();
83 ?>
84 <button class="button is-medium is-fullwidth better-payment-stripe-bt <?php echo esc_attr( self::hide_show_payment_button_class($settings, 'stripe') ); ?> <?php echo esc_attr( $args['extra_classes'] ); ?>" ><?php echo esc_html( $stripe_button_text ); ?></button>
85 <?php
86 $stripe_button_html = ob_get_clean();
87 $stripe_button_html = apply_filters( 'better_payment/elementor/editor/stripe_button_html', $stripe_button_html, $widget_id, $settings );
88
89 echo wp_kses( $stripe_button_html, (new Handler())->bp_allowed_tags() );
90 }
91
92 /**
93 * Paystack button
94 *
95 * @since 0.0.1
96 */
97 public static function paystack_button( $widget_id = '' , $settings = [], $args = [] ) {
98 $args['extra_classes'] = ! empty( $args['extra_classes'] ) ? $args['extra_classes'] : '';
99 $args['extra_classes'] = is_array($args['extra_classes']) ? implode(' ', $args['extra_classes']) : $args['extra_classes'];
100 $args['button_text_default'] = ! empty( $args['button_text_default'] ) ? sanitize_text_field( $args['button_text_default'] ) : 'Proceed to Payment';
101
102 $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' );
103
104 ob_start();
105 ?>
106 <button class="button is-medium is-fullwidth better-payment-paystack-bt <?php echo esc_attr( self::hide_show_payment_button_class($settings, 'paystack') ); ?> <?php echo esc_attr( $args['extra_classes'] ); ?>" ><?php echo esc_html( $paystack_button_text ); ?></button>
107 <?php
108 $paystack_button_html = ob_get_clean();
109 $paystack_button_html = apply_filters( 'better_payment/elementor/editor/paystack_button_html', $paystack_button_html, $widget_id, $settings );
110
111 echo wp_kses( $paystack_button_html, (new Handler())->bp_allowed_tags() );
112 }
113
114 public static function hide_show_payment_button_class( $settings, $type = 'paypal' ){
115 $button_hidden_class = 'is-hidden';
116 $types = ['paypal', 'stripe', 'paystack'];
117
118 if( ! in_array($type, $types) ){
119 return $button_hidden_class;
120 }
121
122 $is_type_enable = ! empty( $settings[ "better_payment_form_{$type}_enable" ] ) && 'yes' === $settings[ "better_payment_form_{$type}_enable" ];
123
124 if( ! $is_type_enable ){
125 return $button_hidden_class;
126 }
127
128 $is_paypal_enable = ! empty( $settings[ "better_payment_form_paypal_enable" ] ) && 'yes' === $settings[ "better_payment_form_paypal_enable" ];
129 $is_stripe_enable = ! empty( $settings[ "better_payment_form_stripe_enable" ] ) && 'yes' === $settings[ "better_payment_form_stripe_enable" ];
130 $is_paystack_enable = ! empty( $settings[ "better_payment_form_paystack_enable" ] ) && 'yes' === $settings[ "better_payment_form_paystack_enable" ];
131
132 switch( $type ){
133 case 'paypal':
134 $button_hidden_class = '';
135 break;
136
137 case 'stripe':
138 if( ( ! $is_paypal_enable ) ){
139 $button_hidden_class = '';
140 }
141
142 break;
143
144 case 'paystack':
145 if( ( ! $is_paypal_enable ) && ( ! $is_stripe_enable ) ){
146 $button_hidden_class = '';
147 }
148 break;
149
150 default:
151 break;
152 }
153
154 return $button_hidden_class;
155 }
156
157 /**
158 * Payment create in db
159 *
160 * @since 0.0.1
161 */
162 public static function payment_create( $data ) {
163 global $wpdb;
164 $table = "{$wpdb->prefix}better_payment";
165 $wpdb->insert( $table, self::normalize_pending_stripe_status( $data ) );
166 if ( $wpdb->insert_id ) {
167 return $wpdb->insert_id;
168 } else {
169 return false;
170 }
171 }
172
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 /**
225 * Mamage response
226 *
227 * @since 0.0.1
228 */
229 public static function manage_response( $settings = [], $widget_id = '' ) {
230 $status = false;
231
232 //if widget id is different then return false
233 if ( !empty( $_REQUEST[ 'better_payment_widget_id' ] ) && $_REQUEST[ 'better_payment_widget_id' ] !== $widget_id ) {
234 return $status;
235 }
236
237 if ( !empty( $_REQUEST[ 'better_payment_error_status' ] ) ) {
238 // Determine order_id based on payment method
239 $order_id = '';
240 if ( !empty( $_REQUEST['better_payment_stripe_id'] ) ) {
241 $order_id = sanitize_text_field( $_REQUEST['better_payment_stripe_id'] );
242 } elseif ( !empty( $_REQUEST['better_payment_paystack_id'] ) ) {
243 $order_id = sanitize_text_field( $_REQUEST['better_payment_paystack_id'] );
244 } elseif ( !empty( $_REQUEST['better_payment_paypal_id'] ) ) {
245 $order_id = sanitize_text_field( $_REQUEST['better_payment_paypal_id'] );
246 }
247
248 self::render_failed_response( $settings, $order_id );
249
250 return $status;
251 }
252
253 if ( !empty( $_REQUEST[ 'better_payment_paypal_status' ] ) && $_REQUEST[ 'better_payment_paypal_status' ] == 'success' ) {
254 $status = self::paypal_payment_success( $settings );
255 } elseif ( !empty( $_REQUEST[ 'better_payment_stripe_status' ] ) && $_REQUEST[ 'better_payment_stripe_status' ] == 'success' ) {
256 $status = self::stripe_payment_success( $settings );
257 } elseif ( !empty( $_REQUEST[ 'better_payment_paystack_status' ] ) && $_REQUEST[ 'better_payment_paystack_status' ] == 'success' ) {
258 $status = self::paystack_payment_success( $settings );
259 }
260
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'] ) ) {
280 // Use home_url() instead of HTTP_HOST to avoid open-redirect via forged Host headers.
281 $return_url = home_url( $_SERVER['REQUEST_URI'] );
282 // PayPal uses rm=2 (POST redirect), so item_number arrives in $_POST and is absent from the URL.
283 // Add it explicitly so the poll-complete redirect (a plain GET) still carries item_number.
284 if ( ! empty( $status['order_id'] ) && strpos( $return_url, 'item_number=' ) === false ) {
285 $return_url = add_query_arg( 'item_number', $status['order_id'], $return_url );
286 }
287 ?>
288 <section class="bp-thank_page">
289 <div class="bp-thank_page-wrapper bp-paypal-pending"
290 data-order-id="<?php echo esc_attr( $status['order_id'] ); ?>"
291 data-return-url="<?php echo esc_url( $return_url ); ?>">
292
293 <div class="bp-thank_page-logo">
294 <span class="bp-paypal-pending__spinner-wrap">
295 <span class="bp-paypal-pending__ring"></span>
296 <img class="bp-paypal-pending__logo"
297 src="<?php echo esc_url( BETTER_PAYMENT_ASSETS . '/img/paypal-2.svg' ); ?>"
298 alt="PayPal">
299 </span>
300 </div>
301
302 <div class="bp-thank_page-text bp-paypal-pending__body">
303 <h2 class="bp-font_ibm bp-page_header bp-paypal-pending__heading">
304 <?php esc_html_e( 'Verifying your payment…', 'better-payment' ); ?>
305 </h2>
306 <p class="bp-font_ibm bp-payment_info bp-paypal-pending__sub">
307 <?php esc_html_e( 'Please do not close this page. This usually takes a few seconds.', 'better-payment' ); ?>
308 </p>
309 <div class="bp-paypal-pending__dots">
310 <span></span><span></span><span></span>
311 </div>
312 </div>
313
314 <div class="bp-paypal-pending__bar">
315 <div class="bp-paypal-pending__bar-inner"></div>
316 </div>
317
318 </div>
319 </section>
320 <?php
321 } elseif ( $status ) {
322 self::success_message_template( $settings, $status );
323 $redirection_url_success = ! empty( $settings['better_payment_form_success_page_url']['url'] ) ? esc_url( $settings['better_payment_form_success_page_url']['url'] ) : '';
324
325 if( $redirection_url_success ){
326 ?>
327 <script>
328 setTimeout(function(){
329 window.location.replace("<?php echo esc_url( $redirection_url_success ); ?>");
330 }, 2000);
331 </script>
332 <?php
333 }
334 }
335 self::remove_arg();
336
337 return $status;
338 }
339
340 /**
341 * Paypal payment success
342 *
343 * The DB write happens exclusively in handle_paypal_ipn() after PayPal verifies
344 * the IPN post-back. This method is read-only: it never writes to the DB.
345 *
346 * Display data priority:
347 * 1. DB row (IPN already fired and confirmed) — authoritative.
348 * 2. PayPal return-URL params ($_REQUEST) — for display only when IPN hasn't
349 * fired yet (local dev, race condition). Safe because no DB write occurs here.
350 *
351 * @since 0.0.1
352 */
353 public static function paypal_payment_success( $settings = [] ) {
354 $item_number = ! empty( $_REQUEST['item_number'] ) ? sanitize_text_field( $_REQUEST['item_number'] ) : '';
355
356 if ( empty( $item_number ) ) {
357 return false;
358 }
359
360 global $wpdb;
361 $table = "{$wpdb->prefix}better_payment";
362 $row = $wpdb->get_row(
363 $wpdb->prepare(
364 "SELECT transaction_id, status, email, amount, currency FROM $table WHERE order_id = %s LIMIT 1",
365 $item_number
366 )
367 );
368
369 // No matching order — reject (prevents fake success display with forged item_number).
370 if ( empty( $row ) ) {
371 return false;
372 }
373
374 // IPN confirmed — return verified DB data.
375 if ( $row->status === 'Completed' ) {
376 return [
377 'amount' => floatval( $row->amount ),
378 'email' => sanitize_email( $row->email ),
379 'transaction_id' => sanitize_text_field( $row->transaction_id ),
380 'currency' => sanitize_text_field( $row->currency ),
381 'method' => 'paypal',
382 ];
383 }
384
385 // Order exists but IPN hasn't arrived yet — signal pending so JS can poll.
386 return [ 'pending' => true, 'order_id' => $item_number ];
387 }
388
389 /**
390 * AJAX: check whether a PayPal order has been confirmed by IPN.
391 *
392 * Called by the frontend polling loop after the user lands on the return URL
393 * before the IPN fires. Returns {status:'pending'} or {status:'completed'}.
394 *
395 * Lives on Handler (not Actions) so it can be registered unconditionally —
396 * PayPal payments flow through the block/campaign path without Elementor.
397 */
398 public static function check_paypal_status() {
399 check_ajax_referer( 'better-payment', 'security' );
400
401 $order_id = ! empty( $_POST['order_id'] ) ? sanitize_text_field( $_POST['order_id'] ) : '';
402
403 if ( empty( $order_id ) ) {
404 wp_send_json_error( [ 'message' => 'Missing order_id' ] );
405 }
406
407 global $wpdb;
408 $table = "{$wpdb->prefix}better_payment";
409 $row = $wpdb->get_row(
410 $wpdb->prepare(
411 "SELECT status FROM $table WHERE order_id = %s LIMIT 1",
412 $order_id
413 )
414 );
415
416 if ( empty( $row ) ) {
417 wp_send_json_error( [ 'message' => 'Order not found' ] );
418 }
419
420 if ( $row->status === 'Completed' ) {
421 wp_send_json_success( [ 'status' => 'completed' ] );
422 }
423
424 wp_send_json_success( [ 'status' => 'pending' ] );
425 }
426
427 /**
428 * PayPal IPN listener.
429 *
430 * PayPal POSTs the raw IPN body here server-to-server. We echo it back with
431 * cmd=_notify-validate; only a VERIFIED response from PayPal's servers reaches
432 * apply_paypal_ipn(), the only writer. The browser return URL
433 * (paypal_payment_success) never writes.
434 *
435 * @since 2.1.3
436 */
437 public static function handle_paypal_ipn() {
438 $raw_post = file_get_contents( 'php://input' );
439
440 if ( empty( $raw_post ) ) {
441 status_header( 400 );
442 exit;
443 }
444
445 $data = [];
446 wp_parse_str( $raw_post, $data );
447
448 // PayPal includes test_ipn=1 for sandbox notifications.
449 $verify_url = ! empty( $data['test_ipn'] )
450 ? 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'
451 : 'https://ipnpb.paypal.com/cgi-bin/webscr';
452
453 $response = wp_remote_post(
454 $verify_url,
455 [
456 'body' => 'cmd=_notify-validate&' . $raw_post,
457 'timeout' => 30,
458 'sslverify' => true,
459 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded' ],
460 ]
461 );
462
463 // Always respond 200 to PayPal to prevent retries, but only act on VERIFIED.
464 if ( is_wp_error( $response ) || wp_remote_retrieve_body( $response ) !== 'VERIFIED' ) {
465 status_header( 200 );
466 exit;
467 }
468
469 self::apply_paypal_ipn( $data );
470
471 status_header( 200 );
472 exit;
473 }
474
475 /**
476 * Apply a PayPal IPN to the order it names.
477 *
478 * Call ONLY with a notification handle_paypal_ipn() posted back and PayPal answered VERIFIED —
479 * nothing here authenticates the data. Changes nothing unless the notification is a
480 * `Completed` payment for a pending order (`status IS NULL`) paid to the merchant's address,
481 * for at least the order's amount, in its currency, under a transaction id no order holds.
482 * Then the row is claimed with one update (`WHERE id = … AND status IS NULL`): PayPal redelivers
483 * a notification it saw no 200 for and can deliver one twice at once, and only the delivery that
484 * wins the claim sends the receipts.
485 *
486 * @since 2.4.0
487 *
488 * @param array $data The notification's fields.
489 * @return string 'confirmed' | 'ignored' | 'unmatched' | 'refused' | 'duplicate'
490 */
491 public static function apply_paypal_ipn( array $data ) {
492 $data = array_map( 'sanitize_text_field', $data );
493
494 if ( empty( $data['payment_status'] ) || empty( $data['item_number'] ) || $data['payment_status'] !== 'Completed' ) {
495 return 'ignored';
496 }
497
498 global $wpdb;
499 $table = "{$wpdb->prefix}better_payment";
500 $results = $wpdb->get_row(
501 $wpdb->prepare(
502 "SELECT id, amount, currency, form_fields_info, referer FROM $table WHERE order_id = %s AND status IS NULL LIMIT 1",
503 $data['item_number']
504 )
505 );
506
507 if ( empty( $results->id ) ) {
508 return 'unmatched';
509 }
510
511 // Validate receiver_email matches the merchant's configured PayPal email.
512 $form_info = maybe_unserialize( $results->form_fields_info );
513 $expected_email = ! empty( $form_info['paypal_business_email'] ) ? strtolower( $form_info['paypal_business_email'] ) : '';
514 $receiver = ! empty( $data['receiver_email'] ) ? strtolower( $data['receiver_email'] ) : ( ! empty( $data['business'] ) ? strtolower( $data['business'] ) : '' );
515
516 if ( $expected_email && $receiver && $expected_email !== $receiver ) {
517 return 'refused';
518 }
519
520 // Validate the paid amount is not less than the expected order amount.
521 $paid_amount = ! empty( $data['mc_gross'] ) ? floatval( $data['mc_gross'] ) : 0;
522 if ( $paid_amount < floatval( $results->amount ) ) {
523 return 'refused';
524 }
525
526 // Validate currency matches.
527 $paid_currency = ! empty( $data['mc_currency'] ) ? strtoupper( $data['mc_currency'] ) : '';
528 if ( $paid_currency && $results->currency && strtoupper( $results->currency ) !== $paid_currency ) {
529 return 'refused';
530 }
531
532 $txn_id = ! empty( $data['txn_id'] ) ? $data['txn_id'] : '';
533
534 // Prevent txn_id replay: reject if this transaction ID was already recorded.
535 if ( $txn_id ) {
536 $existing = $wpdb->get_var(
537 $wpdb->prepare(
538 "SELECT id FROM $table WHERE transaction_id = %s LIMIT 1",
539 $txn_id
540 )
541 );
542 if ( $existing ) {
543 return 'duplicate';
544 }
545 }
546
547 $payer_email = ! empty( $data['payer_email'] ) ? sanitize_email( $data['payer_email'] ) : '';
548
549 $claimed = $wpdb->update(
550 $table,
551 [
552 'transaction_id' => $txn_id,
553 'status' => $data['payment_status'],
554 'email' => $payer_email,
555 'customer_info' => maybe_serialize( $data ),
556 ],
557 [
558 'id' => (int) $results->id,
559 'status' => null,
560 ]
561 );
562
563 if ( 1 !== (int) $claimed ) {
564 // A concurrent delivery of this notification confirmed the order first.
565 return 'duplicate';
566 }
567
568 $is_elementor_form = ! empty( $results->referer ) && $results->referer === 'elementor-form' ? 1 : 0;
569
570 // Rebuild the e-mail settings persisted at payment-create time. IPN runs
571 // server-to-server with no live widget context, so without this the body
572 // would collapse to the bare transaction id (every section flag defaults
573 // to 0). Legacy / pending rows created before this fix fall back to [].
574 $email_settings = ! empty( $form_info['email_settings'] ) && is_array( $form_info['email_settings'] )
575 ? $form_info['email_settings']
576 : [];
577
578 // Mirror the Stripe / Paystack gate: honour the form's "Send Email" toggle,
579 // and always e-mail for Elementor Pro Form submissions.
580 if (
581 ( isset( $email_settings['better_payment_form_email_enable'] ) && 'yes' === $email_settings['better_payment_form_email_enable'] )
582 || $is_elementor_form
583 ) {
584 self::better_email_notification( $txn_id, $payer_email, $email_settings, 'PayPal', $results->form_fields_info, $is_elementor_form );
585 }
586
587 return 'confirmed';
588 }
589
590 /**
591 * Stripe payment success — the return page.
592 *
593 * Reads the transaction id from the request, EXCEPT when the caller
594 * supplies `better_payment_stripe_id` in $settings. A caller that has
595 * already authorized the id against server-side state (the WooCommerce
596 * return handler matches it to the order's own `_bp_payment_id`) must
597 * pass it explicitly: a caller-side guard that reads $_GET cannot bind
598 * this lookup, because PHP's default request_order=GP lets a POST body
599 * overwrite the query string and the two would then see different ids.
600 *
601 * A pending order is verified against Stripe and confirmed through
602 * confirm_stripe_session(). An order an earlier return (a reload, a second
603 * tab) already confirmed renders the success page again without asking
604 * Stripe or re-running anything. See stripe_settled_return().
605 *
606 * @since 0.0.1
607 *
608 * @param array $settings Form settings; `better_payment_stripe_secret_key` is required to verify.
609 * @return array|false|\WP_Error Success data, false for no order to show, or why nothing was confirmed.
610 */
611 public static function stripe_payment_success( $settings = [] ) {
612
613 $data = $_REQUEST;
614
615 if ( ! empty( $settings[ 'better_payment_stripe_id' ] ) ) {
616 $data[ 'better_payment_stripe_id' ] = $settings[ 'better_payment_stripe_id' ];
617 }
618
619 if ( empty( $data['better_payment_stripe_id'] ) || ! is_scalar( $data['better_payment_stripe_id'] ) ) {
620 return false;
621 }
622
623 $order_id = sanitize_text_field( (string) $data['better_payment_stripe_id'] );
624 $row = self::get_stripe_row( 'order_id', $order_id );
625
626 if ( empty( $row->id ) ) {
627 return false;
628 }
629
630 if ( self::STRIPE_PENDING_STATUS !== $row->status ) {
631 return self::stripe_settled_return( $row, $order_id );
632 }
633
634 if ( empty( $row->obj_id ) || empty( $settings[ 'better_payment_stripe_secret_key' ] ) ) {
635 return false;
636 }
637
638 // Subscription and split-payment orders are Pro's: Pro (or, for an older Pro, Lite's
639 // transitional copy) adds the subscription expansion and the recurring / split flags.
640 $verification = self::stripe_verification_for( $row );
641
642 $response = wp_safe_remote_post(
643 'https://api.stripe.com/v1/checkout/sessions/' . rawurlencode( (string) $row->obj_id ),
644 array(
645 'method' => 'GET',
646 'headers' => array(
647 'Authorization' => 'Basic ' . base64_encode( sanitize_text_field( $settings[ 'better_payment_stripe_secret_key' ] ) . ':' ),
648 'Stripe-Version' => '2019-05-16',
649 ),
650 'timeout' => 70,
651 'body' => array(
652 'expand' => $verification['expand'],
653 ),
654 )
655 );
656
657 if ( is_wp_error( $response ) || empty( $response[ 'body' ] ) ) {
658 return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
659 }
660
661 $session = json_decode( $response[ 'body' ] );
662
663 if ( ! is_object( $session ) || isset( $session->error ) ) {
664 return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
665 }
666
667 return self::confirm_stripe_session( $row, $session, $settings, $verification );
668 }
669
670 /**
671 * Confirm a pending Stripe order from its verified Checkout Session.
672 *
673 * The one place a Stripe payment is confirmed: the return page. In order:
674 *
675 * 1. The session must be this row's (when it carries an id), must not be a setup-mode session
676 * (the WooCommerce free-trial checkout, which OrderHandler confirms by its SetupIntent), and
677 * must be paid (stripe_session_is_paid()). Nothing below runs for a session that is not: an
678 * abandoned checkout, or a completed one whose delayed payment has not cleared (a later
679 * return confirms it once it has).
680 * 2. Pro's verified-session step, the customer e-mail and the charged amount are computed. The
681 * amount is `amount_total / 100` whenever Stripe reports one — 0 for a 100%-off promotion
682 * code or a trial — and the form's amount is kept as `original_amount` when they differ.
683 * Pro's step must stay free of side effects: a request that loses the claim has asked it too.
684 * 3. The row is claimed with ONE update, `WHERE id = … AND status = 'unpaid'`, which writes the
685 * confirmed status, amount, e-mail, session and Pro's details together. However many returns
686 * race for a session (a reload, a second tab), exactly one update matches.
687 * 4. Only that winner fires `better_payment/stripe_payment/success` and
688 * `better_payment/payment_confirmed` and sends the receipts. A caller that lost the claim to a
689 * confirmation gets the same success data back, so the visitor still sees the thank-you page.
690 *
691 * The confirmed status is always `paid`, including for a session Stripe reports as
692 * `no_payment_required`: campaign totals and donor counts, Pro's Donors Wall and the WooCommerce
693 * bridge count `paid`, and a $0 donation is still a donation. Stripe's own value stays in
694 * `customer_info`.
695 *
696 * @since 2.4.0
697 *
698 * @param object|null $row Pending row from get_stripe_row() (null is refused).
699 * @param mixed $session Decoded Checkout Session, with $verification['expand'] expanded where Pro needs it (anything but an object is refused).
700 * @param array|null $settings Form settings for the receipts; null or [] for none.
701 * @param array|null $verification stripe_verification_for( $row ); computed when null.
702 * @return array|\WP_Error Success data for the thank-you page, or why nothing was confirmed.
703 */
704 public static function confirm_stripe_session( $row, $session, $settings = null, $verification = null ) {
705 global $wpdb;
706
707 $table = "{$wpdb->prefix}better_payment";
708 $settings = is_array( $settings ) ? $settings : [];
709
710 if ( ! is_object( $row ) || empty( $row->id ) || ! is_object( $session ) ) {
711 return new \WP_Error( 'bp_stripe_confirm_invalid', __( 'There is no Stripe payment to confirm.', 'better-payment' ) );
712 }
713
714 if ( isset( $session->id ) && ( empty( $row->obj_id ) || (string) $session->id !== (string) $row->obj_id ) ) {
715 return new \WP_Error( 'bp_stripe_session_mismatch', __( 'Stripe did not confirm a payment for this checkout.', 'better-payment' ) );
716 }
717
718 if ( isset( $session->mode ) && 'setup' === $session->mode ) {
719 return new \WP_Error( 'bp_stripe_setup_session', __( 'This checkout saves a payment method and is confirmed by its order.', 'better-payment' ) );
720 }
721
722 $stored_fields = isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
723 $form_fields_info = is_array( $stored_fields ) ? $stored_fields : [];
724
725 if ( ! is_array( $verification ) || ! isset( $verification['expand'] ) ) {
726 $verification = self::stripe_verification_for( $row );
727 }
728
729 // Stripe returns the session whether or not anyone paid for it. Nothing below —
730 // Pro's verified-session step, the row update, the confirmation hooks, the e-mails,
731 // the success page — may run for a session that is not complete and paid. The row
732 // stays 'unpaid', so a later return can still confirm it.
733 if ( ! self::stripe_session_is_paid( $session ) ) {
734 return self::stripe_unpaid_session_error( $session );
735 }
736
737 $action_data = [
738 'form_fields_info' => $form_fields_info,
739 'is_payment_recurring' => $verification['is_payment_recurring'],
740 'is_payment_split_payment' => $verification['is_payment_split_payment'],
741 'checkout_session_id' => $row->obj_id,
742 ];
743
744 // A subscription's id, customer e-mail and details, and the thank-you page's
745 // recurring / split flags, are Pro's to add.
746 $verified = PaymentEntitlement::stripe_verified_session(
747 [
748 'form_fields_info' => $form_fields_info,
749 'transaction_id' => ! empty( $row->transaction_id ) ? sanitize_text_field( $row->transaction_id ) : '',
750 'email' => self::stripe_session_customer_email( $session ),
751 'frontend_data' => [],
752 ],
753 $session,
754 $verification
755 );
756
757 $confirmed_fields = is_array( $verified['form_fields_info'] ) ? $verified['form_fields_info'] : $form_fields_info;
758 $transaction_id = $verified['transaction_id'];
759 $customer_email = $verified['email'];
760 $amounts = self::stripe_paid_amounts( $session, isset( $row->amount ) ? $row->amount : 0, $confirmed_fields );
761 $paid_amount = $amounts['amount'];
762 $confirmed_fields = $amounts['form_fields_info'];
763
764 $claimed = $wpdb->update(
765 $table,
766 array(
767 'amount' => $paid_amount,
768 'status' => self::STRIPE_CONFIRMED_STATUS,
769 'email' => $customer_email,
770 'customer_info' => maybe_serialize( $session ),
771 'form_fields_info' => maybe_serialize( $confirmed_fields ),
772 'transaction_id' => $transaction_id,
773 ),
774 array(
775 'id' => (int) $row->id,
776 'status' => self::STRIPE_PENDING_STATUS,
777 )
778 );
779
780 if ( 1 !== (int) $claimed ) {
781 // Another return (a reload, a second tab) got here first (or the write failed).
782 // Whatever it recorded is the outcome; nothing is fired or sent twice.
783 $current = self::get_stripe_row( 'id', (int) $row->id );
784
785 if ( ! empty( $current->id ) && self::STRIPE_CONFIRMED_STATUS === $current->status ) {
786 return self::stripe_frontend_data( $current, $verified['frontend_data'] );
787 }
788
789 return new \WP_Error( 'bp_stripe_confirm_failed', __( 'The payment could not be recorded. Please reload this page.', 'better-payment' ) );
790 }
791
792 do_action('better_payment/stripe_payment/success', $action_data);
793 do_action( 'better_payment/payment_confirmed', (int) $row->id );
794
795 $frontend_data = [
796 'amount' => $paid_amount,
797 'email' => $customer_email,
798 'transaction_id' => $transaction_id,
799 'currency' => ! empty( $row->currency ) ? $row->currency : __( 'USD', 'better-payment' ),
800 'method' => 'stripe',
801 ] + $verified['frontend_data'];
802
803 //Send email notification
804 if (
805 ( isset( $settings[ 'better_payment_form_email_enable' ] ) && 'yes' === $settings[ 'better_payment_form_email_enable' ] )
806 || ( isset( $row->referer ) && 'elementor-form' === $row->referer )
807 ) {
808 $is_elementor_form = isset( $row->referer ) && 'elementor-form' === $row->referer ? 1 : 0;
809
810 self::better_email_notification( $transaction_id, $customer_email, $settings, 'Stripe', $row->form_fields_info, $is_elementor_form );
811 }
812
813 return $frontend_data;
814 }
815
816 /**
817 * A Stripe transaction row, looked up by `id`, `order_id` or `obj_id` (the Checkout Session id).
818 *
819 * @since 2.4.0
820 *
821 * @param string $field 'id' | 'order_id' | 'obj_id'.
822 * @param mixed $value Value to match; anything but a non-empty scalar matches nothing.
823 * @return object|null
824 */
825 public static function get_stripe_row( $field, $value ) {
826 global $wpdb;
827
828 if ( ! in_array( $field, [ 'id', 'order_id', 'obj_id' ], true ) || ! is_scalar( $value ) || '' === (string) $value ) {
829 return null;
830 }
831
832 $table = "{$wpdb->prefix}better_payment";
833 $format = 'id' === $field ? '%d' : '%s';
834
835 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from $wpdb->prefix, column and format from the whitelist above.
836 $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 ) );
837
838 return is_object( $row ) ? $row : null;
839 }
840
841 /**
842 * How a row's Checkout Session is retrieved and whether it is a subscription — Pro's answer.
843 *
844 * @since 2.4.0
845 *
846 * @param object|null $row Transaction row; null reads as a row with no stored fields.
847 * @return array `expand`, `is_payment_recurring`, `is_payment_split_payment`.
848 */
849 public static function stripe_verification_for( $row ) {
850 $form_fields_info = is_object( $row ) && isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
851
852 return PaymentEntitlement::stripe_verification_args(
853 [
854 'expand' => [ 'payment_intent' ],
855 'is_payment_recurring' => false,
856 'is_payment_split_payment' => false,
857 ],
858 is_array( $form_fields_info ) ? $form_fields_info : []
859 );
860 }
861
862 /**
863 * What a verified Checkout Session charged, and what the transaction's fields record about it.
864 *
865 * The charged amount is `amount_total / 100` whenever Stripe reports a numeric total — 0
866 * included: a 100%-off promotion code or a trial charges nothing, and a row claiming otherwise
867 * would add money nobody paid to a campaign's total. It used to fall back to the form's amount
868 * whenever the total was 0. Only a session with no total at all keeps the row's amount.
869 *
870 * `exact_paid_amount` is always recorded. When the charge differs from the amount the order was
871 * created for, that amount is kept as `original_amount`; `is_coupon_applied` and
872 * `paid_amount_diff` (read by the transaction details and the receipt) are set only when a
873 * discount explains the difference — a trial is not a coupon.
874 *
875 * @since 2.4.0
876 *
877 * @param mixed $session Decoded Checkout Session.
878 * @param mixed $row_amount The row's amount (what the order was created for).
879 * @param array $form_fields_info The fields to record into.
880 * @return array `amount` (float) and `form_fields_info`.
881 */
882 public static function stripe_paid_amounts( $session, $row_amount, array $form_fields_info ) {
883 $original = is_numeric( $row_amount ) ? (float) $row_amount : 0.0;
884 $total = is_object( $session ) && isset( $session->amount_total ) && is_numeric( $session->amount_total ) ? (float) $session->amount_total : null;
885 $paid = null === $total ? $original : $total / 100;
886
887 $form_fields_info['is_coupon_applied'] = 0;
888 $form_fields_info['exact_paid_amount'] = $paid;
889 unset( $form_fields_info['original_amount'], $form_fields_info['paid_amount_diff'] );
890
891 if ( (int) round( $paid * 100 ) !== (int) round( $original * 100 ) ) {
892 $form_fields_info['original_amount'] = $original;
893
894 $discounted = ( is_object( $session ) && isset( $session->total_details->amount_discount ) && is_numeric( $session->total_details->amount_discount ) && (float) $session->total_details->amount_discount > 0 )
895 || ( isset( $form_fields_info['stripe_coupon_code'] ) && is_scalar( $form_fields_info['stripe_coupon_code'] ) && '' !== trim( (string) $form_fields_info['stripe_coupon_code'] ) );
896
897 if ( $discounted ) {
898 $form_fields_info['paid_amount_diff'] = $original - $paid;
899 $form_fields_info['is_coupon_applied'] = 1;
900 }
901 }
902
903 return [
904 'amount' => $paid,
905 'form_fields_info' => $form_fields_info,
906 ];
907 }
908
909 /**
910 * The customer's e-mail on a Checkout Session: the charge's billing e-mail when the PaymentIntent
911 * is expanded, else the session's `customer_details` / `customer_email`.
912 *
913 * @since 2.4.0
914 *
915 * @param mixed $session Decoded Checkout Session.
916 * @return string
917 */
918 public static function stripe_session_customer_email( $session ) {
919 if ( ! is_object( $session ) ) {
920 return '';
921 }
922
923 if ( isset( $session->payment_intent->charges->data ) && is_array( $session->payment_intent->charges->data ) && ! empty( $session->payment_intent->charges->data ) ) {
924 $charge = current( $session->payment_intent->charges->data );
925
926 if ( isset( $charge->billing_details->email ) && is_string( $charge->billing_details->email ) && '' !== $charge->billing_details->email ) {
927 return sanitize_email( $charge->billing_details->email );
928 }
929 }
930
931 if ( isset( $session->customer_details->email ) && is_string( $session->customer_details->email ) && '' !== $session->customer_details->email ) {
932 return sanitize_email( $session->customer_details->email );
933 }
934
935 if ( isset( $session->customer_email ) && is_string( $session->customer_email ) ) {
936 return sanitize_email( $session->customer_email );
937 }
938
939 return '';
940 }
941
942 /**
943 * Whether an order id is one Handler::new_order_id() minted for Stripe.
944 *
945 * A return for an order that is already confirmed shows its receipt from the row, by id alone.
946 * Orders from before 2.3.4 carry a time-based id that could be enumerated, so only an
947 * unguessable one gets that page.
948 *
949 * @since 2.4.0
950 *
951 * @param mixed $order_id Order id.
952 * @return bool
953 */
954 public static function stripe_order_id_is_unguessable( $order_id ) {
955 return is_string( $order_id ) && 1 === preg_match( '/^stripe_[0-9a-f]{24}$/', $order_id );
956 }
957
958 /**
959 * The return page for an order that is no longer pending.
960 *
961 * - Confirmed (`paid`): the success data, rebuilt from the row — no Stripe call, no hook, no
962 * e-mail — with Pro's thank-you page flags. Only for an unguessable order id.
963 * - Anything else (refunded, an older release's statuses): nothing, as before.
964 *
965 * @param object $row The row.
966 * @param string $order_id The order id the return names.
967 * @return array|false
968 */
969 private static function stripe_settled_return( $row, $order_id ) {
970 if ( self::STRIPE_CONFIRMED_STATUS !== $row->status || ! self::stripe_order_id_is_unguessable( $order_id ) ) {
971 return false;
972 }
973
974 $form_fields_info = isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
975
976 // Only the thank-you page flags are used, and Pro derives them from the stored fields.
977 $details = PaymentEntitlement::stripe_verified_session(
978 [
979 'form_fields_info' => is_array( $form_fields_info ) ? $form_fields_info : [],
980 'transaction_id' => (string) $row->transaction_id,
981 'email' => (string) $row->email,
982 'frontend_data' => [],
983 ],
984 new \stdClass(),
985 self::stripe_verification_for( $row )
986 );
987
988 return self::stripe_frontend_data( $row, $details['frontend_data'] );
989 }
990
991 /**
992 * Success data for a confirmed Stripe row.
993 *
994 * @param object $row Confirmed row.
995 * @param array $extra Pro's thank-you page flags.
996 * @return array
997 */
998 private static function stripe_frontend_data( $row, array $extra = [] ) {
999 return [
1000 'amount' => (float) $row->amount,
1001 'email' => sanitize_email( (string) $row->email ),
1002 'transaction_id' => sanitize_text_field( (string) $row->transaction_id ),
1003 'currency' => ! empty( $row->currency ) ? sanitize_text_field( $row->currency ) : __( 'USD', 'better-payment' ),
1004 'method' => 'stripe',
1005 ] + $extra;
1006 }
1007
1008 /**
1009 * Whether a retrieved Stripe Checkout Session has really been paid for.
1010 *
1011 * Retrieving a session proves only that it exists. A visitor can come back to the return
1012 * URL from a checkout they abandoned (`status` is `open`, or `expired`), and a completed
1013 * checkout paid with a delayed method (bank debits, vouchers) is `complete` with
1014 * `payment_status: unpaid` until the money arrives — sometimes days later, sometimes never.
1015 * Both used to confirm the order: the row, `better_payment/payment_confirmed`, Pro's
1016 * payment types, the receipts and the success page.
1017 *
1018 * Paid means `payment_status` is `paid` (the funds are there), or `no_payment_required`
1019 * (a 100%-off promotion code, a subscription trial) on a `complete` session: Stripe also
1020 * reports `no_payment_required` for such a session before it is completed. A present
1021 * `status` other than `complete` is never paid. A missing `status` is tolerated only for
1022 * `paid` — requests pin `Stripe-Version: 2019-05-16`, and `paid` is only ever reported for
1023 * a completed checkout — so a response without the field cannot stall every payment.
1024 * Anything else missing or unrecognised is unpaid.
1025 *
1026 * @since 2.4.0
1027 *
1028 * @param mixed $session Decoded Checkout Session.
1029 * @return bool
1030 */
1031 public static function stripe_session_is_paid( $session ) {
1032 if ( ! is_object( $session ) ) {
1033 return false;
1034 }
1035
1036 $status = isset( $session->status ) && is_string( $session->status ) ? $session->status : '';
1037 $payment_status = isset( $session->payment_status ) && is_string( $session->payment_status ) ? $session->payment_status : '';
1038
1039 if ( 'paid' === $payment_status ) {
1040 return '' === $status || 'complete' === $status;
1041 }
1042
1043 return 'no_payment_required' === $payment_status && 'complete' === $status;
1044 }
1045
1046 /**
1047 * Why a Stripe return did not confirm its order.
1048 *
1049 * `bp_stripe_payment_processing`: the checkout was completed but Stripe has not been paid
1050 * yet (a delayed payment method). `bp_stripe_payment_incomplete`: anything else — the
1051 * checkout was never completed. The message is shown in WooCommerce order notes.
1052 *
1053 * @since 2.4.0
1054 *
1055 * @param mixed $session Decoded Checkout Session.
1056 * @return \WP_Error
1057 */
1058 public static function stripe_unpaid_session_error( $session ) {
1059 $status = is_object( $session ) && isset( $session->status ) && is_string( $session->status ) ? $session->status : '';
1060 $payment_status = is_object( $session ) && isset( $session->payment_status ) && is_string( $session->payment_status ) ? $session->payment_status : '';
1061 $data = [
1062 'status' => sanitize_text_field( $status ),
1063 'payment_status' => sanitize_text_field( $payment_status ),
1064 ];
1065
1066 if ( 'complete' === $status && 'unpaid' === $payment_status ) {
1067 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 );
1068 }
1069
1070 return new \WP_Error( 'bp_stripe_payment_incomplete', __( 'Stripe did not confirm a payment for this checkout.', 'better-payment' ), $data );
1071 }
1072
1073 /**
1074 * The return page for a completed Stripe checkout whose payment has not cleared yet.
1075 *
1076 * Deliberately neither the success page (nothing has been paid) nor the failure page (the
1077 * payment may still succeed).
1078 *
1079 * @since 2.4.0
1080 *
1081 * @return void
1082 */
1083 private static function stripe_processing_message_template() {
1084 ?>
1085 <section class="bp-thank_page">
1086 <div class="bp-thank_page-wrapper bp-stripe-processing">
1087 <div class="bp-thank_page-text">
1088 <h2 class="bp-font_ibm bp-page_header"><?php esc_html_e( 'Your payment is processing', 'better-payment' ); ?></h2>
1089 <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>
1090 </div>
1091 </div>
1092 </section>
1093 <?php
1094 }
1095
1096 /**
1097 * The failed-payment page, with the optional redirect to the form's error page.
1098 *
1099 * @param array $settings Form settings.
1100 * @param string $order_id The order the return is for.
1101 * @return void
1102 */
1103 private static function render_failed_response( $settings, $order_id ) {
1104 self::failed_message_template( $settings, self::get_failed_transaction_data( $order_id ) );
1105
1106 $redirection_url_error = ! empty( $settings['better_payment_form_error_page_url']['url'] ) ? esc_url( $settings['better_payment_form_error_page_url']['url'] ) : '';
1107
1108 if ( $redirection_url_error ) {
1109 ?>
1110 <script>
1111 setTimeout(function(){
1112 window.location.replace("<?php echo esc_url( $redirection_url_error ); ?>");
1113 }, 2000);
1114 </script>
1115 <?php
1116 }
1117 }
1118
1119 /**
1120 * Paystack payment success
1121 *
1122 * Verifies a pending order against Paystack, then claims it with one update
1123 * (`WHERE id = … AND status = 'unpaid'`). Only the request that wins the claim fires
1124 * `better_payment/payment_confirmed`, sends the receipts and fires
1125 * `better_payment/payment/success`, so a reload, a double-click or two tabs returning at once
1126 * confirm the order exactly once. A request that loses the claim to a confirmation, and a later
1127 * return for an order that is already confirmed, render the success page from the row — only
1128 * when the return's `reference` is the one that order holds.
1129 *
1130 * @since 0.0.1
1131 */
1132 public static function paystack_payment_success( $settings = [] ) {
1133 $data = $_REQUEST;
1134
1135 if ( empty( $data[ 'better_payment_paystack_id' ] ) || ! is_scalar( $data[ 'better_payment_paystack_id' ] ) ) {
1136 return false;
1137 }
1138
1139 if ( empty( $data['reference'] ) || ! is_scalar( $data['reference'] ) ) {
1140 return false;
1141 }
1142
1143 global $wpdb;
1144 $table = "{$wpdb->prefix}better_payment";
1145 $order_id = sanitize_text_field( (string) $data[ 'better_payment_paystack_id' ] );
1146 $reference = sanitize_text_field( (string) $data['reference'] );
1147 $results = $wpdb->get_row(
1148 $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 )
1149 );
1150
1151 if ( empty( $results->id ) ) {
1152 return false;
1153 }
1154
1155 if ( 'unpaid' !== $results->status ) {
1156 return self::paystack_settled_return( $results, $reference );
1157 }
1158
1159 $header_info = array(
1160 'Authorization' => 'Bearer ' . sanitize_text_field( isset( $settings[ 'better_payment_paystack_secret_key' ] ) ? $settings[ 'better_payment_paystack_secret_key' ] : '' ),
1161 "Cache-Control: no-cache",
1162 );
1163
1164 $response = wp_safe_remote_get(
1165 'https://api.paystack.co/transaction/verify/'. $reference,
1166 array(
1167 'headers' => $header_info,
1168 'timeout' => 70,
1169 )
1170 );
1171
1172 $response = json_decode(wp_remote_retrieve_body($response));
1173
1174 if ( empty( $response->status ) ) {
1175 return false;
1176 }
1177
1178 $verified = isset( $response->data ) ? $response->data : null;
1179
1180 if ( ! self::paystack_verification_matches( $results, $verified )
1181 || self::paystack_reference_claimed_elsewhere( $verified->reference, (int) $results->id ) ) {
1182 return false;
1183 }
1184
1185 $claimed = $wpdb->update(
1186 $table,
1187 array(
1188 'status' => ! empty( $response->data->status ) ? sanitize_text_field( $response->data->status ) : '',
1189 'amount' => ! empty( $response->data->amount ) ? floatval( ( $response->data->amount ) / 100 ) : 0,
1190 'obj_id' => ! empty( $response->data->id ) ? sanitize_text_field( $response->data->id ) : '',
1191 'transaction_id' => ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '',
1192 'customer_info' => ! empty( $response->data->customer ) ? maybe_serialize( $response->data->customer ) : '',
1193 'email' => ! empty( $response->data->customer->email ) ? sanitize_email( $response->data->customer->email ) : '',
1194 ),
1195 array(
1196 'id' => (int) $results->id,
1197 'status' => 'unpaid',
1198 )
1199 );
1200
1201 if ( 1 !== (int) $claimed ) {
1202 // Another return confirmed this order first (or the write failed): nothing is fired or sent twice.
1203 $current = $wpdb->get_row(
1204 $wpdb->prepare( "SELECT id,transaction_id,amount,currency,email,status FROM $table WHERE id=%d limit 1", (int) $results->id )
1205 );
1206
1207 return ! empty( $current->id ) ? self::paystack_settled_return( $current, $reference ) : false;
1208 }
1209
1210 do_action( 'better_payment/payment_confirmed', (int) $results->id );
1211
1212 $better_customer_email = ! empty( $response->data->customer->email ) ? maybe_serialize( $response->data->customer->email ) : '';
1213 $transaction_id = ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '';
1214
1215 $frontend_data = [
1216 'amount' => ! empty( $response->data->amount ) ? floatval( ( $response->data->amount ) / 100 ) : 0,
1217 'email' => ! empty( $better_customer_email ) ? $better_customer_email : '',
1218 'transaction_id' => $transaction_id,
1219 'currency' => ! empty( $response->data->currency ) ? sanitize_text_field( $response->data->currency ) : __( 'USD', 'better-payment' ),
1220 'method' => 'paystack',
1221 ];
1222
1223 //Send email notification
1224 if (
1225 $better_customer_email
1226 && (
1227 ( isset( $settings[ 'better_payment_form_email_enable' ] ) && $settings[ 'better_payment_form_email_enable' ] == 'yes' )
1228 || ( $results->referer === 'elementor-form' )
1229 )
1230 ) {
1231 $is_elementor_form = ! empty( $results->referer ) && $results->referer === 'elementor-form' ? 1 : 0;
1232 self::better_email_notification($transaction_id, $better_customer_email, $settings, 'Paystack', $results->form_fields_info, $is_elementor_form);
1233 }
1234
1235 do_action( 'better_payment/payment/success', $order_id, (int) $results->id, 'paystack' );
1236
1237 return $frontend_data;
1238 }
1239
1240 /**
1241 * The return page for a Paystack order that is no longer pending.
1242 *
1243 * A confirmed order (`success`) shows its success page again, from the row, when the return
1244 * carries the reference the order holds — the order id alone is not enough to see a receipt.
1245 * Anything else shows nothing, as before.
1246 *
1247 * @param object $row The row.
1248 * @param string $reference The return's `reference`.
1249 * @return array|false
1250 */
1251 private static function paystack_settled_return( $row, $reference ) {
1252 $stored = isset( $row->transaction_id ) ? (string) $row->transaction_id : '';
1253
1254 if ( ! isset( $row->status ) || 'success' !== $row->status || '' === $stored || ! hash_equals( $stored, (string) $reference ) ) {
1255 return false;
1256 }
1257
1258 return [
1259 'amount' => (float) $row->amount,
1260 'email' => sanitize_email( (string) $row->email ),
1261 'transaction_id' => sanitize_text_field( $stored ),
1262 'currency' => ! empty( $row->currency ) ? sanitize_text_field( $row->currency ) : __( 'USD', 'better-payment' ),
1263 'method' => 'paystack',
1264 ];
1265 }
1266
1267 /**
1268 * Whether a verified Paystack transaction really pays for the pending order.
1269 *
1270 * The verify call looks up whatever `reference` the return URL carries, and the return
1271 * URL is the visitor's to edit. Without this, a cheap paid transaction could be replayed
1272 * against an expensive pending order and mark it paid. Checked, in order:
1273 * - the transaction itself succeeded. Paystack's top-level `status: true` only says the
1274 * reference resolved — it is just as true for an abandoned or failed checkout, which is
1275 * how a visitor who paid nothing used to reach the success page;
1276 * - the reference is the one Paystack issued for this order at initialize (rows created
1277 * before references were stored have none, and fall through to the amount check —
1278 * paystack_reference_claimed_elsewhere() keeps that fallback from being replayable);
1279 * - the amount paid covers the amount the order was created for — which
1280 * PaymentRequestGuard has already held to what the form allows;
1281 * - the currency is the order's.
1282 *
1283 * @param object $row Pending row: transaction_id, amount, currency.
1284 * @param mixed $verified `data` object from Paystack's /transaction/verify response.
1285 * @return bool
1286 */
1287 private static function paystack_verification_matches( $row, $verified ) {
1288 if ( ! is_object( $verified ) ) {
1289 return false;
1290 }
1291
1292 if ( ! isset( $verified->status ) || 'success' !== $verified->status ) {
1293 return false;
1294 }
1295
1296 $expected_reference = isset( $row->transaction_id ) ? (string) $row->transaction_id : '';
1297 $verified_reference = isset( $verified->reference ) ? (string) $verified->reference : '';
1298
1299 if ( '' !== $expected_reference && $verified_reference !== $expected_reference ) {
1300 return false;
1301 }
1302
1303 $paid = isset( $verified->amount ) && is_numeric( $verified->amount ) ? floatval( $verified->amount ) / 100 : 0.0;
1304
1305 if ( $paid + 0.005 < floatval( isset( $row->amount ) ? $row->amount : 0 ) ) {
1306 return false;
1307 }
1308
1309 $paid_currency = isset( $verified->currency ) ? strtoupper( (string) $verified->currency ) : '';
1310 $order_currency = isset( $row->currency ) ? strtoupper( (string) $row->currency ) : '';
1311
1312 return '' === $paid_currency || '' === $order_currency || $paid_currency === $order_currency;
1313 }
1314
1315 /**
1316 * Whether a Paystack reference already belongs to a different order.
1317 *
1318 * Every order created since references were stored holds its own reference from
1319 * initialize, so this only ever decides the legacy fallback — an order with no stored
1320 * reference. Without it, one genuine payment could be replayed to mark every such
1321 * order paid. A reference no order has claimed can still settle exactly one of them.
1322 *
1323 * @param mixed $reference Verified reference.
1324 * @param int $row_id The order being verified.
1325 * @return bool True when the reference must not settle this order.
1326 */
1327 private static function paystack_reference_claimed_elsewhere( $reference, $row_id ) {
1328 global $wpdb;
1329
1330 $reference = is_scalar( $reference ) ? sanitize_text_field( (string) $reference ) : '';
1331
1332 if ( '' === $reference ) {
1333 return true;
1334 }
1335
1336 $table = "{$wpdb->prefix}better_payment";
1337 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
1338 $other = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM $table WHERE transaction_id = %s AND id <> %d LIMIT 1", $reference, (int) $row_id ) );
1339
1340 return ! empty( $other );
1341 }
1342
1343 /**
1344 * Success message template
1345 *
1346 * @since 0.0.1
1347 */
1348 public static function success_message_template( $settings = [], $tr_id = '' ) {
1349 if ( empty( $tr_id ) || is_wp_error($tr_id)) {
1350 return false;
1351 }
1352 wp_enqueue_script('better-payment-admin-script');
1353 $image_url = BETTER_PAYMENT_ASSETS . '/img/success.svg';
1354 $show_icon = 0;
1355 $default_icon = 1;
1356 $helper_obj = new Helper();
1357
1358 $store_name = ( isset( $settings['better_payment_form_payment_source'] ) && $settings['better_payment_form_payment_source'] === 'manual' && !empty( $settings['better_payment_form_title'] ) )
1359 ? esc_html($settings['better_payment_form_title'])
1360 : esc_html( get_bloginfo('name') );
1361
1362 $payment_desc_text = !empty( $settings['better_payment_form_success_message_heading'] )
1363 ? esc_html($settings['better_payment_form_success_message_heading'])
1364 : esc_html( __('You paid', 'better-payment') . ' [currency_symbol][amount] ' . __('to', 'better-payment') . ' [store_name]');
1365
1366 $bp__currency_symbol = esc_html( ! empty( $tr_id['currency'] ) ? $helper_obj->get_currency_symbol( $tr_id['currency'] ) : '' );
1367
1368 $payment_desc_text = str_replace( '[currency_symbol]', '<span class="bp-bold">' . $bp__currency_symbol . '</span>', $payment_desc_text );
1369
1370 $payment_desc_text = str_replace( '[amount]', '<span class="bp-bold">' . esc_html( ! empty( $tr_id['amount'] ) ? $tr_id['amount'] : 0 ) . '</span>', $payment_desc_text );
1371
1372 $payment_desc_text = str_replace( '[store_name]', '<span class="bp-bold">' . esc_html( $store_name ) . '</span>', $payment_desc_text );
1373
1374 $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]');
1375
1376 $payment_mail_text = str_replace( '[customer_email]', '<span class="bp-bold">' . esc_html( ! empty( $tr_id['email'] ) ? $tr_id['email'] : '' ) . '</span>', $payment_mail_text );
1377
1378 if ( !empty( $settings[ 'better_payment_form_success_message_icon' ][ 'library' ] ) ) {
1379 if ( $settings[ 'better_payment_form_success_message_icon' ][ 'library' ] == 'svg' ) {
1380 $image_url = $settings[ 'better_payment_form_success_message_icon' ][ 'value' ][ 'url' ];
1381 $default_icon = 0;
1382 } else {
1383 $show_icon = 1;
1384 $image_url = $settings[ 'better_payment_form_success_message_icon' ][ 'value' ];
1385 $default_icon = 0;
1386 }
1387 }
1388
1389
1390 $allowed_payment_methods = [
1391 'paypal' => BETTER_PAYMENT_ASSETS . '/img/paypal-2.svg',
1392 'stripe' => BETTER_PAYMENT_ASSETS . '/img/stripe-2.svg',
1393 'paystack' => BETTER_PAYMENT_ASSETS . '/img/paystack-2.svg',
1394 ];
1395 $payment_method_logo = isset( $tr_id['method'] ) && isset( $allowed_payment_methods[ $tr_id['method'] ] ) ? $allowed_payment_methods[ $tr_id['method'] ] : '';
1396
1397 $better_payment_form_success_message_thanks = !empty( $settings['better_payment_form_success_message_thanks'] )
1398 ? esc_html( $settings['better_payment_form_success_message_thanks'] )
1399 : esc_html__('Thank You!', 'better-payment');
1400 $better_payment_form_success_message_transaction = !empty( $settings['better_payment_form_success_message_transaction'] )
1401 ? esc_html__( $settings['better_payment_form_success_message_transaction'], 'better-payment' )
1402 : esc_html__('Transaction ID', 'better-payment');
1403 $better_payment_form_success_message_amount_text = !empty( $settings['better_payment_form_success_message_amount_text'] )
1404 ? esc_html( $settings['better_payment_form_success_message_amount_text'] )
1405 : esc_html__( 'Amount', 'better-payment' );
1406 $better_payment_form_success_message_currency_text = !empty( $settings['better_payment_form_success_message_currency_text'] )
1407 ? esc_html( $settings['better_payment_form_success_message_currency_text'] )
1408 : esc_html__( 'Currency', 'better-payment' );
1409 $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' );
1410 // Rows only Pro's payment types add: Payment Type, and a split payment's "Paid Amount … out of …".
1411 $pro_rows_after_payment_method = PaymentEntitlement::success_rows( 'after_payment_method', $settings, $tr_id, [ 'currency_symbol' => $bp__currency_symbol ] );
1412 $pro_rows_after_merchant_details = PaymentEntitlement::success_rows( 'after_merchant_details', $settings, $tr_id, [ 'currency_symbol' => $bp__currency_symbol ] );
1413 $better_payment_form_success_message_merchant_details_text = !empty( $settings['better_payment_form_success_message_merchant_details_text'] )
1414 ? esc_html( $settings['better_payment_form_success_message_merchant_details_text'] )
1415 : esc_html__( 'Merchant Details', 'better-payment' );
1416 $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'] )
1417 ? esc_html($settings['better_payment_form_title'])
1418 : esc_html( get_bloginfo( 'name' ) );
1419 $better_payment_form_success_message_purchase_details_text = !empty( $settings['better_payment_form_success_message_purchase_details_text'] )
1420 ? esc_html( $settings['better_payment_form_success_message_purchase_details_text'] )
1421 : esc_html__( 'Purchase Details', 'better-payment' );
1422 $better_payment_form_success_message_print_text = !empty( $settings['better_payment_form_success_message_print_text'] )
1423 ? esc_html( $settings['better_payment_form_success_message_print_text'] )
1424 : esc_html__( 'Print', 'better-payment' );
1425 $better_payment_form_success_message_view_details_btn_text = !empty( $settings['better_payment_form_success_message_view_details_btn_text'] )
1426 ? esc_html( $settings['better_payment_form_success_message_view_details_btn_text'] )
1427 : esc_html__( 'View Details', 'better-payment' );
1428 $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)';
1429 ?>
1430 <section class="bp-thank_page">
1431 <div class="bp-thank_page-wrapper">
1432 <div class="bp-thank_page-logo">
1433 <?php if( $default_icon ): ?>
1434 <span class="bp-thank_page-logo_wrapper">
1435 <img src="<?php echo esc_url( BETTER_PAYMENT_ASSETS . '/img/success-2.svg' ); ?>" alt="Better Payment logo">
1436 </span>
1437 <?php endif; ?>
1438 <?php if( ! $default_icon ): ?>
1439 <?php if( $show_icon ): ?>
1440 <?php
1441 // Add 'pf-block' class if this is a Gutenberg block (not Elementor)
1442 $success_wrapper_class = 'bp-thank_page-custom-logo_wrapper-success';
1443 if ( ! empty( $settings['better_payment_form_source'] ) && 'block' === $settings['better_payment_form_source'] ) {
1444 $success_wrapper_class .= ' pf-block';
1445 }
1446 ?>
1447 <div class="<?php echo esc_attr( $success_wrapper_class ); ?>">
1448 <?php
1449 // Render icon safely (handles FontAwesome, Dashicons, SVG with or without Elementor)
1450 if (!empty($settings['better_payment_form_success_message_icon'])) {
1451 self::render_icon($settings['better_payment_form_success_message_icon'], ['aria-hidden' => 'true']);
1452 } else {
1453 // Fallback to default icons
1454 echo '<i class="' . esc_attr($image_url) . '"></i>';
1455 }
1456 ?>
1457 </div>
1458 <?php endif; ?>
1459 <?php if( ! $show_icon ): ?>
1460 <span class="bp-thank_page-logo_wrapper">
1461 <img src="<?php echo esc_url($image_url); ?>" alt="Success logo">
1462 </span>
1463 <?php endif; ?>
1464 <?php endif; ?>
1465 </div>
1466 <div class="bp-thank_page-text">
1467 <h2 class="bp-font_ibm bp-page_header">
1468 <?php echo wp_kses_post( $better_payment_form_success_message_thanks ); ?>
1469 <span> &#127881;</span>
1470 </h2>
1471 <p class="bp-font_ibm bp-reason_for-text">
1472 <?php echo wp_kses_post( $payment_desc_text ); ?>
1473 </p>
1474 <?php if (isset( $settings['better_payment_form_email_enable'] ) && !empty( $tr_id['email'] )): ?>
1475 <p class="bp-font_ibm bp-payment_info">
1476 <?php echo wp_kses_post( $payment_mail_text ); ?>
1477 </p>
1478 <?php endif; ?>
1479
1480 <div class="bp-flex bp-transaction_info-wrapper">
1481 <p class="bp-flex bp-font_ibm bp-transaction_info"> <span class="line-height-0">
1482 <img src="<?php echo esc_url( BETTER_PAYMENT_ASSETS . '/img/wallet.svg' ); ?>" alt="Better Payment wallet">
1483 </span>
1484 <span>
1485 <?php echo wp_kses_post( $better_payment_form_success_message_transaction ); ?>:
1486 </span>
1487 <span class="bp-bold bp-transaction_id"><?php echo esc_html( ! empty( $tr_id['transaction_id']) ? $tr_id['transaction_id'] : '' ); ?></span>
1488 </p>
1489
1490 <button class="bp-transaction_data-copy_btn ">
1491 <svg width="20" height="20" viewBox="0 0 20 20"
1492 fill="none" xmlns="http://www.w3.org/2000/svg">
1493 <path
1494 d="M15.0002 15.601C16.3257 15.601 17.4002 14.5265 17.4002 13.201V8.40098C17.4002 5.38399 17.4002 3.87549 16.463 2.93823C15.5257 2.00098 14.0172 2.00098 11.0002 2.00098H7.80024C6.47476 2.00098 5.40024 3.07549 5.40024 4.40098M10.2002 18.001H7.80024C5.5375 18.001 4.40613 18.001 3.70319 17.298C3.00024 16.5951 3.00024 15.4637 3.00024 13.201V9.20098C3.00024 6.93823 3.00024 5.80686 3.70319 5.10392C4.40613 4.40098 5.5375 4.40098 7.80024 4.40098H10.2002C12.463 4.40098 13.5944 4.40098 14.2973 5.10392C15.0002 5.80686 15.0002 6.93824 15.0002 9.20098V13.201C15.0002 15.4637 15.0002 16.5951 14.2973 17.298C13.5944 18.001 12.463 18.001 10.2002 18.001Z"
1495 stroke-width="1.152" />
1496 </svg>
1497 </button>
1498 </div>
1499 </div>
1500
1501 <div class="bp-thank_page-info">
1502 <ul class="bp-page_info-list">
1503 <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1504 <span class="bp-info_content">
1505 <?php
1506 echo wp_kses_post( $better_payment_form_success_message_amount_text );
1507 ?>
1508 </span>
1509 <span class="bp-info_content bp-bold"><?php echo $bp__currency_symbol . esc_html( ! empty( $tr_id['amount'] ) ? $tr_id['amount'] : 0 ); ?></span>
1510 </li>
1511 <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1512 <span class=" bp-info_content">
1513 <?php
1514 echo wp_kses_post( $better_payment_form_success_message_currency_text );
1515 ?>
1516 </span>
1517 <span class="bp-info_content bp-bold"><?php echo esc_html( ! empty( $tr_id['currency'] ) ? $tr_id['currency'] : '' ); ?></span>
1518 </li>
1519 <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1520 <span class="bp-info_content">
1521 <?php
1522 echo wp_kses_post( $better_payment_form_success_message_pay_method_text );
1523 ?>
1524 </span>
1525 <div class="bp-payment_method-logo-wrapper">
1526 <?php if ( !empty( $payment_method_logo ) ) : ?>
1527 <img src="<?php echo esc_url( $payment_method_logo ); ?>" alt="<?php echo esc_attr( $tr_id['method'] ); ?>" class="bp-payment_method-logo">
1528 <?php endif; ?>
1529 </div>
1530 </li>
1531 <?php foreach ( $pro_rows_after_payment_method as $pro_row ) : ?>
1532 <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1533 <span class="bp-info_content">
1534 <?php echo wp_kses_post( $pro_row['label'] ); ?>
1535 </span>
1536 <span class="bp-info_content bp-bold"><?php echo wp_kses_post( $pro_row['value'] ); ?></span>
1537 </li>
1538 <?php endforeach; ?>
1539 <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1540 <span class="bp-info_content">
1541 <?php
1542 echo wp_kses_post( $better_payment_form_success_message_merchant_details_text );
1543 ?>
1544 </span>
1545 <span class="bp-info_content bp-bold">
1546 <?php echo wp_kses_post( $better_payment_form_success_message_merchant_details_value ); ?>
1547 </span>
1548 </li>
1549 <?php foreach ( $pro_rows_after_merchant_details as $pro_row ) : ?>
1550 <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1551 <span class="bp-info_content">
1552 <?php echo wp_kses_post( $pro_row['label'] ); ?>
1553 </span>
1554 <span class="bp-info_content bp-bold"><?php echo wp_kses_post( $pro_row['value'] ); ?></span>
1555 </li>
1556 <?php endforeach; ?>
1557 <?php
1558 $has_woo_products = !empty($settings['better_payment_form_woocommerce_product_id']) || !empty($settings['better_payment_form_woocommerce_product_ids']);
1559 $has_fluentcart_products = !empty($settings['better_payment_form_fluentcart_product_id']) || !empty($settings['better_payment_form_fluentcart_product_ids']);
1560
1561 if( $has_woo_products || $has_fluentcart_products ):
1562 ?>
1563 <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1564 <span class="bp-info_content">
1565 <?php
1566 echo wp_kses_post( $better_payment_form_success_message_purchase_details_text );
1567 ?>
1568 </span>
1569 <?php
1570 $product_names = [];
1571
1572 if( !empty( $settings['better_payment_form_woocommerce_product_id'] ) ):
1573 $product_names[] = get_the_title( $settings['better_payment_form_woocommerce_product_id'] );
1574 endif;
1575
1576 if( !empty( $settings['better_payment_form_woocommerce_product_ids'] ) ):
1577 $product_ids = $settings['better_payment_form_woocommerce_product_ids'];
1578 foreach( $product_ids as $product_id ):
1579 $product_names[] = get_the_title( $product_id );
1580 endforeach;
1581 endif;
1582
1583 if( !empty( $settings['better_payment_form_fluentcart_product_id'] ) && function_exists('fluentCart') && class_exists( '\FluentCart\App\Models\Product' ) ):
1584 try {
1585 $fluentcart_product = \FluentCart\App\Models\Product::query()->find($settings['better_payment_form_fluentcart_product_id']);
1586 if ($fluentcart_product) {
1587 $product_names[] = sanitize_text_field( $fluentcart_product->post_title );
1588 }
1589 } catch ( \Exception $e ) {
1590 //
1591 }
1592 endif;
1593
1594 if( !empty( $settings['better_payment_form_fluentcart_product_ids'] ) && function_exists('fluentCart') && class_exists( '\FluentCart\App\Models\Product' ) ):
1595 $product_ids = $settings['better_payment_form_fluentcart_product_ids'];
1596 foreach( $product_ids as $product_id ):
1597 try {
1598 $fluentcart_product = \FluentCart\App\Models\Product::query()->find($product_id);
1599 if ($fluentcart_product) {
1600 $product_names[] = sanitize_text_field( $fluentcart_product->post_title );
1601 }
1602 } catch ( \Exception $e ) {
1603 //
1604 }
1605 endforeach;
1606 endif;
1607
1608 if( !empty($product_names) ):
1609 echo '<span class="bp-info_content bp-bold">' . implode( ', ', $product_names ) . '</span>';
1610 endif;
1611 ?>
1612 </li>
1613 <?php endif; ?>
1614 </ul>
1615 </div>
1616
1617 <div class="bp-flex bp-button_group">
1618 <button class="bp-flex bp-font_ibm bp-print_btn">
1619 <span style="line-height: 0;">
1620 <img src="<?php echo esc_url( BETTER_PAYMENT_ASSETS . '/img/print.svg' ); ?>" alt="Better Payment print">
1621 </span>
1622 <span class="bp-font_ibm">
1623 <?php echo wp_kses_post( $better_payment_form_success_message_print_text ); ?>
1624 </span>
1625 </button>
1626
1627 <div class="bp-flex bp-btn_wrapper">
1628 <a href="<?php echo $better_payment_form_success_page_view_details_url; ?>" target="_blank" class="bp-font_ibm bp-details_btn">
1629 <?php echo wp_kses_post( $better_payment_form_success_message_view_details_btn_text ); ?>
1630 </a>
1631 </div>
1632 </div>
1633 </div>
1634 </section>
1635 <?php
1636 }
1637
1638 /**
1639 * Error message template
1640 *
1641 * @since 0.0.1
1642 */
1643 public static function failed_message_template( $settings = [], $transaction_data = false ) {
1644
1645 $image_url = BETTER_PAYMENT_ASSETS . '/img/fail.svg';
1646 $show_icon = 0;
1647 $default_icon = 1;
1648 $failed_heading = !empty( $settings['better_payment_form_error_message_heading'] ) ? esc_html( $settings['better_payment_form_error_message_heading'] ) : esc_html__( 'Payment Failed!', 'better-payment' );
1649 $failed_sub_heading = !empty( $settings['better_payment_form_error_message_sub_heading'] ) ? esc_html( $settings['better_payment_form_error_message_sub_heading'] ) : esc_html__( 'Your payment has failed. Please check your payment details', 'better-payment' );
1650 $transaction_id_text = !empty( $settings['better_payment_form_error_message_transaction_id_text'] ) ? esc_html( $settings['better_payment_form_error_message_transaction_id_text'] ) : esc_html__( 'Transaction ID', 'better-payment' );
1651 $show_details_button = !empty( $settings['better_payment_form_error_details_button_switch'] ) && $settings['better_payment_form_error_details_button_switch'] == 'yes' ? true : false;
1652 $details_button_text = !empty( $settings['better_payment_form_error_details_button_text'] ) ? esc_html( $settings['better_payment_form_error_details_button_text'] ) : esc_html__( 'View Details', 'better-payment' );
1653 $better_payment_form_error_details_page_url = !empty( $settings['better_payment_form_error_details_page_url']['url'] ) ? esc_url( $settings['better_payment_form_error_details_page_url']['url'] ) : 'javascript:void(0)';
1654 $store_name = ( isset( $settings['better_payment_form_payment_source'] ) && $settings['better_payment_form_payment_source'] === 'manual' && !empty( $settings['better_payment_form_title'] ) )
1655 ? esc_html($settings['better_payment_form_title'])
1656 : esc_html( get_bloginfo('name') );
1657
1658 // Get transaction details
1659 $transaction_amount = '';
1660 $transaction_id = '';
1661 $currency_symbol = '';
1662
1663 if ( !empty( $transaction_data ) ) {
1664 $transaction_amount = !empty( $transaction_data['amount'] ) ? floatval( $transaction_data['amount'] ) : '';
1665 $currency_symbol = !empty( $transaction_data['currency_symbol'] ) ? esc_html( $transaction_data['currency_symbol'] ) : '';
1666 $transaction_id = !empty( $transaction_data['transaction_id'] ) ? esc_html( $transaction_data['transaction_id'] ) : '';
1667 }
1668
1669 if ( !empty( $settings[ 'better_payment_form_error_message_icon' ][ 'library' ] ) ) {
1670 if ( $settings[ 'better_payment_form_error_message_icon' ][ 'library' ] == 'svg' ) {
1671 $image_url = $settings[ 'better_payment_form_error_message_icon' ][ 'value' ][ 'url' ];
1672 $default_icon = 0;
1673 } else {
1674 $show_icon = 1;
1675 $image_url = $settings[ 'better_payment_form_error_message_icon' ][ 'value' ];
1676 $default_icon = 0;
1677 }
1678 }
1679 ?>
1680 <section class="payment-failed-screen-section">
1681 <div class="bp-thank_page-wrapper">
1682 <div class="bp-thank_page-logo">
1683 <?php if( $default_icon ): ?>
1684 <span class="bp-thank_page-logo_wrapper">
1685 <svg width="58" height="58" viewBox="0 0 58 58" fill="none" xmlns="http://www.w3.org/2000/svg">
1686 <path d="M40.804 10.7998H16.8039C13.9409 10.8041 11.1964 11.9434 9.17192 13.9678C7.14746 15.9923 6.00822 18.7368 6.00391 21.5998H51.604C51.5997 18.7368 50.4604 15.9923 48.436 13.9678C46.4115 11.9434 43.667 10.8041 40.804 10.7998ZM6.00391 26.3998V35.9998C6.00822 38.8628 7.14746 41.6073 9.17192 43.6318C11.1964 45.6562 13.9409 46.7955 16.8039 46.7998H40.804C43.667 46.7955 46.4115 45.6562 48.436 43.6318C50.4604 41.6073 51.5997 38.8628 51.604 35.9998V26.3998H6.00391ZM24.0039 38.3998H16.8039C16.1674 38.3998 15.557 38.1469 15.1069 37.6968C14.6568 37.2468 14.4039 36.6363 14.4039 35.9998C14.4039 35.3633 14.6568 34.7529 15.1069 34.3028C15.557 33.8527 16.1674 33.5998 16.8039 33.5998H24.0039C24.6404 33.5998 25.2509 33.8527 25.7009 34.3028C26.151 34.7529 26.4039 35.3633 26.4039 35.9998C26.4039 36.6363 26.151 37.2468 25.7009 37.6968C25.2509 38.1469 24.6404 38.3998 24.0039 38.3998Z" fill="#F44336"></path>
1687 <circle cx="45.5" cy="41.5" r="6.5" fill="white"></circle>
1688 <foreignObject x="21.4686" y="17.3701" width="47.2066" height="47.3399">
1689 <div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(6.36px);clip-path:url(#bgblur_0_3595_8284_clip_path);height:100%;width:100%">
1690 </div>
1691 </foreignObject>
1692 <g filter="url(#filter0_d_3595_8284)" data-figma-bg-blur-radius="12.71">
1693 <path d="M55.765 38.782C55.3067 36.6386 54.2191 34.6825 52.6429 33.1664C50.6276 31.1788 47.9133 30.0699 45.0893 30.0804C42.1969 30.0848 39.4243 31.2411 37.3791 33.2958C35.334 35.3505 34.183 38.136 34.1786 41.0417V41.8849C34.2614 43.0459 34.5336 44.1853 34.9843 45.2576C36.0299 47.7766 37.9733 49.8131 40.4344 50.9688C42.8955 52.1245 45.6972 52.3162 48.2915 51.5064C50.8859 50.6967 53.0862 48.9437 54.4622 46.5904C55.8381 44.2371 56.2907 41.4528 55.7314 38.782H55.765ZM46.4154 44.7686L45.0893 43.4364L43.7632 44.7686C43.6072 44.9266 43.4215 45.0521 43.217 45.1377C43.0124 45.2233 42.793 45.2674 42.5714 45.2674C42.3498 45.2674 42.1304 45.2233 41.9259 45.1377C41.7213 45.0521 41.5357 44.9266 41.3796 44.7686C41.2223 44.6118 41.0974 44.4253 41.0122 44.2198C40.927 44.0143 40.8831 43.7939 40.8831 43.5713C40.8831 43.3486 40.927 43.1282 41.0122 42.9227C41.0974 42.7172 41.2223 42.5307 41.3796 42.374L41.8664 41.8849L42.7057 41.0417L41.3796 39.7095C41.0636 39.3919 40.886 38.9613 40.886 38.5122C40.886 38.0631 41.0636 37.6324 41.3796 37.3149C41.6957 36.9973 42.1244 36.8189 42.5714 36.8189C43.0184 36.8189 43.4471 36.9973 43.7632 37.3149L45.0893 38.6471L46.4154 37.3149C46.7314 36.9973 47.1601 36.8189 47.6071 36.8189C48.0541 36.8189 48.4828 36.9973 48.7989 37.3149C49.115 37.6324 49.2926 38.0631 49.2926 38.5122C49.2926 38.9613 49.115 39.3919 48.7989 39.7095L47.4729 41.0417L48.2618 41.8343L48.7989 42.374C48.9563 42.5307 49.0811 42.7172 49.1664 42.9227C49.2516 43.1282 49.2954 43.3486 49.2954 43.5713C49.2954 43.7939 49.2516 44.0143 49.1664 44.2198C49.0811 44.4253 48.9563 44.6118 48.7989 44.7686C48.6429 44.9266 48.4572 45.0521 48.2527 45.1377C48.0481 45.2233 47.8287 45.2674 47.6071 45.2674C47.3855 45.2674 47.1661 45.2233 46.9616 45.1377C46.7571 45.0521 46.5714 44.9266 46.4154 44.7686Z" fill="#F44336" fill-opacity="0.5" shape-rendering="crispEdges"></path>
1694 </g>
1695 <defs>
1696 <filter id="filter0_d_3595_8284" x="21.4686" y="17.3701" width="47.2066" height="47.3399" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
1697 <feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood>
1698 <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"></feColorMatrix>
1699 <feOffset dx="-4"></feOffset>
1700 <feGaussianBlur stdDeviation="2"></feGaussianBlur>
1701 <feComposite in2="hardAlpha" operator="out"></feComposite>
1702 <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"></feColorMatrix>
1703 <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3595_8284"></feBlend>
1704 <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3595_8284" result="shape"></feBlend>
1705 </filter>
1706 <clipPath id="bgblur_0_3595_8284_clip_path" transform="translate(-21.4686 -17.3701)">
1707 <path d="M55.765 38.782C55.3067 36.6386 54.2191 34.6825 52.6429 33.1664C50.6276 31.1788 47.9133 30.0699 45.0893 30.0804C42.1969 30.0848 39.4243 31.2411 37.3791 33.2958C35.334 35.3505 34.183 38.136 34.1786 41.0417V41.8849C34.2614 43.0459 34.5336 44.1853 34.9843 45.2576C36.0299 47.7766 37.9733 49.8131 40.4344 50.9688C42.8955 52.1245 45.6972 52.3162 48.2915 51.5064C50.8859 50.6967 53.0862 48.9437 54.4622 46.5904C55.8381 44.2371 56.2907 41.4528 55.7314 38.782H55.765ZM46.4154 44.7686L45.0893 43.4364L43.7632 44.7686C43.6072 44.9266 43.4215 45.0521 43.217 45.1377C43.0124 45.2233 42.793 45.2674 42.5714 45.2674C42.3498 45.2674 42.1304 45.2233 41.9259 45.1377C41.7213 45.0521 41.5357 44.9266 41.3796 44.7686C41.2223 44.6118 41.0974 44.4253 41.0122 44.2198C40.927 44.0143 40.8831 43.7939 40.8831 43.5713C40.8831 43.3486 40.927 43.1282 41.0122 42.9227C41.0974 42.7172 41.2223 42.5307 41.3796 42.374L41.8664 41.8849L42.7057 41.0417L41.3796 39.7095C41.0636 39.3919 40.886 38.9613 40.886 38.5122C40.886 38.0631 41.0636 37.6324 41.3796 37.3149C41.6957 36.9973 42.1244 36.8189 42.5714 36.8189C43.0184 36.8189 43.4471 36.9973 43.7632 37.3149L45.0893 38.6471L46.4154 37.3149C46.7314 36.9973 47.1601 36.8189 47.6071 36.8189C48.0541 36.8189 48.4828 36.9973 48.7989 37.3149C49.115 37.6324 49.2926 38.0631 49.2926 38.5122C49.2926 38.9613 49.115 39.3919 48.7989 39.7095L47.4729 41.0417L48.2618 41.8343L48.7989 42.374C48.9563 42.5307 49.0811 42.7172 49.1664 42.9227C49.2516 43.1282 49.2954 43.3486 49.2954 43.5713C49.2954 43.7939 49.2516 44.0143 49.1664 44.2198C49.0811 44.4253 48.9563 44.6118 48.7989 44.7686C48.6429 44.9266 48.4572 45.0521 48.2527 45.1377C48.0481 45.2233 47.8287 45.2674 47.6071 45.2674C47.3855 45.2674 47.1661 45.2233 46.9616 45.1377C46.7571 45.0521 46.5714 44.9266 46.4154 44.7686Z"></path>
1708 </clipPath>
1709 </defs>
1710 </svg>
1711 </span>
1712 <?php else: ?>
1713 <?php if( $show_icon ): ?>
1714 <?php
1715 // Add 'pf-block' class if this is a Gutenberg block (not Elementor)
1716 $error_wrapper_class = 'bp-thank_page-custom-logo_wrapper-error';
1717 if ( ! empty( $settings['better_payment_form_source'] ) && 'block' === $settings['better_payment_form_source'] ) {
1718 $error_wrapper_class .= ' pf-block';
1719 }
1720 ?>
1721 <div class="<?php echo esc_attr( $error_wrapper_class ); ?>">
1722 <?php
1723 // Render icon safely (handles FontAwesome, Dashicons, SVG with or without Elementor)
1724 if (!empty($settings['better_payment_form_error_message_icon'])) {
1725 self::render_icon($settings['better_payment_form_error_message_icon'], ['aria-hidden' => 'true']);
1726 } else {
1727 // Fallback to default icons
1728 echo '<i class="' . esc_attr($image_url) . '"></i>';
1729 }
1730 ?>
1731 </div>
1732 <?php else: ?>
1733 <span class="bp-thank_page-logo_wrapper">
1734 <img src="<?php echo esc_url($image_url); ?>" alt="Error logo">
1735 </span>
1736 <?php endif; ?>
1737 <?php endif; ?>
1738 </div>
1739 <div class="bp-thank_page-text">
1740 <h2 class="bp-font_ibm bp-page_header"><?php echo wp_kses_post( $failed_heading ); ?></h2>
1741 <p class="bp-font_ibm bp-reason_for-text">Payment of <?php echo $currency_symbol . $transaction_amount; ?> to <?php echo $store_name; ?> failed.</p>
1742 <p class="bp-font_ibm bp-payment_info"><?php echo wp_kses_post( $failed_sub_heading ); ?></p>
1743
1744 <div class="bp-flex bp-transaction_info-wrapper">
1745 <p class="bp-flex bp-font_ibm bp-transaction_info"> <span>
1746 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
1747 <g clip-path="url(#clip0_2914_6421)">
1748 <path d="M12.2002 12.825C11.8821 12.825 11.6242 13.0829 11.6242 13.401C11.6242 13.7191 11.8821 13.977 12.2002 13.977V12.825ZM17.0002 13.401L17.4075 13.8083C17.5156 13.7002 17.5762 13.5537 17.5762 13.401C17.5762 13.2482 17.5156 13.1017 17.4075 12.9937L17.0002 13.401ZM14.993 14.5937C14.768 14.8186 14.768 15.1833 14.993 15.4083C15.2179 15.6332 15.5826 15.6332 15.8075 15.4083L14.993 14.5937ZM15.8075 11.3937C15.5826 11.1687 15.2179 11.1687 14.993 11.3937C14.768 11.6186 14.768 11.9833 14.993 12.2083L15.8075 11.3937ZM10.6002 16.377C10.9184 16.377 11.1762 16.1191 11.1762 15.801C11.1762 15.4829 10.9184 15.225 10.6002 15.225V16.377ZM16.4242 9.40098C16.4242 9.71909 16.6821 9.97698 17.0002 9.97698C17.3184 9.97698 17.5762 9.71909 17.5762 9.40098H16.4242ZM7.40024 13.177C7.71836 13.177 7.97624 12.9191 7.97624 12.601C7.97624 12.2829 7.71836 12.025 7.40024 12.025V13.177ZM4.20024 12.025C3.88213 12.025 3.62424 12.2829 3.62424 12.601C3.62424 12.9191 3.88213 13.177 4.20024 13.177V12.025ZM9.80024 13.177C10.1184 13.177 10.3762 12.9191 10.3762 12.601C10.3762 12.2829 10.1184 12.025 9.80024 12.025V13.177ZM9.40024 12.025C9.08213 12.025 8.82424 12.2829 8.82424 12.601C8.82424 12.9191 9.08213 13.177 9.40024 13.177V12.025ZM1.00024 7.22498C0.682128 7.22498 0.424244 7.48286 0.424244 7.80098C0.424244 8.11909 0.682128 8.37698 1.00024 8.37698V7.22498ZM17.0002 8.37698C17.3184 8.37698 17.5762 8.11909 17.5762 7.80098C17.5762 7.48286 17.3184 7.22498 17.0002 7.22498V8.37698ZM12.2002 13.977H17.0002V12.825H12.2002V13.977ZM16.593 12.9937L14.993 14.5937L15.8075 15.4083L17.4075 13.8083L16.593 12.9937ZM17.4075 12.9937L15.8075 11.3937L14.993 12.2083L16.593 13.8083L17.4075 12.9937ZM7.40024 3.57698H10.6002V2.42498H7.40024V3.57698ZM10.6002 15.225H7.40024V16.377H10.6002V15.225ZM7.40024 15.225C5.87547 15.225 4.78983 15.2238 3.96572 15.113C3.15819 15.0044 2.68857 14.8002 2.3448 14.4564L1.53021 15.271C2.1237 15.8645 2.87695 16.1289 3.81222 16.2547C4.73093 16.3782 5.90803 16.377 7.40024 16.377V15.225ZM0.424244 9.40098C0.424244 10.8932 0.423021 12.0703 0.546538 12.989C0.672282 13.9243 0.936721 14.6775 1.53021 15.271L2.3448 14.4564C2.00103 14.1127 1.79684 13.643 1.68827 12.8355C1.57747 12.0114 1.57624 10.9258 1.57624 9.40098H0.424244ZM10.6002 3.57698C12.125 3.57698 13.2107 3.5782 14.0348 3.689C14.8423 3.79757 15.3119 4.00176 15.6557 4.34553L16.4703 3.53094C15.8768 2.93745 15.1235 2.67301 14.1883 2.54727C13.2696 2.42375 12.0925 2.42498 10.6002 2.42498V3.57698ZM17.5762 9.40098C17.5762 7.90876 17.5775 6.73166 17.4539 5.81296C17.3282 4.87768 17.0638 4.12443 16.4703 3.53094L15.6557 4.34553C15.9995 4.6893 16.2037 5.15892 16.3122 5.96646C16.423 6.79056 16.4242 7.8762 16.4242 9.40098H17.5762ZM7.40024 2.42498C5.90803 2.42498 4.73093 2.42375 3.81222 2.54727C2.87695 2.67301 2.1237 2.93745 1.53021 3.53094L2.3448 4.34553C2.68857 4.00176 3.15819 3.79757 3.96572 3.689C4.78983 3.5782 5.87547 3.57698 7.40024 3.57698V2.42498ZM1.57624 9.40098C1.57624 7.8762 1.57747 6.79056 1.68827 5.96646C1.79684 5.15892 2.00103 4.6893 2.3448 4.34553L1.53021 3.53094C0.936721 4.12443 0.672282 4.87768 0.546538 5.81296C0.423021 6.73166 0.424244 7.90876 0.424244 9.40098H1.57624ZM7.40024 12.025H4.20024V13.177H7.40024V12.025ZM9.80024 12.025H9.40024V13.177H9.80024V12.025ZM1.00024 8.37698H17.0002V7.22498H1.00024V8.37698Z" fill="#8F9AB0"></path>
1749 </g>
1750 <defs>
1751 <clipPath id="clip0_2914_6421">
1752 <rect width="18" height="18" fill="white"></rect>
1753 </clipPath>
1754 </defs>
1755 </svg>
1756 </span>
1757 <span><?php echo wp_kses_post( $transaction_id_text ); ?>:</span>
1758 <span class="bp-bolt bp-transaction_id"><?php echo $transaction_id; ?></span>
1759 </p>
1760
1761 <button class="bp-transaction_data-copy_btn ">
1762 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
1763 <path d="M15.0002 15.601C16.3257 15.601 17.4002 14.5265 17.4002 13.201V8.40098C17.4002 5.38399 17.4002 3.87549 16.463 2.93823C15.5257 2.00098 14.0172 2.00098 11.0002 2.00098H7.80024C6.47476 2.00098 5.40024 3.07549 5.40024 4.40098M10.2002 18.001H7.80024C5.5375 18.001 4.40613 18.001 3.70319 17.298C3.00024 16.5951 3.00024 15.4637 3.00024 13.201V9.20098C3.00024 6.93823 3.00024 5.80686 3.70319 5.10392C4.40613 4.40098 5.5375 4.40098 7.80024 4.40098H10.2002C12.463 4.40098 13.5944 4.40098 14.2973 5.10392C15.0002 5.80686 15.0002 6.93824 15.0002 9.20098V13.201C15.0002 15.4637 15.0002 16.5951 14.2973 17.298C13.5944 18.001 12.463 18.001 10.2002 18.001Z" stroke-width="1.152"></path>
1764 </svg>
1765 </button>
1766 </div>
1767 </div>
1768 <?php if( $show_details_button ): ?>
1769 <div class="bp-btn_wrapper">
1770 <a href="<?php echo $better_payment_form_error_details_page_url; ?>" target="_blank" class="bp-font_ibm bp-details_btn"><?php echo $details_button_text; ?></a>
1771 </div>
1772 <?php endif; ?>
1773 </div>
1774 </section>
1775 <?php
1776 }
1777
1778 /**
1779 * Remove args from url
1780 *
1781 * @since 0.0.1
1782 */
1783 public static function remove_arg() {
1784 ?>
1785 <script>
1786
1787 if(typeof params === 'undefined'){
1788 let params = '';
1789 }
1790
1791 params = new URLSearchParams(location.search);
1792
1793 if (params.has('better_payment_paypal_status') || params.has('better_payment_stripe_status') || params.has('better_payment_paystack_status')) {
1794 params.delete('better_payment_paypal_status');
1795 params.delete('better_payment_stripe_status');
1796 params.delete('better_payment_paystack_status');
1797 params.delete('better_payment_widget_id');
1798 params.delete('better_payment_stripe_id');
1799 params.delete('better_payment_paystack_id');
1800 params.delete('item_number');
1801 window.history.replaceState({}, '', `${location.pathname}?${params}`);
1802 }
1803 </script>
1804 <?php
1805 }
1806
1807 /**
1808 * Get failed transaction data
1809 *
1810 * @since 1.0.0
1811 */
1812 public static function get_failed_transaction_data( $order_id = '' ) {
1813 if ( empty( $order_id ) ) {
1814 return false;
1815 }
1816
1817 global $wpdb;
1818 $table = "{$wpdb->prefix}better_payment";
1819
1820 $result = $wpdb->get_row(
1821 $wpdb->prepare(
1822 "SELECT amount, currency, transaction_id, order_id, source FROM $table WHERE order_id=%s LIMIT 1",
1823 $order_id
1824 )
1825 );
1826
1827 if ( !empty( $result ) ) {
1828 $helper_obj = new Helper();
1829 return [
1830 'amount' => floatval( $result->amount ),
1831 'currency' => sanitize_text_field( $result->currency ),
1832 'currency_symbol' => $helper_obj->get_currency_symbol( $result->currency ),
1833 'transaction_id' => !empty( $result->transaction_id ) ? sanitize_text_field( $result->transaction_id ) : 'N/A',
1834 'method' => sanitize_text_field( $result->source ),
1835 ];
1836 }
1837
1838 return false;
1839 }
1840
1841 /**
1842 * Whitelist the e-mail related settings that better_email_notification() reads.
1843 *
1844 * PayPal confirms payments asynchronously through the IPN listener
1845 * (handle_paypal_ipn), where the live Elementor widget / block settings are NOT
1846 * available. Stripe and Paystack build their e-mail synchronously on the return
1847 * page, so they still have the full settings array in hand. To reach parity we
1848 * persist this subset into the transaction's form_fields_info at payment-create
1849 * time, and the IPN handler rebuilds the same $settings to produce an identical
1850 * e-mail body (heading, From / To / Transaction Summary / footer sections,
1851 * subjects, headers) and to honour the "Send Email" on/off toggle.
1852 *
1853 * @param mixed $settings Full widget / block / form settings (guarded: non-array input returns []).
1854 *
1855 * @return array Whitelisted e-mail settings (safe to serialize into form_fields_info).
1856 * @since 2.2.2
1857 */
1858 public static function extract_email_settings( $settings = [] ) {
1859 $email_settings = [];
1860
1861 if ( ! is_array( $settings ) ) {
1862 return $email_settings;
1863 }
1864
1865 foreach ( $settings as $key => $value ) {
1866 if (
1867 0 === strpos( $key, 'better_payment_email_' )
1868 || 0 === strpos( $key, 'better_payment_form_email_' )
1869 || 'better_payment_form_title' === $key
1870 ) {
1871 $email_settings[ $key ] = $value;
1872 }
1873 }
1874
1875 return $email_settings;
1876 }
1877
1878 /**
1879 * Email template
1880 *
1881 * @since 0.0.1
1882 */
1883 public static function better_email_notification($transaction_id, $customer_email, $settings, $referrer='Stripe', $form_fields_info='', $is_elementor_form = 0){
1884 //Send Email: customer and admin
1885 $better_txn_id = sanitize_text_field( $transaction_id );
1886 $better_cus_email = sanitize_email( $customer_email );
1887 $default_subject = sprintf(__('Better Payment transaction on %s', 'better-payment'), esc_html(get_option('blogname')));
1888 $better_payment_global_settings = DB::get_settings();
1889
1890 $better_payment_email_to = !empty($better_payment_global_settings['better_payment_settings_general_email_to']) ? $better_payment_global_settings['better_payment_settings_general_email_to'] : get_option( 'admin_email' );
1891
1892 $better_payment_email_subject = !empty($better_payment_global_settings['better_payment_settings_general_email_subject']) ? $better_payment_global_settings['better_payment_settings_general_email_subject'] : $default_subject;
1893 $better_payment_email_content = !empty($better_payment_global_settings['better_payment_settings_general_email_message_admin']) ? $better_payment_global_settings['better_payment_settings_general_email_message_admin'] : 'Email Content';
1894 $better_payment_email_from = !empty($better_payment_global_settings['better_payment_settings_general_email_from_email']) ? $better_payment_global_settings['better_payment_settings_general_email_from_email'] : '';
1895 $better_payment_email_from_name = !empty($better_payment_global_settings['better_payment_settings_general_email_from_name']) ? $better_payment_global_settings['better_payment_settings_general_email_from_name'] : '';
1896 $better_payment_email_reply_to = !empty($better_payment_global_settings['better_payment_settings_general_email_reply_to']) ? $better_payment_global_settings['better_payment_settings_general_email_reply_to'] : '';
1897 $better_payment_email_cc = !empty($better_payment_global_settings['better_payment_settings_general_email_cc']) ? $better_payment_global_settings['better_payment_settings_general_email_cc'] : '';
1898 $better_payment_email_bcc = !empty($better_payment_global_settings['better_payment_settings_general_email_bcc']) ? $better_payment_global_settings['better_payment_settings_general_email_bcc'] : '';
1899 $better_payment_email_content_type = !empty($better_payment_global_settings['better_payment_settings_general_email_send_as']) ? $better_payment_global_settings['better_payment_settings_general_email_send_as'] : 'html';
1900
1901 $better_payment_email_subject_customer = !empty($better_payment_global_settings['better_payment_settings_general_email_subject_customer']) ? $better_payment_global_settings['better_payment_settings_general_email_subject_customer'] : $default_subject;
1902 $better_payment_email_content_customer = !empty($better_payment_global_settings['better_payment_settings_general_email_message_customer']) ? $better_payment_global_settings['better_payment_settings_general_email_message_customer'] : 'Email Content';
1903 $better_payment_email_from_customer = !empty($better_payment_global_settings['better_payment_settings_general_email_from_email_customer']) ? $better_payment_global_settings['better_payment_settings_general_email_from_email_customer'] : '';
1904 $better_payment_email_from_name_customer = !empty($better_payment_global_settings['better_payment_settings_general_email_from_name_customer']) ? $better_payment_global_settings['better_payment_settings_general_email_from_name_customer'] : '';
1905 $better_payment_email_reply_to_customer = !empty($better_payment_global_settings['better_payment_settings_general_email_reply_to_customer']) ? $better_payment_global_settings['better_payment_settings_general_email_reply_to_customer'] : '';
1906 $better_payment_email_cc_customer = !empty($better_payment_global_settings['better_payment_settings_general_email_cc_customer']) ? $better_payment_global_settings['better_payment_settings_general_email_cc_customer'] : '';
1907 $better_payment_email_bcc_customer = !empty($better_payment_global_settings['better_payment_settings_general_email_bcc_customer']) ? $better_payment_global_settings['better_payment_settings_general_email_bcc_customer'] : '';
1908 $better_payment_email_content_type_customer = !empty($better_payment_global_settings['better_payment_settings_general_email_send_as_customer']) ? $better_payment_global_settings['better_payment_settings_general_email_send_as_customer'] : 'html';
1909 $args = [];
1910
1911 $args['email_form_name'] = ! empty( $settings['better_payment_form_title'] ) ? sanitize_text_field( $settings['better_payment_form_title'] ) : 'N/A';
1912 $args['email_content_heading_show'] = ! empty( $settings['better_payment_email_content_heading'] ) && 'yes' === $settings['better_payment_email_content_heading'] ? 1 : 0;
1913 $args['email_content_from_section_show'] = ! empty( $settings['better_payment_email_content_from_section'] ) && 'yes' === $settings['better_payment_email_content_from_section'] ? 1 : 0;
1914 $args['email_content_to_section_show'] = ! empty( $settings['better_payment_email_content_to_section'] ) && 'yes' === $settings['better_payment_email_content_to_section'] ? 1 : 0;
1915 $args['email_content_transaction_summary_show'] = ! empty( $settings['better_payment_email_content_transaction_summary'] ) && 'yes' === $settings['better_payment_email_content_transaction_summary'] ? 1 : 0;
1916 $args['email_content_footer_text_show'] = ! empty( $settings['better_payment_email_content_footer_text'] ) && 'yes' === $settings['better_payment_email_content_footer_text'] ? 1 : 0;
1917 $args['email_content_customer'] = ! empty( $settings['better_payment_email_content_customer'] ) ? $settings['better_payment_email_content_customer'] : '';
1918 $args['email_content_admin'] = ! empty( $settings['better_payment_email_content'] ) ? $settings['better_payment_email_content'] : '';
1919 $args['is_elementor_form'] = $is_elementor_form;
1920
1921 if ( ! empty( $settings['better_payment_email_content_greeting'] ) ) {
1922 $args['email_content_greeting'] = $settings['better_payment_email_content_greeting'];
1923 }
1924
1925 if ( ! empty( $settings['better_payment_form_email_logo']['url'] ) ) {
1926 $args['email_logo_url'] = $settings['better_payment_form_email_logo']['url'];
1927 }
1928
1929 if ( ! empty( $settings['better_payment_form_email_attachment']['id'] ) ) {
1930 $args['email_attachment_id'] = intval( $settings['better_payment_form_email_attachment']['id'] );
1931 $args['email_attachment_path'] = get_attached_file( $args['email_attachment_id'] );
1932 }
1933
1934 if ( ! empty( $settings['better_payment_form_email_attachment_pdf_show'] ) && ! empty( $settings['better_payment_form_email_attachment_pdf']['id'] ) ) {
1935 $args['email_attachment_id'] = intval( $settings['better_payment_form_email_attachment_pdf']['id'] );
1936 $args['email_attachment_path'] = get_attached_file( $args['email_attachment_id'] );
1937 }
1938
1939 if(isset($settings['better_payment_email_to'])){
1940 $better_payment_email_to = sanitize_email($settings['better_payment_email_to']);
1941 }
1942
1943 if(isset($settings['better_payment_email_subject'])){
1944 $better_payment_email_subject = sanitize_text_field($settings['better_payment_email_subject']);
1945 }
1946
1947 if(isset($settings['better_payment_email_subject_customer'])){
1948 $better_payment_email_subject_customer = sanitize_text_field($settings['better_payment_email_subject_customer']);
1949 }
1950
1951 if(isset($settings['better_payment_email_content_type'])){
1952 $better_payment_email_content_type = sanitize_text_field($settings['better_payment_email_content_type']);
1953 }
1954
1955 if(isset($settings['better_payment_email_content_type_customer'])){
1956 $better_payment_email_content_type_customer = sanitize_text_field($settings['better_payment_email_content_type_customer']);
1957 }
1958
1959 $args['email_content_type'] = $better_payment_email_content_type;
1960 $args['email_content_type_customer'] = $better_payment_email_content_type_customer;
1961
1962 $line_break = $better_payment_email_content_type == 'html' ? '<br>' : "\n";
1963 $line_break_customer = $better_payment_email_content_type_customer == 'html' ? '<br>' : "\n";
1964
1965 global $wpdb;
1966 $table = "{$wpdb->prefix}better_payment";
1967 $transaction_obj = $wpdb->get_row(
1968 $wpdb->prepare( "SELECT * FROM $table WHERE transaction_id=%s limit 1", sanitize_text_field( $transaction_id ) )
1969 );
1970
1971 if(isset($settings['better_payment_email_content'])){
1972 $better_payment_email_content = sanitize_text_field($settings['better_payment_email_content']);
1973 $better_payment_email_content = self::better_render_email_body( $better_payment_email_content, $form_fields_info, $line_break, 'admin', $transaction_obj, $args );
1974 } else if ( !empty($better_payment_email_content) ){
1975 $better_payment_email_content = self::better_render_email_body( $better_payment_email_content, $form_fields_info, $line_break, 'admin', $transaction_obj, $args );
1976 }
1977
1978 if(isset($settings['better_payment_email_content_customer'])){
1979 $better_payment_email_content_customer = sanitize_text_field($settings['better_payment_email_content_customer']);
1980 $better_payment_email_content_customer = self::better_render_email_body( $better_payment_email_content_customer, $form_fields_info, $line_break_customer, 'customer', $transaction_obj, $args );
1981 } else if ( !empty($better_payment_email_content_customer) ){
1982 $better_payment_email_content_customer = self::better_render_email_body( $better_payment_email_content_customer, $form_fields_info, $line_break, 'customer', $transaction_obj, $args ); //$line_break_customer not used : need to check type on parent method and add headers accordingly.
1983 }
1984
1985 if(isset($settings['better_payment_email_from'])){
1986 $better_payment_email_from = sanitize_email($settings['better_payment_email_from']);
1987 }
1988
1989 if(isset($settings['better_payment_email_from_name'])){
1990 $better_payment_email_from_name = sanitize_text_field($settings['better_payment_email_from_name']);
1991 }
1992
1993 if(isset($settings['better_payment_email_reply_to'])){
1994 $better_payment_email_reply_to = sanitize_email($settings['better_payment_email_reply_to']);
1995 }
1996
1997 if(isset($settings['better_payment_email_cc'])){
1998 $better_payment_email_cc = sanitize_email($settings['better_payment_email_cc']);
1999 }
2000
2001 if(isset($settings['better_payment_email_bcc'])){
2002 $better_payment_email_bcc = sanitize_email($settings['better_payment_email_bcc']);
2003 }
2004
2005 //Customer Email
2006 if(isset($settings['better_payment_email_from_customer'])){
2007 $better_payment_email_from_customer = sanitize_email($settings['better_payment_email_from_customer']);
2008 }
2009
2010 if(isset($settings['better_payment_email_from_name_customer'])){
2011 $better_payment_email_from_name_customer = sanitize_text_field($settings['better_payment_email_from_name_customer']);
2012 }
2013
2014 if(isset($settings['better_payment_email_reply_to_customer'])){
2015 $better_payment_email_reply_to_customer = sanitize_email($settings['better_payment_email_reply_to_customer']);
2016 }
2017
2018 if(isset($settings['better_payment_email_cc_customer'])){
2019 $better_payment_email_cc_customer = sanitize_email($settings['better_payment_email_cc_customer']);
2020 }
2021
2022 if(isset($settings['better_payment_email_bcc_customer'])){
2023 $better_payment_email_bcc_customer = sanitize_email($settings['better_payment_email_bcc_customer']);
2024 }
2025
2026 if($better_payment_email_content == ''){
2027 $better_payment_email_content = __('New better payment transaction! ', 'better-payment');
2028 }
2029
2030 if($better_payment_email_content_customer == ''){
2031 $better_payment_email_content_customer = __('New better payment transaction! ', 'better-payment');
2032 }
2033
2034 $better_payment_email_headers = [];
2035 $better_payment_email_headers_customer = [];
2036
2037 if($better_payment_email_content_type == 'html'){
2038 $better_payment_email_headers[] = 'Content-Type: text/html; charset=UTF-8';
2039 }
2040
2041 if($better_payment_email_content_type_customer == 'html'){
2042 $better_payment_email_headers_customer[] = 'Content-Type: text/html; charset=UTF-8';
2043 }
2044
2045 if($better_payment_email_from != ''){
2046 $better_payment_email_headers[] = "From: $better_payment_email_from_name <$better_payment_email_from>";
2047 }
2048
2049 if($better_payment_email_cc != ''){
2050 $better_payment_email_headers[] = "Cc: <$better_payment_email_cc>";
2051 }
2052
2053 if($better_payment_email_bcc != ''){
2054 $better_payment_email_headers[] = "BCc: $better_payment_email_bcc";
2055 }
2056
2057 if($better_payment_email_reply_to != ''){
2058 $better_payment_email_headers[] = "Reply-To: $better_payment_email_reply_to";
2059 }
2060
2061 //Customer Email
2062 if($better_payment_email_from_customer != ''){
2063 $better_payment_email_headers_customer[] = "From: $better_payment_email_from_name_customer <$better_payment_email_from_customer>";
2064 }
2065
2066 $form_fields_info_array = [];
2067
2068 if( ! empty( $form_fields_info ) ){
2069 $form_fields_info_array = maybe_unserialize( $form_fields_info );
2070 }
2071
2072 $form_fields_info_cus_email = ! empty( $form_fields_info_array['primary_email'] ) ? sanitize_email( $form_fields_info_array['primary_email'] ) : '';
2073
2074 if($better_payment_email_cc_customer != '' || ! empty( $form_fields_info_cus_email ) ){
2075 $better_payment_email_cc_customer_multiple = implode(',', [$form_fields_info_cus_email, $better_payment_email_cc_customer]);
2076 $better_payment_email_headers_customer[] = "Cc: $better_payment_email_cc_customer_multiple";
2077 }
2078
2079 if($better_payment_email_bcc_customer != ''){
2080 $better_payment_email_headers_customer[] = "BCc: $better_payment_email_bcc_customer";
2081 }
2082
2083 if($better_payment_email_reply_to_customer != ''){
2084 $better_payment_email_headers_customer[] = "Reply-To: $better_payment_email_reply_to_customer";
2085 }
2086
2087 $email_attachments = ! empty( $args['email_attachment_path'] ) ? [ $args['email_attachment_path'] ] : [];
2088 $allowed_extensions = array('jpg', 'jpeg', 'png', 'pdf');
2089
2090 $file_info = count( $email_attachments ) ? pathinfo($email_attachments[0]) : [];
2091 $file_extension = count( $file_info ) ? strtolower($file_info['extension']) : '';
2092
2093 if ( ! in_array( $file_extension, $allowed_extensions ) ){
2094 $email_attachments = [];
2095 }
2096
2097 self::send_better_email($better_cus_email, $better_payment_email_subject_customer, $better_payment_email_content_customer, $better_payment_email_headers_customer, $email_attachments); //customer mail
2098 self::send_better_email($better_payment_email_to, $better_payment_email_subject, $better_payment_email_content, $better_payment_email_headers); //admin mail
2099 }
2100
2101 /**
2102 * Sends an HTML email.
2103 *
2104 * @since 0.0.2
2105 *
2106 * @param string $to
2107 * @param string $subject
2108 * @param array $message
2109 *
2110 * @return bool false indicates mail sending failed while true doesn't gurantee that mail sent.
2111 * true just indicate script processed successfully without any errors.
2112 * Ref: https://developer.wordpress.org/reference/functions/wp_mail/
2113 */
2114 public static function send_better_email( $to, $subject, $message, $headers=[], $attachments = [] ) {
2115 $response = false;
2116
2117 $mailValidated = filter_var($to, FILTER_VALIDATE_EMAIL);
2118
2119 if ($mailValidated) {
2120 $response = wp_mail( $to, $subject, $message, $headers, $attachments );
2121 }
2122
2123 return $response;
2124 }
2125
2126 /**
2127 * Email body
2128 *
2129 *
2130 * @return string
2131 * @since 0.0.1
2132 */
2133 public static function better_render_email_body( $email_body, $form_fields_info, $line_break, $type = 'admin', $transaction_obj = null, $args = [] ) {
2134 $transaction_id =
2135 $currency =
2136 $payment_date =
2137 $customer_name =
2138 $customer_first_name =
2139 $customer_last_name =
2140 $amount = '';
2141 $helper_obj = new Helper();
2142
2143 if(null !== $transaction_obj && is_object($transaction_obj)){
2144 $transaction_id = $transaction_obj->transaction_id;
2145 $amount = $transaction_obj->amount;
2146 $status = $transaction_obj->status;
2147 $source = $transaction_obj->source;
2148 $currency = $transaction_obj->currency;
2149 $currency_symbol = $helper_obj->get_currency_symbol( esc_html($currency) );
2150 $payment_date = wp_date(get_option('date_format').' '.get_option('time_format'), strtotime($transaction_obj->payment_date));
2151 }
2152
2153 $referer_content_page_link = '#';
2154
2155 $form_fields_info_arr = maybe_unserialize($form_fields_info);
2156 $form_fields_info_arr = is_array( $form_fields_info_arr ) ? $form_fields_info_arr : [];
2157
2158 $email_logo_url = ! empty( $args['email_logo_url'] ) ? $args['email_logo_url'] : '';
2159 $email_content_greeting = ! empty( $args['email_content_greeting'] ) ? 1 : 0;
2160 $email_form_name = ! empty( $args['email_form_name'] ) ? $args['email_form_name'] : 'N/A';
2161
2162 $form_field_rows = [];
2163
2164 foreach ( $form_fields_info_arr as $key => $field ) {
2165 //Hide few fields: storage keys, not fields the visitor filled in (the From section hides the same ones)
2166 if(
2167 $key === 'referer_page_id' || $key === 'referer_widget_id' || $key === 'source' || $key === 'el_form_fields'
2168 || $key === 'amount_quantity'
2169 || $key === 'woo_product_id'
2170 || $key === 'woo_product_ids'
2171 || $key === 'fluentcart_product_id'
2172 || $key === 'fluentcart_product_ids'
2173 || $key === 'detailed_product_info'
2174 || $key === 'email_settings'
2175 ) {
2176 if($key === 'referer_page_id'){
2177 $referer_content_page_link = !empty($field) ? get_permalink( $field ) : $referer_content_page_link;
2178 }
2179
2180 continue;
2181 }
2182
2183 $label_key = $key;
2184
2185 if($key === 'primary_first_name'){
2186 $label_key = 'first_name';
2187 $customer_first_name = $field;
2188 }
2189 if($key === 'primary_last_name'){
2190 $label_key = 'last_name';
2191 $customer_last_name = $field;
2192 }
2193
2194 if($key === 'primary_email'){
2195 $label_key = 'email';
2196 }
2197
2198 if($key === 'primary_payment_amount'){
2199 $label_key = 'payment_amount';
2200 }
2201
2202 $key_formatted = self::better_title_case($label_key);
2203 if($key_formatted === 'Amount'){
2204 $key_formatted = __('Paid', 'better-payment');
2205 }
2206
2207 $form_field_rows[] = [
2208 'key' => $key,
2209 'label' => $key_formatted,
2210 'value' => $field,
2211 ];
2212 }
2213
2214 // A Pro payment's own fields (split payments, layout 6) are hidden or relabelled by Pro.
2215 $all_fields_rows = PaymentEntitlement::email_rows( $form_field_rows, 'all_fields', $form_fields_info_arr );
2216
2217 if($customer_first_name) {
2218 $customer_name = $customer_first_name . ' ';
2219 }
2220 if($customer_last_name) {
2221 $customer_name .= $customer_last_name . ' ';
2222 }
2223
2224 $allowed_sources = ['paypal', 'stripe', 'paystack'];
2225 $source_image_url = BETTER_PAYMENT_ASSETS . '/img/stripe.png';
2226 $source_image_alt = 'Stripe';
2227
2228 if( in_array( strtolower( $transaction_obj->source ), $allowed_sources ) ){
2229 $source_image_url = strtolower( $transaction_obj->source ) == 'paypal' ? BETTER_PAYMENT_ASSETS . '/img/paypal.png' : BETTER_PAYMENT_ASSETS . "/img/{$transaction_obj->source}.png";
2230 $source_image_alt = strtolower( $transaction_obj->source ) == 'paypal' ? 'PayPal' : ucfirst( strtolower( $transaction_obj->source ) );
2231 }
2232
2233 $amount_quantity = ! empty( $form_fields_info_arr['amount_quantity'] ) ? intval( $form_fields_info_arr['amount_quantity'] ) : 1;
2234 // #ToDo Product id or ids with comma
2235 $woo_product_id = ! empty( $form_fields_info_arr['woo_product_id'] ) ? intval( $form_fields_info_arr['woo_product_id'] ) : 0;
2236 $fluentcart_product_id = ! empty( $form_fields_info_arr['fluentcart_product_id'] ) ? intval( $form_fields_info_arr['fluentcart_product_id'] ) : 0;
2237 $woo_product_ids = ! empty( $form_fields_info_arr['woo_product_ids'] ) ? maybe_unserialize( $form_fields_info_arr['woo_product_ids'] ) : [0];
2238 $fluentcart_product_ids = ! empty( $form_fields_info_arr['fluentcart_product_ids'] ) ? maybe_unserialize( $form_fields_info_arr['fluentcart_product_ids'] ) : [0];
2239 $product_name = '';
2240 $product_permalink = '';
2241 $product_image_src = '';
2242
2243 // Handle WooCommerce products
2244 if (function_exists('wc_get_product') && $woo_product_id ) {
2245 $bp_woocommerce_product = wc_get_product($woo_product_id);
2246 $product = $bp_woocommerce_product;
2247
2248 if ($product) {
2249 $product_name = $product->get_name();
2250 $product_permalink = get_permalink($product->get_id());
2251 $product_price = $product->get_price();
2252 $product_image_src_array = wp_get_attachment_image_src( get_post_thumbnail_id( $product->get_id() ), 'single-post-thumbnail' );
2253 $product_image_src = is_array( $product_image_src_array ) && count( $product_image_src_array ) ? $product_image_src_array[0] : '';
2254 }
2255 }
2256
2257 if (function_exists('fluentCart') && $fluentcart_product_id ) {
2258 try {
2259 $fluentcart_product = \FluentCart\App\Models\Product::query()
2260 ->with(['detail', 'variants'])
2261 ->find($fluentcart_product_id);
2262
2263 if ($fluentcart_product) {
2264 $product_name = $fluentcart_product->post_title;
2265 $product_permalink = get_permalink($fluentcart_product->ID);
2266
2267 // Get price from the first variant or detail
2268 $product_price = 0;
2269 if (!empty($fluentcart_product->variants) && count($fluentcart_product->variants) > 0) {
2270 $product_price = $fluentcart_product->variants[0]->item_price;
2271 } elseif (!empty($fluentcart_product->detail)) {
2272 $product_price = $fluentcart_product->detail->min_price;
2273 }
2274
2275 $product_image_src_array = wp_get_attachment_image_src( get_post_thumbnail_id( $fluentcart_product->ID ), 'single-post-thumbnail' );
2276 $product_image_src = is_array( $product_image_src_array ) && count( $product_image_src_array ) ? $product_image_src_array[0] : '';
2277 }
2278 } catch ( \Exception $e ) {
2279 // Handle error silently
2280 }
2281 }
2282
2283 $detailed_product_info = isset( $form_fields_info_arr['detailed_product_info'] ) ? maybe_unserialize( $form_fields_info_arr['detailed_product_info'] ) : [];
2284
2285 $all_data = [
2286 'amount' => $amount,
2287 'amount_quantity' => $amount_quantity,
2288 'woo_product_id' => $woo_product_id,
2289 'woo_product_ids' => $woo_product_ids,
2290 'fluentcart_product_id' => $fluentcart_product_id,
2291 'fluentcart_product_ids' => $fluentcart_product_ids,
2292 'product_name' => $product_name,
2293 'product_permalink' => $product_permalink,
2294 'product_image_src' => $product_image_src,
2295 'detailed_product_info' => $detailed_product_info,
2296 'amount_single' => $amount_quantity > 0 ? floatval( $amount / $amount_quantity ) : $amount,
2297 'currency' => ! empty( $currency ) ? $currency : '',
2298 'currency_symbol' => ! empty( $currency_symbol ) ? $currency_symbol : '',
2299 'payment_date_time' => ! empty( $payment_date ) ? $payment_date : '',
2300 'transaction_id' => ! empty( $transaction_id ) ? $transaction_id : '',
2301 'payment_date_only' => wp_date(get_option('date_format'), strtotime($transaction_obj->payment_date)),
2302 'status' => ucfirst( $status ),
2303 'form_fields_info_arr' => $form_fields_info_arr,
2304 // 'admin_name' => $admin_name,
2305 'customer_name' => $customer_name,
2306 'site_title' => get_bloginfo( 'name' ),
2307 'site_admin_email' => get_bloginfo( 'admin_email' ),
2308 'form_name' => $email_form_name,
2309 'payment_method' => ucfirst( $source ),
2310 'email_logo_url' => $email_logo_url,
2311 'source_image_url' => $source_image_url,
2312 'source_image_alt' => $source_image_alt,
2313 'view_transaction_link' => admin_url("admin.php?page=better-payment-transactions&action=view&id={$transaction_obj->id}"),
2314 'email_content_heading_show' => intval( $args['email_content_heading_show'] ),
2315 'email_content_from_section_show' => intval( $args['email_content_from_section_show'] ),
2316 'email_content_to_section_show' => intval( $args['email_content_to_section_show'] ),
2317 'email_content_transaction_summary_show' => intval( $args['email_content_transaction_summary_show'] ),
2318 'email_content_footer_text_show' => intval( $args['email_content_footer_text_show'] ),
2319 // The form's messages, with `[bp-all-fields]` replaced where the author placed it.
2320 'email_content_customer' => self::render_email_message( $args['email_content_customer'] ?? '', $all_fields_rows, 'plain' === ( $args['email_content_type_customer'] ?? 'html' ) ),
2321 'email_content_admin' => self::render_email_message( $args['email_content_admin'] ?? '', $all_fields_rows, 'plain' === ( $args['email_content_type'] ?? 'html' ) ),
2322 'is_elementor_form' => intval( $args['is_elementor_form'] ),
2323 'email_content_type' => $args['email_content_type'] ?? 'html',
2324 'email_content_type_customer' => $args['email_content_type_customer'] ?? 'html',
2325 ];
2326
2327 // Email V2: the template is the whole body.
2328 ob_start();
2329
2330 include BETTER_PAYMENT_ADMIN_VIEWS_PATH . "/template-email-notification.php";
2331
2332 $email_body = ob_get_contents();
2333 ob_end_clean();
2334
2335 return $email_body;
2336 }
2337
2338 /**
2339 * A form's e-mail message, with `[bp-all-fields]` replaced by the submitted fields.
2340 *
2341 * The template prints the message; the fields used to be built into a body the template's
2342 * output then overwrote, so the token reached every e-mail as literal text. The message is
2343 * the form's own setting and is kses-filtered as it always was. The fields are the
2344 * visitor's, so they are escaped here and inserted after the filter — never passed through
2345 * it or do_shortcode(), where a submitted value could add markup or run a shortcode.
2346 *
2347 * @since 2.4.0
2348 *
2349 * @param mixed $message The form's message setting.
2350 * @param array[] $rows `label` and `value` of each field to list, in order.
2351 * @param bool $is_plain Whether the e-mail is sent as plain text.
2352 * @return string
2353 */
2354 public static function render_email_message( $message, array $rows, $is_plain = false ) {
2355 $message = wp_kses_post( is_scalar( $message ) ? (string) $message : '' );
2356
2357 if ( false === strpos( $message, '[bp-all-fields]' ) ) {
2358 return $message;
2359 }
2360
2361 return str_replace( '[bp-all-fields]', self::email_all_fields_markup( $rows, $is_plain ), $message );
2362 }
2363
2364 /**
2365 * The `[bp-all-fields]` block: a two-column table, or `Label: value` lines in a plain-text e-mail.
2366 *
2367 * @param array[] $rows `label` and `value` of each field.
2368 * @param bool $is_plain Plain text instead of HTML.
2369 * @return string
2370 */
2371 private static function email_all_fields_markup( array $rows, $is_plain ) {
2372 $lines = [];
2373 $cells = '';
2374
2375 foreach ( $rows as $row ) {
2376 if ( ! isset( $row['label'] ) || ! is_scalar( $row['label'] ) || ! array_key_exists( 'value', $row ) ) {
2377 continue;
2378 }
2379
2380 // A nested value (stored settings, a product list) is not something a visitor typed.
2381 if ( ! is_scalar( $row['value'] ) && null !== $row['value'] ) {
2382 continue;
2383 }
2384
2385 $label = (string) $row['label'];
2386 $value = (string) $row['value'];
2387
2388 if ( $is_plain ) {
2389 $lines[] = rtrim( wp_strip_all_tags( $label ), ': ' ) . ': ' . wp_strip_all_tags( $value );
2390 continue;
2391 }
2392
2393 $cells .= '<tr>'
2394 . '<td style="padding: 7px; border: 1px solid #666;"><strong>' . esc_html( $label ) . '</strong></td>'
2395 . '<td style="padding: 7px; border: 1px solid #666;">' . esc_html( $value ) . '</td>'
2396 . '</tr>';
2397 }
2398
2399 if ( $is_plain ) {
2400 return implode( "\n", $lines );
2401 }
2402
2403 return '<table class="bp-all-fields" style="margin-bottom: 20px; font-size: 14px; border-collapse: collapse; width: 100%;">' . $cells . '</table>';
2404 }
2405
2406 /**
2407 * String helper method
2408 * helps to convert string to title case
2409 *
2410 * @return string
2411 * @since 0.0.1
2412 */
2413 public static function better_title_case($string){
2414 $string = str_replace('_',' ', $string);
2415 $string = ucwords($string);
2416 return $string;
2417 }
2418
2419 /**
2420 * Safe icon rendering for payment form messages
2421 *
2422 * Handles multiple icon types: FontAwesome, Dashicons, SVG from media library
2423 * Works with or without Elementor being active
2424 *
2425 * @param array $icon_settings Icon settings from block/widget controls
2426 * @param array $html_attributes Optional HTML attributes for the icon element
2427 *
2428 * @return void (outputs HTML directly)
2429 * @since 2.0.4
2430 */
2431 public static function render_icon( $icon_settings = [], $html_attributes = [] ) {
2432 // Bail if no settings provided
2433 if ( empty( $icon_settings ) || ! is_array( $icon_settings ) ) {
2434 return;
2435 }
2436
2437 // If value is empty, nothing to render
2438 if ( empty( $icon_settings['value'] ) ) {
2439 return;
2440 }
2441
2442 $library = ! empty( $icon_settings['library'] ) ? $icon_settings['library'] : '';
2443 $value = $icon_settings['value'];
2444
2445 // Try to use Elementor's Icons_Manager if it's available and loaded
2446 if ( did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Icons_Manager' ) ) {
2447 try {
2448 \Elementor\Icons_Manager::render_icon( $icon_settings, $html_attributes );
2449 return;
2450 } catch ( \Exception $e ) {
2451 // If Elementor rendering fails, fall back to manual rendering
2452 }
2453 }
2454
2455 // Handle SVG (uploaded via media library)
2456 if ( $library === 'svg' ) {
2457 if ( is_array( $value ) && ! empty( $value['url'] ) ) {
2458 $svg_url = esc_url( $value['url'] );
2459 echo '<img src="' . $svg_url . '" alt="Icon" class="bp-icon-svg" />';
2460 return;
2461 }
2462 }
2463
2464 // Handle FontAwesome icons (all variants: fa, fas, far, fab, fal, fad, etc.)
2465 if ( in_array( $library, [ 'fa', 'fas', 'far', 'fab', 'fal', 'fad', 'fontawesome', 'font-awesome' ], true ) ) {
2466 if ( is_string( $value ) && ! empty( $value ) ) {
2467 // Build complete FontAwesome class
2468 $icon_class = 'fa';
2469
2470 // Add variant if specified (fas, far, fab, fal)
2471 if ( ! empty( $library ) && $library !== 'fa' && $library !== 'fontawesome' && $library !== 'font-awesome' ) {
2472 $icon_class = esc_attr( $library );
2473 } else {
2474 // Default to 'fas' if no variant specified
2475 $icon_class = 'fas';
2476 }
2477
2478 // Add the specific icon class
2479 $icon_class .= ' ' . esc_attr( $value );
2480
2481 $html_class = ! empty( $html_attributes['class'] ) ? ' ' . $html_attributes['class'] : '';
2482 $aria_hidden = isset( $html_attributes['aria-hidden'] ) ? ' aria-hidden="' . esc_attr( $html_attributes['aria-hidden'] ) . '"' : ' aria-hidden="true"';
2483
2484 echo '<i class="' . $icon_class . $html_class . '"' . $aria_hidden . '></i>';
2485 return;
2486 }
2487 }
2488
2489 // Handle Dashicons
2490 if ( in_array( $library, [ 'dashicons' ], true ) ) {
2491 if ( is_string( $value ) && ! empty( $value ) ) {
2492 $icon_class = 'dashicons dashicons-' . esc_attr( $value );
2493
2494 $html_class = ! empty( $html_attributes['class'] ) ? ' ' . $html_attributes['class'] : '';
2495 $aria_hidden = isset( $html_attributes['aria-hidden'] ) ? ' aria-hidden="' . esc_attr( $html_attributes['aria-hidden'] ) . '"' : ' aria-hidden="true"';
2496
2497 echo '<span class="' . $icon_class . $html_class . '"' . $aria_hidden . '></span>';
2498 return;
2499 }
2500 }
2501
2502 // Handle custom CSS classes (like 'bp-icon bp-check-circle')
2503 // These are custom icon fonts or CSS-based icons defined by the theme/plugin
2504 if ( 'custom' === $library ) {
2505 if ( is_string( $value ) && ! empty( $value ) ) {
2506 $html_class = ! empty( $html_attributes['class'] ) ? ' ' . $html_attributes['class'] : '';
2507 $aria_hidden = isset( $html_attributes['aria-hidden'] ) ? ' aria-hidden="' . esc_attr( $html_attributes['aria-hidden'] ) . '"' : ' aria-hidden="true"';
2508
2509 echo '<i class="' . esc_attr( $value ) . $html_class . '"' . $aria_hidden . '></i>';
2510 return;
2511 }
2512 }
2513
2514 // If value is a plain string (might be a FontAwesome class without library specified)
2515 if ( is_string( $value ) && ! empty( $value ) && empty( $library ) ) {
2516 // Check if it looks like a FontAwesome class
2517 if ( strpos( $value, 'fa' ) !== false || strpos( $value, 'fa-' ) !== false ) {
2518 $icon_class = 'fas ' . esc_attr( $value );
2519
2520 $html_class = ! empty( $html_attributes['class'] ) ? ' ' . $html_attributes['class'] : '';
2521 $aria_hidden = isset( $html_attributes['aria-hidden'] ) ? ' aria-hidden="' . esc_attr( $html_attributes['aria-hidden'] ) . '"' : ' aria-hidden="true"';
2522
2523 echo '<i class="' . $icon_class . $html_class . '"' . $aria_hidden . '></i>';
2524 return;
2525 }
2526
2527 // Check if it looks like a dashicon
2528 if ( strpos( $value, 'dashicon' ) !== false || strpos( $value, 'dashicons-' ) !== false ) {
2529 $icon_class = 'dashicons ' . esc_attr( $value );
2530
2531 $html_class = ! empty( $html_attributes['class'] ) ? ' ' . $html_attributes['class'] : '';
2532 $aria_hidden = isset( $html_attributes['aria-hidden'] ) ? ' aria-hidden="' . esc_attr( $html_attributes['aria-hidden'] ) . '"' : ' aria-hidden="true"';
2533
2534 echo '<span class="' . $icon_class . $html_class . '"' . $aria_hidden . '></span>';
2535 return;
2536 }
2537 }
2538 }
2539 }
2540