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/Helper.php +298 -88 2.3.0 → 2.4.1 View file →
@@ -21,93 +21,280 @@
21 21 {
22 22 use TraitsHelper;
23 23
24 24 /**
25 - * check is plugin active or not
26 - *
27 - * @param $plugin
28 - * @return bool
29 - * @since 0.0.2
30 - */
31 - public function is_plugin_active($plugin) {
32 - if ( !function_exists( 'is_plugin_active' ) ){
33 - require_once( ABSPATH . '/wp-admin/includes/plugin.php' );
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';
34 160 }
35 161
36 - return is_plugin_active( $plugin );
162 + return ( new self() )->get_type_by_transaction_status( self::row_value( $row, 'status' ), $version );
37 163 }
38 164
39 - public static function sanitize_bp_field($key, $arr, $default_value='', $type='text'){
40 - $value = $default_value;
41 -
42 - if( !empty( $arr[ $key ] ) ) {
43 - $value = $arr[ $key ];
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 + }
44 176
45 - switch($type){
46 - case 'text':
47 - $value = sanitize_text_field( $value );
48 - break;
49 - case 'email':
50 - $value = sanitize_email( $value );
51 - break;
52 - case 'textarea':
53 - $value = sanitize_textarea_field( $value );
54 - break;
55 - default:
56 - $value = sanitize_text_field( $value );
57 - break;
58 - }
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'];
59 192 }
60 193
61 - return $value;
194 + return $helper->get_color_by_transaction_status( self::row_value( $row, 'status' ), 'v2' );
62 195 }
63 196
64 197 /**
65 - * Get widget settings
198 + * PHP twin of COUNTABLE_AMOUNT_SQL: what a loaded row contributes to a money total.
66 199 *
67 - * @since 0.0.1
200 + * @since 2.4.0
201 + *
202 + * @param object|array $row A transaction row.
203 + * @return float
68 204 */
69 - public function get_better_payment_widget_settings( $page_id, $widget_id ) {
70 - $document = \Elementor\Plugin::$instance->documents->get( $page_id );
71 - $settings = [];
72 - if ( $document ) {
73 - $elements = \Elementor\Plugin::instance()->documents->get( $page_id )->get_elements_data();
74 - $widget_data = $this->find_element_recursive( $elements, $widget_id );
75 - if ( $widget_data ) {
76 - $widget = \Elementor\Plugin::instance()->elements_manager->create_element_instance( $widget_data );
77 - if ( $widget ) {
78 - $settings = $widget->get_settings_for_display();
79 - }
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;
80 242 }
81 -
243 +
244 + $totals['amount'] += self::countable_amount( $row );
245 + ++$totals['count'];
82 246 }
83 - return $settings;
247 +
248 + return $totals;
84 249 }
85 250
86 251 /**
87 - * Find element recursively
252 + * A scalar column of a row object or array, as a string ('' when absent or not scalar).
88 253 *
89 - * @since 0.0.1
254 + * @param object|array $row A transaction row.
255 + * @param string $key Column name.
256 + * @return string
90 257 */
91 - public function find_element_recursive( $elements, $form_id ) {
258 + private static function row_value( $row, string $key ): string {
259 + $value = null;
92 260
93 - foreach ( $elements as $element ) {
94 - if ( $form_id === $element[ 'id' ] ) {
95 - return $element;
96 - }
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 + }
97 266
98 - if ( !empty( $element[ 'elements' ] ) ) {
99 - $element = $this->find_element_recursive( $element[ 'elements' ], $form_id );
267 + return is_scalar( $value ) ? (string) $value : '';
268 + }
100 269
101 - if ( $element ) {
102 - return $element;
103 - }
104 - }
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'];
105 284 }
106 285
107 - return false;
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 : '';
108 292 }
109 293
294 + // find_element_recursive() comes from Traits\Helper; this class used to carry
295 + // a byte-identical copy of it.
296 +
110 297 public function titleToSnake($text, $divider = '_') {
111 298 // Handle null or empty values to prevent deprecated warnings.
112 299 if ( null === $text || '' === $text ) {
113 300 return 'n_a';
@@ -127,17 +314,8 @@
127 314
128 315 return $text;
129 316 }
130 317
131 - public function arrayToString($array, $separator = ', ', $extraString = ''){
132 - $string = rtrim( implode($separator, $array ), $separator ) ;
133 - if($extraString) {
134 - $string .= $extraString;
135 - }
136 -
137 - return $string;
138 - }
139 -
140 318 public function transaction_statuses_with_type(){
141 319 $statuses = [
142 320 'completed' => [
143 321 'statuses' => [
@@ -143,8 +321,14 @@
143 321 'statuses' => [
144 322 'paid',
145 323 'Completed',
146 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',
147 331 ],
148 332 'color' => '#0ECA86',
149 333 ],
150 334 'processing' => [
@@ -163,8 +347,10 @@
163 347 ],
164 348 'unpaid' => [
165 349 'statuses' => [
166 350 'unpaid',
351 + // A payment that failed.
352 + 'failed',
167 353 ],
168 354 'color' => '#999',
169 355 ],
170 356 'all' => [
@@ -171,12 +357,14 @@
171 357 'statuses' => [
172 358 'paid',
173 359 'Completed',
174 360 'completed',
361 + 'no_payment_required',
175 362 'unpaid',
176 363 'refunded',
177 364 'pending',
178 365 'Pending',
366 + 'failed',
179 367 NULL,
180 368 ],
181 369 'color' => '#999',
182 370 ],
@@ -192,8 +380,16 @@
192 380 'paid',
193 381 'Completed',
194 382 'completed',
195 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',
196 392 ],
197 393 'color' => '#0ECA86',
198 394 ],
199 395 'incomplete' => [
@@ -202,8 +398,10 @@
202 398 'Pending',
203 399 'unpaid',
204 400 'incomplete',
205 401 'Incomplete',
402 + // A payment that failed.
403 + 'failed',
206 404 NULL,
207 405 ],
208 406 'color' => '#FFDA15',
209 407 ],
@@ -217,8 +415,9 @@
217 415 'statuses' => [
218 416 'paid',
219 417 'Completed',
220 418 'completed',
419 + 'no_payment_required',
221 420 'unpaid',
222 421 'refunded',
223 422 'pending',
224 423 'Pending',
@@ -224,8 +423,9 @@
224 423 'Pending',
225 424 'success',
226 425 'incomplete',
227 426 'Incomplete',
427 + 'failed',
228 428 NULL,
229 429 ],
230 430 'color' => '#999',
231 431 ],
@@ -282,28 +482,8 @@
282 482 return $color;
283 483
284 484 }
285 485
286 - public function get_color_by_transaction_type($status_type = '', $version = 'v1'){
287 - if('v1' === $version){
288 - $statuses = $this->transaction_statuses_with_type();
289 - } else {
290 - $statuses = $this->transaction_statuses_with_type_v2();
291 - }
292 -
293 - $color = '';
294 - if(!empty($status_type)){
295 - foreach($statuses as $type => $statuses_and_color){
296 - if($type == $status_type){
297 - $color = $statuses_and_color['color'];
298 - break;
299 - }
300 - }
301 - }
302 -
303 - return $color;
304 - }
305 -
306 486 public function get_statuses_by_transaction_type($status_type = 'all', $version = 'v1'){
307 487 if('v1' === $version){
308 488 $statuses = $this->transaction_statuses_with_type();
309 489 } else {
@@ -337,9 +517,16 @@
337 517
338 518 /**
339 519 * Get interval text for recurring and split payment
340 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 + *
341 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.
342 529 */
343 530 public function get_interval_text( $interval = '' ){
344 531 $interval_text = '';
345 532
@@ -358,8 +545,31 @@
358 545 break;
359 546 }
360 547
361 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;
362 572 }
363 573
364 574 /**
365 575 * Output the page header, compatible with both classic and block themes.