PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.18.0
GiveWP – Donation Plugin and Fundraising Platform v4.18.0
4.18.0 4.17.0 4.16.9 4.16.8.1 4.16.8 4.16.7.2 4.16.7.1 4.16.7 4.16.6.1 4.16.6 4.16.5.1 4.16.5 4.16.4 4.16.3 4.16.2 4.16.1 4.16.0 4.15.5 4.15.4 4.15.3 4.15.2 4.15.1 4.15.0 2.3.0 2.3.1 All 257 releases
give / includes / payments / class-payment-stats.php

class-payment-stats.php in GiveWP – Donation Plugin and Fundraising Platform 4.18.0, at includes/payments/class-payment-stats.php

506 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Earnings / Sales Stats
4 *
5 * @package Give
6 * @subpackage Classes/Stats
7 * @copyright Copyright (c) 2016, GiveWP
8 * @license https://opensource.org/licenses/gpl-license GNU Public License
9 * @since 1.0
10 */
11
12 // Exit if accessed directly.
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Give_Stats Class
19 *
20 * This class is for retrieving stats for earnings and sales.
21 *
22 * Stats can be retrieved for date ranges and pre-defined periods.
23 *
24 * @since 1.0
25 */
26 class Give_Payment_Stats extends Give_Stats {
27
28 /**
29 * Retrieve sale stats
30 *
31 * @since 4.18.0 Count donations with one SQL query instead of loading every ID
32 * @since 1.0
33 * @access public
34 *
35 * @param $form_id int The donation form to retrieve stats for. If false, gets stats for all forms
36 * @param $start_date string|bool The starting date for which we'd like to filter our sale stats. If false, we'll use the default start date of `this_month`
37 * @param $end_date string|bool The end date for which we'd like to filter our sale stats. If false, we'll use the default end date of `this_month`
38 * @param $status string|array The sale status(es) to count. Only valid when retrieving global stats
39 *
40 * @return float|int Total amount of donations based on the passed arguments.
41 */
42 public function get_sales( $form_id = 0, $start_date = false, $end_date = false, $status = 'publish' ) {
43
44 $this->setup_dates( $start_date, $end_date );
45
46 // Make sure start date is valid
47 if ( is_wp_error( $this->start_date ) ) {
48 return $this->start_date;
49 }
50
51 // Make sure end date is valid
52 if ( is_wp_error( $this->end_date ) ) {
53 return $this->end_date;
54 }
55
56 $args = [
57 'status' => 'publish',
58 'start_date' => $this->start_date,
59 'end_date' => $this->end_date,
60 'fields' => 'ids',
61 'number' => - 1,
62 'output' => '',
63 ];
64
65 if ( ! empty( $form_id ) ) {
66 $args['give_forms'] = $form_id;
67 }
68
69 $count = $this->count_donations_in_sql( $args );
70
71 if ( null !== $count ) {
72 return $count;
73 }
74
75 /* @var Give_Payments_Query $payments */
76 $payments = new Give_Payments_Query( $args );
77 $payments = $payments->get_payments();
78
79 return count( $payments );
80 }
81
82
83 /**
84 * Retrieve earning stats
85 *
86 * @since 4.18.0 Sum donation totals with one SQL query instead of loading every ID and formatting each amount
87 * @since 1.0
88 * @access public
89 *
90 * @param $form_id int The donation form to retrieve stats for. If false, gets stats for all forms.
91 * @param $start_date string|bool The starting date for which we'd like to filter our donation earnings stats. If false, method will use the default start date of `this_month`.
92 * @param $end_date string|bool The end date for which we'd like to filter the donations stats. If false, method will use the default end date of `this_month`.
93 * @param $gateway_id string|bool The gateway to get earnings for such as 'paypal' or 'stripe'.
94 *
95 * @return float|int Total amount of donations based on the passed arguments.
96 */
97 public function get_earnings( $form_id = 0, $start_date = false, $end_date = false, $gateway_id = false ) {
98 global $wpdb;
99 $this->setup_dates( $start_date, $end_date );
100
101 // Make sure start date is valid
102 if ( is_wp_error( $this->start_date ) ) {
103 return $this->start_date;
104 }
105
106 // Make sure end date is valid
107 if ( is_wp_error( $this->end_date ) ) {
108 return $this->end_date;
109 }
110
111 $args = [
112 'status' => 'publish',
113 'start_date' => $this->start_date,
114 'end_date' => $this->end_date,
115 'fields' => 'ids',
116 'number' => - 1,
117 'output' => '',
118 ];
119
120 // Filter by Gateway ID meta_key
121 if ( $gateway_id ) {
122 $args['meta_query'][] = [
123 'key' => '_give_payment_gateway',
124 'value' => $gateway_id,
125 ];
126 }
127
128 // Filter by Gateway ID meta_key
129 if ( $form_id ) {
130 $args['meta_query'][] = [
131 'key' => '_give_payment_form_id',
132 'value' => $form_id,
133 ];
134 }
135
136 if ( ! empty( $args['meta_query'] ) && 1 < count( $args['meta_query'] ) ) {
137 $args['meta_query']['relation'] = 'AND';
138 }
139
140 $args = apply_filters( 'give_stats_earnings_args', $args );
141 $key = Give_Cache::get_key( 'give_stats', $args );
142
143 // Set transient for faster stats.
144 $earnings = Give_Cache::get( $key );
145
146 if ( false === $earnings ) {
147
148 $this->timestamp = false;
149 $payments = [];
150 $earnings = $this->sum_earnings_in_sql( $args );
151
152 if ( null === $earnings ) {
153 $payments = new Give_Payments_Query( $args );
154 $payments = $payments->get_payments();
155 $earnings = 0;
156 }
157
158 if ( ! empty( $payments ) ) {
159 $donation_id_col = Give()->payment_meta->get_meta_type() . '_id';
160 $query = "SELECT {$donation_id_col} as id, meta_value as total
161 FROM {$wpdb->donationmeta}
162 WHERE meta_key='_give_payment_total'
163 AND {$donation_id_col} IN ('" . implode( '\',\'', $payments ) . "')";
164
165 $payments = $wpdb->get_results( $query, ARRAY_A );
166
167 if ( ! empty( $payments ) ) {
168 foreach ( $payments as $payment ) {
169 $currency_code = give_get_payment_currency_code( $payment['id'] );
170
171 /**
172 * Filter the donation amount
173 * Note: this filter documented in payments/functions.php:give_donation_amount()
174 *
175 * @since 2.1
176 */
177 $formatted_amount = apply_filters(
178 'give_donation_amount',
179 give_format_amount( $payment['total'], [ 'donation_id' => $payment['id'] ] ),
180 $payment['total'],
181 $payment['id'],
182 [
183 'type' => 'stats',
184 'currency' => false,
185 'amount' => false,
186 ]
187 );
188
189 $earnings += (float) give_maybe_sanitize_amount( $formatted_amount, [ 'currency' => $currency_code ] );
190 }
191 }
192 }
193
194 // Cache the results for one hour.
195 Give_Cache::set( $key, give_sanitize_amount_for_db( $earnings ), 60 * 60 );
196 }
197
198 /**
199 * Filter the earnings.
200 *
201 * @since 1.8.17
202 *
203 * @param float $earnings Earning amount.
204 * @param int $form_id Donation Form ID.
205 * @param string|bool $start_date Earning start date.
206 * @param string|bool $end_date Earning end date.
207 * @param string|bool $gateway_id Payment gateway id.
208 */
209 $earnings = apply_filters( 'give_get_earnings', $earnings, $form_id, $start_date, $end_date, $gateway_id );
210
211 // return earnings
212 return round( $earnings, give_get_price_decimals( $form_id ) );
213
214 }
215
216 /**
217 * Retrieve earning stat transient key
218 *
219 * @since 1.0
220 * @access public
221 *
222 * @param $form_id int The donation form to retrieve stats for. If false, gets stats for all forms
223 * @param $start_date string|bool The starting date for which we'd like to filter our donation earnings stats. If false, we'll use the default start date of `this_month`
224 * @param $end_date string|bool The end date for which we'd like to filter our sale stats. If false, we'll use the default end date of `this_month`
225 * @param $gateway_id string|bool The gateway to get earnings for such as 'paypal' or 'stripe'
226 *
227 * @return float|int Total amount of donations based on the passed arguments.
228 */
229 public function get_earnings_cache_key( $form_id = 0, $start_date = false, $end_date = false, $gateway_id = false ) {
230
231 $this->setup_dates( $start_date, $end_date );
232
233 // Make sure start date is valid
234 if ( is_wp_error( $this->start_date ) ) {
235 return $this->start_date;
236 }
237
238 // Make sure end date is valid
239 if ( is_wp_error( $this->end_date ) ) {
240 return $this->end_date;
241 }
242
243 $args = [
244 'status' => 'publish',
245 'start_date' => $this->start_date,
246 'end_date' => $this->end_date,
247 'fields' => 'ids',
248 'number' => - 1,
249 ];
250
251 // Filter by Gateway ID meta_key
252 if ( $gateway_id ) {
253 $args['meta_query'][] = [
254 'key' => '_give_payment_gateway',
255 'value' => $gateway_id,
256 ];
257 }
258
259 // Filter by Gateway ID meta_key
260 if ( $form_id ) {
261 $args['meta_query'][] = [
262 'key' => '_give_payment_form_id',
263 'value' => $form_id,
264 ];
265 }
266
267 if ( ! empty( $args['meta_query'] ) && 1 < count( $args['meta_query'] ) ) {
268 $args['meta_query']['relation'] = 'AND';
269 }
270
271 $args = apply_filters( 'give_stats_earnings_args', $args );
272 $key = Give_Cache::get_key( 'give_stats', $args );
273
274 // return earnings
275 return $key;
276
277 }
278
279 /**
280 * Sum donation totals in one query instead of loading every matching donation ID into PHP
281 * and formatting each amount. Uses the Currency Switcher base amount when a donation has one,
282 * which is what its `give_donation_amount` filter returns for stats.
283 *
284 * Returns null when the query args contain something this method does not translate, or when an
285 * add-on filters `give_donation_amount`, so the caller falls back to the per-donation loop.
286 *
287 * @since 4.18.0
288 *
289 * @param array $args Give_Payments_Query arguments.
290 *
291 * @return float|null
292 */
293 private function sum_earnings_in_sql( array $args ) {
294 global $wpdb;
295
296 if ( $this->has_donation_amount_filter() ) {
297 return null;
298 }
299
300 $where = $this->stats_where_sql( $args );
301
302 if ( null === $where ) {
303 return null;
304 }
305
306 $donation_id_col = Give()->payment_meta->get_meta_type() . '_id';
307
308 $sql = "SELECT SUM( COALESCE( NULLIF( base.meta_value, '' ), total.meta_value ) + 0 )
309 FROM {$wpdb->posts} AS p
310 INNER JOIN {$wpdb->donationmeta} AS total ON total.{$donation_id_col} = p.ID AND total.meta_key = '_give_payment_total'
311 LEFT JOIN {$wpdb->donationmeta} AS base ON base.{$donation_id_col} = p.ID AND base.meta_key = '_give_cs_base_amount'
312 WHERE p.post_type = 'give_payment' {$where}";
313
314 return (float) $wpdb->get_var( $sql );
315 }
316
317 /**
318 * Count matching donations in one query instead of loading every ID into PHP.
319 *
320 * @since 4.18.0
321 *
322 * @param array $args Give_Payments_Query arguments.
323 *
324 * @return int|null
325 */
326 private function count_donations_in_sql( array $args ) {
327 global $wpdb;
328
329 $where = $this->stats_where_sql( $args );
330
331 if ( null === $where ) {
332 return null;
333 }
334
335 return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} AS p WHERE p.post_type = 'give_payment' {$where}" );
336 }
337
338 /**
339 * Add-ons that change amounts through `give_donation_amount` (Fee Recovery, Currency Switcher) need
340 * the per-donation loop for earnings so their callbacks run. Counts are unaffected and stay in SQL. Core itself always hooks the deprecated filter
341 * mapping there, so that one callback does not count.
342 *
343 * @since 4.18.0
344 *
345 * @return bool
346 */
347 private function has_donation_amount_filter() {
348 global $wp_filter;
349
350 if ( has_filter( 'give_payment_amount' ) ) {
351 return true;
352 }
353
354 foreach ( $wp_filter['give_donation_amount']->callbacks ?? [] as $callbacks ) {
355 foreach ( $callbacks as $callback ) {
356 if ( 'give_deprecated_filter_mapping' !== $callback['function'] ) {
357 return true;
358 }
359 }
360 }
361
362 return false;
363 }
364
365 /**
366 * Translate the subset of Give_Payments_Query arguments the stats methods build (status, date
367 * range, form, parent, and simple meta equality) into a WHERE fragment. Anything else returns null.
368 *
369 * @since 4.18.0
370 *
371 * @param array $args
372 *
373 * @return string|null
374 */
375 private function stats_where_sql( array $args ) {
376 global $wpdb;
377
378 /**
379 * Allow add-ons that alter stats amounts some other way to keep the per-donation code path.
380 *
381 * @since 4.18.0
382 *
383 * @param bool $aggregate_in_sql
384 * @param array $args
385 */
386 if ( ! apply_filters( 'givewp_payment_stats_aggregate_in_sql', true, $args ) ) {
387 return null;
388 }
389
390 $known = [ 'status', 'post_status', 'start_date', 'end_date', 'fields', 'number', 'output', 'meta_query', 'give_forms' ];
391
392 if ( array_diff( array_keys( $args ), $known ) ) {
393 return null;
394 }
395
396 if ( isset( $args['number'] ) && -1 !== (int) $args['number'] ) {
397 return null;
398 }
399
400 /*
401 * Give_Payments_Query excludes child donations such as renewals by setting post_parent to 0, and
402 * add-ons like Recurring clear that through `give_pre_get_payments`. Give the hook the same query
403 * object it would see on the legacy path so the SQL path keeps the same rows.
404 */
405 $query = new Give_Payments_Query( $args );
406 $query->__set( 'post_parent', 0 );
407 do_action( 'give_pre_get_payments', $query );
408
409 $statuses = (array) ( $args['post_status'] ?? $args['status'] ?? 'publish' );
410 $where = ' AND p.post_status IN (' . implode( ',', array_map( static function ( $status ) use ( $wpdb ) {
411 return $wpdb->prepare( '%s', $status );
412 }, $statuses ) ) . ')';
413
414 if ( isset( $query->args['post_parent'] ) && is_numeric( $query->args['post_parent'] ) ) {
415 $where .= $wpdb->prepare( ' AND p.post_parent = %d', $query->args['post_parent'] );
416 }
417
418 if ( ! empty( $args['start_date'] ) && ! is_wp_error( $args['start_date'] ) ) {
419 $where .= $wpdb->prepare( ' AND p.post_date >= %s', date( 'Y-m-d H:i:s', $args['start_date'] ) );
420 }
421
422 if ( ! empty( $args['end_date'] ) && ! is_wp_error( $args['end_date'] ) ) {
423 $where .= $wpdb->prepare( ' AND p.post_date <= %s', date( 'Y-m-d H:i:s', $args['end_date'] ) );
424 }
425
426 $donation_id_col = Give()->payment_meta->get_meta_type() . '_id';
427 $meta_conditions = [];
428
429 if ( ! empty( $args['give_forms'] ) ) {
430 $meta_conditions[] = [ 'key' => '_give_payment_form_id', 'value' => $args['give_forms'] ];
431 }
432
433 foreach ( (array) ( $args['meta_query'] ?? [] ) as $index => $condition ) {
434 if ( 'relation' === $index ) {
435 if ( 'AND' !== strtoupper( $condition ) ) {
436 return null;
437 }
438 continue;
439 }
440
441 if ( ! is_array( $condition ) || empty( $condition['key'] ) || ! isset( $condition['value'] ) ) {
442 return null;
443 }
444
445 if ( ! in_array( strtoupper( $condition['compare'] ?? '=' ), [ '=', 'IN' ], true ) ) {
446 return null;
447 }
448
449 if ( array_diff( array_keys( $condition ), [ 'key', 'value', 'compare' ] ) ) {
450 return null;
451 }
452
453 $meta_conditions[] = $condition;
454 }
455
456 foreach ( $meta_conditions as $condition ) {
457 $values = array_map( 'strval', (array) $condition['value'] );
458
459 if ( ! $values ) {
460 return null;
461 }
462
463 $placeholders = implode( ',', array_fill( 0, count( $values ), '%s' ) );
464 $where .= $wpdb->prepare(
465 " AND p.ID IN (SELECT {$donation_id_col} FROM {$wpdb->donationmeta} WHERE meta_key = %s AND meta_value IN ({$placeholders}))",
466 array_merge( [ $condition['key'] ], $values )
467 );
468 }
469
470 return $where;
471 }
472
473 /**
474 * Get the best selling forms
475 *
476 * @since 1.0
477 * @since 2.9.6 Added an explicit ORDER BY on which to apply a DESC order.
478 * @since 2.18.0 Updated sales calculation so that the results are ordered as integers.
479 * @access public
480 * @global wpdb $wpdb
481 *
482 * @param $number int The number of results to retrieve with the default set to 10.
483 *
484 * @return array Best selling forms
485 */
486 public function get_best_selling( $number = 10 ) {
487 global $wpdb;
488
489 $meta_table = give_v20_bc_table_details( 'form' );
490
491 $give_forms = $wpdb->get_results(
492 $wpdb->prepare(
493 "SELECT {$meta_table['column']['id']} as form_id, max(ABS(meta_value)) as sales
494 FROM {$meta_table['name']} WHERE meta_key='_give_form_sales' AND meta_value > 0
495 GROUP BY meta_value+0
496 ORDER BY sales DESC
497 LIMIT %d;",
498 $number
499 )
500 );
501
502 return $give_forms;
503 }
504
505 }
506