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
better-payment / includes / Classes / Helper.php

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

642 lines 23.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\Classes;
4
5 use Better_Payment\Lite\Controller;
6 use Better_Payment\Lite\Traits\Helper as TraitsHelper;
7
8 /**
9 * Exit if accessed directly
10 */
11 if (!defined('ABSPATH')) {
12 exit;
13 }
14
15 /**
16 * This class responsible for database work
17 * using wordpress functionality
18 * get_option and update_option.
19 */
20 class Helper extends Controller
21 {
22 use TraitsHelper;
23
24 /**
25 * The statuses of a confirmed payment, as campaign totals, donor counts and Pro's Donors Wall
26 * count them. See approved_donation_sql() for the one other row that counts.
27 *
28 * `success` is the status Paystack verification writes. It was missing, so Paystack donations
29 * never counted toward a campaign's total or donor count, even though the transactions screen
30 * and analytics already treated it as paid. Only a transaction Paystack reported as succeeded
31 * is ever stored as `success`.
32 *
33 * @since 2.4.0 (was Campaign\CampaignStats::approved_statuses())
34 */
35 const APPROVED_DONATION_STATUSES = array( 'Completed', 'paid', 'complete', 'succeeded', 'success' );
36
37 /**
38 * The status rows written before 2.4.0 can carry for a free Stripe checkout (a 100%-off
39 * promotion code, a subscription trial).
40 *
41 * Such a row is one of two things, and the status alone cannot tell them apart:
42 * - a CONFIRMED free checkout. The old confirmation stored Stripe's `payment_status` as the
43 * row status and, because `amount_total` was 0 and `! empty()` treated 0 as missing, kept
44 * the form's full amount. Nothing was charged.
45 * - a checkout Stripe reported as `no_payment_required` when it was CREATED and that nobody
46 * completed. The old confirmation only claimed `unpaid` rows, so it was never touched.
47 * 2.4.0 stores neither: a confirmed row is always `paid` at its real amount, and a pending
48 * one `unpaid`. Stored rows are left as they are and counted by the rule below.
49 *
50 * @since 2.4.0
51 */
52 const LEGACY_FREE_CHECKOUT_STATUS = 'no_payment_required';
53
54 /**
55 * What only the old Stripe confirmation wrote: the serialized `exact_paid_amount` key of
56 * `form_fields_info`, followed by its float value.
57 *
58 * Every confirmation from the release that introduced discounts (and with them the only way
59 * to reach a free checkout) onwards set `$form_fields_info['exact_paid_amount']` to a float
60 * in the same UPDATE that wrote the status. No creation path ever writes that key. The
61 * columns the confirmation also wrote are no use: `customer_info` and `transaction_id` are
62 * already set at creation (the created session and its PaymentIntent), and `email` comes from
63 * the charge's billing details, which a checkout that charged nothing does not have.
64 *
65 * The trailing `d:` makes it the key: after a string VALUE the next token is a key (`s:` or
66 * `i:`) or `}`, so a form field whose value happens to be "exact_paid_amount" cannot match.
67 *
68 * @since 2.4.0
69 */
70 const LEGACY_CONFIRMATION_MARKER = 's:17:"exact_paid_amount";d:';
71
72 /**
73 * SQL condition: true only for an unconfirmed legacy free checkout. Null-safe: a NULL status or
74 * NULL form_fields_info never makes it true.
75 *
76 * OR it into the `incomplete` bucket's `status IN (…)` clause, so the Incomplete tab lists the
77 * rows the Completed tab drops (DB::get_transactions()). Contains no `%`.
78 *
79 * @since 2.4.0
80 */
81 const UNCONFIRMED_LEGACY_SQL = "( COALESCE(status, '') = '" . self::LEGACY_FREE_CHECKOUT_STATUS . "' AND INSTR(COALESCE(form_fields_info, ''), '" . self::LEGACY_CONFIRMATION_MARKER . "') = 0 )";
82
83 /**
84 * SQL condition: false only for an unconfirmed legacy free checkout, the exact negation of
85 * UNCONFIRMED_LEGACY_SQL. Null-safe, so a row with a NULL status (PayPal's pending sentinel) or
86 * NULL form_fields_info is never dropped by it.
87 *
88 * AND it onto any aggregation that selects rows by a status bucket containing
89 * `no_payment_required` (`completed`, `all`). Admin\DB and Pro's AnalyticsModel do; Pro keeps
90 * the literal value, and AnalyticsModelParityTest compares the two values.
91 *
92 * @since 2.4.0
93 */
94 const COUNTED_ROW_SQL = 'NOT ' . self::UNCONFIRMED_LEGACY_SQL;
95
96 /**
97 * SQL expression: what a row contributes to a money total. A legacy free checkout adds 0,
98 * confirmed or not, because its stored amount (the form's) was never charged.
99 *
100 * @since 2.4.0
101 */
102 const COUNTABLE_AMOUNT_SQL = "CASE WHEN status = '" . self::LEGACY_FREE_CHECKOUT_STATUS . "' THEN 0 ELSE amount END";
103
104 /**
105 * SQL condition: the row is a confirmed donation.
106 *
107 * An approved status, or a CONFIRMED legacy free checkout (see LEGACY_CONFIRMATION_MARKER).
108 * Pair it with COUNTABLE_AMOUNT_SQL for any sum. Contains no `%`, so it can be interpolated
109 * into a string passed to `$wpdb->prepare()`.
110 *
111 * Pro keeps an identical copy in Models\AnalyticsModel (it supports a Lite older than this
112 * method), pinned by Pro's AnalyticsModelParityTest.
113 *
114 * @since 2.4.0
115 *
116 * @return string
117 */
118 public static function approved_donation_sql(): string {
119 return "( status IN ('" . implode( "', '", self::APPROVED_DONATION_STATUSES ) . "')"
120 . " OR ( status = '" . self::LEGACY_FREE_CHECKOUT_STATUS . "' AND INSTR(COALESCE(form_fields_info, ''), '" . self::LEGACY_CONFIRMATION_MARKER . "') > 0 ) )";
121 }
122
123 /**
124 * PHP twin of `NOT COUNTED_ROW_SQL`, for rows already loaded: is this a legacy free checkout
125 * that was never confirmed?
126 *
127 * A row without `form_fields_info` loaded is treated as unconfirmed (never counted), the safe
128 * side of the rule. An already-unserialized `form_fields_info` array is read as the string it was
129 * stored as, so a caller that unserialized the row first gets the same answer.
130 *
131 * @since 2.4.0
132 *
133 * @param object|array $row A transaction row.
134 * @return bool
135 */
136 public static function is_unconfirmed_legacy_free_checkout( $row ): bool {
137 return self::LEGACY_FREE_CHECKOUT_STATUS === self::row_value( $row, 'status' )
138 && false === strpos( self::form_fields_info_string( $row ), self::LEGACY_CONFIRMATION_MARKER );
139 }
140
141 /**
142 * The status bucket a loaded transaction row is labelled and listed under.
143 *
144 * The raw status's bucket (get_type_by_transaction_status()), except for an unconfirmed legacy
145 * free checkout: no count or total includes it, so it is `incomplete` (v1: `unpaid`) rather than
146 * the `completed` its `no_payment_required` status maps to. A confirmed one stays `completed`.
147 * The row itself is never changed. Every PHP surface that labels a row reads this: the admin REST
148 * rows (`status_type`), the CSV export, UserAPI, the User Dashboard template and the block's
149 * editor rows. DB::get_transactions() applies the same rule to the Completed / Incomplete tabs.
150 *
151 * @since 2.4.0
152 *
153 * @param object|array $row A transaction row.
154 * @param string $version 'v2' (completed / incomplete / refunded) or 'v1'.
155 * @return string The bucket key; '' for a status no bucket lists, as get_type_by_transaction_status().
156 */
157 public static function transaction_status_type( $row, string $version = 'v2' ): string {
158 if ( self::is_unconfirmed_legacy_free_checkout( $row ) ) {
159 return 'v1' === $version ? 'unpaid' : 'incomplete';
160 }
161
162 return ( new self() )->get_type_by_transaction_status( self::row_value( $row, 'status' ), $version );
163 }
164
165 /**
166 * The status label of a loaded row ("Completed", "Incomplete", "Refunded"), v2 buckets.
167 *
168 * @since 2.4.0
169 *
170 * @param object|array $row A transaction row.
171 * @return string
172 */
173 public static function transaction_status_label( $row ): string {
174 return ucfirst( self::transaction_status_type( $row, 'v2' ) );
175 }
176
177 /**
178 * The status colour of a loaded row, v2 buckets: the colour of transaction_status_type()'s bucket.
179 *
180 * @since 2.4.0
181 *
182 * @param object|array $row A transaction row.
183 * @return string
184 */
185 public static function transaction_status_color( $row ): string {
186 $helper = new self();
187
188 if ( self::is_unconfirmed_legacy_free_checkout( $row ) ) {
189 $buckets = $helper->transaction_statuses_with_type_v2();
190
191 return $buckets['incomplete']['color'];
192 }
193
194 return $helper->get_color_by_transaction_status( self::row_value( $row, 'status' ), 'v2' );
195 }
196
197 /**
198 * PHP twin of COUNTABLE_AMOUNT_SQL: what a loaded row contributes to a money total.
199 *
200 * @since 2.4.0
201 *
202 * @param object|array $row A transaction row.
203 * @return float
204 */
205 public static function countable_amount( $row ): float {
206 if ( self::LEGACY_FREE_CHECKOUT_STATUS === self::row_value( $row, 'status' ) ) {
207 return 0.0;
208 }
209
210 $amount = self::row_value( $row, 'amount' );
211
212 return '' === $amount ? 0.0 : floatval( $amount );
213 }
214
215 /**
216 * Raised total and payment count over loaded transaction rows (the Elementor Fundraising
217 * Campaign widget's progress).
218 *
219 * Counts the same rows CampaignStats counts: an approved status (Stripe `paid`, Paystack
220 * `success`, `Completed`, …) or a confirmed legacy free checkout, which adds 0. It used to
221 * count only `paid`, so Paystack and manually completed donations never moved the bar.
222 *
223 * @since 2.4.0
224 *
225 * @param mixed $rows Transaction rows (objects or arrays).
226 * @return array{amount: float, count: int}
227 */
228 public static function campaign_totals_from_rows( $rows ): array {
229 $totals = array(
230 'amount' => 0.0,
231 'count' => 0,
232 );
233
234 foreach ( is_array( $rows ) ? $rows : array() as $row ) {
235 $status = self::row_value( $row, 'status' );
236
237 $counted = in_array( $status, self::APPROVED_DONATION_STATUSES, true )
238 || ( self::LEGACY_FREE_CHECKOUT_STATUS === $status && ! self::is_unconfirmed_legacy_free_checkout( $row ) );
239
240 if ( ! $counted ) {
241 continue;
242 }
243
244 $totals['amount'] += self::countable_amount( $row );
245 ++$totals['count'];
246 }
247
248 return $totals;
249 }
250
251 /**
252 * A scalar column of a row object or array, as a string ('' when absent or not scalar).
253 *
254 * @param object|array $row A transaction row.
255 * @param string $key Column name.
256 * @return string
257 */
258 private static function row_value( $row, string $key ): string {
259 $value = null;
260
261 if ( is_object( $row ) && isset( $row->$key ) ) {
262 $value = $row->$key;
263 } elseif ( is_array( $row ) && isset( $row[ $key ] ) ) {
264 $value = $row[ $key ];
265 }
266
267 return is_scalar( $value ) ? (string) $value : '';
268 }
269
270 /**
271 * A row's `form_fields_info` as stored: the serialized string, an already-unserialized array
272 * serialized back, or '' when absent. Only ever searched for LEGACY_CONFIRMATION_MARKER.
273 *
274 * @param object|array $row A transaction row.
275 * @return string
276 */
277 private static function form_fields_info_string( $row ): string {
278 $value = null;
279
280 if ( is_object( $row ) && isset( $row->form_fields_info ) ) {
281 $value = $row->form_fields_info;
282 } elseif ( is_array( $row ) && isset( $row['form_fields_info'] ) ) {
283 $value = $row['form_fields_info'];
284 }
285
286 if ( is_array( $value ) ) {
287 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- searched for the marker, never unserialized.
288 return serialize( $value );
289 }
290
291 return is_scalar( $value ) ? (string) $value : '';
292 }
293
294 // find_element_recursive() comes from Traits\Helper; this class used to carry
295 // a byte-identical copy of it.
296
297 public function titleToSnake($text, $divider = '_') {
298 // Handle null or empty values to prevent deprecated warnings.
299 if ( null === $text || '' === $text ) {
300 return 'n_a';
301 }
302
303 $text = (string) $text;
304 $text = preg_replace('~[^\pL\d]+~u', $divider, $text);
305 $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
306 $text = preg_replace('~[^-\w]+~', '', $text);
307 $text = trim($text, $divider);
308 $text = preg_replace('~-+~', $divider, $text);
309 $text = strtolower($text);
310
311 if (empty($text)) {
312 return 'n_a';
313 }
314
315 return $text;
316 }
317
318 public function transaction_statuses_with_type(){
319 $statuses = [
320 'completed' => [
321 'statuses' => [
322 'paid',
323 'Completed',
324 'completed',
325 // Stripe's own status for a fully-discounted / $0 Checkout
326 // Session (100% coupon or sale, free trial). The payment
327 // IS settled - there was simply nothing to charge - so it
328 // must classify as completed, not fall through to the
329 // unknown-status default. See Helper::get_type_by_transaction_status().
330 'no_payment_required',
331 ],
332 'color' => '#0ECA86',
333 ],
334 'processing' => [
335 'statuses' => [
336 'pending',
337 'Pending',
338 NULL,
339 ],
340 'color' => '#FFDA15',
341 ],
342 'refunded' => [
343 'statuses' => [
344 'refunded',
345 ],
346 'color' => '#FF0202',
347 ],
348 'unpaid' => [
349 'statuses' => [
350 'unpaid',
351 // A payment that failed.
352 'failed',
353 ],
354 'color' => '#999',
355 ],
356 'all' => [
357 'statuses' => [
358 'paid',
359 'Completed',
360 'completed',
361 'no_payment_required',
362 'unpaid',
363 'refunded',
364 'pending',
365 'Pending',
366 'failed',
367 NULL,
368 ],
369 'color' => '#999',
370 ],
371 ];
372
373 return $statuses;
374 }
375
376 public function transaction_statuses_with_type_v2(){
377 $statuses = [
378 'completed' => [
379 'statuses' => [
380 'paid',
381 'Completed',
382 'completed',
383 'success',
384 // See the v1 note: a $0 Stripe session settles as
385 // 'no_payment_required'. Every status bucket is also a
386 // `status IN (...)` filter in DB::get_transactions(), so a
387 // status missing from BOTH 'completed' and 'incomplete'
388 // is invisible under either tab while still showing on
389 // the unfiltered list - which is exactly how a paid $0
390 // order looked like a stray "Incomplete" row.
391 'no_payment_required',
392 ],
393 'color' => '#0ECA86',
394 ],
395 'incomplete' => [
396 'statuses' => [
397 'pending',
398 'Pending',
399 'unpaid',
400 'incomplete',
401 'Incomplete',
402 // A payment that failed.
403 'failed',
404 NULL,
405 ],
406 'color' => '#FFDA15',
407 ],
408 'refunded' => [
409 'statuses' => [
410 'refunded',
411 ],
412 'color' => '#FF0202',
413 ],
414 'all' => [
415 'statuses' => [
416 'paid',
417 'Completed',
418 'completed',
419 'no_payment_required',
420 'unpaid',
421 'refunded',
422 'pending',
423 'Pending',
424 'success',
425 'incomplete',
426 'Incomplete',
427 'failed',
428 NULL,
429 ],
430 'color' => '#999',
431 ],
432 ];
433
434 return $statuses;
435 }
436
437 public function get_type_by_transaction_status($status = '', $version = 'v1'){
438 if('v1' === $version){
439 $statuses = $this->transaction_statuses_with_type();
440 } else {
441 $statuses = $this->transaction_statuses_with_type_v2();
442 }
443
444 $status_type = '';
445 if(!empty($status)){
446 foreach($statuses as $type => $statuses_and_color){
447 if(in_array($status, $statuses_and_color['statuses'])){
448 $status_type = $type;
449 break;
450 }
451 }
452 }
453
454 if($status === '' || $status === NULL){
455 $status_type = 'incomplete';
456 }
457
458 return $status_type;
459 }
460
461 public function get_color_by_transaction_status($status = '', $version = 'v1'){
462 if('v1' === $version){
463 $statuses = $this->transaction_statuses_with_type();
464 } else {
465 $statuses = $this->transaction_statuses_with_type_v2();
466 }
467
468 $color = '#999';
469 if(!empty($status)){
470 foreach($statuses as $type => $statuses_and_color){
471 if(in_array($status, $statuses_and_color['statuses'])){
472 $color = $statuses_and_color['color'];
473 break;
474 }
475 }
476 }
477
478 if($status === '' || $status === NULL){
479 $color = '#FFDA15'; //incomplete
480 }
481
482 return $color;
483
484 }
485
486 public function get_statuses_by_transaction_type($status_type = 'all', $version = 'v1'){
487 if('v1' === $version){
488 $statuses = $this->transaction_statuses_with_type();
489 } else {
490 $statuses = $this->transaction_statuses_with_type_v2();
491 }
492
493 $transaction_statuses = [];
494 if(!empty($status_type)){
495 foreach($statuses as $type => $statuses_and_color){
496 if($type == $status_type){
497 $transaction_statuses = $statuses_and_color['statuses'];
498 break;
499 }
500 }
501 }
502
503 return $transaction_statuses;
504 }
505
506 public function get_transaction_types($version = 'v1'){
507 if('v1' === $version){
508 $statuses = $this->transaction_statuses_with_type();
509 } else {
510 $statuses = $this->transaction_statuses_with_type_v2();
511 }
512
513 $statuses_types = array_keys($statuses);
514
515 return $statuses_types;
516 }
517
518 /**
519 * Get interval text for recurring and split payment
520 *
521 * Lite never calls this. Better Payment Pro <= 1.4.3's layout partials did, from
522 * templates that no longer load (Pro 1.4.3 fills no `layout_template` filter), and
523 * tests/Unit/ProCouplingContractTest.php still pins it. Pro 1.4.4+ uses its own
524 * Admin\Elementor\LayoutRenderer::interval_text().
525 *
526 * @since 1.3.0
527 * @deprecated 2.4.0 Kept as belt-and-braces for Pro 1.4.3. Delete once no supported
528 * site can run it.
529 */
530 public function get_interval_text( $interval = '' ){
531 $interval_text = '';
532
533 switch( $interval ) {
534 case 'day':
535 $interval_text = 'Daily';
536 break;
537
538 case 'month':
539 case 'year':
540 $interval_text = $interval . 'ly';
541 break;
542
543 default :
544 $interval_text = ! empty( $interval ) ? "Per " . esc_html( $interval ) : "";
545 break;
546 }
547
548 return esc_html( ucfirst( $interval_text ) );
549 }
550
551 /**
552 * The payment type label of a transaction, as the User Dashboard REST rows and
553 * the CSV export print it.
554 *
555 * Lite's own checkouts only take one-time payments, so Lite says 'One Time'.
556 * `better_payment/transaction/payment_type` ($label, $form_fields_info) relabels
557 * a row: Pro returns 'Subscription' for its recurring and split payments. The CSV
558 * import reads the label back, so keep these two strings.
559 *
560 * @since 2.4.0
561 *
562 * @param mixed $form_fields_info The transaction's unserialized form_fields_info.
563 * @return string
564 */
565 public static function payment_type_label( $form_fields_info ): string {
566 $form_fields_info = is_array( $form_fields_info ) ? $form_fields_info : [];
567 $label = 'One Time';
568
569 $filtered = apply_filters( 'better_payment/transaction/payment_type', $label, $form_fields_info );
570
571 return is_string( $filtered ) ? $filtered : $label;
572 }
573
574 /**
575 * Output the page header, compatible with both classic and block themes.
576 *
577 * Block themes don't use get_header() — they rely on block template parts.
578 * We detect this with wp_is_block_theme() and render the header template
579 * part via do_blocks() so it appears correctly on CPT single pages that
580 * bypass the theme's block templates.
581 *
582 * @param string $block_header Pre-rendered header markup from do_blocks() (block themes only).
583 * @return void
584 */
585 public static function render_header( string $block_header = '' ): void {
586 if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
587 ?>
588 <!doctype html>
589 <html <?php language_attributes(); ?>>
590 <head>
591 <meta charset="<?php bloginfo( 'charset' ); ?>">
592 <?php wp_head(); ?>
593 </head>
594 <body <?php body_class(); ?>>
595 <?php
596 wp_body_open();
597 echo $block_header; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
598 } else {
599 get_header();
600 }
601 }
602
603 /**
604 * Output the page footer, compatible with both classic and block themes.
605 *
606 * @param string $block_footer Pre-rendered footer markup from do_blocks() (block themes only).
607 * @return void
608 */
609 public static function render_footer( string $block_footer = '' ): void {
610 if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
611 echo $block_footer; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
612 wp_footer();
613 ?>
614 </body>
615 </html>
616 <?php
617 } else {
618 get_footer();
619 }
620 }
621 }
622
623
624 // Helper Functions
625 if ( ! function_exists( 'better_payment_dd' ) ) {
626 function better_payment_dd( $data, $show_query = 0, $print_only = 0 ) {
627 global $wpdb;
628
629 if ( 1 === $show_query ) {
630 echo wp_kses_post( $wpdb->last_query );
631 echo wp_kses_post( $wpdb->last_result );
632 echo wp_kses_post( $wpdb->last_error );
633 }
634
635 echo '<pre>';
636 print_r( $data ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
637 if ( ! $print_only ) {
638 wp_die( 'Printed!' );
639 }
640 }
641 }
642