PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.1
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / my-wordpress / integrations / woocommerce-customers.php

woocommerce-customers.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.1, at includes/my-wordpress/integrations/woocommerce-customers.php

1,385 lines 43.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — My WordPress: WooCommerce **Customers**.
4 *
5 * WooCommerce ships two views of the people who buy from a store, and
6 * neither is a place you can work from: `users.php` is a role list that
7 * knows nothing about money, and Analytics → Customers is a report you
8 * read and then leave. Neither one opens next to the order it explains.
9 *
10 * This file adds a **Customers** section to the Woo folder in the site
11 * window. It renders through the existing `user` entity kind — avatar
12 * tiles, the dossier preview, the drag-out seam, the footprint route —
13 * so a customer is a first-class object on the desktop: drag one onto
14 * the wallpaper, open their orders beside their profile, tie the
15 * windows together with the relations layer.
16 *
17 * What makes it a *customer* list rather than a user list is the
18 * `openstation_woo_customer` payload on every row: lifetime spend,
19 * order count, average order value, first and last order, days since
20 * the last one, and the band that summarises all of it.
21 *
22 * ## Bands
23 *
24 * Ordered so the two bands a merchant can *act on* come first:
25 *
26 * 1. **VIP** — spend at or above the VIP threshold (three times
27 * the store's average order value by default). Who
28 * to look after.
29 * 2. **Lapsed** — has ordered, but not within the lapse window (180
30 * days by default). Who to win back.
31 * 3. **Repeat** — two or more orders, still active.
32 * 4. **New** — exactly one order.
33 * 5. **No orders** — registered, never bought.
34 *
35 * ## Who appears
36 *
37 * Every user who has placed a paid order, plus every user holding the
38 * `customer` role. Guests (orders with no account) have no user to
39 * render — their revenue is reported as a single line on the Woo
40 * folder's Store panel instead of being silently dropped.
41 *
42 * ## Cost
43 *
44 * One grouped query over the order store gives every customer's
45 * aggregate at once — order count, spend, first and last order date —
46 * cached for five minutes and flushed whenever an order changes. The
47 * band ordering, the per-row facts and the folder counts all read that
48 * one map, so a page of customers costs no per-row order queries at
49 * all. Stores past `OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS` users skip
50 * the band ordering and fall back to newest-first, exactly like the
51 * catalogue does past its own cap.
52 *
53 * REST surface (read-only, gated on `list_users` + order access):
54 *
55 * GET desktop-mode/v1/woocommerce/customers
56 * GET desktop-mode/v1/woocommerce/customers/<id>
57 * GET desktop-mode/v1/woocommerce/summary/customer/<id>
58 *
59 * @package OpenStation
60 */
61
62 defined( 'ABSPATH' ) || exit;
63
64 /**
65 * Above this many candidate customers the section stops band-ordering
66 * and falls back to newest-registered-first. The ordering plan holds
67 * one id per customer in a transient; the cap is what keeps that
68 * option from growing without bound on a store with a large user base.
69 */
70 const OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS = 5000;
71
72 /**
73 * Days without an order after which a customer counts as lapsed.
74 * Filterable through `openstation_my_wordpress_woo_customer_lapse_days`.
75 */
76 const OPENSTATION_WOO_CUSTOMER_LAPSE_DAYS = 180;
77
78 /*
79 -------------------------------------------------------------------
80 * Aggregates
81 * ----------------------------------------------------------------
82 */
83
84 /**
85 * Order statuses that count as money actually taken.
86 *
87 * Mirrors `wc_get_customer_total_spent()`, so the lifetime spend on a
88 * tile agrees with the number WooCommerce itself would report.
89 *
90 * @return string[] Statuses WITH the `wc-` prefix.
91 */
92 function openstation_my_wordpress_woo_paid_statuses() {
93 $statuses = function_exists( 'wc_get_is_paid_statuses' )
94 ? (array) wc_get_is_paid_statuses()
95 : array( 'processing', 'completed' );
96
97 return array_values(
98 array_map(
99 static function ( $status ) {
100 return 0 === strpos( (string) $status, 'wc-' ) ? (string) $status : 'wc-' . $status;
101 },
102 $statuses
103 )
104 );
105 }
106
107 /**
108 * Whether the store keeps orders in WooCommerce's own tables (HPOS)
109 * rather than in `wp_posts`.
110 *
111 * @return bool
112 */
113 function openstation_my_wordpress_woo_hpos_enabled() {
114 return class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' )
115 && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
116 }
117
118 /**
119 * Every customer's order aggregate, in one query.
120 *
121 * The whole section — band definitions, band ordering, per-row facts,
122 * folder counts — reads this one map. Doing it per user would be one
123 * or two queries per tile, and the band ordering would need the whole
124 * user base walked before the first tile could paint.
125 *
126 * Guest orders (no account) are aggregated under the `0` key. They can
127 * never appear as a tile — there is no user to render — but their
128 * revenue is real and the Store panel reports it rather than letting
129 * it vanish.
130 *
131 * @return array<int, array{orders:int, spend:float, first:string, last:string}>
132 * Keyed by user id; `0` holds the guest aggregate.
133 */
134 function openstation_my_wordpress_woo_customer_spend_map() {
135 static $memo = null;
136 if ( null !== $memo ) {
137 return $memo;
138 }
139
140 $cached = get_transient( 'desktop_mode_woo_customer_spend' );
141 if ( is_array( $cached ) ) {
142 $memo = $cached;
143 return $memo;
144 }
145
146 global $wpdb;
147
148 $statuses = openstation_my_wordpress_woo_paid_statuses();
149 $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) );
150
151 if ( openstation_my_wordpress_woo_hpos_enabled() ) {
152 $table = $wpdb->prefix . 'wc_orders';
153 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name and the placeholder list are structural; every value is prepared.
154 $sql = $wpdb->prepare(
155 "SELECT customer_id AS uid,
156 COUNT(*) AS orders,
157 SUM(total_amount) AS spend,
158 MIN(date_created_gmt) AS first_order,
159 MAX(date_created_gmt) AS last_order
160 FROM {$table}
161 WHERE type = 'shop_order' AND status IN ( {$placeholders} )
162 GROUP BY customer_id",
163 $statuses
164 );
165 } else {
166 // Legacy storage: `_customer_user` is the account id (0 for a
167 // guest) and `_order_total` the gross. `+0` casts the meta
168 // strings so SUM/comparison behave numerically.
169 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- ditto.
170 $sql = $wpdb->prepare(
171 "SELECT cu.meta_value + 0 AS uid,
172 COUNT(*) AS orders,
173 SUM( tot.meta_value + 0 ) AS spend,
174 MIN(p.post_date_gmt) AS first_order,
175 MAX(p.post_date_gmt) AS last_order
176 FROM {$wpdb->posts} p
177 INNER JOIN {$wpdb->postmeta} cu ON cu.post_id = p.ID AND cu.meta_key = '_customer_user'
178 LEFT JOIN {$wpdb->postmeta} tot ON tot.post_id = p.ID AND tot.meta_key = '_order_total'
179 WHERE p.post_type = 'shop_order' AND p.post_status IN ( {$placeholders} )
180 GROUP BY cu.meta_value",
181 $statuses
182 );
183 }
184
185 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- one grouped aggregate with no core API equivalent; $sql came out of $wpdb->prepare() above, and the result is cached in the transient below.
186 $rows = $wpdb->get_results( $sql );
187
188 $map = array();
189 foreach ( (array) $rows as $row ) {
190 $uid = (int) $row->uid;
191 if ( $uid < 0 ) {
192 continue;
193 }
194 $map[ $uid ] = array(
195 'orders' => (int) $row->orders,
196 'spend' => (float) $row->spend,
197 'first' => (string) $row->first_order,
198 'last' => (string) $row->last_order,
199 );
200 }
201
202 /**
203 * Filter the per-customer order aggregate the Customers section
204 * is built from.
205 *
206 * Keyed by user id (`0` is the guest aggregate); each value is
207 * `array( 'orders' => int, 'spend' => float, 'first' => gmt
208 * datetime, 'last' => gmt datetime )`. A store that keeps order
209 * money somewhere else — a subscriptions plugin, a marketplace
210 * split — can rewrite the whole map here and every band, tile and
211 * panel follows.
212 *
213 * **Status: Experimental**
214 *
215 * @param array $map Aggregate keyed by user id.
216 */
217 $map = (array) apply_filters( 'openstation_my_wordpress_woo_customer_spend_map', $map );
218
219 set_transient( 'desktop_mode_woo_customer_spend', $map, 5 * MINUTE_IN_SECONDS );
220 $memo = $map;
221
222 return $memo;
223 }
224
225 /**
226 * The store's average order value across every paid order.
227 *
228 * Includes guests: a threshold derived only from account holders would
229 * sit wherever the checkout-registration rate happened to put it.
230 *
231 * @return float
232 */
233 function openstation_my_wordpress_woo_store_aov() {
234 $orders = 0;
235 $spend = 0.0;
236 foreach ( openstation_my_wordpress_woo_customer_spend_map() as $stats ) {
237 $orders += (int) $stats['orders'];
238 $spend += (float) $stats['spend'];
239 }
240
241 return $orders > 0 ? $spend / $orders : 0.0;
242 }
243
244 /**
245 * Lifetime spend at or above which a customer is a VIP.
246 *
247 * Derived rather than fixed: "spent over 500" means nothing without
248 * knowing whether the store sells postcards or pianos. Three average
249 * orders is the default — enough to be deliberate repeat custom on any
250 * store, cheap to compute, and one filter away from a merchant's own
251 * number.
252 *
253 * @return float Threshold, or `0.0` when the store has no paid orders
254 * yet (in which case nothing can qualify).
255 */
256 function openstation_my_wordpress_woo_vip_threshold() {
257 $aov = openstation_my_wordpress_woo_store_aov();
258
259 /**
260 * Filter the lifetime-spend threshold for the VIP band.
261 *
262 * **Status: Experimental**
263 *
264 * @param float $threshold Threshold in store currency.
265 * @param float $aov The store's average order value.
266 */
267 return (float) apply_filters(
268 'openstation_my_wordpress_woo_vip_threshold',
269 $aov * 3,
270 $aov
271 );
272 }
273
274 /**
275 * Days without an order after which a customer counts as lapsed.
276 *
277 * @return int
278 */
279 function openstation_my_wordpress_woo_customer_lapse_days() {
280 /**
281 * Filter the lapse window for the Customers section.
282 *
283 * **Status: Experimental**
284 *
285 * @param int $days Days since the last order.
286 */
287 $days = (int) apply_filters(
288 'openstation_my_wordpress_woo_customer_lapse_days',
289 OPENSTATION_WOO_CUSTOMER_LAPSE_DAYS
290 );
291
292 return max( 1, $days );
293 }
294
295 /*
296 -------------------------------------------------------------------
297 * Bands
298 * ----------------------------------------------------------------
299 */
300
301 /**
302 * Band definitions for the Customers section, in display order.
303 *
304 * VIP and Lapsed lead because they are the two the merchant can do
305 * something about — one to look after, one to win back. Everything
306 * else is context, and "No orders" trails because a registered
307 * account that never bought is the least urgent row on the screen.
308 *
309 * @return array[] Each entry: `id`, `label`, `order`, optional `tone`.
310 */
311 function openstation_my_wordpress_woo_customer_band_defs() {
312 $bands = array(
313 array(
314 'id' => 'vip',
315 'label' => __( 'VIP', 'desktop-mode' ),
316 'order' => 10,
317 'tone' => 'success',
318 ),
319 array(
320 'id' => 'lapsed',
321 'label' => __( 'Lapsed', 'desktop-mode' ),
322 'order' => 20,
323 'tone' => 'warn',
324 ),
325 array(
326 'id' => 'repeat',
327 'label' => __( 'Repeat', 'desktop-mode' ),
328 'order' => 30,
329 ),
330 array(
331 'id' => 'new',
332 'label' => __( 'New', 'desktop-mode' ),
333 'order' => 40,
334 ),
335 array(
336 'id' => 'none',
337 'label' => __( 'No orders yet', 'desktop-mode' ),
338 'order' => 50,
339 ),
340 );
341
342 /**
343 * Filter the band definitions for the Customers section.
344 *
345 * Changing a band's membership rule means filtering
346 * `openstation_my_wordpress_woo_customer_band` as well — this
347 * filter only decides what the bands are called and in which
348 * order they render.
349 *
350 * **Status: Experimental**
351 *
352 * @param array[] $bands Band descriptors.
353 */
354 return (array) apply_filters( 'openstation_my_wordpress_woo_customer_bands', $bands );
355 }
356
357 /**
358 * Which band a customer's aggregate puts them in.
359 *
360 * @param array $stats Aggregate row: `orders`, `spend`, `last`.
361 * @return string Band id.
362 */
363 function openstation_my_wordpress_woo_customer_band_id( $stats ) {
364 $orders = (int) ( $stats['orders'] ?? 0 );
365 $spend = (float) ( $stats['spend'] ?? 0 );
366 $last = (string) ( $stats['last'] ?? '' );
367
368 $band = 'none';
369 if ( $orders > 0 ) {
370 $threshold = openstation_my_wordpress_woo_vip_threshold();
371 $lapsed = openstation_my_wordpress_woo_customer_days_since( $last );
372
373 if ( $threshold > 0 && $spend >= $threshold ) {
374 // VIP outranks lapsed on purpose: a big spender who has
375 // gone quiet is still the row you want at the top of the
376 // screen, and the days-since line in the pane says the
377 // rest.
378 $band = 'vip';
379 } elseif ( null !== $lapsed && $lapsed > openstation_my_wordpress_woo_customer_lapse_days() ) {
380 $band = 'lapsed';
381 } elseif ( $orders > 1 ) {
382 $band = 'repeat';
383 } else {
384 $band = 'new';
385 }
386 }
387
388 /**
389 * Filter the band a customer lands in.
390 *
391 * **Status: Experimental**
392 *
393 * @param string $band Band id.
394 * @param array $stats The customer's order aggregate.
395 */
396 return (string) apply_filters( 'openstation_my_wordpress_woo_customer_band', $band, $stats );
397 }
398
399 /**
400 * Whole days between a GMT datetime and now, or `null` when the input
401 * isn't a usable date.
402 *
403 * @param string $gmt_datetime `Y-m-d H:i:s` in GMT.
404 * @return int|null
405 */
406 function openstation_my_wordpress_woo_customer_days_since( $gmt_datetime ) {
407 if ( '' === (string) $gmt_datetime ) {
408 return null;
409 }
410 $stamp = strtotime( $gmt_datetime . ' GMT' );
411 if ( ! $stamp ) {
412 return null;
413 }
414
415 return (int) floor( ( time() - $stamp ) / DAY_IN_SECONDS );
416 }
417
418 /**
419 * Band definitions with an exact row count each, for the client.
420 *
421 * @return array[]
422 */
423 function openstation_my_wordpress_woo_customer_bands_with_counts() {
424 $plan = openstation_my_wordpress_woo_customer_plan();
425 $counts = (array) ( $plan['counts'] ?? array() );
426
427 $bands = array();
428 foreach ( openstation_my_wordpress_woo_customer_band_defs() as $band ) {
429 $band['count'] = (int) ( $counts[ $band['id'] ] ?? 0 );
430 $bands[] = $band;
431 }
432
433 return $bands;
434 }
435
436 /*
437 -------------------------------------------------------------------
438 * The ordering plan
439 * ----------------------------------------------------------------
440 */
441
442 /**
443 * Candidate customer ids — everyone who has paid for something, plus
444 * everyone holding the `customer` role.
445 *
446 * The union matters in both directions: a shop manager who buys from
447 * their own store is a customer, and a checkout-registered account
448 * that hasn't ordered yet is a customer the merchant would want to
449 * see. Neither query alone finds both.
450 *
451 * @return int[] User ids, unordered.
452 */
453 function openstation_my_wordpress_woo_customer_candidate_ids() {
454 $ids = array();
455 foreach ( openstation_my_wordpress_woo_customer_spend_map() as $user_id => $stats ) {
456 $user_id = (int) $user_id;
457 if ( $user_id > 0 ) {
458 $ids[ $user_id ] = true;
459 }
460 }
461
462 $role_users = get_users(
463 array(
464 'role' => 'customer',
465 'fields' => 'ID',
466 'number' => OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS + 1,
467 'orderby' => 'registered',
468 'order' => 'DESC',
469 )
470 );
471 foreach ( (array) $role_users as $user_id ) {
472 $ids[ (int) $user_id ] = true;
473 }
474
475 /**
476 * Filter the set of users the Customers section considers.
477 *
478 * **Status: Experimental**
479 *
480 * @param int[] $ids Candidate user ids.
481 */
482 return array_values(
483 array_map( 'intval', array_keys( (array) apply_filters( 'openstation_my_wordpress_woo_customer_ids', $ids ) ) )
484 );
485 }
486
487 /**
488 * The band-ordered customer id list plus an exact count per band.
489 *
490 * Same contract as the catalogue's plan, and for the same reason:
491 * bands only stop reshuffling if rows *arrive* in band order. A band
492 * that fills late expands above whatever the user is already reading.
493 *
494 * Cached for five minutes and flushed on any order change.
495 *
496 * @return array{ids:int[], counts:array<string,int>, capped:bool, customers:int}
497 */
498 function openstation_my_wordpress_woo_customer_plan() {
499 static $memo = null;
500 if ( null !== $memo ) {
501 return $memo;
502 }
503
504 $cached = get_transient( 'desktop_mode_woo_customer_plan' );
505 if ( is_array( $cached ) && isset( $cached['ids'], $cached['counts'] ) ) {
506 $memo = $cached;
507 return $memo;
508 }
509
510 $candidates = openstation_my_wordpress_woo_customer_candidate_ids();
511 $total = count( $candidates );
512
513 if ( $total > OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS ) {
514 $memo = array(
515 'ids' => array(),
516 'counts' => array(),
517 'capped' => true,
518 'customers' => $total,
519 );
520 set_transient( 'desktop_mode_woo_customer_plan', $memo, 5 * MINUTE_IN_SECONDS );
521 return $memo;
522 }
523
524 $map = openstation_my_wordpress_woo_customer_spend_map();
525 $buckets = array();
526 $counts = array();
527 foreach ( openstation_my_wordpress_woo_customer_band_defs() as $band ) {
528 $buckets[ $band['id'] ] = array();
529 $counts[ $band['id'] ] = 0;
530 }
531
532 foreach ( $candidates as $user_id ) {
533 $stats = $map[ $user_id ] ?? array(
534 'orders' => 0,
535 'spend' => 0.0,
536 'first' => '',
537 'last' => '',
538 );
539 $band = openstation_my_wordpress_woo_customer_band_id( $stats );
540 if ( ! isset( $buckets[ $band ] ) ) {
541 // A filter invented a band the definitions don't declare.
542 // Park it under "no orders" rather than dropping the row:
543 // a customer missing from the list is a worse outcome than
544 // one in an unexpected group.
545 $band = 'none';
546 if ( ! isset( $buckets[ $band ] ) ) {
547 continue;
548 }
549 }
550 // Highest spend first inside every band — the ordering the
551 // merchant would apply by hand.
552 $buckets[ $band ][] = array(
553 'id' => $user_id,
554 'spend' => (float) $stats['spend'],
555 );
556 ++$counts[ $band ];
557 }
558
559 $ordered = array();
560 foreach ( $buckets as $rows ) {
561 usort(
562 $rows,
563 static function ( $a, $b ) {
564 if ( $a['spend'] === $b['spend'] ) {
565 return $a['id'] <=> $b['id'];
566 }
567 return $b['spend'] <=> $a['spend'];
568 }
569 );
570 foreach ( $rows as $row ) {
571 $ordered[] = (int) $row['id'];
572 }
573 }
574
575 $memo = array(
576 'ids' => $ordered,
577 'counts' => $counts,
578 'capped' => false,
579 'customers' => $total,
580 );
581 set_transient( 'desktop_mode_woo_customer_plan', $memo, 5 * MINUTE_IN_SECONDS );
582
583 return $memo;
584 }
585
586 /**
587 * A readable summary of whether the Customers list is band-ordered,
588 * for diagnosing a section whose bands look wrong. Mirrors the
589 * catalogue's `ordering` blob.
590 *
591 * @return array{mode:string, customers:int, ordered:int, limit:int}
592 */
593 function openstation_my_wordpress_woo_customer_ordering_state() {
594 $plan = openstation_my_wordpress_woo_customer_plan();
595
596 return array(
597 'mode' => ! empty( $plan['capped'] ) ? 'capped' : 'ordered',
598 'customers' => (int) ( $plan['customers'] ?? 0 ),
599 'ordered' => count( (array) ( $plan['ids'] ?? array() ) ),
600 'limit' => OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS,
601 );
602 }
603
604 /**
605 * Drop the cached aggregate and plan when an order changes, so a
606 * first-time buyer moves out of "No orders yet" on the next load
607 * rather than five minutes later.
608 *
609 * @return void
610 */
611 function openstation_my_wordpress_woo_flush_customer_caches() {
612 delete_transient( 'desktop_mode_woo_customer_spend' );
613 delete_transient( 'desktop_mode_woo_customer_plan' );
614 }
615 add_action( 'woocommerce_new_order', 'openstation_my_wordpress_woo_flush_customer_caches' );
616 add_action( 'woocommerce_update_order', 'openstation_my_wordpress_woo_flush_customer_caches' );
617 add_action( 'woocommerce_order_status_changed', 'openstation_my_wordpress_woo_flush_customer_caches' );
618 add_action( 'woocommerce_delete_order', 'openstation_my_wordpress_woo_flush_customer_caches' );
619 add_action( 'woocommerce_trash_order', 'openstation_my_wordpress_woo_flush_customer_caches' );
620 // Untrash is its own event, not an update: restoring a paid order has
621 // to move the buyer's spend and band back where they were, and nothing
622 // else fires when it happens.
623 add_action( 'woocommerce_untrash_order', 'openstation_my_wordpress_woo_flush_customer_caches' );
624
625 /*
626 -------------------------------------------------------------------
627 * Per-customer facts
628 * ----------------------------------------------------------------
629 */
630
631 /**
632 * The `openstation_woo_customer` payload for one user — everything a
633 * tile, a band, and the compact pane row need, and nothing that costs
634 * an extra query.
635 *
636 * Every field here is read from the cached aggregate. The deeper
637 * facts (last order number, favourite product, billing address) live
638 * in the customer *summary* below, which only runs for the one row
639 * actually selected.
640 *
641 * @param int $user_id User id.
642 * @return array
643 */
644 function openstation_my_wordpress_woo_customer_facts( $user_id ) {
645 $user_id = (int) $user_id;
646 $map = openstation_my_wordpress_woo_customer_spend_map();
647 $stats = $map[ $user_id ] ?? array(
648 'orders' => 0,
649 'spend' => 0.0,
650 'first' => '',
651 'last' => '',
652 );
653
654 $orders = (int) $stats['orders'];
655 $spend = (float) $stats['spend'];
656 $days = openstation_my_wordpress_woo_customer_days_since( $stats['last'] );
657
658 $facts = array(
659 'band' => openstation_my_wordpress_woo_customer_band_id( $stats ),
660 'orders' => $orders,
661 'spend' => openstation_my_wordpress_woo_price( $spend ),
662 // Raw alongside the formatted string: the client sorts and
663 // compares on this, and no locale can break a float.
664 'spendRaw' => round( $spend, 2 ),
665 'aov' => $orders > 0 ? openstation_my_wordpress_woo_price( $spend / $orders ) : '',
666 'firstOrder' => '' !== $stats['first'] ? mysql2date( 'c', $stats['first'], false ) : '',
667 'lastOrder' => '' !== $stats['last'] ? mysql2date( 'c', $stats['last'], false ) : '',
668 'daysSince' => $days,
669 // The list screen filtered to this person. On the row rather
670 // than only in the summary so the tile's context menu can open
671 // it without first fetching a panel the user never asked for.
672 'ordersUrl' => $orders > 0 ? openstation_my_wordpress_woo_customer_orders_url( $user_id ) : '',
673 );
674
675 /**
676 * Filter the compact customer facts carried on every row of the
677 * Customers section (and on `/wp/v2/users` rows).
678 *
679 * **Status: Experimental**
680 *
681 * @param array $facts Fact payload.
682 * @param int $user_id The customer.
683 */
684 return (array) apply_filters( 'openstation_my_wordpress_woo_customer_facts', $facts, $user_id );
685 }
686
687 /**
688 * Register `openstation_woo_customer` on the core `user` resource.
689 *
690 * Deliberately not limited to our own collection: it means the
691 * built-in Users section, and any plugin reading `/wp/v2/users`, gets
692 * lifetime spend for free. The field is gated the same way the rest of
693 * the section is — a viewer who can't see orders sees no money.
694 *
695 * @return void
696 */
697 function openstation_my_wordpress_woo_register_customer_field() {
698 if ( ! openstation_my_wordpress_woo_active() ) {
699 return;
700 }
701
702 register_rest_field(
703 'user',
704 'openstation_woo_customer',
705 array(
706 'get_callback' => static function ( $user ) {
707 if ( true !== openstation_my_wordpress_woo_customers_permission() ) {
708 return null;
709 }
710 $id = isset( $user['id'] ) ? (int) $user['id'] : 0;
711 return $id > 0 ? openstation_my_wordpress_woo_customer_facts( $id ) : null;
712 },
713 'schema' => array(
714 'description' => __( 'WooCommerce lifetime facts for this customer.', 'desktop-mode' ),
715 'type' => array( 'object', 'null' ),
716 'context' => array( 'view', 'edit', 'embed' ),
717 ),
718 )
719 );
720 }
721 add_action( 'rest_api_init', 'openstation_my_wordpress_woo_register_customer_field' );
722
723 /*
724 -------------------------------------------------------------------
725 * REST
726 * ----------------------------------------------------------------
727 */
728
729 /**
730 * Whether the current user may see customer money.
731 *
732 * Two gates, both required: order access (the data *is* order data)
733 * and `list_users` (the rows are people). An editor who can moderate
734 * comments has neither.
735 *
736 * @return true|WP_Error
737 */
738 function openstation_my_wordpress_woo_customers_permission() {
739 $orders = openstation_my_wordpress_woo_orders_permission();
740 if ( is_wp_error( $orders ) ) {
741 return $orders;
742 }
743
744 if ( ! current_user_can( 'list_users' ) ) {
745 return new WP_Error(
746 'openstation_woo_forbidden',
747 __( 'Sorry, you are not allowed to view customers.', 'desktop-mode' ),
748 array( 'status' => rest_authorization_required_code() )
749 );
750 }
751
752 return true;
753 }
754
755 /**
756 * Shape a user as the row the site window's `user` entity kind reads —
757 * the same field set `/wp/v2/users` returns, plus our two payloads.
758 *
759 * @param WP_User $user User.
760 * @return array
761 */
762 function openstation_my_wordpress_woo_customer_row( $user ) {
763 $avatars = array();
764 foreach ( rest_get_avatar_sizes() as $size ) {
765 $avatars[ (string) $size ] = get_avatar_url( $user->ID, array( 'size' => $size ) );
766 }
767
768 return array(
769 'id' => (int) $user->ID,
770 'name' => $user->display_name,
771 'slug' => $user->user_nicename,
772 'description' => (string) get_user_meta( $user->ID, 'description', true ),
773 'link' => (string) get_author_posts_url( $user->ID ),
774 'avatar_urls' => $avatars,
775 'openstation_summary' => function_exists( 'openstation_my_wordpress_user_summary_payload' )
776 ? openstation_my_wordpress_user_summary_payload( $user->ID )
777 : array(),
778 'openstation_woo_customer' => openstation_my_wordpress_woo_customer_facts( $user->ID ),
779 );
780 }
781
782 /**
783 * `GET /woocommerce/customers` — paginated, user-shaped customer list.
784 *
785 * @param WP_REST_Request $request Request.
786 * @return WP_REST_Response
787 */
788 function openstation_my_wordpress_woo_customers( $request ) {
789 $per_page = max( 1, min( 100, (int) ( $request['per_page'] ?? 24 ) ) );
790 $page = max( 1, (int) ( $request['page'] ?? 1 ) );
791 $search = trim( (string) ( $request['search'] ?? '' ) );
792
793 $plan = openstation_my_wordpress_woo_customer_plan();
794 $capped = ! empty( $plan['capped'] );
795
796 $args = array(
797 'number' => $per_page,
798 'paged' => $page,
799 'fields' => 'all',
800 );
801
802 if ( $capped ) {
803 // Past the cap the plan holds no ids, so hand the ordering
804 // back to the database: newest accounts first, which is the
805 // only useful order left once bands are off.
806 $args['role'] = 'customer';
807 $args['orderby'] = 'registered';
808 $args['order'] = 'DESC';
809 } else {
810 $ids = (array) $plan['ids'];
811 if ( empty( $ids ) ) {
812 $response = rest_ensure_response( array() );
813 $response->header( 'X-WP-Total', '0' );
814 $response->header( 'X-WP-TotalPages', '1' );
815 return $response;
816 }
817 $args['include'] = $ids;
818 // `include` + `orderby => include` replays the plan's order
819 // verbatim, the same trick the catalogue uses with `post__in`.
820 $args['orderby'] = 'include';
821 }
822
823 if ( '' !== $search ) {
824 $args['search'] = '*' . $search . '*';
825 $args['search_columns'] = array( 'user_login', 'user_email', 'user_nicename', 'display_name' );
826 // A search is a different question from "show me the roster",
827 // and the plan's order would hide matches below the fold.
828 // Ordering falls back to relevance-free display name, which is
829 // at least stable.
830 if ( ! $capped ) {
831 $args['orderby'] = 'display_name';
832 $args['order'] = 'ASC';
833 }
834 }
835
836 /**
837 * Filter the `WP_User_Query` args for the Customers section.
838 *
839 * `number` and `paged` are set by the paginator and will be
840 * overwritten.
841 *
842 * **Status: Experimental**
843 *
844 * @param array $args Query args.
845 * @param WP_REST_Request $request The request.
846 */
847 $args = (array) apply_filters( 'openstation_my_wordpress_woo_customer_query_args', $args, $request );
848
849 $query = new WP_User_Query( $args );
850 $total = (int) $query->get_total();
851 $pages = $per_page > 0 ? (int) ceil( $total / $per_page ) : 1;
852
853 $users = array();
854 foreach ( (array) $query->get_results() as $user ) {
855 if ( $user instanceof WP_User ) {
856 $users[] = $user;
857 }
858 }
859
860 // The customer *facts* come from one cached aggregate, but the
861 // generic user summary on each row is two indexed queries a piece
862 // — 200 of them on a full page. Prefetch the page in two grouped
863 // queries and every row below answers from memory.
864 if ( function_exists( 'openstation_my_wordpress_user_summary_prime' ) ) {
865 openstation_my_wordpress_user_summary_prime(
866 array_map(
867 static function ( $user ) {
868 return (int) $user->ID;
869 },
870 $users
871 )
872 );
873 }
874
875 $rows = array();
876 foreach ( $users as $user ) {
877 $rows[] = openstation_my_wordpress_woo_customer_row( $user );
878 }
879
880 $response = rest_ensure_response( $rows );
881 $response->header( 'X-WP-Total', (string) $total );
882 $response->header( 'X-WP-TotalPages', (string) max( 1, $pages ) );
883 // Same diagnostic contract the Orders route carries: an empty
884 // folder with a confident count should be answerable from the
885 // network tab, not guessed at.
886 $response->header( 'X-Desktop-Mode-Woo-Customers-Mode', $capped ? 'capped' : 'ordered' );
887
888 return $response;
889 }
890
891 /**
892 * `GET /woocommerce/customers/<id>` — one user-shaped customer.
893 *
894 * @param WP_REST_Request $request Request.
895 * @return WP_REST_Response|WP_Error
896 */
897 function openstation_my_wordpress_woo_customer( $request ) {
898 $user = get_userdata( (int) $request['id'] );
899 if ( ! $user instanceof WP_User ) {
900 return new WP_Error(
901 'openstation_woo_no_customer',
902 __( 'Customer not found.', 'desktop-mode' ),
903 array( 'status' => 404 )
904 );
905 }
906
907 return rest_ensure_response( openstation_my_wordpress_woo_customer_row( $user ) );
908 }
909
910 /*
911 -------------------------------------------------------------------
912 * The customer summary — the right pane
913 * ----------------------------------------------------------------
914 */
915
916 /**
917 * The customer's most-bought product, resolved from their recent
918 * orders.
919 *
920 * Bounded to the last 50 orders: this runs once per selection, and a
921 * decade of order history would turn a preview pane into a page load.
922 *
923 * @param int $user_id User id.
924 * @return array{label:string, editUrl:string, quantity:int}|null
925 */
926 function openstation_my_wordpress_woo_customer_favourite( $user_id ) {
927 $orders = wc_get_orders(
928 array(
929 'customer_id' => (int) $user_id,
930 'limit' => 50,
931 'status' => openstation_my_wordpress_woo_paid_statuses(),
932 'orderby' => 'date',
933 'order' => 'DESC',
934 'return' => 'objects',
935 )
936 );
937
938 $tally = array();
939 foreach ( (array) $orders as $maybe_order ) {
940 $order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order;
941 if ( ! $order instanceof WC_Abstract_Order ) {
942 continue;
943 }
944 foreach ( $order->get_items() as $item ) {
945 $product_id = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0;
946 if ( $product_id <= 0 ) {
947 continue;
948 }
949 if ( ! isset( $tally[ $product_id ] ) ) {
950 $tally[ $product_id ] = array(
951 'label' => $item->get_name(),
952 'quantity' => 0,
953 );
954 }
955 $tally[ $product_id ]['quantity'] += (int) $item->get_quantity();
956 }
957 }
958
959 if ( empty( $tally ) ) {
960 return null;
961 }
962
963 uasort(
964 $tally,
965 static function ( $a, $b ) {
966 return $b['quantity'] <=> $a['quantity'];
967 }
968 );
969
970 $product_id = (int) array_key_first( $tally );
971 $top = $tally[ $product_id ];
972
973 // The link is gated, the fact isn't: someone who may read customer
974 // money but not edit products should still be told what that
975 // person buys — the name just stops being clickable.
976 // `get_edit_post_link()` returns null without `edit_post`, and the
977 // cast turns that into the empty string the client reads as "no
978 // link"; the explicit check states the intent rather than leaving
979 // it resting on a core side-effect.
980 $can_edit = get_post( $product_id ) && current_user_can( 'edit_post', $product_id );
981
982 return array(
983 'label' => (string) $top['label'],
984 'quantity' => (int) $top['quantity'],
985 'editUrl' => $can_edit ? (string) get_edit_post_link( $product_id, 'raw' ) : '',
986 );
987 }
988
989 /**
990 * A customer's most recent orders, shaped for a list.
991 *
992 * Bounded and only fetched for the one customer being looked at — the
993 * list rows never touch this.
994 *
995 * @param int $user_id User id.
996 * @param int $limit How many.
997 * @return array[]
998 */
999 function openstation_my_wordpress_woo_customer_recent_orders( $user_id, $limit = 8 ) {
1000 $orders = wc_get_orders(
1001 array(
1002 'customer_id' => (int) $user_id,
1003 'limit' => max( 1, (int) $limit ),
1004 'orderby' => 'date',
1005 'order' => 'DESC',
1006 'return' => 'objects',
1007 )
1008 );
1009
1010 $statuses = wc_get_order_statuses();
1011 $rows = array();
1012 foreach ( (array) $orders as $maybe_order ) {
1013 $order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order;
1014 if ( ! $order instanceof WC_Abstract_Order ) {
1015 continue;
1016 }
1017 $rows[] = array(
1018 'id' => (int) $order->get_id(),
1019 'number' => (string) $order->get_order_number(),
1020 'status' => (string) $order->get_status(),
1021 'statusLabel' => (string) ( $statuses[ 'wc-' . $order->get_status() ] ?? $order->get_status() ),
1022 'date' => $order->get_date_created()
1023 ? $order->get_date_created()->date( 'c' )
1024 : '',
1025 'total' => openstation_my_wordpress_woo_price( $order->get_total(), $order->get_currency() ),
1026 'items' => (int) $order->get_item_count(),
1027 'editUrl' => method_exists( $order, 'get_edit_order_url' )
1028 ? (string) $order->get_edit_order_url()
1029 : '',
1030 );
1031 }
1032
1033 return $rows;
1034 }
1035
1036 /**
1037 * Merchant facts for one customer — the right-pane panel.
1038 *
1039 * @param int $id User id.
1040 * @return array|WP_Error
1041 */
1042 function openstation_my_wordpress_woo_customer_summary( $id ) {
1043 $user = get_userdata( (int) $id );
1044 if ( ! $user instanceof WP_User ) {
1045 return new WP_Error(
1046 'openstation_woo_no_customer',
1047 __( 'Customer not found.', 'desktop-mode' ),
1048 array( 'status' => 404 )
1049 );
1050 }
1051
1052 $facts = openstation_my_wordpress_woo_customer_facts( $user->ID );
1053 $bands = array();
1054 foreach ( openstation_my_wordpress_woo_customer_band_defs() as $band ) {
1055 $bands[ $band['id'] ] = (string) $band['label'];
1056 }
1057
1058 // The most recent order, for the "last bought" line and the jump
1059 // into it. One query, only for the selected row.
1060 $recent = wc_get_orders(
1061 array(
1062 'customer_id' => $user->ID,
1063 'limit' => 1,
1064 'orderby' => 'date',
1065 'order' => 'DESC',
1066 'return' => 'objects',
1067 )
1068 );
1069 $last = null;
1070 foreach ( (array) $recent as $maybe_order ) {
1071 $order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order;
1072 if ( $order instanceof WC_Abstract_Order ) {
1073 $last = $order;
1074 break;
1075 }
1076 }
1077
1078 $customer = null;
1079 if ( class_exists( 'WC_Customer' ) ) {
1080 try {
1081 $customer = new WC_Customer( $user->ID );
1082 } catch ( Exception $e ) {
1083 $customer = null;
1084 }
1085 }
1086
1087 $location = '';
1088 $billing = '';
1089 $shipping = '';
1090 $phone = '';
1091 if ( $customer ) {
1092 $parts = array_filter(
1093 array(
1094 $customer->get_billing_city(),
1095 $customer->get_billing_country(),
1096 )
1097 );
1098 $location = implode( ', ', $parts );
1099 $phone = (string) $customer->get_billing_phone();
1100
1101 // `WC_Customer` has no formatted-address accessor of its own,
1102 // so build the lines the way WooCommerce's order screen does.
1103 $format = static function ( array $address ) {
1104 if ( ! function_exists( 'WC' ) || ! WC()->countries ) {
1105 return '';
1106 }
1107 return trim(
1108 wp_strip_all_tags(
1109 (string) WC()->countries->get_formatted_address( $address, ', ' )
1110 )
1111 );
1112 };
1113 $billing = $format(
1114 array(
1115 'address_1' => $customer->get_billing_address_1(),
1116 'address_2' => $customer->get_billing_address_2(),
1117 'city' => $customer->get_billing_city(),
1118 'state' => $customer->get_billing_state(),
1119 'postcode' => $customer->get_billing_postcode(),
1120 'country' => $customer->get_billing_country(),
1121 )
1122 );
1123 $shipping = $format(
1124 array(
1125 'address_1' => $customer->get_shipping_address_1(),
1126 'address_2' => $customer->get_shipping_address_2(),
1127 'city' => $customer->get_shipping_city(),
1128 'state' => $customer->get_shipping_state(),
1129 'postcode' => $customer->get_shipping_postcode(),
1130 'country' => $customer->get_shipping_country(),
1131 )
1132 );
1133 }
1134
1135 return array(
1136 'type' => 'customer',
1137 'id' => (int) $user->ID,
1138 'name' => $user->display_name,
1139 'username' => $user->user_login,
1140 'avatar' => (string) get_avatar_url( $user->ID, array( 'size' => 96 ) ),
1141 'email' => $user->user_email,
1142 'phone' => $phone,
1143 'billing' => $billing,
1144 'shipping' => $shipping,
1145 // Only the window asks for these; the preview pane's panel
1146 // ignores them. One payload, two consumers — cheaper than a
1147 // second route, and the window is where the depth belongs.
1148 'recentOrders' => openstation_my_wordpress_woo_customer_recent_orders( $user->ID ),
1149 'spendRaw' => (float) $facts['spendRaw'],
1150 'band' => (string) $facts['band'],
1151 'bandLabel' => $bands[ $facts['band'] ] ?? (string) $facts['band'],
1152 'orders' => (int) $facts['orders'],
1153 'spend' => (string) $facts['spend'],
1154 'aov' => (string) $facts['aov'],
1155 'firstOrder' => (string) $facts['firstOrder'],
1156 'lastOrder' => (string) $facts['lastOrder'],
1157 'daysSince' => $facts['daysSince'],
1158 'lastOrderNo' => $last ? (string) $last->get_order_number() : '',
1159 'lastOrderUrl' => $last && method_exists( $last, 'get_edit_order_url' )
1160 ? (string) $last->get_edit_order_url()
1161 : '',
1162 'lastOrderTotal' => $last
1163 ? openstation_my_wordpress_woo_price( $last->get_total(), $last->get_currency() )
1164 : '',
1165 'favourite' => openstation_my_wordpress_woo_customer_favourite( $user->ID ),
1166 'location' => $location,
1167 'registered' => '' !== $user->user_registered
1168 ? mysql2date( 'c', $user->user_registered, false )
1169 : '',
1170 'ordersUrl' => openstation_my_wordpress_woo_customer_orders_url( $user->ID ),
1171 'profileUrl' => current_user_can( 'edit_user', $user->ID )
1172 ? (string) get_edit_user_link( $user->ID )
1173 : '',
1174 );
1175 }
1176
1177 /**
1178 * The admin URL listing this customer's orders — HPOS and legacy
1179 * storage put that screen in different places.
1180 *
1181 * @param int $user_id User id.
1182 * @return string
1183 */
1184 function openstation_my_wordpress_woo_customer_orders_url( $user_id ) {
1185 $user_id = (int) $user_id;
1186
1187 if ( openstation_my_wordpress_woo_hpos_enabled() ) {
1188 return admin_url( 'admin.php?page=wc-orders&_customer_user=' . $user_id );
1189 }
1190
1191 return admin_url( 'edit.php?post_type=shop_order&_customer_user=' . $user_id );
1192 }
1193
1194 /**
1195 * Add the `customer` type to the shared summary route.
1196 *
1197 * Joining through the route's own extension seam rather than editing
1198 * its switch keeps the whole Customers surface in one file — and
1199 * proves the seam works, since this is the first thing to use it.
1200 *
1201 * @param array|null $data Summary payload (untouched for other types).
1202 * @param string $type Summary type.
1203 * @param int $id Object id.
1204 * @return array|WP_Error|null
1205 */
1206 function openstation_my_wordpress_woo_customer_summary_filter( $data, $type, $id ) {
1207 if ( 'customer' !== $type ) {
1208 return $data;
1209 }
1210
1211 return openstation_my_wordpress_woo_customer_summary( $id );
1212 }
1213
1214 /**
1215 * Gate the `customer` summary type. The generic fallback checks
1216 * `edit_post` against the id, which for a user id is meaningless —
1217 * and, on a site where post and user ids collide, wrong.
1218 *
1219 * @param true|WP_Error|null $allowed Permission verdict so far.
1220 * @param string $type Summary type.
1221 * @param int $id Object id.
1222 * @return true|WP_Error|null
1223 */
1224 function openstation_my_wordpress_woo_customer_summary_capability( $allowed, $type, $id ) {
1225 unset( $id );
1226 if ( 'customer' !== $type ) {
1227 return $allowed;
1228 }
1229
1230 return openstation_my_wordpress_woo_customers_permission();
1231 }
1232
1233 /**
1234 * Register the Customers routes.
1235 *
1236 * @return void
1237 */
1238 function openstation_my_wordpress_woo_register_customer_routes() {
1239 if ( ! openstation_my_wordpress_woo_active() ) {
1240 return;
1241 }
1242
1243 register_rest_route(
1244 'desktop-mode/v1',
1245 '/woocommerce/customers',
1246 array(
1247 'methods' => WP_REST_Server::READABLE,
1248 'callback' => 'openstation_my_wordpress_woo_customers',
1249 'permission_callback' => 'openstation_my_wordpress_woo_customers_permission',
1250 'args' => array(
1251 'page' => array(
1252 'type' => 'integer',
1253 'default' => 1,
1254 ),
1255 'per_page' => array(
1256 'type' => 'integer',
1257 'default' => 24,
1258 ),
1259 'search' => array( 'type' => 'string' ),
1260 ),
1261 )
1262 );
1263
1264 register_rest_route(
1265 'desktop-mode/v1',
1266 '/woocommerce/customers/(?P<id>\d+)',
1267 array(
1268 'methods' => WP_REST_Server::READABLE,
1269 'callback' => 'openstation_my_wordpress_woo_customer',
1270 'permission_callback' => 'openstation_my_wordpress_woo_customers_permission',
1271 'args' => array(
1272 'id' => array( 'type' => 'integer' ),
1273 ),
1274 )
1275 );
1276 }
1277 add_action( 'rest_api_init', 'openstation_my_wordpress_woo_register_customer_routes' );
1278
1279 /*
1280 -------------------------------------------------------------------
1281 * The section
1282 * ----------------------------------------------------------------
1283 */
1284
1285 /**
1286 * Append the Customers section to the Woo folder.
1287 *
1288 * Registered on the same filter as Orders and at the same priority,
1289 * so it lands next to it inside the folder rather than at the end of
1290 * the entity list.
1291 *
1292 * @param array[] $entities Entity descriptors.
1293 * @return array[]
1294 */
1295 function openstation_my_wordpress_woo_customer_entity( $entities ) {
1296 if ( ! is_array( $entities ) || ! openstation_my_wordpress_woo_active() ) {
1297 return $entities;
1298 }
1299 if ( true !== openstation_my_wordpress_woo_customers_permission() ) {
1300 return $entities;
1301 }
1302
1303 $group = openstation_my_wordpress_woo_group(
1304 array(
1305 'id' => 'plugin:woocommerce',
1306 'label' => 'WooCommerce',
1307 'icon' => 'dashicons-admin-plugins',
1308 'order' => 20,
1309 ),
1310 'shop_order'
1311 );
1312
1313 $entities[] = array(
1314 'id' => 'wc-customers',
1315 'label' => __( 'Customers', 'desktop-mode' ),
1316 'icon' => 'dashicons-groups',
1317 'restPath' => 'desktop-mode/v1/woocommerce/customers',
1318 // Renders through the built-in user kind: avatar tiles, the
1319 // dossier pane, the footprint route, the drag-out seam. A
1320 // customer is a person before it is a row of money.
1321 'kind' => 'user',
1322 // Keeps the facts payload from being stripped by `_fields`.
1323 'listFields' => array( 'openstation_woo_customer' ),
1324 'group' => $group['id'],
1325 'groupLabel' => $group['label'],
1326 'groupIcon' => $group['icon'],
1327 'groupOrder' => $group['order'],
1328 );
1329
1330 return $entities;
1331 }
1332
1333 /**
1334 * Add the people numbers to the Woo folder's Store panel.
1335 *
1336 * Guest revenue is here because it is the one figure the Customers
1337 * section structurally cannot show: an order with no account has no
1338 * tile to sit on. Reporting it as a line on the folder is the honest
1339 * alternative to letting it disappear.
1340 *
1341 * @param array $data Store totals.
1342 * @return array
1343 */
1344 function openstation_my_wordpress_woo_customer_store_totals( $data ) {
1345 if ( ! is_array( $data ) || true !== openstation_my_wordpress_woo_customers_permission() ) {
1346 return $data;
1347 }
1348
1349 $map = openstation_my_wordpress_woo_customer_spend_map();
1350 $guest = $map[0] ?? null;
1351 $plan = openstation_my_wordpress_woo_customer_plan();
1352
1353 $data['customers'] = (int) ( $plan['customers'] ?? 0 );
1354
1355 // Past the ordering cap the plan holds no bands, so the band
1356 // counts are not zero — they are unknown. Saying so is the whole
1357 // point: a store with 40,000 customers reporting "0 VIPs" is a
1358 // wrong answer stated confidently, which is worse than no answer.
1359 $data['bandsCapped'] = ! empty( $plan['capped'] );
1360 if ( empty( $data['bandsCapped'] ) ) {
1361 $data['vips'] = (int) ( ( $plan['counts'] ?? array() )['vip'] ?? 0 );
1362 $data['lapsed'] = (int) ( ( $plan['counts'] ?? array() )['lapsed'] ?? 0 );
1363 }
1364
1365 $data['guestSpend'] = $guest && (float) $guest['spend'] > 0
1366 ? openstation_my_wordpress_woo_price( (float) $guest['spend'] )
1367 : '';
1368 $data['guestOrders'] = $guest ? (int) $guest['orders'] : 0;
1369
1370 return $data;
1371 }
1372
1373 /**
1374 * Boot the Customers surface.
1375 *
1376 * @return void
1377 */
1378 function openstation_my_wordpress_woo_customers_boot() {
1379 add_filter( 'openstation_my_wordpress_woo_store', 'openstation_my_wordpress_woo_customer_store_totals' );
1380 add_filter( 'openstation_my_wordpress_entities', 'openstation_my_wordpress_woo_customer_entity', 5 );
1381 add_filter( 'openstation_my_wordpress_woo_summary_type', 'openstation_my_wordpress_woo_customer_summary_filter', 10, 3 );
1382 add_filter( 'openstation_my_wordpress_woo_summary_capability', 'openstation_my_wordpress_woo_customer_summary_capability', 10, 3 );
1383 }
1384 openstation_my_wordpress_woo_customers_boot();
1385