PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
← All changes | includes/Classes/Handler.php +2050 -293 1.0.4 → 2.4.1 View file →
@@ -3,8 +3,10 @@
3 3 namespace Better_Payment\Lite\Classes;
4 4
5 5 use Better_Payment\Lite\Admin\DB;
6 6 use Better_Payment\Lite\Controller;
7 +use Better_Payment\Lite\Classes\Helper;
8 +use Better_Payment\Lite\Traits\Helper as TraitsHelper;
7 9
8 10 /**
9 11 * Exit if accessed directly
10 12 */
@@ -17,18 +19,34 @@
17 19 *
18 20 * @since 0.0.1
19 21 */
20 22 class Handler extends Controller{
23 + use TraitsHelper;
21 24
22 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 + /**
23 36 * PayPal button
24 37 *
25 38 * @since 0.0.1
26 39 */
27 - public static function paypal_button( $widget_id = '' , $settings = [] ) {
40 + public static function paypal_button( $widget_id = '' , $settings = [], $args = [] ) {
28 41 global $wp;
29 42
30 - $paypal_button_text = ! empty( $settings["better_payment_form_form_buttons_paypal_button_text"] ) ? $settings["better_payment_form_form_buttons_paypal_button_text"] : __('Proceed to Payment', 'better-payment');
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 +
31 49 $return_url = add_query_arg( $wp->query_vars, get_the_permalink() . '?better_payment_paypal_status=success&better_payment_widget_id=' . $widget_id );
32 50 $cancel_url = add_query_arg( $wp->query_vars, get_the_permalink() . '?better_payment_error_status=error&better_payment_widget_id=' . $widget_id );
33 51
34 52 ob_start();
@@ -39,9 +57,9 @@
39 57 <input type="hidden" name="return" value="<?php echo esc_url( $return_url ); ?>">
40 58 <input type="hidden" name="action" value="paypal_form_handle">
41 59 <input type="hidden" name="security" value="<?php echo esc_attr( wp_create_nonce('better-payment-paypal') ); ?>">
42 60 <input type="hidden" name="cancel_return" value="<?php echo esc_url( $cancel_url ); ?>">
43 - <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_html( $paypal_button_text ); ?></button>
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>
44 62
45 63 <?php
46 64 $paypal_button_html = ob_get_clean();
47 65 $paypal_button_html = apply_filters( 'better_payment/elementor/editor/paypal_button_html', $paypal_button_html, $widget_id, $settings );
@@ -53,14 +71,18 @@
53 71 * Stripe button
54 72 *
55 73 * @since 0.0.1
56 74 */
57 - public static function stripe_button( $widget_id = '' , $settings = []) {
58 - $stripe_button_text = ! empty( $settings["better_payment_form_form_buttons_stripe_button_text"] ) ? $settings["better_payment_form_form_buttons_stripe_button_text"] : __('Proceed to Payment', 'better-payment');
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';
59 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 +
60 82 ob_start();
61 83 ?>
62 - <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_html( $stripe_button_text ); ?></button>
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>
63 85 <?php
64 86 $stripe_button_html = ob_get_clean();
65 87 $stripe_button_html = apply_filters( 'better_payment/elementor/editor/stripe_button_html', $stripe_button_html, $widget_id, $settings );
66 88
@@ -71,14 +93,18 @@
71 93 * Paystack button
72 94 *
73 95 * @since 0.0.1
74 96 */
75 - public static function paystack_button( $widget_id = '' , $settings = []) {
76 - $paystack_button_text = ! empty( $settings["better_payment_form_form_buttons_paystack_button_text"] ) ? $settings["better_payment_form_form_buttons_paystack_button_text"] : __('Proceed to Payment', 'better-payment');
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';
77 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 +
78 104 ob_start();
79 105 ?>
80 - <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_html( $paystack_button_text ); ?></button>
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>
81 107 <?php
82 108 $paystack_button_html = ob_get_clean();
83 109 $paystack_button_html = apply_filters( 'better_payment/elementor/editor/paystack_button_html', $paystack_button_html, $widget_id, $settings );
84 110
@@ -129,15 +155,15 @@
129 155 }
130 156
131 157 /**
132 158 * Payment create in db
133 - *
159 + *
134 160 * @since 0.0.1
135 161 */
136 162 public static function payment_create( $data ) {
137 163 global $wpdb;
138 164 $table = "{$wpdb->prefix}better_payment";
139 - $wpdb->insert( $table, $data );
165 + $wpdb->insert( $table, self::normalize_pending_stripe_status( $data ) );
140 166 if ( $wpdb->insert_id ) {
141 167 return $wpdb->insert_id;
142 168 } else {
143 169 return false;
@@ -144,8 +170,59 @@
144 170 }
145 171 }
146 172
147 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 + /**
148 225 * Mamage response
149 226 *
150 227 * @since 0.0.1
151 228 */
@@ -157,20 +234,19 @@
157 234 return $status;
158 235 }
159 236
160 237 if ( !empty( $_REQUEST[ 'better_payment_error_status' ] ) ) {
161 - self::failed_message_template( $settings );
162 - $redirection_url_error = ! empty( $settings['better_payment_form_error_page_url']['url'] ) ? esc_url( $settings['better_payment_form_error_page_url']['url'] ) : '';
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 + }
163 247
164 - if( $redirection_url_error ){
165 - ?>
166 - <script>
167 - setTimeout(function(){
168 - window.location.replace("<?php echo esc_url( $redirection_url_error ); ?>");
169 - }, 2000);
170 - </script>
171 - <?php
172 - }
248 + self::render_failed_response( $settings, $order_id );
173 249
174 250 return $status;
175 251 }
176 252
@@ -181,12 +257,72 @@
181 257 } elseif ( !empty( $_REQUEST[ 'better_payment_paystack_status' ] ) && $_REQUEST[ 'better_payment_paystack_status' ] == 'success' ) {
182 258 $status = self::paystack_payment_success( $settings );
183 259 }
184 260
185 - if ( $status ) {
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 ) {
186 322 self::success_message_template( $settings, $status );
187 323 $redirection_url_success = ! empty( $settings['better_payment_form_success_page_url']['url'] ) ? esc_url( $settings['better_payment_form_success_page_url']['url'] ) : '';
188 -
324 +
189 325 if( $redirection_url_success ){
190 326 ?>
191 327 <script>
192 328 setTimeout(function(){
@@ -192,9 +328,9 @@
192 328 setTimeout(function(){
193 329 window.location.replace("<?php echo esc_url( $redirection_url_success ); ?>");
194 330 }, 2000);
195 331 </script>
196 - <?php
332 + <?php
197 333 }
198 334 }
199 335 self::remove_arg();
200 336
@@ -202,227 +338,1075 @@
202 338 }
203 339
204 340 /**
205 341 * Paypal payment success
206 - *
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 + *
207 351 * @since 0.0.1
208 352 */
209 353 public static function paypal_payment_success( $settings = [] ) {
210 - $data = $_REQUEST;
211 -
212 - if ( !empty( $data[ 'payment_status' ] ) && !empty( $data[ 'payer_id' ] ) && !empty( $data[ 'payer_status' ] ) ) {
213 - global $wpdb;
214 - $table = "{$wpdb->prefix}better_payment";
215 - $results = $wpdb->get_row(
216 - $wpdb->prepare( "SELECT id,form_fields_info,referer FROM $table WHERE order_id=%s and status is NULL limit 1", sanitize_text_field( $_REQUEST[ 'item_number' ] ) )
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. The payer's e-mail and the PayPal transaction
375 + // id only for an unguessable order id: before 2.4.0 ids were `paypal_` . uniqid(), which is
376 + // time-based and can be enumerated, so anyone could read them off the return page.
377 + if ( $row->status === 'Completed' ) {
378 + $unguessable = self::paypal_order_id_is_unguessable( $item_number );
379 +
380 + return [
381 + 'amount' => floatval( $row->amount ),
382 + 'email' => $unguessable ? sanitize_email( $row->email ) : '',
383 + 'transaction_id' => $unguessable ? sanitize_text_field( $row->transaction_id ) : '',
384 + 'currency' => sanitize_text_field( $row->currency ),
385 + 'method' => 'paypal',
386 + ];
387 + }
388 +
389 + // Order exists but IPN hasn't arrived yet — signal pending so JS can poll.
390 + return [ 'pending' => true, 'order_id' => $item_number ];
391 + }
392 +
393 + /**
394 + * Whether a PayPal order id came from new_order_id() (24 random characters), not the
395 + * enumerable `paypal_` . uniqid() of releases before 2.4.0.
396 + *
397 + * @since 2.4.1
398 + *
399 + * @param mixed $order_id The order id.
400 + * @return bool
401 + */
402 + public static function paypal_order_id_is_unguessable( $order_id ) {
403 + return is_string( $order_id ) && 1 === preg_match( '/^paypal_[0-9a-z]{24}$/', $order_id );
404 + }
405 +
406 + /**
407 + * Whether an order id of any gateway came from new_order_id() (`{gateway}_` + 24 random
408 + * characters), not the enumerable `{gateway}_` . uniqid() of releases before 2.4.0.
409 + *
410 + * @since 2.4.1
411 + *
412 + * @param mixed $order_id The order id.
413 + * @return bool
414 + */
415 + public static function order_id_is_unguessable( $order_id ) {
416 + return is_string( $order_id ) && 1 === preg_match( '/^[a-z0-9-]+_[0-9a-z]{24}$/', $order_id );
417 + }
418 +
419 + /**
420 + * AJAX: check whether a PayPal order has been confirmed by IPN.
421 + *
422 + * Called by the frontend polling loop after the user lands on the return URL
423 + * before the IPN fires. Returns {status:'pending'} or {status:'completed'}.
424 + *
425 + * Lives on Handler (not Actions) so it can be registered unconditionally —
426 + * PayPal payments flow through the block/campaign path without Elementor.
427 + */
428 + public static function check_paypal_status() {
429 + check_ajax_referer( 'better-payment', 'security' );
430 +
431 + $order_id = ! empty( $_POST['order_id'] ) ? sanitize_text_field( $_POST['order_id'] ) : '';
432 +
433 + if ( empty( $order_id ) ) {
434 + wp_send_json_error( [ 'message' => 'Missing order_id' ] );
435 + }
436 +
437 + global $wpdb;
438 + $table = "{$wpdb->prefix}better_payment";
439 + $row = $wpdb->get_row(
440 + $wpdb->prepare(
441 + "SELECT status FROM $table WHERE order_id = %s LIMIT 1",
442 + $order_id
443 + )
444 + );
445 +
446 + if ( empty( $row ) ) {
447 + wp_send_json_error( [ 'message' => 'Order not found' ] );
448 + }
449 +
450 + if ( $row->status === 'Completed' ) {
451 + wp_send_json_success( [ 'status' => 'completed' ] );
452 + }
453 +
454 + wp_send_json_success( [ 'status' => 'pending' ] );
455 + }
456 +
457 + /**
458 + * PayPal IPN listener.
459 + *
460 + * PayPal POSTs the raw IPN body here server-to-server. We echo it back with
461 + * cmd=_notify-validate; only a VERIFIED response from PayPal's servers reaches
462 + * apply_paypal_ipn(), the only writer. The browser return URL
463 + * (paypal_payment_success) never writes.
464 + *
465 + * @since 2.1.3
466 + */
467 + public static function handle_paypal_ipn() {
468 + $raw_post = file_get_contents( 'php://input' );
469 +
470 + if ( empty( $raw_post ) ) {
471 + status_header( 400 );
472 + exit;
473 + }
474 +
475 + $data = [];
476 + wp_parse_str( $raw_post, $data );
477 +
478 + // PayPal includes test_ipn=1 for sandbox notifications.
479 + $verify_url = ! empty( $data['test_ipn'] )
480 + ? 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'
481 + : 'https://ipnpb.paypal.com/cgi-bin/webscr';
482 +
483 + $response = wp_remote_post(
484 + $verify_url,
485 + [
486 + 'body' => 'cmd=_notify-validate&' . $raw_post,
487 + 'timeout' => 30,
488 + 'sslverify' => true,
489 + 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded' ],
490 + ]
491 + );
492 +
493 + // Always respond 200 to PayPal to prevent retries, but only act on VERIFIED.
494 + if ( is_wp_error( $response ) || wp_remote_retrieve_body( $response ) !== 'VERIFIED' ) {
495 + status_header( 200 );
496 + exit;
497 + }
498 +
499 + self::apply_paypal_ipn( $data );
500 +
501 + status_header( 200 );
502 + exit;
503 + }
504 +
505 + /**
506 + * Whether a notification's PayPal environment matches the order's.
507 + *
508 + * Orders since 2.4.1 record `paypal_mode` (`live` | `sandbox`) when created, and must match
509 + * exactly. An older order has no record, so a live notification is accepted (a real payment
510 + * must never be stranded) and a sandbox one only while the site's PayPal setting is not live.
511 + *
512 + * @since 2.4.1
513 + *
514 + * @param mixed $form_info The order's `form_fields_info`, unserialized.
515 + * @param string $ipn_mode `live` or `sandbox` (from the notification's `test_ipn`).
516 + * @return bool
517 + */
518 + public static function paypal_ipn_mode_matches( $form_info, $ipn_mode ) {
519 + $order_mode = is_array( $form_info ) && isset( $form_info['paypal_mode'] ) ? $form_info['paypal_mode'] : '';
520 +
521 + if ( 'live' === $order_mode || 'sandbox' === $order_mode ) {
522 + return $order_mode === $ipn_mode;
523 + }
524 +
525 + if ( 'live' === $ipn_mode ) {
526 + return true;
527 + }
528 +
529 + return 'yes' !== DB::get_settings( 'better_payment_settings_payment_paypal_live_mode' );
530 + }
531 +
532 + /**
533 + * Apply a PayPal IPN to the order it names.
534 + *
535 + * Call ONLY with a notification handle_paypal_ipn() posted back and PayPal answered VERIFIED —
536 + * nothing here authenticates the data. Changes nothing unless the notification is a
537 + * `Completed` payment for a pending order (`status IS NULL`) paid to the merchant's address,
538 + * for at least the order's amount, in its currency, under a transaction id no order holds.
539 + * Then the row is claimed with one update (`WHERE id = … AND status IS NULL`): PayPal redelivers
540 + * a notification it saw no 200 for and can deliver one twice at once, and only the delivery that
541 + * wins the claim sends the receipts.
542 + *
543 + * @since 2.4.0
544 + *
545 + * @param array $data The notification's fields.
546 + * @return string 'confirmed' | 'ignored' | 'unmatched' | 'refused' | 'duplicate'
547 + */
548 + public static function apply_paypal_ipn( array $data ) {
549 + $data = array_map( 'sanitize_text_field', $data );
550 +
551 + if ( empty( $data['payment_status'] ) || empty( $data['item_number'] ) || $data['payment_status'] !== 'Completed' ) {
552 + return 'ignored';
553 + }
554 +
555 + global $wpdb;
556 + $table = "{$wpdb->prefix}better_payment";
557 + $results = $wpdb->get_row(
558 + $wpdb->prepare(
559 + "SELECT id, amount, currency, form_fields_info, referer FROM $table WHERE order_id = %s AND status IS NULL LIMIT 1",
560 + $data['item_number']
561 + )
562 + );
563 +
564 + if ( empty( $results->id ) ) {
565 + return 'unmatched';
566 + }
567 +
568 + // Validate receiver_email matches the merchant's configured PayPal email.
569 + $form_info = maybe_unserialize( $results->form_fields_info );
570 + $expected_email = ! empty( $form_info['paypal_business_email'] ) ? strtolower( $form_info['paypal_business_email'] ) : '';
571 + $receiver = ! empty( $data['receiver_email'] ) ? strtolower( $data['receiver_email'] ) : ( ! empty( $data['business'] ) ? strtolower( $data['business'] ) : '' );
572 +
573 + if ( $expected_email && $receiver && $expected_email !== $receiver ) {
574 + return 'refused';
575 + }
576 +
577 + // A sandbox notification is VERIFIED by PayPal's sandbox, which pays in play money to an
578 + // account anyone can create under any address. It must never confirm a live order.
579 + $ipn_mode = ! empty( $data['test_ipn'] ) ? 'sandbox' : 'live';
580 +
581 + if ( ! self::paypal_ipn_mode_matches( $form_info, $ipn_mode ) ) {
582 + return 'refused';
583 + }
584 +
585 + // Validate the paid amount is not less than the expected order amount.
586 + $paid_amount = ! empty( $data['mc_gross'] ) ? floatval( $data['mc_gross'] ) : 0;
587 + if ( $paid_amount < floatval( $results->amount ) ) {
588 + return 'refused';
589 + }
590 +
591 + // Validate currency matches.
592 + $paid_currency = ! empty( $data['mc_currency'] ) ? strtoupper( $data['mc_currency'] ) : '';
593 + if ( $paid_currency && $results->currency && strtoupper( $results->currency ) !== $paid_currency ) {
594 + return 'refused';
595 + }
596 +
597 + $txn_id = ! empty( $data['txn_id'] ) ? $data['txn_id'] : '';
598 +
599 + // Prevent txn_id replay: reject if this transaction ID was already recorded.
600 + if ( $txn_id ) {
601 + $existing = $wpdb->get_var(
602 + $wpdb->prepare(
603 + "SELECT id FROM $table WHERE transaction_id = %s LIMIT 1",
604 + $txn_id
605 + )
217 606 );
218 -
219 - if ( !empty( $results->id ) ) {
220 - $updated = $wpdb->update(
221 - $table,
222 - array(
223 - 'transaction_id' => sanitize_text_field( $data[ 'txn_id' ] ),
224 - 'status' => sanitize_text_field( $data[ 'payment_status' ] ),
225 - 'email' => sanitize_email( $data[ 'payer_email' ] ),
226 - 'customer_info' => maybe_serialize( $data ),
227 - ),
228 - array( 'ID' => $results->id )
229 - );
230 -
231 - if ( false !== $updated ) {
232 - //Send email notification
233 - if (
234 - ( isset($settings[ 'better_payment_form_email_enable' ]) && $settings[ 'better_payment_form_email_enable' ] == 'yes' )
235 - || ( $results->referer === 'elementor-form' )
236 - ) {
237 - self::better_email_notification(sanitize_text_field( $data[ 'txn_id' ] ), sanitize_email( $data[ 'payer_email' ] ), $settings, 'PayPal', $results->form_fields_info);
238 - }
607 + if ( $existing ) {
608 + return 'duplicate';
609 + }
610 + }
239 611
240 - return sanitize_text_field( $data[ 'txn_id' ] );
241 - }
242 - }
243 - }else if($data['better_payment_paypal_status'] === 'success'){
244 - return ( isset($data[ 'txn_id' ]) && !empty( sanitize_text_field( $data[ 'txn_id' ] ) ) ) ? sanitize_text_field( $data[ 'txn_id' ] ) : __('Payment under processing!', 'better-payment');
612 + $payer_email = ! empty( $data['payer_email'] ) ? sanitize_email( $data['payer_email'] ) : '';
613 +
614 + $claimed = $wpdb->update(
615 + $table,
616 + [
617 + 'transaction_id' => $txn_id,
618 + 'status' => $data['payment_status'],
619 + 'email' => $payer_email,
620 + 'customer_info' => maybe_serialize( $data ),
621 + ],
622 + [
623 + 'id' => (int) $results->id,
624 + 'status' => null,
625 + ]
626 + );
627 +
628 + if ( 1 !== (int) $claimed ) {
629 + // A concurrent delivery of this notification confirmed the order first.
630 + return 'duplicate';
245 631 }
246 - return false;
632 +
633 + $is_elementor_form = ! empty( $results->referer ) && $results->referer === 'elementor-form' ? 1 : 0;
634 +
635 + // Rebuild the e-mail settings persisted at payment-create time. IPN runs
636 + // server-to-server with no live widget context, so without this the body
637 + // would collapse to the bare transaction id (every section flag defaults
638 + // to 0). Legacy / pending rows created before this fix fall back to [].
639 + $email_settings = ! empty( $form_info['email_settings'] ) && is_array( $form_info['email_settings'] )
640 + ? $form_info['email_settings']
641 + : [];
642 +
643 + // Mirror the Stripe / Paystack gate: honour the form's "Send Email" toggle,
644 + // and always e-mail for Elementor Pro Form submissions.
645 + if (
646 + ( isset( $email_settings['better_payment_form_email_enable'] ) && 'yes' === $email_settings['better_payment_form_email_enable'] )
647 + || $is_elementor_form
648 + ) {
649 + self::better_email_notification( $txn_id, $payer_email, $email_settings, 'PayPal', $results->form_fields_info, $is_elementor_form );
650 + }
651 +
652 + return 'confirmed';
247 653 }
248 654
249 655 /**
250 - * Stripe payment success
251 - *
656 + * Stripe payment success — the return page.
657 + *
658 + * Reads the transaction id from the request, EXCEPT when the caller
659 + * supplies `better_payment_stripe_id` in $settings. A caller that has
660 + * already authorized the id against server-side state (the WooCommerce
661 + * return handler matches it to the order's own `_bp_payment_id`) must
662 + * pass it explicitly: a caller-side guard that reads $_GET cannot bind
663 + * this lookup, because PHP's default request_order=GP lets a POST body
664 + * overwrite the query string and the two would then see different ids.
665 + *
666 + * A pending order is verified against Stripe and confirmed through
667 + * confirm_stripe_session(). An order an earlier return (a reload, a second
668 + * tab) already confirmed renders the success page again without asking
669 + * Stripe or re-running anything. See stripe_settled_return().
670 + *
252 671 * @since 0.0.1
672 + *
673 + * @param array $settings Form settings; `better_payment_stripe_secret_key` is required to verify.
674 + * @return array|false|\WP_Error Success data, false for no order to show, or why nothing was confirmed.
253 675 */
254 676 public static function stripe_payment_success( $settings = [] ) {
255 677
256 678 $data = $_REQUEST;
257 679
258 - if ( !empty( $data[ 'better_payment_stripe_id' ] ) ) {
259 - global $wpdb;
260 - $table = "{$wpdb->prefix}better_payment";
261 - $results = $wpdb->get_row(
262 - $wpdb->prepare( "SELECT id,obj_id,transaction_id,form_fields_info,referer FROM $table WHERE order_id=%s and status = 'unpaid' limit 1", sanitize_text_field( $data[ 'better_payment_stripe_id' ] ) )
263 - );
264 -
265 - if ( !empty( $results->obj_id ) && !empty( $settings[ 'better_payment_stripe_secret_key' ] ) ) {
266 - $header = array(
680 + if ( ! empty( $settings[ 'better_payment_stripe_id' ] ) ) {
681 + $data[ 'better_payment_stripe_id' ] = $settings[ 'better_payment_stripe_id' ];
682 + }
683 +
684 + if ( empty( $data['better_payment_stripe_id'] ) || ! is_scalar( $data['better_payment_stripe_id'] ) ) {
685 + return false;
686 + }
687 +
688 + $order_id = sanitize_text_field( (string) $data['better_payment_stripe_id'] );
689 + $row = self::get_stripe_row( 'order_id', $order_id );
690 +
691 + if ( empty( $row->id ) ) {
692 + return false;
693 + }
694 +
695 + if ( self::STRIPE_PENDING_STATUS !== $row->status ) {
696 + return self::stripe_settled_return( $row, $order_id );
697 + }
698 +
699 + if ( empty( $row->obj_id ) || empty( $settings[ 'better_payment_stripe_secret_key' ] ) ) {
700 + return false;
701 + }
702 +
703 + // Subscription and split-payment orders are Pro's: Pro (or, for an older Pro, Lite's
704 + // transitional copy) adds the subscription expansion and the recurring / split flags.
705 + $verification = self::stripe_verification_for( $row );
706 +
707 + $response = wp_safe_remote_post(
708 + 'https://api.stripe.com/v1/checkout/sessions/' . rawurlencode( (string) $row->obj_id ),
709 + array(
710 + 'method' => 'GET',
711 + 'headers' => array(
267 712 'Authorization' => 'Basic ' . base64_encode( sanitize_text_field( $settings[ 'better_payment_stripe_secret_key' ] ) . ':' ),
268 713 'Stripe-Version' => '2019-05-16',
269 - );
714 + ),
715 + 'timeout' => 70,
716 + 'body' => array(
717 + 'expand' => $verification['expand'],
718 + ),
719 + )
720 + );
270 721
271 - $request = [
272 - 'expand' => [
273 - 'subscription.latest_invoice.payment_intent',
274 - 'payment_intent'
275 - ]
276 - ];
722 + if ( is_wp_error( $response ) || empty( $response[ 'body' ] ) ) {
723 + return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
724 + }
277 725
278 - $response = wp_safe_remote_post(
279 - 'https://api.stripe.com/v1/checkout/sessions/' . $results->obj_id,
280 - array(
281 - 'method' => 'GET',
282 - 'headers' => $header,
283 - 'timeout' => 70,
284 - 'body' => $request
285 - )
286 - );
726 + $session = json_decode( $response[ 'body' ] );
287 727
288 - if ( is_wp_error( $response ) || empty( $response[ 'body' ] ) ) {
289 - return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
290 - }
728 + if ( ! is_object( $session ) || isset( $session->error ) ) {
729 + return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
730 + }
291 731
292 - $response = json_decode( $response[ 'body' ] );
732 + return self::confirm_stripe_session( $row, $session, $settings, $verification );
733 + }
293 734
294 - if ( isset( $response->error->message ) ) {
295 - return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) );
296 - }
735 + /**
736 + * Confirm a pending Stripe order from its verified Checkout Session.
737 + *
738 + * The one place a Stripe payment is confirmed: the return page. In order:
739 + *
740 + * 1. The session must be this row's (when it carries an id), must not be a setup-mode session
741 + * (the WooCommerce free-trial checkout, which OrderHandler confirms by its SetupIntent), and
742 + * must be paid (stripe_session_is_paid()). Nothing below runs for a session that is not: an
743 + * abandoned checkout, or a completed one whose delayed payment has not cleared (a later
744 + * return confirms it once it has).
745 + * 2. Pro's verified-session step, the customer e-mail and the charged amount are computed. The
746 + * amount is `amount_total / 100` whenever Stripe reports one — 0 for a 100%-off promotion
747 + * code or a trial — and the form's amount is kept as `original_amount` when they differ.
748 + * Pro's step must stay free of side effects: a request that loses the claim has asked it too.
749 + * 3. The row is claimed with ONE update, `WHERE id = … AND status = 'unpaid'`, which writes the
750 + * confirmed status, amount, e-mail, session and Pro's details together. However many returns
751 + * race for a session (a reload, a second tab), exactly one update matches.
752 + * 4. Only that winner fires `better_payment/stripe_payment/success` and
753 + * `better_payment/payment_confirmed` and sends the receipts. A caller that lost the claim to a
754 + * confirmation gets the same success data back, so the visitor still sees the thank-you page.
755 + *
756 + * The confirmed status is always `paid`, including for a session Stripe reports as
757 + * `no_payment_required`: campaign totals and donor counts, Pro's Donors Wall and the WooCommerce
758 + * bridge count `paid`, and a $0 donation is still a donation. Stripe's own value stays in
759 + * `customer_info`.
760 + *
761 + * @since 2.4.0
762 + *
763 + * @param object|null $row Pending row from get_stripe_row() (null is refused).
764 + * @param mixed $session Decoded Checkout Session, with $verification['expand'] expanded where Pro needs it (anything but an object is refused).
765 + * @param array|null $settings Form settings for the receipts; null or [] for none.
766 + * @param array|null $verification stripe_verification_for( $row ); computed when null.
767 + * @return array|\WP_Error Success data for the thank-you page, or why nothing was confirmed.
768 + */
769 + public static function confirm_stripe_session( $row, $session, $settings = null, $verification = null ) {
770 + global $wpdb;
297 771
298 - $updated = $wpdb->update(
299 - $table,
300 - array(
301 - 'status' => sanitize_text_field( $response->payment_status ),
302 - 'email' => sanitize_email( current( $response->payment_intent->charges->data )->billing_details->email ),
303 - 'customer_info' => maybe_serialize( $response ),
304 - ),
305 - array( 'ID' => $results->id )
306 - );
772 + $table = "{$wpdb->prefix}better_payment";
773 + $settings = is_array( $settings ) ? $settings : [];
307 774
308 - if ( false !== $updated ) {
309 - //Send email notification
310 - $better_customer_email = sanitize_email( current( $response->payment_intent->charges->data )->billing_details->email );
311 - if (
312 - (isset($settings[ 'better_payment_form_email_enable' ]) && $settings[ 'better_payment_form_email_enable' ] == 'yes' )
313 - || ( $results->referer === 'elementor-form' )
314 - ) {
315 - self::better_email_notification($results->transaction_id, $better_customer_email, $settings, 'Stripe', $results->form_fields_info);
316 - }
775 + if ( ! is_object( $row ) || empty( $row->id ) || ! is_object( $session ) ) {
776 + return new \WP_Error( 'bp_stripe_confirm_invalid', __( 'There is no Stripe payment to confirm.', 'better-payment' ) );
777 + }
317 778
318 - return $results->transaction_id;
319 - }
779 + if ( isset( $session->id ) && ( empty( $row->obj_id ) || (string) $session->id !== (string) $row->obj_id ) ) {
780 + return new \WP_Error( 'bp_stripe_session_mismatch', __( 'Stripe did not confirm a payment for this checkout.', 'better-payment' ) );
781 + }
782 +
783 + if ( isset( $session->mode ) && 'setup' === $session->mode ) {
784 + return new \WP_Error( 'bp_stripe_setup_session', __( 'This checkout saves a payment method and is confirmed by its order.', 'better-payment' ) );
785 + }
786 +
787 + $stored_fields = isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
788 + $form_fields_info = is_array( $stored_fields ) ? $stored_fields : [];
789 +
790 + if ( ! is_array( $verification ) || ! isset( $verification['expand'] ) ) {
791 + $verification = self::stripe_verification_for( $row );
792 + }
793 +
794 + // Stripe returns the session whether or not anyone paid for it. Nothing below —
795 + // Pro's verified-session step, the row update, the confirmation hooks, the e-mails,
796 + // the success page — may run for a session that is not complete and paid. The row
797 + // stays 'unpaid', so a later return can still confirm it.
798 + if ( ! self::stripe_session_is_paid( $session ) ) {
799 + return self::stripe_unpaid_session_error( $session );
800 + }
801 +
802 + $action_data = [
803 + 'form_fields_info' => $form_fields_info,
804 + 'is_payment_recurring' => $verification['is_payment_recurring'],
805 + 'is_payment_split_payment' => $verification['is_payment_split_payment'],
806 + 'checkout_session_id' => $row->obj_id,
807 + ];
808 +
809 + // A subscription's id, customer e-mail and details, and the thank-you page's
810 + // recurring / split flags, are Pro's to add.
811 + $verified = PaymentEntitlement::stripe_verified_session(
812 + [
813 + 'form_fields_info' => $form_fields_info,
814 + 'transaction_id' => ! empty( $row->transaction_id ) ? sanitize_text_field( $row->transaction_id ) : '',
815 + 'email' => self::stripe_session_customer_email( $session ),
816 + 'frontend_data' => [],
817 + ],
818 + $session,
819 + $verification
820 + );
821 +
822 + $confirmed_fields = is_array( $verified['form_fields_info'] ) ? $verified['form_fields_info'] : $form_fields_info;
823 + $transaction_id = $verified['transaction_id'];
824 + $customer_email = $verified['email'];
825 + $amounts = self::stripe_paid_amounts( $session, isset( $row->amount ) ? $row->amount : 0, $confirmed_fields );
826 + $paid_amount = $amounts['amount'];
827 + $confirmed_fields = $amounts['form_fields_info'];
828 +
829 + $claimed = $wpdb->update(
830 + $table,
831 + array(
832 + 'amount' => $paid_amount,
833 + 'status' => self::STRIPE_CONFIRMED_STATUS,
834 + 'email' => $customer_email,
835 + 'customer_info' => maybe_serialize( $session ),
836 + 'form_fields_info' => maybe_serialize( $confirmed_fields ),
837 + 'transaction_id' => $transaction_id,
838 + ),
839 + array(
840 + 'id' => (int) $row->id,
841 + 'status' => self::STRIPE_PENDING_STATUS,
842 + )
843 + );
844 +
845 + if ( 1 !== (int) $claimed ) {
846 + // Another return (a reload, a second tab) got here first (or the write failed).
847 + // Whatever it recorded is the outcome; nothing is fired or sent twice.
848 + $current = self::get_stripe_row( 'id', (int) $row->id );
849 +
850 + if ( ! empty( $current->id ) && self::STRIPE_CONFIRMED_STATUS === $current->status ) {
851 + return self::stripe_frontend_data( $current, $verified['frontend_data'] );
320 852 }
853 +
854 + return new \WP_Error( 'bp_stripe_confirm_failed', __( 'The payment could not be recorded. Please reload this page.', 'better-payment' ) );
321 855 }
322 - return false;
856 +
857 + do_action('better_payment/stripe_payment/success', $action_data);
858 + do_action( 'better_payment/payment_confirmed', (int) $row->id );
859 +
860 + $frontend_data = [
861 + 'amount' => $paid_amount,
862 + 'email' => $customer_email,
863 + 'transaction_id' => $transaction_id,
864 + 'currency' => ! empty( $row->currency ) ? $row->currency : __( 'USD', 'better-payment' ),
865 + 'method' => 'stripe',
866 + ] + $verified['frontend_data'];
867 +
868 + //Send email notification
869 + if (
870 + ( isset( $settings[ 'better_payment_form_email_enable' ] ) && 'yes' === $settings[ 'better_payment_form_email_enable' ] )
871 + || ( isset( $row->referer ) && 'elementor-form' === $row->referer )
872 + ) {
873 + $is_elementor_form = isset( $row->referer ) && 'elementor-form' === $row->referer ? 1 : 0;
874 +
875 + self::better_email_notification( $transaction_id, $customer_email, $settings, 'Stripe', $row->form_fields_info, $is_elementor_form );
876 + }
877 +
878 + return $frontend_data;
323 879 }
324 -
880 +
325 881 /**
882 + * A Stripe transaction row, looked up by `id`, `order_id` or `obj_id` (the Checkout Session id).
883 + *
884 + * @since 2.4.0
885 + *
886 + * @param string $field 'id' | 'order_id' | 'obj_id'.
887 + * @param mixed $value Value to match; anything but a non-empty scalar matches nothing.
888 + * @return object|null
889 + */
890 + public static function get_stripe_row( $field, $value ) {
891 + global $wpdb;
892 +
893 + if ( ! in_array( $field, [ 'id', 'order_id', 'obj_id' ], true ) || ! is_scalar( $value ) || '' === (string) $value ) {
894 + return null;
895 + }
896 +
897 + $table = "{$wpdb->prefix}better_payment";
898 + $format = 'id' === $field ? '%d' : '%s';
899 +
900 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from $wpdb->prefix, column and format from the whitelist above.
901 + $row = $wpdb->get_row( $wpdb->prepare( "SELECT id, order_id, obj_id, amount, transaction_id, currency, email, status, source, referer, form_fields_info, payment_date FROM $table WHERE $field = $format LIMIT 1", $value ) );
902 +
903 + return is_object( $row ) ? $row : null;
904 + }
905 +
906 + /**
907 + * How a row's Checkout Session is retrieved and whether it is a subscription — Pro's answer.
908 + *
909 + * @since 2.4.0
910 + *
911 + * @param object|null $row Transaction row; null reads as a row with no stored fields.
912 + * @return array `expand`, `is_payment_recurring`, `is_payment_split_payment`.
913 + */
914 + public static function stripe_verification_for( $row ) {
915 + $form_fields_info = is_object( $row ) && isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
916 +
917 + return PaymentEntitlement::stripe_verification_args(
918 + [
919 + 'expand' => [ 'payment_intent' ],
920 + 'is_payment_recurring' => false,
921 + 'is_payment_split_payment' => false,
922 + ],
923 + is_array( $form_fields_info ) ? $form_fields_info : []
924 + );
925 + }
926 +
927 + /**
928 + * What a verified Checkout Session charged, and what the transaction's fields record about it.
929 + *
930 + * The charged amount is `amount_total / 100` whenever Stripe reports a numeric total — 0
931 + * included: a 100%-off promotion code or a trial charges nothing, and a row claiming otherwise
932 + * would add money nobody paid to a campaign's total. It used to fall back to the form's amount
933 + * whenever the total was 0. Only a session with no total at all keeps the row's amount.
934 + *
935 + * `exact_paid_amount` is always recorded. When the charge differs from the amount the order was
936 + * created for, that amount is kept as `original_amount`; `is_coupon_applied` and
937 + * `paid_amount_diff` (read by the transaction details and the receipt) are set only when a
938 + * discount explains the difference — a trial is not a coupon.
939 + *
940 + * @since 2.4.0
941 + *
942 + * @param mixed $session Decoded Checkout Session.
943 + * @param mixed $row_amount The row's amount (what the order was created for).
944 + * @param array $form_fields_info The fields to record into.
945 + * @return array `amount` (float) and `form_fields_info`.
946 + */
947 + public static function stripe_paid_amounts( $session, $row_amount, array $form_fields_info ) {
948 + $original = is_numeric( $row_amount ) ? (float) $row_amount : 0.0;
949 + $total = is_object( $session ) && isset( $session->amount_total ) && is_numeric( $session->amount_total ) ? (float) $session->amount_total : null;
950 + $paid = null === $total ? $original : $total / 100;
951 +
952 + $form_fields_info['is_coupon_applied'] = 0;
953 + $form_fields_info['exact_paid_amount'] = $paid;
954 + unset( $form_fields_info['original_amount'], $form_fields_info['paid_amount_diff'] );
955 +
956 + if ( (int) round( $paid * 100 ) !== (int) round( $original * 100 ) ) {
957 + $form_fields_info['original_amount'] = $original;
958 +
959 + $discounted = ( is_object( $session ) && isset( $session->total_details->amount_discount ) && is_numeric( $session->total_details->amount_discount ) && (float) $session->total_details->amount_discount > 0 )
960 + || ( isset( $form_fields_info['stripe_coupon_code'] ) && is_scalar( $form_fields_info['stripe_coupon_code'] ) && '' !== trim( (string) $form_fields_info['stripe_coupon_code'] ) );
961 +
962 + if ( $discounted ) {
963 + $form_fields_info['paid_amount_diff'] = $original - $paid;
964 + $form_fields_info['is_coupon_applied'] = 1;
965 + }
966 + }
967 +
968 + return [
969 + 'amount' => $paid,
970 + 'form_fields_info' => $form_fields_info,
971 + ];
972 + }
973 +
974 + /**
975 + * The customer's e-mail on a Checkout Session: the charge's billing e-mail when the PaymentIntent
976 + * is expanded, else the session's `customer_details` / `customer_email`.
977 + *
978 + * @since 2.4.0
979 + *
980 + * @param mixed $session Decoded Checkout Session.
981 + * @return string
982 + */
983 + public static function stripe_session_customer_email( $session ) {
984 + if ( ! is_object( $session ) ) {
985 + return '';
986 + }
987 +
988 + if ( isset( $session->payment_intent->charges->data ) && is_array( $session->payment_intent->charges->data ) && ! empty( $session->payment_intent->charges->data ) ) {
989 + $charge = current( $session->payment_intent->charges->data );
990 +
991 + if ( isset( $charge->billing_details->email ) && is_string( $charge->billing_details->email ) && '' !== $charge->billing_details->email ) {
992 + return sanitize_email( $charge->billing_details->email );
993 + }
994 + }
995 +
996 + if ( isset( $session->customer_details->email ) && is_string( $session->customer_details->email ) && '' !== $session->customer_details->email ) {
997 + return sanitize_email( $session->customer_details->email );
998 + }
999 +
1000 + if ( isset( $session->customer_email ) && is_string( $session->customer_email ) ) {
1001 + return sanitize_email( $session->customer_email );
1002 + }
1003 +
1004 + return '';
1005 + }
1006 +
1007 + /**
1008 + * Whether an order id is one Handler::new_order_id() minted for Stripe.
1009 + *
1010 + * A return for an order that is already confirmed shows its receipt from the row, by id alone.
1011 + * Orders from before 2.3.4 carry a time-based id that could be enumerated, so only an
1012 + * unguessable one gets that page.
1013 + *
1014 + * @since 2.4.0
1015 + *
1016 + * @param mixed $order_id Order id.
1017 + * @return bool
1018 + */
1019 + public static function stripe_order_id_is_unguessable( $order_id ) {
1020 + return is_string( $order_id ) && 1 === preg_match( '/^stripe_[0-9a-f]{24}$/', $order_id );
1021 + }
1022 +
1023 + /**
1024 + * The return page for an order that is no longer pending.
1025 + *
1026 + * - Confirmed (`paid`): the success data, rebuilt from the row — no Stripe call, no hook, no
1027 + * e-mail — with Pro's thank-you page flags. Only for an unguessable order id.
1028 + * - Anything else (refunded, an older release's statuses): nothing, as before.
1029 + *
1030 + * @param object $row The row.
1031 + * @param string $order_id The order id the return names.
1032 + * @return array|false
1033 + */
1034 + private static function stripe_settled_return( $row, $order_id ) {
1035 + if ( self::STRIPE_CONFIRMED_STATUS !== $row->status || ! self::stripe_order_id_is_unguessable( $order_id ) ) {
1036 + return false;
1037 + }
1038 +
1039 + $form_fields_info = isset( $row->form_fields_info ) ? maybe_unserialize( $row->form_fields_info ) : [];
1040 +
1041 + // Only the thank-you page flags are used, and Pro derives them from the stored fields.
1042 + $details = PaymentEntitlement::stripe_verified_session(
1043 + [
1044 + 'form_fields_info' => is_array( $form_fields_info ) ? $form_fields_info : [],
1045 + 'transaction_id' => (string) $row->transaction_id,
1046 + 'email' => (string) $row->email,
1047 + 'frontend_data' => [],
1048 + ],
1049 + new \stdClass(),
1050 + self::stripe_verification_for( $row )
1051 + );
1052 +
1053 + return self::stripe_frontend_data( $row, $details['frontend_data'] );
1054 + }
1055 +
1056 + /**
1057 + * Success data for a confirmed Stripe row.
1058 + *
1059 + * @param object $row Confirmed row.
1060 + * @param array $extra Pro's thank-you page flags.
1061 + * @return array
1062 + */
1063 + private static function stripe_frontend_data( $row, array $extra = [] ) {
1064 + return [
1065 + 'amount' => (float) $row->amount,
1066 + 'email' => sanitize_email( (string) $row->email ),
1067 + 'transaction_id' => sanitize_text_field( (string) $row->transaction_id ),
1068 + 'currency' => ! empty( $row->currency ) ? sanitize_text_field( $row->currency ) : __( 'USD', 'better-payment' ),
1069 + 'method' => 'stripe',
1070 + ] + $extra;
1071 + }
1072 +
1073 + /**
1074 + * Whether a retrieved Stripe Checkout Session has really been paid for.
1075 + *
1076 + * Retrieving a session proves only that it exists. A visitor can come back to the return
1077 + * URL from a checkout they abandoned (`status` is `open`, or `expired`), and a completed
1078 + * checkout paid with a delayed method (bank debits, vouchers) is `complete` with
1079 + * `payment_status: unpaid` until the money arrives — sometimes days later, sometimes never.
1080 + * Both used to confirm the order: the row, `better_payment/payment_confirmed`, Pro's
1081 + * payment types, the receipts and the success page.
1082 + *
1083 + * Paid means `payment_status` is `paid` (the funds are there), or `no_payment_required`
1084 + * (a 100%-off promotion code, a subscription trial) on a `complete` session: Stripe also
1085 + * reports `no_payment_required` for such a session before it is completed. A present
1086 + * `status` other than `complete` is never paid. A missing `status` is tolerated only for
1087 + * `paid` — requests pin `Stripe-Version: 2019-05-16`, and `paid` is only ever reported for
1088 + * a completed checkout — so a response without the field cannot stall every payment.
1089 + * Anything else missing or unrecognised is unpaid.
1090 + *
1091 + * @since 2.4.0
1092 + *
1093 + * @param mixed $session Decoded Checkout Session.
1094 + * @return bool
1095 + */
1096 + public static function stripe_session_is_paid( $session ) {
1097 + if ( ! is_object( $session ) ) {
1098 + return false;
1099 + }
1100 +
1101 + $status = isset( $session->status ) && is_string( $session->status ) ? $session->status : '';
1102 + $payment_status = isset( $session->payment_status ) && is_string( $session->payment_status ) ? $session->payment_status : '';
1103 +
1104 + if ( 'paid' === $payment_status ) {
1105 + return '' === $status || 'complete' === $status;
1106 + }
1107 +
1108 + return 'no_payment_required' === $payment_status && 'complete' === $status;
1109 + }
1110 +
1111 + /**
1112 + * Why a Stripe return did not confirm its order.
1113 + *
1114 + * `bp_stripe_payment_processing`: the checkout was completed but Stripe has not been paid
1115 + * yet (a delayed payment method). `bp_stripe_payment_incomplete`: anything else — the
1116 + * checkout was never completed. The message is shown in WooCommerce order notes.
1117 + *
1118 + * @since 2.4.0
1119 + *
1120 + * @param mixed $session Decoded Checkout Session.
1121 + * @return \WP_Error
1122 + */
1123 + public static function stripe_unpaid_session_error( $session ) {
1124 + $status = is_object( $session ) && isset( $session->status ) && is_string( $session->status ) ? $session->status : '';
1125 + $payment_status = is_object( $session ) && isset( $session->payment_status ) && is_string( $session->payment_status ) ? $session->payment_status : '';
1126 + $data = [
1127 + 'status' => sanitize_text_field( $status ),
1128 + 'payment_status' => sanitize_text_field( $payment_status ),
1129 + ];
1130 +
1131 + if ( 'complete' === $status && 'unpaid' === $payment_status ) {
1132 + return new \WP_Error( 'bp_stripe_payment_processing', __( 'Stripe has not confirmed this payment yet. It is confirmed once the payment clears.', 'better-payment' ), $data );
1133 + }
1134 +
1135 + return new \WP_Error( 'bp_stripe_payment_incomplete', __( 'Stripe did not confirm a payment for this checkout.', 'better-payment' ), $data );
1136 + }
1137 +
1138 + /**
1139 + * The return page for a completed Stripe checkout whose payment has not cleared yet.
1140 + *
1141 + * Deliberately neither the success page (nothing has been paid) nor the failure page (the
1142 + * payment may still succeed).
1143 + *
1144 + * @since 2.4.0
1145 + *
1146 + * @return void
1147 + */
1148 + private static function stripe_processing_message_template() {
1149 + ?>
1150 + <section class="bp-thank_page">
1151 + <div class="bp-thank_page-wrapper bp-stripe-processing">
1152 + <div class="bp-thank_page-text">
1153 + <h2 class="bp-font_ibm bp-page_header"><?php esc_html_e( 'Your payment is processing', 'better-payment' ); ?></h2>
1154 + <p class="bp-font_ibm bp-payment_info"><?php esc_html_e( 'Stripe has not confirmed your payment yet. Some payment methods take a few days to clear. You do not need to pay again; reload this page later to see your confirmation.', 'better-payment' ); ?></p>
1155 + </div>
1156 + </div>
1157 + </section>
1158 + <?php
1159 + }
1160 +
1161 + /**
1162 + * The failed-payment page, with the optional redirect to the form's error page.
1163 + *
1164 + * @param array $settings Form settings.
1165 + * @param string $order_id The order the return is for.
1166 + * @return void
1167 + */
1168 + private static function render_failed_response( $settings, $order_id ) {
1169 + self::failed_message_template( $settings, self::get_failed_transaction_data( $order_id ) );
1170 +
1171 + $redirection_url_error = ! empty( $settings['better_payment_form_error_page_url']['url'] ) ? esc_url( $settings['better_payment_form_error_page_url']['url'] ) : '';
1172 +
1173 + if ( $redirection_url_error ) {
1174 + ?>
1175 + <script>
1176 + setTimeout(function(){
1177 + window.location.replace("<?php echo esc_url( $redirection_url_error ); ?>");
1178 + }, 2000);
1179 + </script>
1180 + <?php
1181 + }
1182 + }
1183 +
1184 + /**
326 1185 * Paystack payment success
327 - *
1186 + *
1187 + * Verifies a pending order against Paystack, then claims it with one update
1188 + * (`WHERE id = … AND status = 'unpaid'`). Only the request that wins the claim fires
1189 + * `better_payment/payment_confirmed`, sends the receipts and fires
1190 + * `better_payment/payment/success`, so a reload, a double-click or two tabs returning at once
1191 + * confirm the order exactly once. A request that loses the claim to a confirmation, and a later
1192 + * return for an order that is already confirmed, render the success page from the row — only
1193 + * when the return's `reference` is the one that order holds.
1194 + *
328 1195 * @since 0.0.1
329 1196 */
330 1197 public static function paystack_payment_success( $settings = [] ) {
331 1198 $data = $_REQUEST;
332 1199
333 - if ( ! empty( $data[ 'better_payment_paystack_id' ] ) ) {
334 - global $wpdb;
335 - $table = "{$wpdb->prefix}better_payment";
336 - $results = $wpdb->get_row(
337 - $wpdb->prepare( "SELECT id,obj_id,transaction_id,form_fields_info,referer FROM $table WHERE order_id=%s and status = 'unpaid' limit 1", sanitize_text_field( $data[ 'better_payment_paystack_id' ] ) )
1200 + if ( empty( $data[ 'better_payment_paystack_id' ] ) || ! is_scalar( $data[ 'better_payment_paystack_id' ] ) ) {
1201 + return false;
1202 + }
1203 +
1204 + if ( empty( $data['reference'] ) || ! is_scalar( $data['reference'] ) ) {
1205 + return false;
1206 + }
1207 +
1208 + global $wpdb;
1209 + $table = "{$wpdb->prefix}better_payment";
1210 + $order_id = sanitize_text_field( (string) $data[ 'better_payment_paystack_id' ] );
1211 + $reference = sanitize_text_field( (string) $data['reference'] );
1212 + $results = $wpdb->get_row(
1213 + $wpdb->prepare( "SELECT id,obj_id,transaction_id,amount,currency,email,status,form_fields_info,referer FROM $table WHERE order_id=%s limit 1", $order_id )
1214 + );
1215 +
1216 + if ( empty( $results->id ) ) {
1217 + return false;
1218 + }
1219 +
1220 + if ( 'unpaid' !== $results->status ) {
1221 + return self::paystack_settled_return( $results, $reference );
1222 + }
1223 +
1224 + $header_info = array(
1225 + 'Authorization' => 'Bearer ' . sanitize_text_field( isset( $settings[ 'better_payment_paystack_secret_key' ] ) ? $settings[ 'better_payment_paystack_secret_key' ] : '' ),
1226 + "Cache-Control: no-cache",
1227 + );
1228 +
1229 + $response = wp_safe_remote_get(
1230 + 'https://api.paystack.co/transaction/verify/' . rawurlencode( $reference ),
1231 + array(
1232 + 'headers' => $header_info,
1233 + 'timeout' => 70,
1234 + )
1235 + );
1236 +
1237 + $response = json_decode(wp_remote_retrieve_body($response));
1238 +
1239 + if ( empty( $response->status ) ) {
1240 + return false;
1241 + }
1242 +
1243 + $verified = isset( $response->data ) ? $response->data : null;
1244 +
1245 + if ( ! self::paystack_verification_matches( $results, $verified )
1246 + || self::paystack_reference_claimed_elsewhere( $verified->reference, (int) $results->id ) ) {
1247 + return false;
1248 + }
1249 +
1250 + $claimed = $wpdb->update(
1251 + $table,
1252 + array(
1253 + 'status' => ! empty( $response->data->status ) ? sanitize_text_field( $response->data->status ) : '',
1254 + 'amount' => ! empty( $response->data->amount ) ? floatval( ( $response->data->amount ) / 100 ) : 0,
1255 + 'obj_id' => ! empty( $response->data->id ) ? sanitize_text_field( $response->data->id ) : '',
1256 + 'transaction_id' => ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '',
1257 + 'customer_info' => ! empty( $response->data->customer ) ? maybe_serialize( $response->data->customer ) : '',
1258 + 'email' => ! empty( $response->data->customer->email ) ? sanitize_email( $response->data->customer->email ) : '',
1259 + ),
1260 + array(
1261 + 'id' => (int) $results->id,
1262 + 'status' => 'unpaid',
1263 + )
1264 + );
1265 +
1266 + if ( 1 !== (int) $claimed ) {
1267 + // Another return confirmed this order first (or the write failed): nothing is fired or sent twice.
1268 + $current = $wpdb->get_row(
1269 + $wpdb->prepare( "SELECT id,transaction_id,amount,currency,email,status FROM $table WHERE id=%d limit 1", (int) $results->id )
338 1270 );
339 -
340 - $header_info = array(
341 - 'Authorization' => 'Bearer ' . sanitize_text_field( $settings[ 'better_payment_paystack_secret_key' ] ),
342 - "Cache-Control: no-cache",
343 - );
344 -
345 - $response = wp_safe_remote_get(
346 - 'https://api.paystack.co/transaction/verify/'. sanitize_text_field( $data[ 'reference' ] ),
347 - array(
348 - 'headers' => $header_info,
349 - 'timeout' => 70,
350 - )
351 - );
352 1271
353 - $response = json_decode(wp_remote_retrieve_body($response));
354 -
355 - if ( ! empty( $response->status ) ) {
356 - $updated = $wpdb->update(
357 - $table,
358 - array(
359 - 'status' => ! empty( $response->data->status ) ? sanitize_text_field( $response->data->status ) : '',
360 - 'amount' => ! empty( $response->data->amount ) ? floatval( $response->data->amount ) : 0,
361 - 'obj_id' => ! empty( $response->data->id ) ? sanitize_text_field( $response->data->id ) : '',
362 - 'transaction_id' => ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '',
363 - 'customer_info' => ! empty( $response->data->customer ) ? maybe_serialize( $response->data->customer ) : '',
364 - 'email' => ! empty( $response->data->customer->email ) ? sanitize_email( $response->data->customer->email ) : '',
365 - ),
366 - array( 'ID' => $results->id )
367 - );
368 - }
1272 + return ! empty( $current->id ) ? self::paystack_settled_return( $current, $reference ) : false;
1273 + }
369 1274
370 - $better_customer_email = ! empty( $response->data->customer->email ) ? maybe_serialize( $response->data->customer->email ) : '';
371 - $transaction_id = ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '';
1275 + do_action( 'better_payment/payment_confirmed', (int) $results->id );
372 1276
373 - if ( ! empty( $updated ) && $better_customer_email ) {
374 - //Send email notification
375 - if (
376 - (isset($settings[ 'better_payment_form_email_enable' ]) && $settings[ 'better_payment_form_email_enable' ] == 'yes' )
377 - || ( $results->referer === 'elementor-form' )
378 - ) {
379 - self::better_email_notification($transaction_id, $better_customer_email, $settings, 'Stripe', $results->form_fields_info);
380 - }
1277 + $better_customer_email = ! empty( $response->data->customer->email ) ? maybe_serialize( $response->data->customer->email ) : '';
1278 + $transaction_id = ! empty( $response->data->reference ) ? sanitize_text_field( $response->data->reference ) : '';
381 1279
382 - return $transaction_id;
383 - }
1280 + $frontend_data = [
1281 + 'amount' => ! empty( $response->data->amount ) ? floatval( ( $response->data->amount ) / 100 ) : 0,
1282 + 'email' => ! empty( $better_customer_email ) ? $better_customer_email : '',
1283 + 'transaction_id' => $transaction_id,
1284 + 'currency' => ! empty( $response->data->currency ) ? sanitize_text_field( $response->data->currency ) : __( 'USD', 'better-payment' ),
1285 + 'method' => 'paystack',
1286 + ];
1287 +
1288 + //Send email notification
1289 + if (
1290 + $better_customer_email
1291 + && (
1292 + ( isset( $settings[ 'better_payment_form_email_enable' ] ) && $settings[ 'better_payment_form_email_enable' ] == 'yes' )
1293 + || ( $results->referer === 'elementor-form' )
1294 + )
1295 + ) {
1296 + $is_elementor_form = ! empty( $results->referer ) && $results->referer === 'elementor-form' ? 1 : 0;
1297 + self::better_email_notification($transaction_id, $better_customer_email, $settings, 'Paystack', $results->form_fields_info, $is_elementor_form);
384 1298 }
385 - return false;
1299 +
1300 + do_action( 'better_payment/payment/success', $order_id, (int) $results->id, 'paystack' );
1301 +
1302 + return $frontend_data;
386 1303 }
387 1304
388 1305 /**
389 - * Get currency symbols
390 - *
391 - * @since 0.0.1
1306 + * The return page for a Paystack order that is no longer pending.
1307 + *
1308 + * A confirmed order (`success`) shows its success page again, from the row, when the return
1309 + * carries the reference the order holds — the order id alone is not enough to see a receipt.
1310 + * Anything else shows nothing, as before.
1311 + *
1312 + * @param object $row The row.
1313 + * @param string $reference The return's `reference`.
1314 + * @return array|false
392 1315 */
393 - public static function get_currency_symbols( $currency = 'USD' ) {
394 - $list = [
395 - 'USD' => "$",
396 - 'EUR' => "€",
397 - 'GBP' => "£",
398 - 'AED' => "د.إ",
399 - 'AUD' => "$",
400 - 'CAD' => "$",
401 - 'CZK' => "Kč",
402 - 'DKK' => "kr",
403 - 'HKD' => "$",
404 - 'HUF' => "ft",
405 - 'ILS' => "₪",
406 - 'JPY' => "¥",
407 - 'MXN' => "$",
408 - 'NOK' => "kr",
409 - 'NZD' => "$",
410 - 'PHP' => "₱",
411 - 'PLN' => "zł",
412 - 'RUB' => "₽",
413 - 'SGD' => "$",
414 - 'SEK' => "kr",
415 - 'CHF' => "CHF",
416 - 'TWD' => "$",
417 - 'THB' => "฿",
418 - 'TRY' => "₺",
419 - 'ZAR' => "R",
1316 + private static function paystack_settled_return( $row, $reference ) {
1317 + $stored = isset( $row->transaction_id ) ? (string) $row->transaction_id : '';
1318 +
1319 + if ( ! isset( $row->status ) || 'success' !== $row->status || '' === $stored || ! hash_equals( $stored, (string) $reference ) ) {
1320 + return false;
1321 + }
1322 +
1323 + return [
1324 + 'amount' => (float) $row->amount,
1325 + 'email' => sanitize_email( (string) $row->email ),
1326 + 'transaction_id' => sanitize_text_field( $stored ),
1327 + 'currency' => ! empty( $row->currency ) ? sanitize_text_field( $row->currency ) : __( 'USD', 'better-payment' ),
1328 + 'method' => 'paystack',
420 1329 ];
421 - return !empty( $list[ $currency ] ) ? $list[ $currency ] : '$';
422 1330 }
423 1331
424 1332 /**
1333 + * Whether a verified Paystack transaction really pays for the pending order.
1334 + *
1335 + * The verify call looks up whatever `reference` the return URL carries, and the return
1336 + * URL is the visitor's to edit. Without this, a cheap paid transaction could be replayed
1337 + * against an expensive pending order and mark it paid. Checked, in order:
1338 + * - the transaction itself succeeded. Paystack's top-level `status: true` only says the
1339 + * reference resolved — it is just as true for an abandoned or failed checkout, which is
1340 + * how a visitor who paid nothing used to reach the success page;
1341 + * - the reference is the one Paystack issued for this order at initialize (rows created
1342 + * before references were stored have none, and fall through to the amount check —
1343 + * paystack_reference_claimed_elsewhere() keeps that fallback from being replayable);
1344 + * - the amount paid covers the amount the order was created for — which
1345 + * PaymentRequestGuard has already held to what the form allows;
1346 + * - the currency is the order's.
1347 + *
1348 + * @param object $row Pending row: transaction_id, amount, currency.
1349 + * @param mixed $verified `data` object from Paystack's /transaction/verify response.
1350 + * @return bool
1351 + */
1352 + private static function paystack_verification_matches( $row, $verified ) {
1353 + if ( ! is_object( $verified ) ) {
1354 + return false;
1355 + }
1356 +
1357 + if ( ! isset( $verified->status ) || 'success' !== $verified->status ) {
1358 + return false;
1359 + }
1360 +
1361 + $expected_reference = isset( $row->transaction_id ) ? (string) $row->transaction_id : '';
1362 + $verified_reference = isset( $verified->reference ) ? (string) $verified->reference : '';
1363 +
1364 + if ( '' !== $expected_reference && $verified_reference !== $expected_reference ) {
1365 + return false;
1366 + }
1367 +
1368 + $paid = isset( $verified->amount ) && is_numeric( $verified->amount ) ? floatval( $verified->amount ) / 100 : 0.0;
1369 +
1370 + if ( $paid + 0.005 < floatval( isset( $row->amount ) ? $row->amount : 0 ) ) {
1371 + return false;
1372 + }
1373 +
1374 + $paid_currency = isset( $verified->currency ) ? strtoupper( (string) $verified->currency ) : '';
1375 + $order_currency = isset( $row->currency ) ? strtoupper( (string) $row->currency ) : '';
1376 +
1377 + return '' === $paid_currency || '' === $order_currency || $paid_currency === $order_currency;
1378 + }
1379 +
1380 + /**
1381 + * Whether a Paystack reference already belongs to a different order.
1382 + *
1383 + * Every order created since references were stored holds its own reference from
1384 + * initialize, so this only ever decides the legacy fallback — an order with no stored
1385 + * reference. Without it, one genuine payment could be replayed to mark every such
1386 + * order paid. A reference no order has claimed can still settle exactly one of them.
1387 + *
1388 + * @param mixed $reference Verified reference.
1389 + * @param int $row_id The order being verified.
1390 + * @return bool True when the reference must not settle this order.
1391 + */
1392 + private static function paystack_reference_claimed_elsewhere( $reference, $row_id ) {
1393 + global $wpdb;
1394 +
1395 + $reference = is_scalar( $reference ) ? sanitize_text_field( (string) $reference ) : '';
1396 +
1397 + if ( '' === $reference ) {
1398 + return true;
1399 + }
1400 +
1401 + $table = "{$wpdb->prefix}better_payment";
1402 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
1403 + $other = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM $table WHERE transaction_id = %s AND id <> %d LIMIT 1", $reference, (int) $row_id ) );
1404 +
1405 + return ! empty( $other );
1406 + }
1407 +
1408 + /**
425 1409 * Success message template
426 1410 *
427 1411 * @since 0.0.1
428 1412 */
@@ -432,74 +1416,428 @@
432 1416 }
433 1417 wp_enqueue_script('better-payment-admin-script');
434 1418 $image_url = BETTER_PAYMENT_ASSETS . '/img/success.svg';
435 1419 $show_icon = 0;
1420 + $default_icon = 1;
1421 + $helper_obj = new Helper();
436 1422
1423 + $store_name = ( isset( $settings['better_payment_form_payment_source'] ) && $settings['better_payment_form_payment_source'] === 'manual' && !empty( $settings['better_payment_form_title'] ) )
1424 + ? esc_html($settings['better_payment_form_title'])
1425 + : esc_html( get_bloginfo('name') );
1426 +
1427 + $payment_desc_text = !empty( $settings['better_payment_form_success_message_heading'] )
1428 + ? esc_html($settings['better_payment_form_success_message_heading'])
1429 + : esc_html( __('You paid', 'better-payment') . ' [currency_symbol][amount] ' . __('to', 'better-payment') . ' [store_name]');
1430 +
1431 + $bp__currency_symbol = esc_html( ! empty( $tr_id['currency'] ) ? $helper_obj->get_currency_symbol( $tr_id['currency'] ) : '' );
1432 +
1433 + $payment_desc_text = str_replace( '[currency_symbol]', '<span class="bp-bold">' . $bp__currency_symbol . '</span>', $payment_desc_text );
1434 +
1435 + $payment_desc_text = str_replace( '[amount]', '<span class="bp-bold">' . esc_html( ! empty( $tr_id['amount'] ) ? $tr_id['amount'] : 0 ) . '</span>', $payment_desc_text );
1436 +
1437 + $payment_desc_text = str_replace( '[store_name]', '<span class="bp-bold">' . esc_html( $store_name ) . '</span>', $payment_desc_text );
1438 +
1439 + $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]');
1440 +
1441 + $payment_mail_text = str_replace( '[customer_email]', '<span class="bp-bold">' . esc_html( ! empty( $tr_id['email'] ) ? $tr_id['email'] : '' ) . '</span>', $payment_mail_text );
1442 +
437 1443 if ( !empty( $settings[ 'better_payment_form_success_message_icon' ][ 'library' ] ) ) {
438 1444 if ( $settings[ 'better_payment_form_success_message_icon' ][ 'library' ] == 'svg' ) {
439 1445 $image_url = $settings[ 'better_payment_form_success_message_icon' ][ 'value' ][ 'url' ];
1446 + $default_icon = 0;
440 1447 } else {
441 1448 $show_icon = 1;
442 1449 $image_url = $settings[ 'better_payment_form_success_message_icon' ][ 'value' ];
1450 + $default_icon = 0;
443 1451 }
444 1452 }
1453 +
1454 +
1455 + $allowed_payment_methods = [
1456 + 'paypal' => BETTER_PAYMENT_ASSETS . '/img/paypal-2.svg',
1457 + 'stripe' => BETTER_PAYMENT_ASSETS . '/img/stripe-2.svg',
1458 + 'paystack' => BETTER_PAYMENT_ASSETS . '/img/paystack-2.svg',
1459 + ];
1460 + $payment_method_logo = isset( $tr_id['method'] ) && isset( $allowed_payment_methods[ $tr_id['method'] ] ) ? $allowed_payment_methods[ $tr_id['method'] ] : '';
1461 +
1462 + $better_payment_form_success_message_thanks = !empty( $settings['better_payment_form_success_message_thanks'] )
1463 + ? esc_html( $settings['better_payment_form_success_message_thanks'] )
1464 + : esc_html__('Thank You!', 'better-payment');
1465 + $better_payment_form_success_message_transaction = !empty( $settings['better_payment_form_success_message_transaction'] )
1466 + ? esc_html__( $settings['better_payment_form_success_message_transaction'], 'better-payment' )
1467 + : esc_html__('Transaction ID', 'better-payment');
1468 + $better_payment_form_success_message_amount_text = !empty( $settings['better_payment_form_success_message_amount_text'] )
1469 + ? esc_html( $settings['better_payment_form_success_message_amount_text'] )
1470 + : esc_html__( 'Amount', 'better-payment' );
1471 + $better_payment_form_success_message_currency_text = !empty( $settings['better_payment_form_success_message_currency_text'] )
1472 + ? esc_html( $settings['better_payment_form_success_message_currency_text'] )
1473 + : esc_html__( 'Currency', 'better-payment' );
1474 + $better_payment_form_success_message_pay_method_text = !empty( $settings['better_payment_form_success_message_pay_method_text'] ) ? esc_html( $settings['better_payment_form_success_message_pay_method_text'] ) : esc_html__( 'Payment Method', 'better-payment' );
1475 + // Rows only Pro's payment types add: Payment Type, and a split payment's "Paid Amount … out of …".
1476 + $pro_rows_after_payment_method = PaymentEntitlement::success_rows( 'after_payment_method', $settings, $tr_id, [ 'currency_symbol' => $bp__currency_symbol ] );
1477 + $pro_rows_after_merchant_details = PaymentEntitlement::success_rows( 'after_merchant_details', $settings, $tr_id, [ 'currency_symbol' => $bp__currency_symbol ] );
1478 + $better_payment_form_success_message_merchant_details_text = !empty( $settings['better_payment_form_success_message_merchant_details_text'] )
1479 + ? esc_html( $settings['better_payment_form_success_message_merchant_details_text'] )
1480 + : esc_html__( 'Merchant Details', 'better-payment' );
1481 + $better_payment_form_success_message_merchant_details_value = isset( $settings['better_payment_form_payment_source'] ) && $settings['better_payment_form_payment_source'] === 'manual' && ! empty( $settings['better_payment_form_title'] )
1482 + ? esc_html($settings['better_payment_form_title'])
1483 + : esc_html( get_bloginfo( 'name' ) );
1484 + $better_payment_form_success_message_purchase_details_text = !empty( $settings['better_payment_form_success_message_purchase_details_text'] )
1485 + ? esc_html( $settings['better_payment_form_success_message_purchase_details_text'] )
1486 + : esc_html__( 'Purchase Details', 'better-payment' );
1487 + $better_payment_form_success_message_print_text = !empty( $settings['better_payment_form_success_message_print_text'] )
1488 + ? esc_html( $settings['better_payment_form_success_message_print_text'] )
1489 + : esc_html__( 'Print', 'better-payment' );
1490 + $better_payment_form_success_message_view_details_btn_text = !empty( $settings['better_payment_form_success_message_view_details_btn_text'] )
1491 + ? esc_html( $settings['better_payment_form_success_message_view_details_btn_text'] )
1492 + : esc_html__( 'View Details', 'better-payment' );
1493 + $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)';
445 1494 ?>
446 - <div class="pt140 pb140 background__grey2">
447 - <div class="better-payment-payment__report better-payment-success-report">
448 - <div class="report__thumb">
449 - <?php if($show_icon) : ?>
450 - <i class="<?php echo esc_attr($image_url); ?>"></i>
451 - <?php else : ?>
452 - <img src="<?php echo esc_url($image_url); ?>" alt="Success image">
1495 + <section class="bp-thank_page">
1496 + <div class="bp-thank_page-wrapper">
1497 + <div class="bp-thank_page-logo">
1498 + <?php if( $default_icon ): ?>
1499 + <span class="bp-thank_page-logo_wrapper">
1500 + <img src="<?php echo esc_url( BETTER_PAYMENT_ASSETS . '/img/success-2.svg' ); ?>" alt="Better Payment logo">
1501 + </span>
453 1502 <?php endif; ?>
1503 + <?php if( ! $default_icon ): ?>
1504 + <?php if( $show_icon ): ?>
1505 + <?php
1506 + // Add 'pf-block' class if this is a Gutenberg block (not Elementor)
1507 + $success_wrapper_class = 'bp-thank_page-custom-logo_wrapper-success';
1508 + if ( ! empty( $settings['better_payment_form_source'] ) && 'block' === $settings['better_payment_form_source'] ) {
1509 + $success_wrapper_class .= ' pf-block';
1510 + }
1511 + ?>
1512 + <div class="<?php echo esc_attr( $success_wrapper_class ); ?>">
1513 + <?php
1514 + // Render icon safely (handles FontAwesome, Dashicons, SVG with or without Elementor)
1515 + if (!empty($settings['better_payment_form_success_message_icon'])) {
1516 + self::render_icon($settings['better_payment_form_success_message_icon'], ['aria-hidden' => 'true']);
1517 + } else {
1518 + // Fallback to default icons
1519 + echo '<i class="' . esc_attr($image_url) . '"></i>';
1520 + }
1521 + ?>
1522 + </div>
1523 + <?php endif; ?>
1524 + <?php if( ! $show_icon ): ?>
1525 + <span class="bp-thank_page-logo_wrapper">
1526 + <img src="<?php echo esc_url($image_url); ?>" alt="Success logo">
1527 + </span>
1528 + <?php endif; ?>
1529 + <?php endif; ?>
454 1530 </div>
455 - <div class="report__content">
456 - <h3 class="transaction__success"><?php echo ( isset( $settings[ 'better_payment_form_success_message_heading' ] ) ) ? esc_html__( $settings['better_payment_form_success_message_heading'], 'better-payment' ) : esc_html__('Payment Successful', 'better-payment'); ?> </h3>
457 - <p class="transaction__number">
458 - <?php printf( "%s : <span id='bp_copy_clipboard_input_1' class='better-payment-success-transaction-number'>%s</span>", ( isset( $settings['better_payment_form_success_message_transaction'] ) ) ? esc_html__( $settings['better_payment_form_success_message_transaction'], 'better-payment' ) : esc_html__('Transaction Number', 'better-payment'), esc_html( $tr_id ) );; ?>
459 - <span id="bp_copy_clipboard_1" class="bp-icon bp-copy-square bp-copy-clipboard" title="Copy" data-bp_txn_counter="1" style="color: rgb(42, 50, 86);"></span>
1531 + <div class="bp-thank_page-text">
1532 + <h2 class="bp-font_ibm bp-page_header">
1533 + <?php echo wp_kses_post( $better_payment_form_success_message_thanks ); ?>
1534 + <span> &#127881;</span>
1535 + </h2>
1536 + <p class="bp-font_ibm bp-reason_for-text">
1537 + <?php echo wp_kses_post( $payment_desc_text ); ?>
460 1538 </p>
461 - <p class="payment__greeting"><?php echo ( isset( $settings['better_payment_form_success_message_thanks'] ) ) ? esc_html__( $settings['better_payment_form_success_message_thanks'], 'better-payment' ) : esc_html__('Thank you for your payment', 'better-payment'); ?></p>
1539 + <?php if (isset( $settings['better_payment_form_email_enable'] ) && !empty( $tr_id['email'] )): ?>
1540 + <p class="bp-font_ibm bp-payment_info">
1541 + <?php echo wp_kses_post( $payment_mail_text ); ?>
1542 + </p>
1543 + <?php endif; ?>
1544 +
1545 + <div class="bp-flex bp-transaction_info-wrapper">
1546 + <p class="bp-flex bp-font_ibm bp-transaction_info"> <span class="line-height-0">
1547 + <img src="<?php echo esc_url( BETTER_PAYMENT_ASSETS . '/img/wallet.svg' ); ?>" alt="Better Payment wallet">
1548 + </span>
1549 + <span>
1550 + <?php echo wp_kses_post( $better_payment_form_success_message_transaction ); ?>:
1551 + </span>
1552 + <span class="bp-bold bp-transaction_id"><?php echo esc_html( ! empty( $tr_id['transaction_id']) ? $tr_id['transaction_id'] : '' ); ?></span>
1553 + </p>
1554 +
1555 + <button class="bp-transaction_data-copy_btn ">
1556 + <svg width="20" height="20" viewBox="0 0 20 20"
1557 + fill="none" xmlns="http://www.w3.org/2000/svg">
1558 + <path
1559 + 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"
1560 + stroke-width="1.152" />
1561 + </svg>
1562 + </button>
1563 + </div>
462 1564 </div>
1565 +
1566 + <div class="bp-thank_page-info">
1567 + <ul class="bp-page_info-list">
1568 + <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1569 + <span class="bp-info_content">
1570 + <?php
1571 + echo wp_kses_post( $better_payment_form_success_message_amount_text );
1572 + ?>
1573 + </span>
1574 + <span class="bp-info_content bp-bold"><?php echo $bp__currency_symbol . esc_html( ! empty( $tr_id['amount'] ) ? $tr_id['amount'] : 0 ); ?></span>
1575 + </li>
1576 + <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1577 + <span class=" bp-info_content">
1578 + <?php
1579 + echo wp_kses_post( $better_payment_form_success_message_currency_text );
1580 + ?>
1581 + </span>
1582 + <span class="bp-info_content bp-bold"><?php echo esc_html( ! empty( $tr_id['currency'] ) ? $tr_id['currency'] : '' ); ?></span>
1583 + </li>
1584 + <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1585 + <span class="bp-info_content">
1586 + <?php
1587 + echo wp_kses_post( $better_payment_form_success_message_pay_method_text );
1588 + ?>
1589 + </span>
1590 + <div class="bp-payment_method-logo-wrapper">
1591 + <?php if ( !empty( $payment_method_logo ) ) : ?>
1592 + <img src="<?php echo esc_url( $payment_method_logo ); ?>" alt="<?php echo esc_attr( $tr_id['method'] ); ?>" class="bp-payment_method-logo">
1593 + <?php endif; ?>
1594 + </div>
1595 + </li>
1596 + <?php foreach ( $pro_rows_after_payment_method as $pro_row ) : ?>
1597 + <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1598 + <span class="bp-info_content">
1599 + <?php echo wp_kses_post( $pro_row['label'] ); ?>
1600 + </span>
1601 + <span class="bp-info_content bp-bold"><?php echo wp_kses_post( $pro_row['value'] ); ?></span>
1602 + </li>
1603 + <?php endforeach; ?>
1604 + <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1605 + <span class="bp-info_content">
1606 + <?php
1607 + echo wp_kses_post( $better_payment_form_success_message_merchant_details_text );
1608 + ?>
1609 + </span>
1610 + <span class="bp-info_content bp-bold">
1611 + <?php echo wp_kses_post( $better_payment_form_success_message_merchant_details_value ); ?>
1612 + </span>
1613 + </li>
1614 + <?php foreach ( $pro_rows_after_merchant_details as $pro_row ) : ?>
1615 + <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1616 + <span class="bp-info_content">
1617 + <?php echo wp_kses_post( $pro_row['label'] ); ?>
1618 + </span>
1619 + <span class="bp-info_content bp-bold"><?php echo wp_kses_post( $pro_row['value'] ); ?></span>
1620 + </li>
1621 + <?php endforeach; ?>
1622 + <?php
1623 + $has_woo_products = !empty($settings['better_payment_form_woocommerce_product_id']) || !empty($settings['better_payment_form_woocommerce_product_ids']);
1624 + $has_fluentcart_products = !empty($settings['better_payment_form_fluentcart_product_id']) || !empty($settings['better_payment_form_fluentcart_product_ids']);
1625 +
1626 + if( $has_woo_products || $has_fluentcart_products ):
1627 + ?>
1628 + <li class="bp-flex bp-font_ibm bp-page_info-list_item">
1629 + <span class="bp-info_content">
1630 + <?php
1631 + echo wp_kses_post( $better_payment_form_success_message_purchase_details_text );
1632 + ?>
1633 + </span>
1634 + <?php
1635 + $product_names = [];
1636 +
1637 + if( !empty( $settings['better_payment_form_woocommerce_product_id'] ) ):
1638 + $product_names[] = get_the_title( $settings['better_payment_form_woocommerce_product_id'] );
1639 + endif;
1640 +
1641 + if( !empty( $settings['better_payment_form_woocommerce_product_ids'] ) ):
1642 + $product_ids = $settings['better_payment_form_woocommerce_product_ids'];
1643 + foreach( $product_ids as $product_id ):
1644 + $product_names[] = get_the_title( $product_id );
1645 + endforeach;
1646 + endif;
1647 +
1648 + if( !empty( $settings['better_payment_form_fluentcart_product_id'] ) && function_exists('fluentCart') && class_exists( '\FluentCart\App\Models\Product' ) ):
1649 + try {
1650 + $fluentcart_product = \FluentCart\App\Models\Product::query()->find($settings['better_payment_form_fluentcart_product_id']);
1651 + if ($fluentcart_product) {
1652 + $product_names[] = sanitize_text_field( $fluentcart_product->post_title );
1653 + }
1654 + } catch ( \Exception $e ) {
1655 + //
1656 + }
1657 + endif;
1658 +
1659 + if( !empty( $settings['better_payment_form_fluentcart_product_ids'] ) && function_exists('fluentCart') && class_exists( '\FluentCart\App\Models\Product' ) ):
1660 + $product_ids = $settings['better_payment_form_fluentcart_product_ids'];
1661 + foreach( $product_ids as $product_id ):
1662 + try {
1663 + $fluentcart_product = \FluentCart\App\Models\Product::query()->find($product_id);
1664 + if ($fluentcart_product) {
1665 + $product_names[] = sanitize_text_field( $fluentcart_product->post_title );
1666 + }
1667 + } catch ( \Exception $e ) {
1668 + //
1669 + }
1670 + endforeach;
1671 + endif;
1672 +
1673 + if( !empty($product_names) ):
1674 + echo '<span class="bp-info_content bp-bold">' . implode( ', ', $product_names ) . '</span>';
1675 + endif;
1676 + ?>
1677 + </li>
1678 + <?php endif; ?>
1679 + </ul>
1680 + </div>
1681 +
1682 + <div class="bp-flex bp-button_group">
1683 + <button class="bp-flex bp-font_ibm bp-print_btn">
1684 + <span style="line-height: 0;">
1685 + <img src="<?php echo esc_url( BETTER_PAYMENT_ASSETS . '/img/print.svg' ); ?>" alt="Better Payment print">
1686 + </span>
1687 + <span class="bp-font_ibm">
1688 + <?php echo wp_kses_post( $better_payment_form_success_message_print_text ); ?>
1689 + </span>
1690 + </button>
1691 +
1692 + <div class="bp-flex bp-btn_wrapper">
1693 + <a href="<?php echo $better_payment_form_success_page_view_details_url; ?>" target="_blank" class="bp-font_ibm bp-details_btn">
1694 + <?php echo wp_kses_post( $better_payment_form_success_message_view_details_btn_text ); ?>
1695 + </a>
1696 + </div>
1697 + </div>
463 1698 </div>
464 - </div>
1699 + </section>
465 1700 <?php
466 1701 }
467 1702
468 -
469 1703 /**
470 1704 * Error message template
471 1705 *
472 1706 * @since 0.0.1
473 1707 */
474 - public static function failed_message_template( $settings = [] ) {
1708 + public static function failed_message_template( $settings = [], $transaction_data = false ) {
475 1709
476 1710 $image_url = BETTER_PAYMENT_ASSETS . '/img/fail.svg';
477 1711 $show_icon = 0;
1712 + $default_icon = 1;
1713 + $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' );
1714 + $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' );
1715 + $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' );
1716 + $show_details_button = !empty( $settings['better_payment_form_error_details_button_switch'] ) && $settings['better_payment_form_error_details_button_switch'] == 'yes' ? true : false;
1717 + $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' );
1718 + $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)';
1719 + $store_name = ( isset( $settings['better_payment_form_payment_source'] ) && $settings['better_payment_form_payment_source'] === 'manual' && !empty( $settings['better_payment_form_title'] ) )
1720 + ? esc_html($settings['better_payment_form_title'])
1721 + : esc_html( get_bloginfo('name') );
478 1722
1723 + // Get transaction details
1724 + $transaction_amount = '';
1725 + $transaction_id = '';
1726 + $currency_symbol = '';
1727 +
1728 + if ( !empty( $transaction_data ) ) {
1729 + $transaction_amount = !empty( $transaction_data['amount'] ) ? floatval( $transaction_data['amount'] ) : '';
1730 + $currency_symbol = !empty( $transaction_data['currency_symbol'] ) ? esc_html( $transaction_data['currency_symbol'] ) : '';
1731 + $transaction_id = !empty( $transaction_data['transaction_id'] ) ? esc_html( $transaction_data['transaction_id'] ) : '';
1732 + }
1733 +
479 1734 if ( !empty( $settings[ 'better_payment_form_error_message_icon' ][ 'library' ] ) ) {
480 1735 if ( $settings[ 'better_payment_form_error_message_icon' ][ 'library' ] == 'svg' ) {
481 1736 $image_url = $settings[ 'better_payment_form_error_message_icon' ][ 'value' ][ 'url' ];
1737 + $default_icon = 0;
482 1738 } else {
483 1739 $show_icon = 1;
484 1740 $image_url = $settings[ 'better_payment_form_error_message_icon' ][ 'value' ];
1741 + $default_icon = 0;
485 1742 }
486 1743 }
487 1744 ?>
488 - <div class="pb140 background__grey2">
489 - <div class="better-payment-payment__report better-payment-error-report">
490 - <div class="report__thumb">
491 - <?php if($show_icon) : ?>
492 - <i class="<?php echo esc_attr($image_url); ?>"></i>
493 - <?php else : ?>
494 - <img src="<?php echo esc_url($image_url); ?>" alt="Error image">
1745 + <section class="payment-failed-screen-section">
1746 + <div class="bp-thank_page-wrapper">
1747 + <div class="bp-thank_page-logo">
1748 + <?php if( $default_icon ): ?>
1749 + <span class="bp-thank_page-logo_wrapper">
1750 + <svg width="58" height="58" viewBox="0 0 58 58" fill="none" xmlns="http://www.w3.org/2000/svg">
1751 + <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>
1752 + <circle cx="45.5" cy="41.5" r="6.5" fill="white"></circle>
1753 + <foreignObject x="21.4686" y="17.3701" width="47.2066" height="47.3399">
1754 + <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%">
1755 + </div>
1756 + </foreignObject>
1757 + <g filter="url(#filter0_d_3595_8284)" data-figma-bg-blur-radius="12.71">
1758 + <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>
1759 + </g>
1760 + <defs>
1761 + <filter id="filter0_d_3595_8284" x="21.4686" y="17.3701" width="47.2066" height="47.3399" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
1762 + <feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood>
1763 + <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>
1764 + <feOffset dx="-4"></feOffset>
1765 + <feGaussianBlur stdDeviation="2"></feGaussianBlur>
1766 + <feComposite in2="hardAlpha" operator="out"></feComposite>
1767 + <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>
1768 + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3595_8284"></feBlend>
1769 + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3595_8284" result="shape"></feBlend>
1770 + </filter>
1771 + <clipPath id="bgblur_0_3595_8284_clip_path" transform="translate(-21.4686 -17.3701)">
1772 + <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>
1773 + </clipPath>
1774 + </defs>
1775 + </svg>
1776 + </span>
1777 + <?php else: ?>
1778 + <?php if( $show_icon ): ?>
1779 + <?php
1780 + // Add 'pf-block' class if this is a Gutenberg block (not Elementor)
1781 + $error_wrapper_class = 'bp-thank_page-custom-logo_wrapper-error';
1782 + if ( ! empty( $settings['better_payment_form_source'] ) && 'block' === $settings['better_payment_form_source'] ) {
1783 + $error_wrapper_class .= ' pf-block';
1784 + }
1785 + ?>
1786 + <div class="<?php echo esc_attr( $error_wrapper_class ); ?>">
1787 + <?php
1788 + // Render icon safely (handles FontAwesome, Dashicons, SVG with or without Elementor)
1789 + if (!empty($settings['better_payment_form_error_message_icon'])) {
1790 + self::render_icon($settings['better_payment_form_error_message_icon'], ['aria-hidden' => 'true']);
1791 + } else {
1792 + // Fallback to default icons
1793 + echo '<i class="' . esc_attr($image_url) . '"></i>';
1794 + }
1795 + ?>
1796 + </div>
1797 + <?php else: ?>
1798 + <span class="bp-thank_page-logo_wrapper">
1799 + <img src="<?php echo esc_url($image_url); ?>" alt="Error logo">
1800 + </span>
1801 + <?php endif; ?>
495 1802 <?php endif; ?>
496 1803 </div>
497 - <div class="report__content">
498 - <h3 class="transaction__failed"><?php echo ( isset( $settings['better_payment_form_error_message_heading'] ) ) ? esc_html__( $settings['better_payment_form_error_message_heading'], 'better-payment' ) : esc_html__('Payment Failed', 'better-payment'); ?></h3>
1804 + <div class="bp-thank_page-text">
1805 + <h2 class="bp-font_ibm bp-page_header"><?php echo wp_kses_post( $failed_heading ); ?></h2>
1806 + <p class="bp-font_ibm bp-reason_for-text">Payment of <?php echo $currency_symbol . $transaction_amount; ?> to <?php echo $store_name; ?> failed.</p>
1807 + <p class="bp-font_ibm bp-payment_info"><?php echo wp_kses_post( $failed_sub_heading ); ?></p>
1808 +
1809 + <div class="bp-flex bp-transaction_info-wrapper">
1810 + <p class="bp-flex bp-font_ibm bp-transaction_info"> <span>
1811 + <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
1812 + <g clip-path="url(#clip0_2914_6421)">
1813 + <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>
1814 + </g>
1815 + <defs>
1816 + <clipPath id="clip0_2914_6421">
1817 + <rect width="18" height="18" fill="white"></rect>
1818 + </clipPath>
1819 + </defs>
1820 + </svg>
1821 + </span>
1822 + <span><?php echo wp_kses_post( $transaction_id_text ); ?>:</span>
1823 + <span class="bp-bolt bp-transaction_id"><?php echo $transaction_id; ?></span>
1824 + </p>
1825 +
1826 + <button class="bp-transaction_data-copy_btn ">
1827 + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
1828 + <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>
1829 + </svg>
1830 + </button>
1831 + </div>
499 1832 </div>
1833 + <?php if( $show_details_button ): ?>
1834 + <div class="bp-btn_wrapper">
1835 + <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>
1836 + </div>
1837 + <?php endif; ?>
500 1838 </div>
501 - </div>
1839 + </section>
502 1840 <?php
503 1841 }
504 1842
505 1843 /**
@@ -523,8 +1861,9 @@
523 1861 params.delete('better_payment_paystack_status');
524 1862 params.delete('better_payment_widget_id');
525 1863 params.delete('better_payment_stripe_id');
526 1864 params.delete('better_payment_paystack_id');
1865 + params.delete('item_number');
527 1866 window.history.replaceState({}, '', `${location.pathname}?${params}`);
528 1867 }
529 1868 </script>
530 1869 <?php
@@ -530,21 +1869,96 @@
530 1869 <?php
531 1870 }
532 1871
533 1872 /**
1873 + * Get failed transaction data
1874 + *
1875 + * @since 1.0.0
1876 + */
1877 + public static function get_failed_transaction_data( $order_id = '' ) {
1878 + if ( empty( $order_id ) ) {
1879 + return false;
1880 + }
1881 +
1882 + global $wpdb;
1883 + $table = "{$wpdb->prefix}better_payment";
1884 +
1885 + $result = $wpdb->get_row(
1886 + $wpdb->prepare(
1887 + "SELECT amount, currency, transaction_id, order_id, source FROM $table WHERE order_id=%s LIMIT 1",
1888 + $order_id
1889 + )
1890 + );
1891 +
1892 + if ( !empty( $result ) ) {
1893 + $helper_obj = new Helper();
1894 + return [
1895 + 'amount' => floatval( $result->amount ),
1896 + 'currency' => sanitize_text_field( $result->currency ),
1897 + 'currency_symbol' => $helper_obj->get_currency_symbol( $result->currency ),
1898 + // The order id is request data, and ids before 2.4.0 were `{gateway}_` . uniqid() —
1899 + // enumerable — so the gateway's transaction id is shown only for an unguessable id.
1900 + 'transaction_id' => ! empty( $result->transaction_id ) && self::order_id_is_unguessable( $order_id ) ? sanitize_text_field( $result->transaction_id ) : 'N/A',
1901 + 'method' => sanitize_text_field( $result->source ),
1902 + ];
1903 + }
1904 +
1905 + return false;
1906 + }
1907 +
1908 + /**
1909 + * Whitelist the e-mail related settings that better_email_notification() reads.
1910 + *
1911 + * PayPal confirms payments asynchronously through the IPN listener
1912 + * (handle_paypal_ipn), where the live Elementor widget / block settings are NOT
1913 + * available. Stripe and Paystack build their e-mail synchronously on the return
1914 + * page, so they still have the full settings array in hand. To reach parity we
1915 + * persist this subset into the transaction's form_fields_info at payment-create
1916 + * time, and the IPN handler rebuilds the same $settings to produce an identical
1917 + * e-mail body (heading, From / To / Transaction Summary / footer sections,
1918 + * subjects, headers) and to honour the "Send Email" on/off toggle.
1919 + *
1920 + * @param mixed $settings Full widget / block / form settings (guarded: non-array input returns []).
1921 + *
1922 + * @return array Whitelisted e-mail settings (safe to serialize into form_fields_info).
1923 + * @since 2.2.2
1924 + */
1925 + public static function extract_email_settings( $settings = [] ) {
1926 + $email_settings = [];
1927 +
1928 + if ( ! is_array( $settings ) ) {
1929 + return $email_settings;
1930 + }
1931 +
1932 + foreach ( $settings as $key => $value ) {
1933 + if (
1934 + 0 === strpos( $key, 'better_payment_email_' )
1935 + || 0 === strpos( $key, 'better_payment_form_email_' )
1936 + || 'better_payment_form_title' === $key
1937 + ) {
1938 + $email_settings[ $key ] = $value;
1939 + }
1940 + }
1941 +
1942 + return $email_settings;
1943 + }
1944 +
1945 + /**
534 1946 * Email template
535 - *
1947 + *
536 1948 * @since 0.0.1
537 1949 */
538 - public static function better_email_notification($transaction_id, $customer_email, $settings, $referrer='Stripe', $form_fields_info=''){
1950 + public static function better_email_notification($transaction_id, $customer_email, $settings, $referrer='Stripe', $form_fields_info='', $is_elementor_form = 0){
539 1951 //Send Email: customer and admin
540 1952 $better_txn_id = sanitize_text_field( $transaction_id );
541 1953 $better_cus_email = sanitize_email( $customer_email );
1954 + $default_subject = sprintf(__('Better Payment transaction on %s', 'better-payment'), esc_html(get_option('blogname')));
542 1955 $better_payment_global_settings = DB::get_settings();
543 1956
544 1957 $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' );
545 - $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'] : __('Better Payment Transaction', 'better-payment');
546 - $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'] : '';
1958 +
1959 + $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;
1960 + $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';
547 1961 $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'] : '';
548 1962 $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'] : '';
549 1963 $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'] : '';
550 1964 $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'] : '';
@@ -550,10 +1964,10 @@
550 1964 $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'] : '';
551 1965 $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'] : '';
552 1966 $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';
553 1967
554 - $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'] : __('Better Payment Transaction', 'better-payment');
555 - $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'] : '';
1968 + $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;
1969 + $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';
556 1970 $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'] : '';
557 1971 $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'] : '';
558 1972 $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'] : '';
559 1973 $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'] : '';
@@ -558,9 +1972,38 @@
558 1972 $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'] : '';
559 1973 $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'] : '';
560 1974 $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'] : '';
561 1975 $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';
1976 + $args = [];
562 1977
1978 + $args['email_form_name'] = ! empty( $settings['better_payment_form_title'] ) ? sanitize_text_field( $settings['better_payment_form_title'] ) : 'N/A';
1979 + $args['email_content_heading_show'] = ! empty( $settings['better_payment_email_content_heading'] ) && 'yes' === $settings['better_payment_email_content_heading'] ? 1 : 0;
1980 + $args['email_content_from_section_show'] = ! empty( $settings['better_payment_email_content_from_section'] ) && 'yes' === $settings['better_payment_email_content_from_section'] ? 1 : 0;
1981 + $args['email_content_to_section_show'] = ! empty( $settings['better_payment_email_content_to_section'] ) && 'yes' === $settings['better_payment_email_content_to_section'] ? 1 : 0;
1982 + $args['email_content_transaction_summary_show'] = ! empty( $settings['better_payment_email_content_transaction_summary'] ) && 'yes' === $settings['better_payment_email_content_transaction_summary'] ? 1 : 0;
1983 + $args['email_content_footer_text_show'] = ! empty( $settings['better_payment_email_content_footer_text'] ) && 'yes' === $settings['better_payment_email_content_footer_text'] ? 1 : 0;
1984 + $args['email_content_customer'] = ! empty( $settings['better_payment_email_content_customer'] ) ? $settings['better_payment_email_content_customer'] : '';
1985 + $args['email_content_admin'] = ! empty( $settings['better_payment_email_content'] ) ? $settings['better_payment_email_content'] : '';
1986 + $args['is_elementor_form'] = $is_elementor_form;
1987 +
1988 + if ( ! empty( $settings['better_payment_email_content_greeting'] ) ) {
1989 + $args['email_content_greeting'] = $settings['better_payment_email_content_greeting'];
1990 + }
1991 +
1992 + if ( ! empty( $settings['better_payment_form_email_logo']['url'] ) ) {
1993 + $args['email_logo_url'] = $settings['better_payment_form_email_logo']['url'];
1994 + }
1995 +
1996 + if ( ! empty( $settings['better_payment_form_email_attachment']['id'] ) ) {
1997 + $args['email_attachment_id'] = intval( $settings['better_payment_form_email_attachment']['id'] );
1998 + $args['email_attachment_path'] = get_attached_file( $args['email_attachment_id'] );
1999 + }
2000 +
2001 + if ( ! empty( $settings['better_payment_form_email_attachment_pdf_show'] ) && ! empty( $settings['better_payment_form_email_attachment_pdf']['id'] ) ) {
2002 + $args['email_attachment_id'] = intval( $settings['better_payment_form_email_attachment_pdf']['id'] );
2003 + $args['email_attachment_path'] = get_attached_file( $args['email_attachment_id'] );
2004 + }
2005 +
563 2006 if(isset($settings['better_payment_email_to'])){
564 2007 $better_payment_email_to = sanitize_email($settings['better_payment_email_to']);
565 2008 }
566 2009
@@ -579,8 +2022,11 @@
579 2022 if(isset($settings['better_payment_email_content_type_customer'])){
580 2023 $better_payment_email_content_type_customer = sanitize_text_field($settings['better_payment_email_content_type_customer']);
581 2024 }
582 2025
2026 + $args['email_content_type'] = $better_payment_email_content_type;
2027 + $args['email_content_type_customer'] = $better_payment_email_content_type_customer;
2028 +
583 2029 $line_break = $better_payment_email_content_type == 'html' ? '<br>' : "\n";
584 2030 $line_break_customer = $better_payment_email_content_type_customer == 'html' ? '<br>' : "\n";
585 2031
586 2032 global $wpdb;
@@ -590,18 +2036,18 @@
590 2036 );
591 2037
592 2038 if(isset($settings['better_payment_email_content'])){
593 2039 $better_payment_email_content = sanitize_text_field($settings['better_payment_email_content']);
594 - $better_payment_email_content = self::better_render_email_body( $better_payment_email_content, $form_fields_info, $line_break, 'admin', $transaction_obj );
2040 + $better_payment_email_content = self::better_render_email_body( $better_payment_email_content, $form_fields_info, $line_break, 'admin', $transaction_obj, $args );
595 2041 } else if ( !empty($better_payment_email_content) ){
596 - $better_payment_email_content = self::better_render_email_body( $better_payment_email_content, $form_fields_info, $line_break, 'admin', $transaction_obj );
2042 + $better_payment_email_content = self::better_render_email_body( $better_payment_email_content, $form_fields_info, $line_break, 'admin', $transaction_obj, $args );
597 2043 }
598 2044
599 2045 if(isset($settings['better_payment_email_content_customer'])){
600 2046 $better_payment_email_content_customer = sanitize_text_field($settings['better_payment_email_content_customer']);
601 - $better_payment_email_content_customer = self::better_render_email_body( $better_payment_email_content_customer, $form_fields_info, $line_break_customer, 'customer', $transaction_obj );
2047 + $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 );
602 2048 } else if ( !empty($better_payment_email_content_customer) ){
603 - $better_payment_email_content_customer = self::better_render_email_body( $better_payment_email_content_customer, $form_fields_info, $line_break, 'customer', $transaction_obj ); //$line_break_customer not used : need to check type on parent method and add headers accordingly.
2049 + $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.
604 2050 }
605 2051
606 2052 if(isset($settings['better_payment_email_from'])){
607 2053 $better_payment_email_from = sanitize_email($settings['better_payment_email_from']);
@@ -656,8 +2102,11 @@
656 2102 $better_payment_email_headers_customer = [];
657 2103
658 2104 if($better_payment_email_content_type == 'html'){
659 2105 $better_payment_email_headers[] = 'Content-Type: text/html; charset=UTF-8';
2106 + }
2107 +
2108 + if($better_payment_email_content_type_customer == 'html'){
660 2109 $better_payment_email_headers_customer[] = 'Content-Type: text/html; charset=UTF-8';
661 2110 }
662 2111
663 2112 if($better_payment_email_from != ''){
@@ -701,9 +2150,19 @@
701 2150 if($better_payment_email_reply_to_customer != ''){
702 2151 $better_payment_email_headers_customer[] = "Reply-To: $better_payment_email_reply_to_customer";
703 2152 }
704 2153
705 - self::send_better_email($better_cus_email, $better_payment_email_subject_customer, $better_payment_email_content_customer, $better_payment_email_headers_customer); //customer mail
2154 + $email_attachments = ! empty( $args['email_attachment_path'] ) ? [ $args['email_attachment_path'] ] : [];
2155 + $allowed_extensions = array('jpg', 'jpeg', 'png', 'pdf');
2156 +
2157 + $file_info = count( $email_attachments ) ? pathinfo($email_attachments[0]) : [];
2158 + $file_extension = count( $file_info ) ? strtolower($file_info['extension']) : '';
2159 +
2160 + if ( ! in_array( $file_extension, $allowed_extensions ) ){
2161 + $email_attachments = [];
2162 + }
2163 +
2164 + 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
706 2165 self::send_better_email($better_payment_email_to, $better_payment_email_subject, $better_payment_email_content, $better_payment_email_headers); //admin mail
707 2166 }
708 2167
709 2168 /**
@@ -718,15 +2177,15 @@
718 2177 * @return bool false indicates mail sending failed while true doesn't gurantee that mail sent.
719 2178 * true just indicate script processed successfully without any errors.
720 2179 * Ref: https://developer.wordpress.org/reference/functions/wp_mail/
721 2180 */
722 - public static function send_better_email( $to, $subject, $message, $headers=[]) {
2181 + public static function send_better_email( $to, $subject, $message, $headers=[], $attachments = [] ) {
723 2182 $response = false;
724 2183
725 2184 $mailValidated = filter_var($to, FILTER_VALIDATE_EMAIL);
726 2185
727 2186 if ($mailValidated) {
728 - $response = wp_mail( $to, $subject, $message, $headers );
2187 + $response = wp_mail( $to, $subject, $message, $headers, $attachments );
729 2188 }
730 2189
731 2190 return $response;
732 2191 }
@@ -737,10 +2196,10 @@
737 2196 *
738 2197 * @return string
739 2198 * @since 0.0.1
740 2199 */
741 - public static function better_render_email_body( $email_body, $form_fields_info, $line_break, $type = 'admin', $transaction_obj = null ) {
742 - $transaction_id =
2200 + public static function better_render_email_body( $email_body, $form_fields_info, $line_break, $type = 'admin', $transaction_obj = null, $args = [] ) {
2201 + $transaction_id =
743 2202 $currency =
744 2203 $payment_date =
745 2204 $customer_name =
746 2205 $customer_first_name =
@@ -745,89 +2204,199 @@
745 2204 $customer_name =
746 2205 $customer_first_name =
747 2206 $customer_last_name =
748 2207 $amount = '';
2208 + $helper_obj = new Helper();
749 2209
750 2210 if(null !== $transaction_obj && is_object($transaction_obj)){
751 2211 $transaction_id = $transaction_obj->transaction_id;
752 2212 $amount = $transaction_obj->amount;
2213 + $status = $transaction_obj->status;
2214 + $source = $transaction_obj->source;
753 2215 $currency = $transaction_obj->currency;
2216 + $currency_symbol = $helper_obj->get_currency_symbol( esc_html($currency) );
754 2217 $payment_date = wp_date(get_option('date_format').' '.get_option('time_format'), strtotime($transaction_obj->payment_date));
755 2218 }
756 2219
757 - $is_empty_email_body = empty($email_body);
758 -
759 - $email_body = do_shortcode( $email_body );
760 - $bp_form_fields_html_content = $email_body;
761 -
762 - $bp_all_fields_shortcode = 'bp-all-fields';
763 2220 $referer_content_page_link = '#';
764 -
765 - $field_text = __('Field', 'better-payment');
766 - $value_text = __('Entry', 'better-payment');
767 2221
768 - $bp_form_fields_html_header = '<table style="margin-bottom: 20px; font-size: 14px; border-collapse: collapse; width: 100%;">';
2222 + $form_fields_info_arr = maybe_unserialize($form_fields_info);
2223 + $form_fields_info_arr = is_array( $form_fields_info_arr ) ? $form_fields_info_arr : [];
769 2224
770 - $bp_form_fields_html_footer = "</table>";
2225 + $email_logo_url = ! empty( $args['email_logo_url'] ) ? $args['email_logo_url'] : '';
2226 + $email_content_greeting = ! empty( $args['email_content_greeting'] ) ? 1 : 0;
2227 + $email_form_name = ! empty( $args['email_form_name'] ) ? $args['email_form_name'] : 'N/A';
771 2228
772 - if ( false !== strpos( $email_body, $bp_all_fields_shortcode ) ) {
773 - $content = ''. $line_break;
774 - $form_fields_info_arr = maybe_unserialize($form_fields_info);
775 -
776 - foreach ( $form_fields_info_arr as $key => $field ) {
777 - //Hide few fields
778 - if($key === 'referer_page_id' || $key === 'referer_widget_id' || $key === 'source' || $key === 'el_form_fields') {
779 - if($key === 'referer_page_id'){
780 - $referer_content_page_link = !empty($field) ? get_permalink( $field ) : $referer_content_page_link;
781 - }
2229 + $form_field_rows = [];
782 2230
783 - continue;
2231 + foreach ( $form_fields_info_arr as $key => $field ) {
2232 + //Hide few fields: storage keys, not fields the visitor filled in (the From section hides the same ones)
2233 + if(
2234 + $key === 'referer_page_id' || $key === 'referer_widget_id' || $key === 'source' || $key === 'el_form_fields'
2235 + || $key === 'amount_quantity'
2236 + || $key === 'woo_product_id'
2237 + || $key === 'woo_product_ids'
2238 + || $key === 'fluentcart_product_id'
2239 + || $key === 'fluentcart_product_ids'
2240 + || $key === 'detailed_product_info'
2241 + || $key === 'email_settings'
2242 + ) {
2243 + if($key === 'referer_page_id'){
2244 + $referer_content_page_link = !empty($field) ? get_permalink( $field ) : $referer_content_page_link;
784 2245 }
785 2246
786 - if($key === 'primary_first_name'){
787 - $key = 'first_name';
788 - $customer_first_name = $field;
789 - }
790 - if($key === 'primary_last_name'){
791 - $key = 'last_name';
792 - $customer_last_name = $field;
793 - }
2247 + continue;
2248 + }
794 2249
795 - if($key === 'primary_email'){
796 - $key = 'email';
797 - }
2250 + $label_key = $key;
798 2251
799 - if($key === 'primary_payment_amount'){
800 - $key = 'payment_amount';
801 - }
802 -
803 - $key_formatted = self::better_title_case($key);
804 - if($key_formatted === 'Amount'){
805 - $key_formatted = __('Paid', 'better-payment');
806 - }
2252 + if($key === 'primary_first_name'){
2253 + $label_key = 'first_name';
2254 + $customer_first_name = $field;
2255 + }
2256 + if($key === 'primary_last_name'){
2257 + $label_key = 'last_name';
2258 + $customer_last_name = $field;
2259 + }
807 2260
808 - $content .= "<tr>
809 - <td style='padding: 7px; border: 1px solid #666;'> <strong>$key_formatted</strong> </td>
810 - <td style='padding: 7px; border: 1px solid #666;'> $field </td>
811 - </tr>";
812 - }
2261 + if($key === 'primary_email'){
2262 + $label_key = 'email';
2263 + }
813 2264
814 - if($customer_first_name) {
815 - $customer_name = $customer_first_name . ' ';
2265 + if($key === 'primary_payment_amount'){
2266 + $label_key = 'payment_amount';
816 2267 }
817 - if($customer_last_name) {
818 - $customer_name .= $customer_last_name . ' ';
2268 +
2269 + $key_formatted = self::better_title_case($label_key);
2270 + if($key_formatted === 'Amount'){
2271 + $key_formatted = __('Paid', 'better-payment');
819 2272 }
820 2273
821 - $bp_form_fields_html_body = $content;
822 - $bp_form_fields_html_content = $bp_form_fields_html_header . $bp_form_fields_html_body . $bp_form_fields_html_footer ;
2274 + $form_field_rows[] = [
2275 + 'key' => $key,
2276 + 'label' => $key_formatted,
2277 + 'value' => $field,
2278 + ];
2279 + }
823 2280
824 - //Replace shortcode with form fields info
825 - $email_body = str_replace( "[$bp_all_fields_shortcode]", $bp_form_fields_html_content, $email_body );
826 - }
2281 + // A Pro payment's own fields (split payments, layout 6) are hidden or relabelled by Pro.
2282 + $all_fields_rows = PaymentEntitlement::email_rows( $form_field_rows, 'all_fields', $form_fields_info_arr );
827 2283
2284 + if($customer_first_name) {
2285 + $customer_name = $customer_first_name . ' ';
2286 + }
2287 + if($customer_last_name) {
2288 + $customer_name .= $customer_last_name . ' ';
2289 + }
2290 +
2291 + $allowed_sources = ['paypal', 'stripe', 'paystack'];
2292 + $source_image_url = BETTER_PAYMENT_ASSETS . '/img/stripe.png';
2293 + $source_image_alt = 'Stripe';
2294 +
2295 + if( in_array( strtolower( $transaction_obj->source ), $allowed_sources ) ){
2296 + $source_image_url = strtolower( $transaction_obj->source ) == 'paypal' ? BETTER_PAYMENT_ASSETS . '/img/paypal.png' : BETTER_PAYMENT_ASSETS . "/img/{$transaction_obj->source}.png";
2297 + $source_image_alt = strtolower( $transaction_obj->source ) == 'paypal' ? 'PayPal' : ucfirst( strtolower( $transaction_obj->source ) );
2298 + }
2299 +
2300 + $amount_quantity = ! empty( $form_fields_info_arr['amount_quantity'] ) ? intval( $form_fields_info_arr['amount_quantity'] ) : 1;
2301 + // #ToDo Product id or ids with comma
2302 + $woo_product_id = ! empty( $form_fields_info_arr['woo_product_id'] ) ? intval( $form_fields_info_arr['woo_product_id'] ) : 0;
2303 + $fluentcart_product_id = ! empty( $form_fields_info_arr['fluentcart_product_id'] ) ? intval( $form_fields_info_arr['fluentcart_product_id'] ) : 0;
2304 + $woo_product_ids = ! empty( $form_fields_info_arr['woo_product_ids'] ) ? maybe_unserialize( $form_fields_info_arr['woo_product_ids'] ) : [0];
2305 + $fluentcart_product_ids = ! empty( $form_fields_info_arr['fluentcart_product_ids'] ) ? maybe_unserialize( $form_fields_info_arr['fluentcart_product_ids'] ) : [0];
2306 + $product_name = '';
2307 + $product_permalink = '';
2308 + $product_image_src = '';
2309 +
2310 + // Handle WooCommerce products
2311 + if (function_exists('wc_get_product') && $woo_product_id ) {
2312 + $bp_woocommerce_product = wc_get_product($woo_product_id);
2313 + $product = $bp_woocommerce_product;
2314 +
2315 + if ($product) {
2316 + $product_name = $product->get_name();
2317 + $product_permalink = get_permalink($product->get_id());
2318 + $product_price = $product->get_price();
2319 + $product_image_src_array = wp_get_attachment_image_src( get_post_thumbnail_id( $product->get_id() ), 'single-post-thumbnail' );
2320 + $product_image_src = is_array( $product_image_src_array ) && count( $product_image_src_array ) ? $product_image_src_array[0] : '';
2321 + }
2322 + }
2323 +
2324 + if (function_exists('fluentCart') && $fluentcart_product_id ) {
2325 + try {
2326 + $fluentcart_product = \FluentCart\App\Models\Product::query()
2327 + ->with(['detail', 'variants'])
2328 + ->find($fluentcart_product_id);
2329 +
2330 + if ($fluentcart_product) {
2331 + $product_name = $fluentcart_product->post_title;
2332 + $product_permalink = get_permalink($fluentcart_product->ID);
2333 +
2334 + // Get price from the first variant or detail
2335 + $product_price = 0;
2336 + if (!empty($fluentcart_product->variants) && count($fluentcart_product->variants) > 0) {
2337 + $product_price = $fluentcart_product->variants[0]->item_price;
2338 + } elseif (!empty($fluentcart_product->detail)) {
2339 + $product_price = $fluentcart_product->detail->min_price;
2340 + }
2341 +
2342 + $product_image_src_array = wp_get_attachment_image_src( get_post_thumbnail_id( $fluentcart_product->ID ), 'single-post-thumbnail' );
2343 + $product_image_src = is_array( $product_image_src_array ) && count( $product_image_src_array ) ? $product_image_src_array[0] : '';
2344 + }
2345 + } catch ( \Exception $e ) {
2346 + // Handle error silently
2347 + }
2348 + }
2349 +
2350 + $detailed_product_info = isset( $form_fields_info_arr['detailed_product_info'] ) ? maybe_unserialize( $form_fields_info_arr['detailed_product_info'] ) : [];
2351 +
2352 + $all_data = [
2353 + 'amount' => $amount,
2354 + 'amount_quantity' => $amount_quantity,
2355 + 'woo_product_id' => $woo_product_id,
2356 + 'woo_product_ids' => $woo_product_ids,
2357 + 'fluentcart_product_id' => $fluentcart_product_id,
2358 + 'fluentcart_product_ids' => $fluentcart_product_ids,
2359 + 'product_name' => $product_name,
2360 + 'product_permalink' => $product_permalink,
2361 + 'product_image_src' => $product_image_src,
2362 + 'detailed_product_info' => $detailed_product_info,
2363 + 'amount_single' => $amount_quantity > 0 ? floatval( $amount / $amount_quantity ) : $amount,
2364 + 'currency' => ! empty( $currency ) ? $currency : '',
2365 + 'currency_symbol' => ! empty( $currency_symbol ) ? $currency_symbol : '',
2366 + 'payment_date_time' => ! empty( $payment_date ) ? $payment_date : '',
2367 + 'transaction_id' => ! empty( $transaction_id ) ? $transaction_id : '',
2368 + 'payment_date_only' => wp_date(get_option('date_format'), strtotime($transaction_obj->payment_date)),
2369 + 'status' => ucfirst( $status ),
2370 + 'form_fields_info_arr' => $form_fields_info_arr,
2371 + // 'admin_name' => $admin_name,
2372 + 'customer_name' => $customer_name,
2373 + 'site_title' => get_bloginfo( 'name' ),
2374 + 'site_admin_email' => get_bloginfo( 'admin_email' ),
2375 + 'form_name' => $email_form_name,
2376 + 'payment_method' => ucfirst( $source ),
2377 + 'email_logo_url' => $email_logo_url,
2378 + 'source_image_url' => $source_image_url,
2379 + 'source_image_alt' => $source_image_alt,
2380 + 'view_transaction_link' => admin_url("admin.php?page=better-payment-transactions&action=view&id={$transaction_obj->id}"),
2381 + 'email_content_heading_show' => intval( $args['email_content_heading_show'] ),
2382 + 'email_content_from_section_show' => intval( $args['email_content_from_section_show'] ),
2383 + 'email_content_to_section_show' => intval( $args['email_content_to_section_show'] ),
2384 + 'email_content_transaction_summary_show' => intval( $args['email_content_transaction_summary_show'] ),
2385 + 'email_content_footer_text_show' => intval( $args['email_content_footer_text_show'] ),
2386 + // The form's messages, with `[bp-all-fields]` replaced where the author placed it.
2387 + 'email_content_customer' => self::render_email_message( $args['email_content_customer'] ?? '', $all_fields_rows, 'plain' === ( $args['email_content_type_customer'] ?? 'html' ) ),
2388 + 'email_content_admin' => self::render_email_message( $args['email_content_admin'] ?? '', $all_fields_rows, 'plain' === ( $args['email_content_type'] ?? 'html' ) ),
2389 + 'is_elementor_form' => intval( $args['is_elementor_form'] ),
2390 + 'email_content_type' => $args['email_content_type'] ?? 'html',
2391 + 'email_content_type_customer' => $args['email_content_type_customer'] ?? 'html',
2392 + ];
2393 +
2394 + // Email V2: the template is the whole body.
828 2395 ob_start();
829 - include BETTER_PAYMENT_ADMIN_VIEWS_PATH . "/template-email-notification.php";
2396 +
2397 + include BETTER_PAYMENT_ADMIN_VIEWS_PATH . "/template-email-notification.php";
2398 +
830 2399 $email_body = ob_get_contents();
831 2400 ob_end_clean();
832 2401
833 2402 return $email_body;
@@ -833,11 +2402,79 @@
833 2402 return $email_body;
834 2403 }
835 2404
836 2405 /**
2406 + * A form's e-mail message, with `[bp-all-fields]` replaced by the submitted fields.
2407 + *
2408 + * The template prints the message; the fields used to be built into a body the template's
2409 + * output then overwrote, so the token reached every e-mail as literal text. The message is
2410 + * the form's own setting and is kses-filtered as it always was. The fields are the
2411 + * visitor's, so they are escaped here and inserted after the filter — never passed through
2412 + * it or do_shortcode(), where a submitted value could add markup or run a shortcode.
2413 + *
2414 + * @since 2.4.0
2415 + *
2416 + * @param mixed $message The form's message setting.
2417 + * @param array[] $rows `label` and `value` of each field to list, in order.
2418 + * @param bool $is_plain Whether the e-mail is sent as plain text.
2419 + * @return string
2420 + */
2421 + public static function render_email_message( $message, array $rows, $is_plain = false ) {
2422 + $message = wp_kses_post( is_scalar( $message ) ? (string) $message : '' );
2423 +
2424 + if ( false === strpos( $message, '[bp-all-fields]' ) ) {
2425 + return $message;
2426 + }
2427 +
2428 + return str_replace( '[bp-all-fields]', self::email_all_fields_markup( $rows, $is_plain ), $message );
2429 + }
2430 +
2431 + /**
2432 + * The `[bp-all-fields]` block: a two-column table, or `Label: value` lines in a plain-text e-mail.
2433 + *
2434 + * @param array[] $rows `label` and `value` of each field.
2435 + * @param bool $is_plain Plain text instead of HTML.
2436 + * @return string
2437 + */
2438 + private static function email_all_fields_markup( array $rows, $is_plain ) {
2439 + $lines = [];
2440 + $cells = '';
2441 +
2442 + foreach ( $rows as $row ) {
2443 + if ( ! isset( $row['label'] ) || ! is_scalar( $row['label'] ) || ! array_key_exists( 'value', $row ) ) {
2444 + continue;
2445 + }
2446 +
2447 + // A nested value (stored settings, a product list) is not something a visitor typed.
2448 + if ( ! is_scalar( $row['value'] ) && null !== $row['value'] ) {
2449 + continue;
2450 + }
2451 +
2452 + $label = (string) $row['label'];
2453 + $value = (string) $row['value'];
2454 +
2455 + if ( $is_plain ) {
2456 + $lines[] = rtrim( wp_strip_all_tags( $label ), ': ' ) . ': ' . wp_strip_all_tags( $value );
2457 + continue;
2458 + }
2459 +
2460 + $cells .= '<tr>'
2461 + . '<td style="padding: 7px; border: 1px solid #666;"><strong>' . esc_html( $label ) . '</strong></td>'
2462 + . '<td style="padding: 7px; border: 1px solid #666;">' . esc_html( $value ) . '</td>'
2463 + . '</tr>';
2464 + }
2465 +
2466 + if ( $is_plain ) {
2467 + return implode( "\n", $lines );
2468 + }
2469 +
2470 + return '<table class="bp-all-fields" style="margin-bottom: 20px; font-size: 14px; border-collapse: collapse; width: 100%;">' . $cells . '</table>';
2471 + }
2472 +
2473 + /**
837 2474 * String helper method
838 2475 * helps to convert string to title case
839 - *
2476 + *
840 2477 * @return string
841 2478 * @since 0.0.1
842 2479 */
843 2480 public static function better_title_case($string){
@@ -845,5 +2482,125 @@
845 2482 $string = ucwords($string);
846 2483 return $string;
847 2484 }
848 2485
2486 + /**
2487 + * Safe icon rendering for payment form messages
2488 + *
2489 + * Handles multiple icon types: FontAwesome, Dashicons, SVG from media library
2490 + * Works with or without Elementor being active
2491 + *
2492 + * @param array $icon_settings Icon settings from block/widget controls
2493 + * @param array $html_attributes Optional HTML attributes for the icon element
2494 + *
2495 + * @return void (outputs HTML directly)
2496 + * @since 2.0.4
2497 + */
2498 + public static function render_icon( $icon_settings = [], $html_attributes = [] ) {
2499 + // Bail if no settings provided
2500 + if ( empty( $icon_settings ) || ! is_array( $icon_settings ) ) {
2501 + return;
2502 + }
2503 +
2504 + // If value is empty, nothing to render
2505 + if ( empty( $icon_settings['value'] ) ) {
2506 + return;
2507 + }
2508 +
2509 + $library = ! empty( $icon_settings['library'] ) ? $icon_settings['library'] : '';
2510 + $value = $icon_settings['value'];
2511 +
2512 + // Try to use Elementor's Icons_Manager if it's available and loaded
2513 + if ( did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Icons_Manager' ) ) {
2514 + try {
2515 + \Elementor\Icons_Manager::render_icon( $icon_settings, $html_attributes );
2516 + return;
2517 + } catch ( \Exception $e ) {
2518 + // If Elementor rendering fails, fall back to manual rendering
2519 + }
2520 + }
2521 +
2522 + // Handle SVG (uploaded via media library)
2523 + if ( $library === 'svg' ) {
2524 + if ( is_array( $value ) && ! empty( $value['url'] ) ) {
2525 + $svg_url = esc_url( $value['url'] );
2526 + echo '<img src="' . $svg_url . '" alt="Icon" class="bp-icon-svg" />';
2527 + return;
2528 + }
2529 + }
2530 +
2531 + // Handle FontAwesome icons (all variants: fa, fas, far, fab, fal, fad, etc.)
2532 + if ( in_array( $library, [ 'fa', 'fas', 'far', 'fab', 'fal', 'fad', 'fontawesome', 'font-awesome' ], true ) ) {
2533 + if ( is_string( $value ) && ! empty( $value ) ) {
2534 + // Build complete FontAwesome class
2535 + $icon_class = 'fa';
2536 +
2537 + // Add variant if specified (fas, far, fab, fal)
2538 + if ( ! empty( $library ) && $library !== 'fa' && $library !== 'fontawesome' && $library !== 'font-awesome' ) {
2539 + $icon_class = esc_attr( $library );
2540 + } else {
2541 + // Default to 'fas' if no variant specified
2542 + $icon_class = 'fas';
2543 + }
2544 +
2545 + // Add the specific icon class
2546 + $icon_class .= ' ' . esc_attr( $value );
2547 +
2548 + $html_class = ! empty( $html_attributes['class'] ) ? ' ' . $html_attributes['class'] : '';
2549 + $aria_hidden = isset( $html_attributes['aria-hidden'] ) ? ' aria-hidden="' . esc_attr( $html_attributes['aria-hidden'] ) . '"' : ' aria-hidden="true"';
2550 +
2551 + echo '<i class="' . $icon_class . $html_class . '"' . $aria_hidden . '></i>';
2552 + return;
2553 + }
2554 + }
2555 +
2556 + // Handle Dashicons
2557 + if ( in_array( $library, [ 'dashicons' ], true ) ) {
2558 + if ( is_string( $value ) && ! empty( $value ) ) {
2559 + $icon_class = 'dashicons dashicons-' . esc_attr( $value );
2560 +
2561 + $html_class = ! empty( $html_attributes['class'] ) ? ' ' . $html_attributes['class'] : '';
2562 + $aria_hidden = isset( $html_attributes['aria-hidden'] ) ? ' aria-hidden="' . esc_attr( $html_attributes['aria-hidden'] ) . '"' : ' aria-hidden="true"';
2563 +
2564 + echo '<span class="' . $icon_class . $html_class . '"' . $aria_hidden . '></span>';
2565 + return;
2566 + }
2567 + }
2568 +
2569 + // Handle custom CSS classes (like 'bp-icon bp-check-circle')
2570 + // These are custom icon fonts or CSS-based icons defined by the theme/plugin
2571 + if ( 'custom' === $library ) {
2572 + if ( is_string( $value ) && ! empty( $value ) ) {
2573 + $html_class = ! empty( $html_attributes['class'] ) ? ' ' . $html_attributes['class'] : '';
2574 + $aria_hidden = isset( $html_attributes['aria-hidden'] ) ? ' aria-hidden="' . esc_attr( $html_attributes['aria-hidden'] ) . '"' : ' aria-hidden="true"';
2575 +
2576 + echo '<i class="' . esc_attr( $value ) . $html_class . '"' . $aria_hidden . '></i>';
2577 + return;
2578 + }
2579 + }
2580 +
2581 + // If value is a plain string (might be a FontAwesome class without library specified)
2582 + if ( is_string( $value ) && ! empty( $value ) && empty( $library ) ) {
2583 + // Check if it looks like a FontAwesome class
2584 + if ( strpos( $value, 'fa' ) !== false || strpos( $value, 'fa-' ) !== false ) {
2585 + $icon_class = 'fas ' . esc_attr( $value );
2586 +
2587 + $html_class = ! empty( $html_attributes['class'] ) ? ' ' . $html_attributes['class'] : '';
2588 + $aria_hidden = isset( $html_attributes['aria-hidden'] ) ? ' aria-hidden="' . esc_attr( $html_attributes['aria-hidden'] ) . '"' : ' aria-hidden="true"';
2589 +
2590 + echo '<i class="' . $icon_class . $html_class . '"' . $aria_hidden . '></i>';
2591 + return;
2592 + }
2593 +
2594 + // Check if it looks like a dashicon
2595 + if ( strpos( $value, 'dashicon' ) !== false || strpos( $value, 'dashicons-' ) !== false ) {
2596 + $icon_class = 'dashicons ' . esc_attr( $value );
2597 +
2598 + $html_class = ! empty( $html_attributes['class'] ) ? ' ' . $html_attributes['class'] : '';
2599 + $aria_hidden = isset( $html_attributes['aria-hidden'] ) ? ' aria-hidden="' . esc_attr( $html_attributes['aria-hidden'] ) . '"' : ' aria-hidden="true"';
2600 +
2601 + echo '<span class="' . $icon_class . $html_class . '"' . $aria_hidden . '></span>';
2602 + return;
2603 + }
2604 + }
2605 + }
849 2606 }