PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.1.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.1.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / api / analytics.php

analytics.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.1.0, at includes/api/analytics.php

884 lines 30.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\API;
4
5 use DateTime;
6 use StoreEngine\API\Schema\AnalyticsSchema;
7 use StoreEngine\Classes\Countries;
8 use StoreEngine\Classes\OrderCollection;
9 use StoreEngine\Utils\Caching;
10 use StoreEngine\Utils\Helper;
11 use WP_Error;
12 use WP_REST_Controller;
13 use WP_REST_Request;
14 use WP_REST_Response;
15 use WP_REST_Server;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 class Analytics extends WP_REST_Controller {
22 use AnalyticsSchema;
23
24 public static function init() {
25 $self = new self();
26 $self->namespace = STOREENGINE_PLUGIN_SLUG . '/v1';
27 $self->rest_base = 'analytics';
28
29 add_action( 'rest_api_init', [ $self, 'register_routes' ] );
30 }
31
32 public function register_routes() {
33 register_rest_route( $this->namespace, '/' . $this->rest_base, [
34 [
35 'methods' => WP_REST_Server::READABLE,
36 'callback' => [ $this, 'get_analytics' ],
37 'permission_callback' => [ $this, 'get_permission_check' ],
38 'args' => [
39 'context' => $this->get_context_param( [ 'default' => 'view' ] ),
40 'from' => [
41 'title' => __( 'From Date', 'storeengine' ),
42 'type' => 'string',
43 'description' => __( 'Start unix timestamp.', 'storeengine' ),
44 'default' => gmdate( 'Y-m-d', strtotime( '- 1 month' ) ),
45 ],
46 'to' => [
47 'title' => __( 'To', 'storeengine' ),
48 'type' => 'string',
49 'description' => __( 'End unix timestamp.', 'storeengine' ),
50 'default' => gmdate( 'Y-m-d' ),
51 ],
52 'compare' => [
53 'title' => __( 'Compare days', 'storeengine' ),
54 'type' => 'integer',
55 'description' => __( 'Compare data with last xx days.', 'storeengine' ),
56 'default' => 30,
57 ],
58 // Optional currency filter. Defaults to store base currency.
59 // Only currencies that have orders in the requested date range
60 // are returned in currencies_in_period, so the frontend only
61 // shows the filter dropdown when multiple currencies exist.
62 'currency' => [
63 'title' => __( 'Currency', 'storeengine' ),
64 'type' => 'string',
65 'description' => __( 'ISO 4217 currency to filter analytics by. Defaults to store base currency.', 'storeengine' ),
66 'default' => '',
67 'sanitize_callback' => 'sanitize_text_field',
68 ],
69 ],
70 ],
71 ] );
72 }
73
74 public function get_permission_check() {
75 return Helper::check_rest_user_cap( 'manage_options' );
76 }
77
78 public static function add_refund_statuses( array $statuses ): array {
79 $statuses[] = 'refunded';
80
81 return $statuses;
82 }
83
84 public function get_analytics( WP_REST_Request $request ) {
85 if ( ! rest_parse_date( $request->get_param( 'from' ) . ' 00:00:00' ) ) {
86 return new WP_Error( 'invalid_form_date', __( 'Invalid from date.', 'storeengine' ) );
87 }
88
89 if ( ! rest_parse_date( $request->get_param( 'to' ) . ' 00:00:00' ) ) {
90 return new WP_Error( 'invalid_to_date', __( 'Invalid to date.', 'storeengine' ) );
91 }
92
93 if ( strtotime( Helper::get_first_order_date( 'Y-m-d' ) ) > strtotime( $request->get_param( 'from' ) ) ) {
94 $request->set_param( 'from', Helper::get_first_order_date( 'Y-m-d' ) );
95 }
96
97 $from = $request->get_param( 'from' );
98 $to = $request->get_param( 'to' );
99 $compare = (int) $request->get_param( 'compare' );
100
101 // Resolve the currency for this request.
102 // Defaults to the store base currency — all existing behaviour unchanged.
103 // Pass ?currency=BDT to filter all aggregate data to BDT orders only.
104 $base_currency = strtoupper( Helper::get_settings( 'store_currency', 'USD' ) );
105 $currency = strtoupper( trim( $request->get_param( 'currency' ) ) ?: $base_currency );
106
107 // Distinct currencies that have orders in the requested date range.
108 // The frontend uses this to decide whether to show the currency filter
109 // dropdown at all — if only one currency exists, no dropdown is needed.
110 // Only orders within from..to are considered, not all-time.
111 $currencies_in_period = $this->get_currencies_in_period( $from, $to );
112
113 return rest_ensure_response( [
114 // Active currency for this response — what all monetary values are in.
115 'currency' => $currency,
116 // Currencies that have actual orders in this date range.
117 // Empty array or single-item → frontend hides the currency filter.
118 // Two or more → frontend shows a currency switcher dropdown.
119 'currencies_in_period' => $currencies_in_period,
120 // Tile list — addons append more rows via the
121 // `storeengine/analytics/stats` filter (e.g. cost-profit injects
122 // COGS / Profit / Margin here).
123 'stats' => apply_filters(
124 'storeengine/analytics/stats',
125 [
126 [
127 'label' => __( 'Sales', 'storeengine' ),
128 'icon' => 'money-receive',
129 'data' => $this->get_sales_stats( $from, $to, $compare, $currency ),
130 ],
131 [
132 'label' => __( 'Orders', 'storeengine' ),
133 'icon' => 'bag',
134 'data' => $this->get_orders_stats( $from, $to, $compare, $currency ),
135 ],
136 [
137 'label' => __( 'Refund', 'storeengine' ),
138 'icon' => 'money-send',
139 'data' => $this->get_refund_stats( $from, $to, $compare, $currency ),
140 ],
141 [
142 'label' => __( 'New Customers', 'storeengine' ),
143 'icon' => 'users',
144 'data' => $this->get_customer_stats( $from, $to, $compare, $currency ),
145 ],
146 ],
147 $from,
148 $to,
149 $compare,
150 $currency
151 ),
152 'growth' => $this->growth_report( $from, $to, $currency ),
153 'heat_map' => $this->heat_map( $from, $to, $currency ),
154 'recent_orders' => $this->get_recent_orders(),
155 'top_products' => $this->get_top_selling_products( $from, $to, $compare, $currency ),
156 ] );
157 }
158
159 // ── Currency helper ───────────────────────────────────────────────────────
160
161 /**
162 * Distinct currencies that have orders in the given date range.
163 *
164 * Only looks at the requested from..to window, not all-time.
165 * The frontend uses this to decide whether to render a currency filter
166 * dropdown — if only the base currency exists, the dropdown is hidden.
167 *
168 * Result is cached per date range.
169 *
170 * @return array e.g. ['USD'] or ['BDT', 'USD'] (always sorted, base first)
171 */
172 protected function get_currencies_in_period( string $from, string $to ): array {
173 global $wpdb;
174
175 $key = $this->get_cache_key( 'storeengine_orders', 'currencies_in_period', $from, $to );
176 $cached = wp_cache_get( $key, 'storeengine_orders-queries' );
177
178 if ( false !== $cached ) {
179 return $cached;
180 }
181
182 $query = $wpdb->prepare(
183 "SELECT DISTINCT o.currency
184 FROM {$wpdb->prefix}storeengine_orders AS o
185 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS dm
186 ON dm.order_id = o.id
187 AND dm.meta_key = '_order_placed_date_gmt'
188 WHERE o.type = 'order'
189 AND o.currency IS NOT NULL
190 AND o.currency <> ''
191 AND CAST( dm.meta_value AS DATE ) BETWEEN %s AND %s
192 ORDER BY o.currency ASC",
193 $from, $to
194 );
195
196 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
197 $rows = $wpdb->get_col( $query );
198
199 $base = strtoupper( Helper::get_settings( 'store_currency', 'USD' ) );
200 $currencies = array_map( 'strtoupper', $rows ?: [] );
201
202 // Base currency always first.
203 if ( in_array( $base, $currencies, true ) ) {
204 $currencies = array_merge( [ $base ], array_diff( $currencies, [ $base ] ) );
205 }
206
207 wp_cache_set( $key, $currencies, 'storeengine_orders-queries' );
208
209 return $currencies;
210 }
211
212 // ── Sales stats ───────────────────────────────────────────────────────────
213
214 /**
215 * Total sales for the period vs comparison period.
216 *
217 * $currency defaults to base currency — behaviour identical to original
218 * when no ?currency= param is passed.
219 * When ?currency=BDT is passed, only BDT orders are summed.
220 */
221 protected function get_sales_stats( string $from, string $to, int $compare, string $currency ): array {
222 global $wpdb;
223
224 $query = $wpdb->prepare( "
225 SELECT
226 curr.total_sales AS current_sales,
227 prev.total_sales AS previous_sales,
228 CASE
229 WHEN prev.total_sales = 0 THEN NULL
230 ELSE ROUND(
231 ((curr.total_sales - prev.total_sales) / prev.total_sales) * 100,
232 2
233 )
234 END AS sales_rate
235 FROM (
236 SELECT
237 COALESCE(SUM(CAST(total.meta_value AS DECIMAL(10,2))), 0) AS total_sales
238 FROM {$wpdb->prefix}storeengine_orders_meta AS total
239 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS date_meta
240 ON total.order_id = date_meta.order_id
241 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
242 ON o.id = total.order_id
243 WHERE total.meta_key = '_total'
244 AND date_meta.meta_key = '_order_placed_date_gmt'
245 AND CAST(date_meta.meta_value AS DATE) BETWEEN %s AND %s
246 AND o.currency = %s
247 AND o.status IN ('processing','payment_confirmed','completed')
248 ) AS curr
249 CROSS JOIN (
250 SELECT
251 COALESCE(SUM(CAST(total.meta_value AS DECIMAL(10,2))), 0) AS total_sales
252 FROM {$wpdb->prefix}storeengine_orders_meta AS total
253 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS date_meta
254 ON total.order_id = date_meta.order_id
255 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
256 ON o.id = total.order_id
257 WHERE total.meta_key = '_total'
258 AND date_meta.meta_key = '_order_placed_date_gmt'
259 AND CAST(date_meta.meta_value AS DATE)
260 BETWEEN DATE_SUB(%s, INTERVAL %d DAY) AND DATE_SUB(%s, INTERVAL %d DAY)
261 AND o.currency = %s
262 ) AS prev
263 ",
264 $from, $to, $currency,
265 $from, $compare, $to, $compare, $currency
266 );
267
268 $key = $this->get_cache_key( 'storeengine_orders', $query, $from, $to, $compare, $currency );
269 $data = wp_cache_get( $key, 'storeengine_orders-queries' );
270
271 if ( false === $data ) {
272 $result = $wpdb->get_row( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
273
274 if ( $result ) {
275 $data = [
276 'count' => (float) $result->current_sales,
277 'format' => true,
278 'rate' => null !== $result->sales_rate ? (float) $result->sales_rate : null,
279 'currency' => $currency,
280 ];
281 wp_cache_set( $key, $data, 'storeengine_orders-queries' );
282 } else {
283 $data = [
284 'count' => __( 'N/A', 'storeengine' ),
285 'rate' => 0,
286 'currency' => $currency,
287 ];
288 }
289 }
290
291 return $data;
292 }
293
294 // ── Orders stats — currency-neutral ───────────────────────────────────────
295
296 protected function get_orders_stats( string $from, string $to, int $compare, string $currency ): array {
297 global $wpdb;
298
299 $query = $wpdb->prepare( "
300 SELECT
301 SUM(CASE
302 WHEN DATE(m.meta_value) BETWEEN %s AND %s THEN 1
303 ELSE 0
304 END) AS total_orders,
305 SUM(CASE
306 WHEN DATE(m.meta_value) BETWEEN DATE_SUB(%s, INTERVAL %d DAY)
307 AND DATE_SUB(%s, INTERVAL 1 DAY)
308 THEN 1
309 ELSE 0
310 END) AS compare_orders
311 FROM {$wpdb->prefix}storeengine_orders AS o
312 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS m
313 ON o.id = m.order_id
314 WHERE
315 o.type = 'order'
316 AND m.meta_key = '_order_placed_date_gmt'
317 AND m.meta_value <> ''
318 AND o.currency = %s
319 AND o.status IN ('processing','payment_confirmed','completed')
320 ", $from, $to, $from, $compare, $from, $currency );
321
322 $key = $this->get_cache_key( 'storeengine_orders', $query, $from, $to, $compare );
323 $data = wp_cache_get( $key, 'storeengine_orders-queries' );
324
325 if ( false === $data ) {
326 $result = $wpdb->get_row( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
327
328 if ( $result ) {
329 $total_orders = (int) $result->total_orders;
330 $compare_orders = (int) $result->compare_orders;
331
332 if ( $compare_orders > 0 ) {
333 $order_rate = round( ( ( $total_orders - $compare_orders ) / $compare_orders ) * 100, 2 );
334 } else {
335 $order_rate = $total_orders > 0 ? 100 : 0;
336 }
337
338 $data = [ 'count' => $total_orders, 'rate' => $order_rate ];
339 wp_cache_set( $key, $data, 'storeengine_orders-queries' );
340 } else {
341 $data = [ 'count' => __( 'N/A', 'storeengine' ), 'rate' => 0 ];
342 }
343 }
344
345 return $data;
346 }
347
348 // ── Refund stats ──────────────────────────────────────────────────────────
349
350 protected function get_refund_stats( string $from, string $to, int $compare, string $currency ): array {
351 global $wpdb;
352
353 $query = $wpdb->prepare( "
354 SELECT
355 curr.total_refunds AS current_refunds,
356 prev.total_refunds AS previous_refunds,
357 CASE
358 WHEN prev.total_refunds = 0 THEN NULL
359 ELSE ROUND(
360 ((curr.total_refunds - prev.total_refunds) / prev.total_refunds) * 100,
361 2
362 )
363 END AS refund_rate
364 FROM (
365 SELECT COALESCE(SUM(CAST(o.total_amount AS DECIMAL(10,2))), 0) AS total_refunds
366 FROM {$wpdb->prefix}storeengine_orders AS o
367 WHERE o.type = 'refund_order'
368 AND CAST(o.date_created_gmt AS DATE) BETWEEN %s AND %s
369 AND o.currency = %s
370 ) AS curr
371 CROSS JOIN (
372 SELECT COALESCE(SUM(CAST(o.total_amount AS DECIMAL(10,2))), 0) AS total_refunds
373 FROM {$wpdb->prefix}storeengine_orders AS o
374 WHERE o.type = 'refund_order'
375 AND CAST(o.date_created_gmt AS DATE)
376 BETWEEN DATE_SUB(%s, INTERVAL %d DAY) AND DATE_SUB(%s, INTERVAL %d DAY)
377 AND o.currency = %s
378 ) AS prev
379 ",
380 $from, $to, $currency,
381 $from, $compare, $to, $compare, $currency
382 );
383
384 $key = $this->get_cache_key( 'storeengine_orders', $query, $from, $to, $compare, $currency );
385 $data = wp_cache_get( $key, 'storeengine_orders-queries' );
386
387 if ( false === $data ) {
388 $result = $wpdb->get_row( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
389
390 if ( $result ) {
391 $data = [
392 'count' => (float) $result->current_refunds,
393 'format' => true,
394 'rate' => null !== $result->refund_rate ? (float) $result->refund_rate : null,
395 'currency' => $currency,
396 ];
397 wp_cache_set( $key, $data, 'storeengine_orders-queries' );
398 } else {
399 $data = [ 'count' => __( 'N/A', 'storeengine' ), 'format' => false, 'rate' => 0 ];
400 }
401 }
402
403 return $data;
404 }
405
406 // ── Customer stats — currency-neutral ─────────────────────────────────────
407
408 protected function get_customer_stats( string $from, string $to, int $compare, string $currency ): array {
409 global $wpdb;
410
411 $query = $wpdb->prepare( "
412 SELECT
413 curr.new_customers AS current_customers,
414 prev.new_customers AS previous_customers,
415 CASE
416 WHEN prev.new_customers = 0 THEN NULL
417 ELSE ROUND(
418 ((curr.new_customers - prev.new_customers) / prev.new_customers) * 100,
419 2
420 )
421 END AS customer_rate
422 FROM (
423
424 /* CURRENT PERIOD */
425 SELECT COUNT(DISTINCT o.customer_id) AS new_customers
426 FROM {$wpdb->prefix}storeengine_orders o
427 WHERE o.type = 'order'
428 AND o.currency = %s
429 AND o.status IN ('processing','payment_confirmed','completed')
430 AND DATE(o.date_created_gmt) BETWEEN %s AND %s
431 AND NOT EXISTS (
432 SELECT 1
433 FROM {$wpdb->prefix}storeengine_orders o2
434 WHERE o2.type = 'order'
435 AND o2.currency = %s
436 AND o2.customer_id = o.customer_id
437 AND DATE(o2.date_created_gmt) < %s
438 )
439
440 ) AS curr
441
442 CROSS JOIN (
443
444 /* PREVIOUS PERIOD */
445 SELECT COUNT(DISTINCT o.customer_id) AS new_customers
446 FROM {$wpdb->prefix}storeengine_orders o
447 WHERE o.type = 'order'
448 AND o.currency = %s
449 AND DATE(o.date_created_gmt)
450 BETWEEN DATE_SUB(%s, INTERVAL %d DAY)
451 AND DATE_SUB(%s, INTERVAL %d DAY)
452 AND NOT EXISTS (
453 SELECT 1
454 FROM {$wpdb->prefix}storeengine_orders o2
455 WHERE o2.type = 'order'
456 AND o2.currency = %s
457 AND o2.customer_id = o.customer_id
458 AND DATE(o2.date_created_gmt) < DATE_SUB(%s, INTERVAL %d DAY)
459 )
460
461 ) AS prev
462 ",
463 $currency, $from, $to, $currency, $from,
464 $currency, $from, $compare, $to, $compare,
465 $currency, $from, $compare
466 );
467
468 $key = $this->get_cache_key( 'users', $query, $from, $to, $compare, $currency );
469 $data = wp_cache_get( $key, 'users-queries' );
470
471 if ( false === $data ) {
472 $result = $wpdb->get_row( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
473
474 if ( $result ) {
475 $data = [
476 'count' => (int) $result->current_customers,
477 'rate' => $result->customer_rate !== null ? (float) $result->customer_rate : null,
478 'currency' => $currency,
479 ];
480
481 wp_cache_set( $key, $data, 'storeengine_orders-queries' );
482 } else {
483 $data = [
484 'count' => 0,
485 'rate' => 0,
486 'currency' => $currency,
487 ];
488 }
489 }
490
491 return $data;
492 }
493
494 // ── Growth chart ──────────────────────────────────────────────────────────
495
496 /**
497 * Day-by-day sales, refunds and order counts for the chart.
498 *
499 * Filters by $currency so amounts are all in the same unit.
500 * Default = base currency → single-line chart, same as original.
501 * Pass ?currency=BDT → BDT-only chart.
502 */
503 protected function growth_report( string $from, string $to, string $currency ): array {
504 global $wpdb;
505
506 $chart_data = [
507 'datasets' => [
508 [
509 'label' => __( 'Sales', 'storeengine' ),
510 'format' => true,
511 'data' => [],
512 'borderColor' => '#16A34A',
513 'backgroundColor' => '#16A34A',
514 ],
515 [
516 'label' => __( 'Refunds', 'storeengine' ),
517 'format' => true,
518 'data' => [],
519 'borderColor' => '#FF4D4D',
520 'backgroundColor' => '#FF4D4D',
521 ],
522 [
523 'label' => __( 'Orders', 'storeengine' ),
524 'data' => [],
525 'borderColor' => '#FF7A00',
526 'backgroundColor' => '#FF7A00',
527 ],
528 ],
529 ];
530
531 $query = $wpdb->prepare(
532 "SELECT
533 daily.date,
534 COALESCE(SUM(daily.sales), 0) AS total_sales,
535 COALESCE(SUM(daily.refunds), 0) AS total_refunds,
536 COALESCE(SUM(daily.orders), 0) AS total_orders
537 FROM (
538 SELECT
539 DATE(dm.meta_value) AS date,
540 SUM(CAST(o.total_amount AS DECIMAL(10,2))) AS sales,
541 0 AS refunds,
542 COUNT(DISTINCT dm.order_id) AS orders
543 FROM {$wpdb->prefix}storeengine_orders_meta AS dm
544 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
545 ON o.id = dm.order_id
546 WHERE dm.meta_key = '_order_placed_date_gmt'
547 AND dm.meta_value <> ''
548 AND DATE(dm.meta_value) BETWEEN %s AND %s
549 AND o.type = 'order'
550 AND o.currency = %s
551 GROUP BY DATE(dm.meta_value)
552
553 UNION ALL
554
555 SELECT
556 DATE(o.date_created_gmt) AS date,
557 0 AS sales,
558 SUM(CAST(o.total_amount AS DECIMAL(10,2))) AS refunds,
559 0 AS orders
560 FROM {$wpdb->prefix}storeengine_orders AS o
561 WHERE o.type = 'refund_order'
562 AND DATE(o.date_created_gmt) BETWEEN %s AND %s
563 AND o.currency = %s
564 GROUP BY DATE(o.date_created_gmt)
565 ) AS daily
566 GROUP BY daily.date
567 ORDER BY daily.date ASC",
568 $from, $to, $currency,
569 $from, $to, $currency
570 );
571
572 $key = $this->get_cache_key( 'storeengine_orders', $query, $from, $to, $currency );
573 $data = wp_cache_get( $key, 'storeengine_orders-queries' );
574
575 if ( false === $data ) {
576 $results = $wpdb->get_results( $query, OBJECT_K ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
577
578 $start = new DateTime( $from );
579 $end = new DateTime( $to );
580 $end->modify( '+1 day' );
581
582 $data = [ 'labels' => [], 'sales' => [], 'refunds' => [], 'orders' => [] ];
583
584 for ( $date = $start; $date < $end; $date->modify( '+1 day' ) ) {
585 $day = $date->format( 'Y-m-d' );
586 $data['labels'][] = $date->format( 'M j, Y' );
587
588 if ( isset( $results[ $day ] ) ) {
589 $row = $results[ $day ];
590 $data['sales'][] = abs( (float) $row->total_sales );
591 $data['refunds'][] = abs( (float) $row->total_refunds );
592 $data['orders'][] = (int) $row->total_orders;
593 } else {
594 $data['sales'][] = 0;
595 $data['refunds'][] = 0;
596 $data['orders'][] = 0;
597 }
598 }
599
600 $data['totals'] = [
601 'sales' => array_sum( $data['sales'] ),
602 'refunds' => array_sum( $data['refunds'] ),
603 'orders' => array_sum( $data['orders'] ),
604 'avg_return' => 0,
605 ];
606
607 if ( $data['totals']['sales'] && $data['totals']['refunds'] ) {
608 $data['totals']['avg_return'] = ( $data['totals']['refunds'] / $data['totals']['sales'] ) * 100;
609 }
610
611 wp_cache_set( $key, $data, 'storeengine_orders-queries' );
612 }
613
614 $chart_data['labels'] = $data['labels'];
615 $chart_data['totals'] = $data['totals'];
616 $chart_data['currency'] = $currency;
617 $chart_data['datasets'][0]['data'] = $data['sales'];
618 $chart_data['datasets'][1]['data'] = $data['refunds'];
619 $chart_data['datasets'][2]['data'] = $data['orders'];
620
621 return $chart_data;
622 }
623
624 // ── Heat map — currency-neutral ───────────────────────────────────────────
625
626 protected function heat_map( string $from, string $to, string $currency ): array {
627 global $wpdb;
628
629 $query = $wpdb->prepare( "
630 SELECT
631 oa.country AS country,
632 COUNT(DISTINCT oa.order_id) AS total
633 FROM {$wpdb->prefix}storeengine_orders_meta AS om
634 INNER JOIN {$wpdb->prefix}storeengine_order_addresses AS oa
635 ON om.order_id = oa.order_id
636 AND oa.address_type = 'billing'
637 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
638 ON o.id = om.order_id
639 WHERE om.meta_key = '_order_placed_date_gmt'
640 AND om.meta_value <> ''
641 AND DATE(om.meta_value) BETWEEN %s AND %s
642 AND o.currency = %s
643 AND o.status IN ('processing','payment_confirmed','completed')
644 GROUP BY oa.country
645 ORDER BY total DESC
646 ",
647 $from, $to, $currency
648 );
649
650 $key = $this->get_cache_key(
651 'storeengine_orders',
652 $query,
653 $from,
654 $to,
655 $currency
656 );
657
658 $data = wp_cache_get( $key, 'storeengine_orders-queries' );
659
660 if ( false === $data ) {
661 $data = [];
662 $results = $wpdb->get_results( $query, ARRAY_N ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
663
664 foreach ( $results as [ $cc, $total ] ) {
665 if ( ! $cc ) {
666 continue;
667 }
668
669 $cc = strtoupper( $cc );
670
671 $data[$cc] = [
672 'cc' => $cc,
673 'name' => Countries::init()->get_country( $cc ) ?? $cc,
674 'value' => (int) $total,
675 ];
676 }
677
678 wp_cache_set( $key, $data, 'storeengine_orders-queries' );
679 }
680
681 return array_values( $data );
682 }
683
684 // ── Recent orders — each in its own currency ──────────────────────────────
685
686 protected function get_recent_orders(): array {
687 $query = new OrderCollection( [
688 'per_page' => 6,
689 'orderby' => 'id',
690 'order' => 'DESC',
691 'where' => [
692 'relation' => 'AND',
693 [ 'key' => 'type', 'value' => 'order' ],
694 [
695 'key' => 'status',
696 'value' => [ 'pending', 'completed', 'processing', 'payment_confirmed' ],
697 'compare' => 'IN',
698 ],
699 ],
700 ] );
701
702 if ( ! $query->have_results() ) {
703 return [];
704 }
705
706 $data = [];
707 $na = __( 'N/A', 'storeengine' );
708
709 while ( $query->have_results() ) {
710 $order = $query->next_result();
711 $products = $order->get_line_product_items();
712 $total = count( $products );
713
714 if ( $total ) {
715 $product = reset( $products )->get_name();
716 if ( $total > 1 ) {
717 // translators: %1$s: product name, %2$d: remaining count.
718 $product = sprintf( __( '%1$s and %2$d more', 'storeengine' ), $product, $total - 1 );
719 }
720 } else {
721 $product = $na;
722 }
723
724 $date = $na;
725 $since = $na;
726
727 if ( $order->get_order_placed_date_gmt() ) {
728 $date = $order->get_order_placed_date_gmt()->format( 'Y-m-d H:i:s' );
729 /* translators: %s: Human-readable time difference. */
730 $since = sprintf( __( '%s ago', 'storeengine' ), human_time_diff( $order->get_order_placed_date_gmt()->format( 'U' ) ) );
731 }
732
733 $data[] = [
734 'product' => $product,
735 'customer' => $order->get_customer() ? $order->get_customer()->get_name() : $na,
736 'amount' => (float) $order->get_total(),
737 'currency' => $order->get_currency(),
738 'status' => $order->get_status(),
739 'payment' => $order->get_paid_status(),
740 'date' => $date,
741 'since' => $since,
742 ];
743 }
744
745 return $data;
746 }
747
748 // ── Top selling products ──────────────────────────────────────────────────
749
750 /**
751 * Top 5 products by revenue in $currency.
752 * Default = base currency → same as original.
753 */
754 protected function get_top_selling_products( string $from, string $to, int $compare, string $currency ): array {
755 global $wpdb;
756
757 $query = $wpdb->prepare( "
758 SELECT
759 product_id,
760 product_name,
761 total_sales,
762 units_sold,
763 compare_sales,
764 compare_units,
765 CASE
766 WHEN compare_sales = 0 AND total_sales > 0 THEN 100
767 WHEN compare_sales = 0 THEN 0
768 ELSE ROUND( (total_sales - compare_sales) / compare_sales * 100, 2 )
769 END AS sales_rate,
770 CASE
771 WHEN compare_units = 0 AND units_sold > 0 THEN 100
772 WHEN compare_units = 0 THEN 0
773 ELSE ROUND( (units_sold - compare_units) / compare_units * 100, 2 )
774 END AS units_rate
775 FROM (
776 SELECT
777 p.ID AS product_id,
778 p.post_title AS product_name,
779 SUM(CASE WHEN DATE(date_meta.meta_value) BETWEEN %s AND %s
780 THEN CAST(line_total.meta_value AS DECIMAL(10,2)) ELSE 0 END) AS total_sales,
781 SUM(CASE WHEN DATE(date_meta.meta_value) BETWEEN %s AND %s
782 THEN CAST(quantity.meta_value AS UNSIGNED) ELSE 0 END) AS units_sold,
783 SUM(CASE WHEN DATE(date_meta.meta_value) BETWEEN DATE_SUB(%s, INTERVAL %d DAY) AND DATE_SUB(%s, INTERVAL 1 DAY)
784 THEN CAST(line_total.meta_value AS DECIMAL(10,2)) ELSE 0 END) AS compare_sales,
785 SUM(CASE WHEN DATE(date_meta.meta_value) BETWEEN DATE_SUB(%s, INTERVAL %d DAY) AND DATE_SUB(%s, INTERVAL 1 DAY)
786 THEN CAST(quantity.meta_value AS UNSIGNED) ELSE 0 END) AS compare_units
787 FROM {$wpdb->prefix}storeengine_order_items AS oi
788 INNER JOIN {$wpdb->prefix}storeengine_order_item_meta AS product_id
789 ON product_id.order_item_id = oi.order_item_id
790 AND product_id.meta_key = '_product_id'
791 INNER JOIN {$wpdb->prefix}storeengine_order_item_meta AS quantity
792 ON quantity.order_item_id = oi.order_item_id
793 AND quantity.meta_key = '_quantity'
794 INNER JOIN {$wpdb->prefix}storeengine_order_item_meta AS line_total
795 ON line_total.order_item_id = oi.order_item_id
796 AND line_total.meta_key = '_line_total'
797 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
798 ON o.id = oi.order_id
799 AND o.type = 'order'
800 AND o.status IN ('processing', 'payment_confirmed', 'completed')
801 AND o.currency = %s
802 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS date_meta
803 ON date_meta.order_id = o.id
804 AND date_meta.meta_key = '_order_placed_date_gmt'
805 INNER JOIN {$wpdb->prefix}posts AS p
806 ON p.ID = product_id.meta_value
807 AND p.post_type = 'storeengine_product'
808 AND p.post_status = 'publish'
809 WHERE date_meta.meta_value <> ''
810 GROUP BY p.ID, p.post_title
811 ) AS totals
812 WHERE total_sales > 0
813 ORDER BY total_sales DESC
814 LIMIT 5;
815 ",
816 $from, $to,
817 $from, $to,
818 $from, $compare, $from,
819 $from, $compare, $from,
820 $currency
821 );
822
823 $key = $this->get_cache_key( 'storeengine_orders', $query, $from, $to, $compare, $currency )
824 . ':' . wp_cache_get_last_changed( 'posts' );
825 $results = wp_cache_get( $key, 'storeengine_orders-queries' );
826
827 if ( false === $results ) {
828 $results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
829
830 if ( ! empty( $results ) ) {
831 foreach ( $results as $result ) {
832 $result->product_id = (int) $result->product_id;
833 $result->total_sales = (float) $result->total_sales;
834 $result->units_sold = (int) $result->units_sold;
835 $result->compare_sales = (float) $result->compare_sales;
836 $result->compare_units = (int) $result->compare_units;
837 $result->sales_rate = (float) $result->sales_rate;
838 $result->units_rate = (float) $result->units_rate;
839 }
840 }
841
842 wp_cache_set( $key, $results, 'storeengine_orders-queries' );
843 }
844
845 return $results ?: [];
846 }
847
848 // ── Helpers ───────────────────────────────────────────────────────────────
849
850 protected function get_cache_key( $group, $sql, ...$args ): string {
851 return get_class( $this ) . ':' . Caching::get_query_cache_key( $group, $sql, ...$args );
852 }
853
854 /**
855 * @deprecated 1.5.7
856 */
857 public function get_analytics_old( $request ): WP_REST_Response {
858 $start_date = gmdate( 'Y-m-d H:i:00', strtotime( $request->get_param( 'start_date' ) ) );
859 $end_date = gmdate( 'Y-m-d H:i:59', strtotime( $request->get_param( 'end_date' ) ?? gmdate( 'd-m-Y h:i:s', strtotime( '-7 days' ) ) ) );
860
861 add_filter( 'storeengine/order_paid_statuses', [ __CLASS__, 'add_refund_statuses' ] );
862
863 $analytics = new \StoreEngine\Classes\Analytics();
864 $totals = $analytics->get_orders_totals( $start_date, $end_date );
865 $total_orders = (float) $totals->total_orders;
866 $total_sales = (float) $totals->total_sales;
867 $total_tax = (float) $totals->total_tax;
868
869 $total_refunds = $analytics->get_total_refunds( $start_date, $end_date );
870 $total_refunds = $total_refunds ? (float) $total_refunds->total_refunds : 0;
871 $gross_sales = $total_sales - $total_refunds;
872
873 $product_sold = $analytics->get_product_sold( $start_date, $end_date );
874 $total_products_sold = $product_sold ? (float) $product_sold->total_products_sold : 0;
875 $new_customers_count = Helper::get_new_customers_count( $start_date, $end_date );
876
877 remove_filter( 'storeengine/order_paid_statuses', [ __CLASS__, 'add_refund_statuses' ] );
878
879 $response = compact( 'total_orders', 'total_sales', 'total_refunds', 'gross_sales', 'total_tax', 'total_products_sold', 'new_customers_count' );
880
881 return new WP_REST_Response( $response, 200 );
882 }
883 }
884