PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.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 / product-analytics.php

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

804 lines 32.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Per-product analytics REST controller.
4 *
5 * Serves the "Product Analytics" drill-down screen (one product at a time):
6 * revenue / units / orders / average-order-value tiles, a day-by-day growth
7 * chart, a per-price breakdown, top customers and recent orders — all scoped
8 * to a single product and filterable by date range and currency.
9 *
10 * Mirrors the conventions of {@see \StoreEngine\API\Analytics} (the store-wide
11 * dashboard endpoint): same from/to/compare/currency params, the same
12 * `_order_placed_date_gmt` + order-item-meta aggregation, and the same
13 * per-query wp_cache pattern. Revenue is summed from `_line_total` order-item
14 * meta (not the order_product_lookup table, whose revenue columns are not
15 * populated) so figures stay consistent with the dashboard's Top Products.
16 */
17
18 namespace StoreEngine\API;
19
20 use DateTime;
21 use StoreEngine\Utils\Caching;
22 use StoreEngine\Utils\Formatting;
23 use StoreEngine\Utils\Helper;
24 use WP_Error;
25 use WP_REST_Controller;
26 use WP_REST_Request;
27 use WP_REST_Server;
28
29 if ( ! defined( 'ABSPATH' ) ) {
30 exit;
31 }
32
33 class ProductAnalytics extends WP_REST_Controller {
34
35 /**
36 * Order statuses that count as a completed sale.
37 */
38 const PAID_STATUSES = "'processing','payment_confirmed','completed'";
39
40 public static function init() {
41 $self = new self();
42 $self->namespace = STOREENGINE_PLUGIN_SLUG . '/v1';
43 $self->rest_base = 'product-analytics';
44
45 add_action( 'rest_api_init', [ $self, 'register_routes' ] );
46 }
47
48 public function register_routes() {
49 // Collection route — analytics for ALL products (the "Show all products"
50 // overview). Paginated + searchable, ordered by revenue.
51 register_rest_route( $this->namespace, '/' . $this->rest_base, [
52 [
53 'methods' => WP_REST_Server::READABLE,
54 'callback' => [ $this, 'get_products_list' ],
55 'permission_callback' => [ $this, 'get_permission_check' ],
56 'args' => [
57 'context' => $this->get_context_param( [ 'default' => 'view' ] ),
58 'from' => [
59 'type' => 'string',
60 'default' => gmdate( 'Y-m-d', strtotime( '- 1 month' ) ),
61 ],
62 'to' => [
63 'type' => 'string',
64 'default' => gmdate( 'Y-m-d' ),
65 ],
66 'currency' => [
67 'type' => 'string',
68 'default' => '',
69 'sanitize_callback' => 'sanitize_text_field',
70 ],
71 'search' => [
72 'type' => 'string',
73 'default' => '',
74 'sanitize_callback' => 'sanitize_text_field',
75 ],
76 'page' => [
77 'type' => 'integer',
78 'default' => 1,
79 'minimum' => 1,
80 ],
81 'per_page' => [
82 'type' => 'integer',
83 'default' => 20,
84 'minimum' => 1,
85 'maximum' => 100,
86 ],
87 ],
88 ],
89 ] );
90
91 register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', [
92 [
93 'methods' => WP_REST_Server::READABLE,
94 'callback' => [ $this, 'get_analytics' ],
95 'permission_callback' => [ $this, 'get_permission_check' ],
96 'args' => [
97 'context' => $this->get_context_param( [ 'default' => 'view' ] ),
98 'id' => [
99 'description' => __( 'Product ID.', 'storeengine' ),
100 'type' => 'integer',
101 'required' => true,
102 ],
103 'from' => [
104 'title' => __( 'From Date', 'storeengine' ),
105 'type' => 'string',
106 'description' => __( 'Range start date (Y-m-d).', 'storeengine' ),
107 'default' => gmdate( 'Y-m-d', strtotime( '- 1 month' ) ),
108 ],
109 'to' => [
110 'title' => __( 'To Date', 'storeengine' ),
111 'type' => 'string',
112 'description' => __( 'Range end date (Y-m-d).', 'storeengine' ),
113 'default' => gmdate( 'Y-m-d' ),
114 ],
115 'compare' => [
116 'title' => __( 'Compare days', 'storeengine' ),
117 'type' => 'integer',
118 'description' => __( 'Compare data with the preceding xx days.', 'storeengine' ),
119 'default' => 30,
120 ],
121 'currency' => [
122 'title' => __( 'Currency', 'storeengine' ),
123 'type' => 'string',
124 'description' => __( 'ISO 4217 currency to filter by. Defaults to store base currency.', 'storeengine' ),
125 'default' => '',
126 'sanitize_callback' => 'sanitize_text_field',
127 ],
128 ],
129 ],
130 ] );
131 }
132
133 public function get_permission_check() {
134 return Helper::check_rest_user_cap( 'manage_options' );
135 }
136
137 public function get_analytics( WP_REST_Request $request ) {
138 $product_id = absint( $request->get_param( 'id' ) );
139
140 if ( ! $product_id || 'storeengine_product' !== get_post_type( $product_id ) ) {
141 return new WP_Error( 'invalid_product', __( 'Invalid product.', 'storeengine' ), [ 'status' => 404 ] );
142 }
143
144 if ( ! rest_parse_date( $request->get_param( 'from' ) . ' 00:00:00' ) ) {
145 return new WP_Error( 'invalid_from_date', __( 'Invalid from date.', 'storeengine' ) );
146 }
147
148 if ( ! rest_parse_date( $request->get_param( 'to' ) . ' 00:00:00' ) ) {
149 return new WP_Error( 'invalid_to_date', __( 'Invalid to date.', 'storeengine' ) );
150 }
151
152 if ( strtotime( Helper::get_first_order_date( 'Y-m-d' ) ) > strtotime( $request->get_param( 'from' ) ) ) {
153 $request->set_param( 'from', Helper::get_first_order_date( 'Y-m-d' ) );
154 }
155
156 $from = $request->get_param( 'from' );
157 $to = $request->get_param( 'to' );
158 $compare = (int) $request->get_param( 'compare' );
159
160 $base_currency = strtoupper( Helper::get_settings( 'store_currency', 'USD' ) );
161 $currency = strtoupper( trim( $request->get_param( 'currency' ) ) ?: $base_currency );
162
163 $revenue = $this->get_revenue_stats( $product_id, $from, $to, $compare, $currency );
164 $units = $this->get_units_stats( $product_id, $from, $to, $compare, $currency );
165 $orders = $this->get_orders_stats( $product_id, $from, $to, $compare, $currency );
166 $aov = $this->get_aov_stats( $revenue, $orders, $currency );
167
168 return rest_ensure_response( [
169 'product' => $this->get_product_summary( $product_id ),
170 'currency' => $currency,
171 'currencies_in_period' => $this->get_currencies_in_period( $product_id, $from, $to ),
172 // Tile list — addons (e.g. license-management, cost-profit) may append
173 // more product-scoped tiles via the filter below.
174 'stats' => apply_filters(
175 'storeengine/analytics/product_stats',
176 [
177 [
178 'label' => __( 'Revenue', 'storeengine' ),
179 'icon' => 'money-receive',
180 'data' => $revenue,
181 ],
182 [
183 'label' => __( 'Units Sold', 'storeengine' ),
184 'icon' => 'bag',
185 'data' => $units,
186 ],
187 [
188 'label' => __( 'Orders', 'storeengine' ),
189 'icon' => 'invoice',
190 'data' => $orders,
191 ],
192 [
193 'label' => __( 'Avg. Order Value', 'storeengine' ),
194 'icon' => 'chart-alt',
195 'data' => $aov,
196 ],
197 ],
198 $product_id,
199 $from,
200 $to,
201 $compare,
202 $currency
203 ),
204 'growth' => $this->growth_report( $product_id, $from, $to, $currency ),
205 'price_breakdown' => $this->get_price_breakdown( $product_id, $from, $to, $currency ),
206 'top_customers' => $this->get_top_customers( $product_id, $from, $to, $currency ),
207 'recent_orders' => $this->get_recent_orders( $product_id, $currency ),
208 ] );
209 }
210
211 // ── Collection: all products overview ─────────────────────────────────────────
212
213 public function get_products_list( WP_REST_Request $request ) {
214 global $wpdb;
215
216 if ( ! rest_parse_date( $request->get_param( 'from' ) . ' 00:00:00' ) ) {
217 return new WP_Error( 'invalid_from_date', __( 'Invalid from date.', 'storeengine' ) );
218 }
219 if ( ! rest_parse_date( $request->get_param( 'to' ) . ' 00:00:00' ) ) {
220 return new WP_Error( 'invalid_to_date', __( 'Invalid to date.', 'storeengine' ) );
221 }
222
223 $from = $request->get_param( 'from' );
224 $to = $request->get_param( 'to' );
225 $search = trim( (string) $request->get_param( 'search' ) );
226 $page = max( 1, (int) $request->get_param( 'page' ) );
227 $per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ) );
228 $offset = ( $page - 1 ) * $per_page;
229
230 $base_currency = strtoupper( Helper::get_settings( 'store_currency', 'USD' ) );
231 $currency = strtoupper( trim( $request->get_param( 'currency' ) ) ?: $base_currency );
232 $statuses = self::PAID_STATUSES;
233
234 $like = '';
235 if ( '' !== $search ) {
236 $like = $wpdb->prepare( ' AND p.post_title LIKE %s', '%' . $wpdb->esc_like( $search ) . '%' );
237 }
238
239 // Total published products (respecting search) for pagination.
240 $count_sql = "SELECT COUNT(*) FROM {$wpdb->prefix}posts AS p
241 WHERE p.post_type = 'storeengine_product' AND p.post_status = 'publish'{$like}";
242 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $like is a pre-prepared LIKE fragment; only $wpdb->prefix (safe) is interpolated; aggregate report, not cacheable per request.
243 $total = (int) $wpdb->get_var( $count_sql );
244
245 // Every product (LEFT JOIN the period aggregate) so zero-sale products
246 // still appear, ordered by revenue then title.
247 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Values passed as %s/%d via prepare(); interpolated tokens are $wpdb->prefix, the fixed PAID_STATUSES constant and a pre-prepared LIKE fragment (no user input); aggregate report over custom StoreEngine tables, not cacheable per request.
248 $query = $wpdb->prepare(
249 "SELECT
250 p.ID AS product_id,
251 p.post_title AS name,
252 COALESCE( agg.revenue, 0 ) AS revenue,
253 COALESCE( agg.units, 0 ) AS units,
254 COALESCE( agg.orders, 0 ) AS orders
255 FROM {$wpdb->prefix}posts AS p
256 LEFT JOIN (
257 SELECT
258 pid.meta_value AS product_id,
259 SUM( CAST( lt.meta_value AS DECIMAL(18,2) ) ) AS revenue,
260 SUM( CAST( qty.meta_value AS UNSIGNED ) ) AS units,
261 COUNT( DISTINCT oi.order_id ) AS orders
262 FROM {$wpdb->prefix}storeengine_order_items AS oi
263 INNER JOIN {$wpdb->prefix}storeengine_order_item_meta AS pid
264 ON pid.order_item_id = oi.order_item_id AND pid.meta_key = '_product_id'
265 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS lt
266 ON lt.order_item_id = oi.order_item_id AND lt.meta_key = '_line_total'
267 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS qty
268 ON qty.order_item_id = oi.order_item_id AND qty.meta_key = '_quantity'
269 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
270 ON o.id = oi.order_id AND o.type = 'order' AND o.currency = %s
271 AND o.status IN ( {$statuses} )
272 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS dm
273 ON dm.order_id = o.id AND dm.meta_key = '_order_placed_date_gmt'
274 WHERE DATE( dm.meta_value ) BETWEEN %s AND %s
275 GROUP BY pid.meta_value
276 ) AS agg ON agg.product_id = p.ID
277 WHERE p.post_type = 'storeengine_product' AND p.post_status = 'publish'{$like}
278 ORDER BY revenue DESC, p.post_title ASC
279 LIMIT %d OFFSET %d",
280 $currency, $from, $to, $per_page, $offset
281 );
282
283 $rows = $wpdb->get_results( $query );
284 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
285 $products = [];
286
287 foreach ( (array) $rows as $row ) {
288 $pid = (int) $row->product_id;
289 $is_license = Formatting::string_to_bool( get_post_meta( $pid, '_storeengine_product_enable_license_creation', true ) );
290
291 $products[] = [
292 'product_id' => $pid,
293 'name' => $row->name,
294 'thumbnail' => get_the_post_thumbnail_url( $pid, 'thumbnail' ) ?: '',
295 'is_license' => $is_license,
296 'revenue' => (float) $row->revenue,
297 'units' => (int) $row->units,
298 'orders' => (int) $row->orders,
299 ];
300 }
301
302 return rest_ensure_response( [
303 'currency' => $currency,
304 'currencies_in_period' => $this->get_currencies_in_period_all( $from, $to ),
305 'products' => $products,
306 'total' => $total,
307 'total_pages' => (int) ceil( $total / $per_page ),
308 'page' => $page,
309 ] );
310 }
311
312 /**
313 * Distinct currencies with orders in the range across ALL products.
314 * Drives the currency filter on the overview list.
315 */
316 protected function get_currencies_in_period_all( string $from, string $to ): array {
317 global $wpdb;
318
319 $query = $wpdb->prepare(
320 "SELECT DISTINCT o.currency
321 FROM {$wpdb->prefix}storeengine_orders AS o
322 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS dm
323 ON dm.order_id = o.id AND dm.meta_key = '_order_placed_date_gmt'
324 WHERE o.type = 'order' AND o.currency IS NOT NULL AND o.currency <> ''
325 AND CAST( dm.meta_value AS DATE ) BETWEEN %s AND %s
326 ORDER BY o.currency ASC",
327 $from, $to
328 );
329
330 $key = $this->get_cache_key( 'storeengine_orders', $query, $from, $to );
331 $cached = wp_cache_get( $key, 'storeengine_orders-queries' );
332 if ( false !== $cached ) {
333 return $cached;
334 }
335
336 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
337 $rows = $wpdb->get_col( $query );
338 $base = strtoupper( Helper::get_settings( 'store_currency', 'USD' ) );
339 $currencies = array_map( 'strtoupper', $rows ?: [] );
340
341 if ( in_array( $base, $currencies, true ) ) {
342 $currencies = array_merge( [ $base ], array_diff( $currencies, [ $base ] ) );
343 }
344
345 wp_cache_set( $key, $currencies, 'storeengine_orders-queries' );
346
347 return $currencies;
348 }
349
350 // ── Product meta ────────────────────────────────────────────────────────────
351
352 protected function get_product_summary( int $product_id ): array {
353 // `_storeengine_product_enable_license_creation` is written by StoreEngine
354 // Pro's license-management addon. Reading it here is harmless when Pro is
355 // inactive (returns '' → is_license false) and lets the frontend decide
356 // whether to request the license analytics section.
357 $is_license = Formatting::string_to_bool( get_post_meta( $product_id, '_storeengine_product_enable_license_creation', true ) );
358
359 return [
360 'id' => $product_id,
361 'name' => get_the_title( $product_id ),
362 'thumbnail' => get_the_post_thumbnail_url( $product_id, 'thumbnail' ) ?: '',
363 'status' => get_post_status( $product_id ),
364 'is_license' => $is_license,
365 'permalink' => get_permalink( $product_id ) ?: '',
366 ];
367 }
368
369 // ── Currency helper (product-scoped) ─────────────────────────────────────────
370
371 protected function get_currencies_in_period( int $product_id, string $from, string $to ): array {
372 global $wpdb;
373
374 $query = $wpdb->prepare(
375 "SELECT DISTINCT o.currency
376 FROM {$wpdb->prefix}storeengine_order_items AS oi
377 INNER JOIN {$wpdb->prefix}storeengine_order_item_meta AS pid
378 ON pid.order_item_id = oi.order_item_id AND pid.meta_key = '_product_id'
379 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
380 ON o.id = oi.order_id AND o.type = 'order'
381 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS dm
382 ON dm.order_id = o.id AND dm.meta_key = '_order_placed_date_gmt'
383 WHERE pid.meta_value = %d
384 AND o.currency IS NOT NULL AND o.currency <> ''
385 AND CAST( dm.meta_value AS DATE ) BETWEEN %s AND %s
386 ORDER BY o.currency ASC",
387 $product_id, $from, $to
388 );
389
390 $key = $this->get_cache_key( 'storeengine_orders', $query, $product_id, $from, $to );
391 $cached = wp_cache_get( $key, 'storeengine_orders-queries' );
392
393 if ( false !== $cached ) {
394 return $cached;
395 }
396
397 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
398 $rows = $wpdb->get_col( $query );
399 $base = strtoupper( Helper::get_settings( 'store_currency', 'USD' ) );
400 $currencies = array_map( 'strtoupper', $rows ?: [] );
401
402 if ( in_array( $base, $currencies, true ) ) {
403 $currencies = array_merge( [ $base ], array_diff( $currencies, [ $base ] ) );
404 }
405
406 wp_cache_set( $key, $currencies, 'storeengine_orders-queries' );
407
408 return $currencies;
409 }
410
411 // ── Stat tiles ───────────────────────────────────────────────────────────────
412
413 /**
414 * A period-over-period aggregate for a single product-scoped column.
415 *
416 * $expr is a SQL aggregate expression evaluated over the joined order-item
417 * rows (e.g. SUM(_line_total) or COUNT(DISTINCT order_id)).
418 */
419 protected function period_stat( string $expr, int $product_id, string $from, string $to, int $compare, string $currency ): array {
420 global $wpdb;
421
422 $statuses = self::PAID_STATUSES;
423
424 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Values passed as %s/%d via prepare(); interpolated tokens are $wpdb->prefix, the fixed PAID_STATUSES constant and the internal $expr aggregate literal (no user input); custom StoreEngine tables. Result is cached below.
425 $query = $wpdb->prepare(
426 "SELECT
427 curr.val AS current_val,
428 prev.val AS previous_val,
429 CASE
430 WHEN prev.val = 0 AND curr.val > 0 THEN 100
431 WHEN prev.val = 0 THEN 0
432 ELSE ROUND( ( ( curr.val - prev.val ) / prev.val ) * 100, 2 )
433 END AS rate
434 FROM (
435 SELECT COALESCE( {$expr}, 0 ) AS val
436 FROM {$wpdb->prefix}storeengine_order_items AS oi
437 INNER JOIN {$wpdb->prefix}storeengine_order_item_meta AS pid
438 ON pid.order_item_id = oi.order_item_id AND pid.meta_key = '_product_id'
439 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS lt
440 ON lt.order_item_id = oi.order_item_id AND lt.meta_key = '_line_total'
441 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS qty
442 ON qty.order_item_id = oi.order_item_id AND qty.meta_key = '_quantity'
443 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
444 ON o.id = oi.order_id AND o.type = 'order' AND o.currency = %s
445 AND o.status IN ( {$statuses} )
446 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS dm
447 ON dm.order_id = o.id AND dm.meta_key = '_order_placed_date_gmt'
448 WHERE pid.meta_value = %d
449 AND DATE( dm.meta_value ) BETWEEN %s AND %s
450 ) AS curr
451 CROSS JOIN (
452 SELECT COALESCE( {$expr}, 0 ) AS val
453 FROM {$wpdb->prefix}storeengine_order_items AS oi
454 INNER JOIN {$wpdb->prefix}storeengine_order_item_meta AS pid
455 ON pid.order_item_id = oi.order_item_id AND pid.meta_key = '_product_id'
456 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS lt
457 ON lt.order_item_id = oi.order_item_id AND lt.meta_key = '_line_total'
458 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS qty
459 ON qty.order_item_id = oi.order_item_id AND qty.meta_key = '_quantity'
460 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
461 ON o.id = oi.order_id AND o.type = 'order' AND o.currency = %s
462 AND o.status IN ( {$statuses} )
463 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS dm
464 ON dm.order_id = o.id AND dm.meta_key = '_order_placed_date_gmt'
465 WHERE pid.meta_value = %d
466 AND DATE( dm.meta_value ) BETWEEN DATE_SUB( %s, INTERVAL %d DAY ) AND DATE_SUB( %s, INTERVAL 1 DAY )
467 ) AS prev",
468 $currency, $product_id, $from, $to,
469 $currency, $product_id, $from, $compare, $from
470 );
471
472 $key = $this->get_cache_key( 'storeengine_orders', $query, $product_id, $from, $to, $compare, $currency );
473 $data = wp_cache_get( $key, 'storeengine_orders-queries' );
474
475 if ( false === $data ) {
476 $result = $wpdb->get_row( $query );
477 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
478
479 $data = [
480 'current' => $result ? (float) $result->current_val : 0.0,
481 'rate' => ( $result && null !== $result->rate ) ? (float) $result->rate : 0.0,
482 ];
483
484 wp_cache_set( $key, $data, 'storeengine_orders-queries' );
485 }
486
487 return $data;
488 }
489
490 protected function get_revenue_stats( int $product_id, string $from, string $to, int $compare, string $currency ): array {
491 $r = $this->period_stat( 'SUM( CAST( lt.meta_value AS DECIMAL(18,2) ) )', $product_id, $from, $to, $compare, $currency );
492
493 return [
494 'count' => (float) $r['current'],
495 'rate' => $r['rate'],
496 'format' => true,
497 'currency' => $currency,
498 ];
499 }
500
501 protected function get_units_stats( int $product_id, string $from, string $to, int $compare, string $currency ): array {
502 $r = $this->period_stat( 'SUM( CAST( qty.meta_value AS UNSIGNED ) )', $product_id, $from, $to, $compare, $currency );
503
504 return [
505 'count' => (int) $r['current'],
506 'rate' => $r['rate'],
507 'currency' => $currency,
508 ];
509 }
510
511 protected function get_orders_stats( int $product_id, string $from, string $to, int $compare, string $currency ): array {
512 $r = $this->period_stat( 'COUNT( DISTINCT oi.order_id )', $product_id, $from, $to, $compare, $currency );
513
514 return [
515 'count' => (int) $r['current'],
516 'rate' => $r['rate'],
517 'currency' => $currency,
518 ];
519 }
520
521 protected function get_aov_stats( array $revenue, array $orders, string $currency ): array {
522 $order_count = (int) $orders['count'];
523 $value = $order_count > 0 ? round( (float) $revenue['count'] / $order_count, 2 ) : 0.0;
524
525 return [
526 'count' => $value,
527 'rate' => null,
528 'format' => true,
529 'currency' => $currency,
530 ];
531 }
532
533 // ── Growth chart ──────────────────────────────────────────────────────────────
534
535 protected function growth_report( int $product_id, string $from, string $to, string $currency ): array {
536 global $wpdb;
537
538 $statuses = self::PAID_STATUSES;
539
540 $chart_data = [
541 'datasets' => [
542 [
543 'label' => __( 'Revenue', 'storeengine' ),
544 'format' => true,
545 'data' => [],
546 'borderColor' => '#006BFF',
547 'backgroundColor' => '#006BFF',
548 ],
549 [
550 'label' => __( 'Units', 'storeengine' ),
551 'data' => [],
552 'borderColor' => '#16A34A',
553 'backgroundColor' => '#16A34A',
554 ],
555 ],
556 ];
557
558 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Interpolated token is the fixed PAID_STATUSES constant (and $wpdb->prefix); all values passed as %s/%d via prepare(). No user input.
559 $query = $wpdb->prepare(
560 "SELECT
561 DATE( dm.meta_value ) AS date,
562 COALESCE( SUM( CAST( lt.meta_value AS DECIMAL(18,2) ) ), 0 ) AS revenue,
563 COALESCE( SUM( CAST( qty.meta_value AS UNSIGNED ) ), 0 ) AS units
564 FROM {$wpdb->prefix}storeengine_order_items AS oi
565 INNER JOIN {$wpdb->prefix}storeengine_order_item_meta AS pid
566 ON pid.order_item_id = oi.order_item_id AND pid.meta_key = '_product_id'
567 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS lt
568 ON lt.order_item_id = oi.order_item_id AND lt.meta_key = '_line_total'
569 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS qty
570 ON qty.order_item_id = oi.order_item_id AND qty.meta_key = '_quantity'
571 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
572 ON o.id = oi.order_id AND o.type = 'order' AND o.currency = %s
573 AND o.status IN ( {$statuses} )
574 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS dm
575 ON dm.order_id = o.id AND dm.meta_key = '_order_placed_date_gmt'
576 WHERE pid.meta_value = %d
577 AND dm.meta_value <> ''
578 AND DATE( dm.meta_value ) BETWEEN %s AND %s
579 GROUP BY DATE( dm.meta_value )
580 ORDER BY date ASC",
581 $currency, $product_id, $from, $to
582 );
583 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
584
585 $key = $this->get_cache_key( 'storeengine_orders', $query, $product_id, $from, $to, $currency );
586 $data = wp_cache_get( $key, 'storeengine_orders-queries' );
587
588 if ( false === $data ) {
589 $results = $wpdb->get_results( $query, OBJECT_K ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
590
591 $start = new DateTime( $from );
592 $end = new DateTime( $to );
593 $end->modify( '+1 day' );
594
595 $data = [ 'labels' => [], 'revenue' => [], 'units' => [] ];
596
597 for ( $date = $start; $date < $end; $date->modify( '+1 day' ) ) {
598 $day = $date->format( 'Y-m-d' );
599 $data['labels'][] = $date->format( 'M j, Y' );
600
601 if ( isset( $results[ $day ] ) ) {
602 $data['revenue'][] = abs( (float) $results[ $day ]->revenue );
603 $data['units'][] = (int) $results[ $day ]->units;
604 } else {
605 $data['revenue'][] = 0;
606 $data['units'][] = 0;
607 }
608 }
609
610 $data['totals'] = [
611 'revenue' => array_sum( $data['revenue'] ),
612 'units' => array_sum( $data['units'] ),
613 ];
614
615 wp_cache_set( $key, $data, 'storeengine_orders-queries' );
616 }
617
618 $chart_data['labels'] = $data['labels'];
619 $chart_data['totals'] = $data['totals'];
620 $chart_data['currency'] = $currency;
621 $chart_data['datasets'][0]['data'] = $data['revenue'];
622 $chart_data['datasets'][1]['data'] = $data['units'];
623
624 return $chart_data;
625 }
626
627 // ── Price / variation breakdown ───────────────────────────────────────────────
628
629 protected function get_price_breakdown( int $product_id, string $from, string $to, string $currency ): array {
630 global $wpdb;
631
632 $statuses = self::PAID_STATUSES;
633
634 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Interpolated token is the fixed PAID_STATUSES constant (and $wpdb->prefix); all values passed as %s/%d via prepare(). No user input.
635 $query = $wpdb->prepare(
636 "SELECT
637 price_id.meta_value AS price_id,
638 COALESCE( SUM( CAST( lt.meta_value AS DECIMAL(18,2) ) ), 0 ) AS revenue,
639 COALESCE( SUM( CAST( qty.meta_value AS UNSIGNED ) ), 0 ) AS units,
640 COUNT( DISTINCT oi.order_id ) AS orders
641 FROM {$wpdb->prefix}storeengine_order_items AS oi
642 INNER JOIN {$wpdb->prefix}storeengine_order_item_meta AS pid
643 ON pid.order_item_id = oi.order_item_id AND pid.meta_key = '_product_id'
644 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS price_id
645 ON price_id.order_item_id = oi.order_item_id AND price_id.meta_key = '_price_id'
646 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS lt
647 ON lt.order_item_id = oi.order_item_id AND lt.meta_key = '_line_total'
648 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS qty
649 ON qty.order_item_id = oi.order_item_id AND qty.meta_key = '_quantity'
650 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
651 ON o.id = oi.order_id AND o.type = 'order' AND o.currency = %s
652 AND o.status IN ( {$statuses} )
653 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS dm
654 ON dm.order_id = o.id AND dm.meta_key = '_order_placed_date_gmt'
655 WHERE pid.meta_value = %d
656 AND DATE( dm.meta_value ) BETWEEN %s AND %s
657 GROUP BY price_id.meta_value
658 ORDER BY revenue DESC",
659 $currency, $product_id, $from, $to
660 );
661 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
662
663 $key = $this->get_cache_key( 'storeengine_orders', $query, $product_id, $from, $to, $currency );
664 $results = wp_cache_get( $key, 'storeengine_orders-queries' );
665
666 if ( false === $results ) {
667 $rows = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
668 $results = [];
669
670 foreach ( (array) $rows as $row ) {
671 $price_id = (int) $row->price_id;
672 $results[] = [
673 'price_id' => $price_id,
674 'name' => $price_id ? ( get_the_title( $price_id ) ?: sprintf( /* translators: %d: price id */ __( 'Price #%d', 'storeengine' ), $price_id ) ) : __( 'Default', 'storeengine' ),
675 'revenue' => (float) $row->revenue,
676 'units' => (int) $row->units,
677 'orders' => (int) $row->orders,
678 ];
679 }
680
681 wp_cache_set( $key, $results, 'storeengine_orders-queries' );
682 }
683
684 return $results;
685 }
686
687 // ── Top customers for this product ────────────────────────────────────────────
688
689 protected function get_top_customers( int $product_id, string $from, string $to, string $currency ): array {
690 global $wpdb;
691
692 $statuses = self::PAID_STATUSES;
693
694 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Interpolated token is the fixed PAID_STATUSES constant (and $wpdb->prefix); all values passed as %s/%d via prepare(). No user input.
695 $query = $wpdb->prepare(
696 "SELECT
697 o.customer_id AS customer_id,
698 COALESCE( SUM( CAST( lt.meta_value AS DECIMAL(18,2) ) ), 0 ) AS revenue,
699 COUNT( DISTINCT oi.order_id ) AS orders
700 FROM {$wpdb->prefix}storeengine_order_items AS oi
701 INNER JOIN {$wpdb->prefix}storeengine_order_item_meta AS pid
702 ON pid.order_item_id = oi.order_item_id AND pid.meta_key = '_product_id'
703 LEFT JOIN {$wpdb->prefix}storeengine_order_item_meta AS lt
704 ON lt.order_item_id = oi.order_item_id AND lt.meta_key = '_line_total'
705 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
706 ON o.id = oi.order_id AND o.type = 'order' AND o.currency = %s
707 AND o.status IN ( {$statuses} )
708 INNER JOIN {$wpdb->prefix}storeengine_orders_meta AS dm
709 ON dm.order_id = o.id AND dm.meta_key = '_order_placed_date_gmt'
710 WHERE pid.meta_value = %d
711 AND o.customer_id IS NOT NULL AND o.customer_id > 0
712 AND DATE( dm.meta_value ) BETWEEN %s AND %s
713 GROUP BY o.customer_id
714 ORDER BY revenue DESC
715 LIMIT 5",
716 $currency, $product_id, $from, $to
717 );
718 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
719
720 $key = $this->get_cache_key( 'storeengine_orders', $query, $product_id, $from, $to, $currency );
721 $results = wp_cache_get( $key, 'storeengine_orders-queries' );
722
723 if ( false === $results ) {
724 $rows = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
725 $results = [];
726
727 foreach ( (array) $rows as $row ) {
728 $user = get_userdata( (int) $row->customer_id );
729
730 $results[] = [
731 'customer_id' => (int) $row->customer_id,
732 'name' => $user ? $user->display_name : __( 'Guest', 'storeengine' ),
733 'email' => $user ? $user->user_email : '',
734 'avatar' => get_avatar_url( (int) $row->customer_id, [ 'size' => 40 ] ),
735 'revenue' => (float) $row->revenue,
736 'orders' => (int) $row->orders,
737 ];
738 }
739
740 wp_cache_set( $key, $results, 'storeengine_orders-queries' );
741 }
742
743 return $results;
744 }
745
746 // ── Recent orders containing this product ─────────────────────────────────────
747
748 protected function get_recent_orders( int $product_id, string $currency ): array {
749 global $wpdb;
750
751 $query = $wpdb->prepare(
752 "SELECT DISTINCT
753 o.id AS order_id,
754 o.status AS status,
755 o.total_amount AS total,
756 o.currency AS currency,
757 o.customer_id AS customer_id,
758 dm.meta_value AS placed_at
759 FROM {$wpdb->prefix}storeengine_order_items AS oi
760 INNER JOIN {$wpdb->prefix}storeengine_order_item_meta AS pid
761 ON pid.order_item_id = oi.order_item_id AND pid.meta_key = '_product_id'
762 INNER JOIN {$wpdb->prefix}storeengine_orders AS o
763 ON o.id = oi.order_id AND o.type = 'order' AND o.currency = %s
764 LEFT JOIN {$wpdb->prefix}storeengine_orders_meta AS dm
765 ON dm.order_id = o.id AND dm.meta_key = '_order_placed_date_gmt'
766 WHERE pid.meta_value = %d
767 ORDER BY o.id DESC
768 LIMIT 6",
769 $currency, $product_id
770 );
771
772 $key = $this->get_cache_key( 'storeengine_orders', $query, $product_id, $currency );
773 $results = wp_cache_get( $key, 'storeengine_orders-queries' );
774
775 if ( false === $results ) {
776 $rows = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
777 $results = [];
778
779 foreach ( (array) $rows as $row ) {
780 $user = $row->customer_id ? get_userdata( (int) $row->customer_id ) : null;
781
782 $results[] = [
783 'order_id' => (int) $row->order_id,
784 'customer' => $user ? $user->display_name : __( 'Guest', 'storeengine' ),
785 'status' => $row->status,
786 'amount' => (float) $row->total,
787 'currency' => $row->currency ?: $currency,
788 'date' => $row->placed_at ? gmdate( 'M j, Y', strtotime( $row->placed_at ) ) : '',
789 ];
790 }
791
792 wp_cache_set( $key, $results, 'storeengine_orders-queries' );
793 }
794
795 return $results;
796 }
797
798 // ── Helpers ───────────────────────────────────────────────────────────────────
799
800 protected function get_cache_key( $group, $sql, ...$args ): string {
801 return get_class( $this ) . ':' . Caching::get_query_cache_key( $group, $sql, ...$args );
802 }
803 }
804