* GET desktop-mode/v1/woocommerce/summary/customer/ * * @package OpenStation */ defined( 'ABSPATH' ) || exit; /** * Above this many candidate customers the section stops band-ordering * and falls back to newest-registered-first. The ordering plan holds * one id per customer in a transient; the cap is what keeps that * option from growing without bound on a store with a large user base. */ const OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS = 5000; /** * Days without an order after which a customer counts as lapsed. * Filterable through `openstation_my_wordpress_woo_customer_lapse_days`. */ const OPENSTATION_WOO_CUSTOMER_LAPSE_DAYS = 180; /* ------------------------------------------------------------------- * Aggregates * ---------------------------------------------------------------- */ /** * Order statuses that count as money actually taken. * * Mirrors `wc_get_customer_total_spent()`, so the lifetime spend on a * tile agrees with the number WooCommerce itself would report. * * @return string[] Statuses WITH the `wc-` prefix. */ function openstation_my_wordpress_woo_paid_statuses() { $statuses = function_exists( 'wc_get_is_paid_statuses' ) ? (array) wc_get_is_paid_statuses() : array( 'processing', 'completed' ); return array_values( array_map( static function ( $status ) { return 0 === strpos( (string) $status, 'wc-' ) ? (string) $status : 'wc-' . $status; }, $statuses ) ); } /** * Whether the store keeps orders in WooCommerce's own tables (HPOS) * rather than in `wp_posts`. * * @return bool */ function openstation_my_wordpress_woo_hpos_enabled() { return class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); } /** * Every customer's order aggregate, in one query. * * The whole section — band definitions, band ordering, per-row facts, * folder counts — reads this one map. Doing it per user would be one * or two queries per tile, and the band ordering would need the whole * user base walked before the first tile could paint. * * Guest orders (no account) are aggregated under the `0` key. They can * never appear as a tile — there is no user to render — but their * revenue is real and the Store panel reports it rather than letting * it vanish. * * @return array * Keyed by user id; `0` holds the guest aggregate. */ function openstation_my_wordpress_woo_customer_spend_map() { static $memo = null; if ( null !== $memo ) { return $memo; } $cached = get_transient( 'desktop_mode_woo_customer_spend' ); if ( is_array( $cached ) ) { $memo = $cached; return $memo; } global $wpdb; $statuses = openstation_my_wordpress_woo_paid_statuses(); $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); if ( openstation_my_wordpress_woo_hpos_enabled() ) { $table = $wpdb->prefix . 'wc_orders'; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name and the placeholder list are structural; every value is prepared. $sql = $wpdb->prepare( "SELECT customer_id AS uid, COUNT(*) AS orders, SUM(total_amount) AS spend, MIN(date_created_gmt) AS first_order, MAX(date_created_gmt) AS last_order FROM {$table} WHERE type = 'shop_order' AND status IN ( {$placeholders} ) GROUP BY customer_id", $statuses ); } else { // Legacy storage: `_customer_user` is the account id (0 for a // guest) and `_order_total` the gross. `+0` casts the meta // strings so SUM/comparison behave numerically. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- ditto. $sql = $wpdb->prepare( "SELECT cu.meta_value + 0 AS uid, COUNT(*) AS orders, SUM( tot.meta_value + 0 ) AS spend, MIN(p.post_date_gmt) AS first_order, MAX(p.post_date_gmt) AS last_order FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} cu ON cu.post_id = p.ID AND cu.meta_key = '_customer_user' LEFT JOIN {$wpdb->postmeta} tot ON tot.post_id = p.ID AND tot.meta_key = '_order_total' WHERE p.post_type = 'shop_order' AND p.post_status IN ( {$placeholders} ) GROUP BY cu.meta_value", $statuses ); } // 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. $rows = $wpdb->get_results( $sql ); $map = array(); foreach ( (array) $rows as $row ) { $uid = (int) $row->uid; if ( $uid < 0 ) { continue; } $map[ $uid ] = array( 'orders' => (int) $row->orders, 'spend' => (float) $row->spend, 'first' => (string) $row->first_order, 'last' => (string) $row->last_order, ); } /** * Filter the per-customer order aggregate the Customers section * is built from. * * Keyed by user id (`0` is the guest aggregate); each value is * `array( 'orders' => int, 'spend' => float, 'first' => gmt * datetime, 'last' => gmt datetime )`. A store that keeps order * money somewhere else — a subscriptions plugin, a marketplace * split — can rewrite the whole map here and every band, tile and * panel follows. * * **Status: Experimental** * * @param array $map Aggregate keyed by user id. */ $map = (array) apply_filters( 'openstation_my_wordpress_woo_customer_spend_map', $map ); set_transient( 'desktop_mode_woo_customer_spend', $map, 5 * MINUTE_IN_SECONDS ); $memo = $map; return $memo; } /** * The store's average order value across every paid order. * * Includes guests: a threshold derived only from account holders would * sit wherever the checkout-registration rate happened to put it. * * @return float */ function openstation_my_wordpress_woo_store_aov() { $orders = 0; $spend = 0.0; foreach ( openstation_my_wordpress_woo_customer_spend_map() as $stats ) { $orders += (int) $stats['orders']; $spend += (float) $stats['spend']; } return $orders > 0 ? $spend / $orders : 0.0; } /** * Lifetime spend at or above which a customer is a VIP. * * Derived rather than fixed: "spent over 500" means nothing without * knowing whether the store sells postcards or pianos. Three average * orders is the default — enough to be deliberate repeat custom on any * store, cheap to compute, and one filter away from a merchant's own * number. * * @return float Threshold, or `0.0` when the store has no paid orders * yet (in which case nothing can qualify). */ function openstation_my_wordpress_woo_vip_threshold() { $aov = openstation_my_wordpress_woo_store_aov(); /** * Filter the lifetime-spend threshold for the VIP band. * * **Status: Experimental** * * @param float $threshold Threshold in store currency. * @param float $aov The store's average order value. */ return (float) apply_filters( 'openstation_my_wordpress_woo_vip_threshold', $aov * 3, $aov ); } /** * Days without an order after which a customer counts as lapsed. * * @return int */ function openstation_my_wordpress_woo_customer_lapse_days() { /** * Filter the lapse window for the Customers section. * * **Status: Experimental** * * @param int $days Days since the last order. */ $days = (int) apply_filters( 'openstation_my_wordpress_woo_customer_lapse_days', OPENSTATION_WOO_CUSTOMER_LAPSE_DAYS ); return max( 1, $days ); } /* ------------------------------------------------------------------- * Bands * ---------------------------------------------------------------- */ /** * Band definitions for the Customers section, in display order. * * VIP and Lapsed lead because they are the two the merchant can do * something about — one to look after, one to win back. Everything * else is context, and "No orders" trails because a registered * account that never bought is the least urgent row on the screen. * * @return array[] Each entry: `id`, `label`, `order`, optional `tone`. */ function openstation_my_wordpress_woo_customer_band_defs() { $bands = array( array( 'id' => 'vip', 'label' => __( 'VIP', 'desktop-mode' ), 'order' => 10, 'tone' => 'success', ), array( 'id' => 'lapsed', 'label' => __( 'Lapsed', 'desktop-mode' ), 'order' => 20, 'tone' => 'warn', ), array( 'id' => 'repeat', 'label' => __( 'Repeat', 'desktop-mode' ), 'order' => 30, ), array( 'id' => 'new', 'label' => __( 'New', 'desktop-mode' ), 'order' => 40, ), array( 'id' => 'none', 'label' => __( 'No orders yet', 'desktop-mode' ), 'order' => 50, ), ); /** * Filter the band definitions for the Customers section. * * Changing a band's membership rule means filtering * `openstation_my_wordpress_woo_customer_band` as well — this * filter only decides what the bands are called and in which * order they render. * * **Status: Experimental** * * @param array[] $bands Band descriptors. */ return (array) apply_filters( 'openstation_my_wordpress_woo_customer_bands', $bands ); } /** * Which band a customer's aggregate puts them in. * * @param array $stats Aggregate row: `orders`, `spend`, `last`. * @return string Band id. */ function openstation_my_wordpress_woo_customer_band_id( $stats ) { $orders = (int) ( $stats['orders'] ?? 0 ); $spend = (float) ( $stats['spend'] ?? 0 ); $last = (string) ( $stats['last'] ?? '' ); $band = 'none'; if ( $orders > 0 ) { $threshold = openstation_my_wordpress_woo_vip_threshold(); $lapsed = openstation_my_wordpress_woo_customer_days_since( $last ); if ( $threshold > 0 && $spend >= $threshold ) { // VIP outranks lapsed on purpose: a big spender who has // gone quiet is still the row you want at the top of the // screen, and the days-since line in the pane says the // rest. $band = 'vip'; } elseif ( null !== $lapsed && $lapsed > openstation_my_wordpress_woo_customer_lapse_days() ) { $band = 'lapsed'; } elseif ( $orders > 1 ) { $band = 'repeat'; } else { $band = 'new'; } } /** * Filter the band a customer lands in. * * **Status: Experimental** * * @param string $band Band id. * @param array $stats The customer's order aggregate. */ return (string) apply_filters( 'openstation_my_wordpress_woo_customer_band', $band, $stats ); } /** * Whole days between a GMT datetime and now, or `null` when the input * isn't a usable date. * * @param string $gmt_datetime `Y-m-d H:i:s` in GMT. * @return int|null */ function openstation_my_wordpress_woo_customer_days_since( $gmt_datetime ) { if ( '' === (string) $gmt_datetime ) { return null; } $stamp = strtotime( $gmt_datetime . ' GMT' ); if ( ! $stamp ) { return null; } return (int) floor( ( time() - $stamp ) / DAY_IN_SECONDS ); } /** * Band definitions with an exact row count each, for the client. * * @return array[] */ function openstation_my_wordpress_woo_customer_bands_with_counts() { $plan = openstation_my_wordpress_woo_customer_plan(); $counts = (array) ( $plan['counts'] ?? array() ); $bands = array(); foreach ( openstation_my_wordpress_woo_customer_band_defs() as $band ) { $band['count'] = (int) ( $counts[ $band['id'] ] ?? 0 ); $bands[] = $band; } return $bands; } /* ------------------------------------------------------------------- * The ordering plan * ---------------------------------------------------------------- */ /** * Candidate customer ids — everyone who has paid for something, plus * everyone holding the `customer` role. * * The union matters in both directions: a shop manager who buys from * their own store is a customer, and a checkout-registered account * that hasn't ordered yet is a customer the merchant would want to * see. Neither query alone finds both. * * @return int[] User ids, unordered. */ function openstation_my_wordpress_woo_customer_candidate_ids() { $ids = array(); foreach ( openstation_my_wordpress_woo_customer_spend_map() as $user_id => $stats ) { $user_id = (int) $user_id; if ( $user_id > 0 ) { $ids[ $user_id ] = true; } } $role_users = get_users( array( 'role' => 'customer', 'fields' => 'ID', 'number' => OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS + 1, 'orderby' => 'registered', 'order' => 'DESC', ) ); foreach ( (array) $role_users as $user_id ) { $ids[ (int) $user_id ] = true; } /** * Filter the set of users the Customers section considers. * * **Status: Experimental** * * @param int[] $ids Candidate user ids. */ return array_values( array_map( 'intval', array_keys( (array) apply_filters( 'openstation_my_wordpress_woo_customer_ids', $ids ) ) ) ); } /** * The band-ordered customer id list plus an exact count per band. * * Same contract as the catalogue's plan, and for the same reason: * bands only stop reshuffling if rows *arrive* in band order. A band * that fills late expands above whatever the user is already reading. * * Cached for five minutes and flushed on any order change. * * @return array{ids:int[], counts:array, capped:bool, customers:int} */ function openstation_my_wordpress_woo_customer_plan() { static $memo = null; if ( null !== $memo ) { return $memo; } $cached = get_transient( 'desktop_mode_woo_customer_plan' ); if ( is_array( $cached ) && isset( $cached['ids'], $cached['counts'] ) ) { $memo = $cached; return $memo; } $candidates = openstation_my_wordpress_woo_customer_candidate_ids(); $total = count( $candidates ); if ( $total > OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS ) { $memo = array( 'ids' => array(), 'counts' => array(), 'capped' => true, 'customers' => $total, ); set_transient( 'desktop_mode_woo_customer_plan', $memo, 5 * MINUTE_IN_SECONDS ); return $memo; } $map = openstation_my_wordpress_woo_customer_spend_map(); $buckets = array(); $counts = array(); foreach ( openstation_my_wordpress_woo_customer_band_defs() as $band ) { $buckets[ $band['id'] ] = array(); $counts[ $band['id'] ] = 0; } foreach ( $candidates as $user_id ) { $stats = $map[ $user_id ] ?? array( 'orders' => 0, 'spend' => 0.0, 'first' => '', 'last' => '', ); $band = openstation_my_wordpress_woo_customer_band_id( $stats ); if ( ! isset( $buckets[ $band ] ) ) { // A filter invented a band the definitions don't declare. // Park it under "no orders" rather than dropping the row: // a customer missing from the list is a worse outcome than // one in an unexpected group. $band = 'none'; if ( ! isset( $buckets[ $band ] ) ) { continue; } } // Highest spend first inside every band — the ordering the // merchant would apply by hand. $buckets[ $band ][] = array( 'id' => $user_id, 'spend' => (float) $stats['spend'], ); ++$counts[ $band ]; } $ordered = array(); foreach ( $buckets as $rows ) { usort( $rows, static function ( $a, $b ) { if ( $a['spend'] === $b['spend'] ) { return $a['id'] <=> $b['id']; } return $b['spend'] <=> $a['spend']; } ); foreach ( $rows as $row ) { $ordered[] = (int) $row['id']; } } $memo = array( 'ids' => $ordered, 'counts' => $counts, 'capped' => false, 'customers' => $total, ); set_transient( 'desktop_mode_woo_customer_plan', $memo, 5 * MINUTE_IN_SECONDS ); return $memo; } /** * A readable summary of whether the Customers list is band-ordered, * for diagnosing a section whose bands look wrong. Mirrors the * catalogue's `ordering` blob. * * @return array{mode:string, customers:int, ordered:int, limit:int} */ function openstation_my_wordpress_woo_customer_ordering_state() { $plan = openstation_my_wordpress_woo_customer_plan(); return array( 'mode' => ! empty( $plan['capped'] ) ? 'capped' : 'ordered', 'customers' => (int) ( $plan['customers'] ?? 0 ), 'ordered' => count( (array) ( $plan['ids'] ?? array() ) ), 'limit' => OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS, ); } /** * Drop the cached aggregate and plan when an order changes, so a * first-time buyer moves out of "No orders yet" on the next load * rather than five minutes later. * * @return void */ function openstation_my_wordpress_woo_flush_customer_caches() { delete_transient( 'desktop_mode_woo_customer_spend' ); delete_transient( 'desktop_mode_woo_customer_plan' ); } add_action( 'woocommerce_new_order', 'openstation_my_wordpress_woo_flush_customer_caches' ); add_action( 'woocommerce_update_order', 'openstation_my_wordpress_woo_flush_customer_caches' ); add_action( 'woocommerce_order_status_changed', 'openstation_my_wordpress_woo_flush_customer_caches' ); add_action( 'woocommerce_delete_order', 'openstation_my_wordpress_woo_flush_customer_caches' ); add_action( 'woocommerce_trash_order', 'openstation_my_wordpress_woo_flush_customer_caches' ); // Untrash is its own event, not an update: restoring a paid order has // to move the buyer's spend and band back where they were, and nothing // else fires when it happens. add_action( 'woocommerce_untrash_order', 'openstation_my_wordpress_woo_flush_customer_caches' ); /* ------------------------------------------------------------------- * Per-customer facts * ---------------------------------------------------------------- */ /** * The `openstation_woo_customer` payload for one user — everything a * tile, a band, and the compact pane row need, and nothing that costs * an extra query. * * Every field here is read from the cached aggregate. The deeper * facts (last order number, favourite product, billing address) live * in the customer *summary* below, which only runs for the one row * actually selected. * * @param int $user_id User id. * @return array */ function openstation_my_wordpress_woo_customer_facts( $user_id ) { $user_id = (int) $user_id; $map = openstation_my_wordpress_woo_customer_spend_map(); $stats = $map[ $user_id ] ?? array( 'orders' => 0, 'spend' => 0.0, 'first' => '', 'last' => '', ); $orders = (int) $stats['orders']; $spend = (float) $stats['spend']; $days = openstation_my_wordpress_woo_customer_days_since( $stats['last'] ); $facts = array( 'band' => openstation_my_wordpress_woo_customer_band_id( $stats ), 'orders' => $orders, 'spend' => openstation_my_wordpress_woo_price( $spend ), // Raw alongside the formatted string: the client sorts and // compares on this, and no locale can break a float. 'spendRaw' => round( $spend, 2 ), 'aov' => $orders > 0 ? openstation_my_wordpress_woo_price( $spend / $orders ) : '', 'firstOrder' => '' !== $stats['first'] ? mysql2date( 'c', $stats['first'], false ) : '', 'lastOrder' => '' !== $stats['last'] ? mysql2date( 'c', $stats['last'], false ) : '', 'daysSince' => $days, // The list screen filtered to this person. On the row rather // than only in the summary so the tile's context menu can open // it without first fetching a panel the user never asked for. 'ordersUrl' => $orders > 0 ? openstation_my_wordpress_woo_customer_orders_url( $user_id ) : '', ); /** * Filter the compact customer facts carried on every row of the * Customers section (and on `/wp/v2/users` rows). * * **Status: Experimental** * * @param array $facts Fact payload. * @param int $user_id The customer. */ return (array) apply_filters( 'openstation_my_wordpress_woo_customer_facts', $facts, $user_id ); } /** * Register `openstation_woo_customer` on the core `user` resource. * * Deliberately not limited to our own collection: it means the * built-in Users section, and any plugin reading `/wp/v2/users`, gets * lifetime spend for free. The field is gated the same way the rest of * the section is — a viewer who can't see orders sees no money. * * @return void */ function openstation_my_wordpress_woo_register_customer_field() { if ( ! openstation_my_wordpress_woo_active() ) { return; } register_rest_field( 'user', 'openstation_woo_customer', array( 'get_callback' => static function ( $user ) { if ( true !== openstation_my_wordpress_woo_customers_permission() ) { return null; } $id = isset( $user['id'] ) ? (int) $user['id'] : 0; return $id > 0 ? openstation_my_wordpress_woo_customer_facts( $id ) : null; }, 'schema' => array( 'description' => __( 'WooCommerce lifetime facts for this customer.', 'desktop-mode' ), 'type' => array( 'object', 'null' ), 'context' => array( 'view', 'edit', 'embed' ), ), ) ); } add_action( 'rest_api_init', 'openstation_my_wordpress_woo_register_customer_field' ); /* ------------------------------------------------------------------- * REST * ---------------------------------------------------------------- */ /** * Whether the current user may see customer money. * * Two gates, both required: order access (the data *is* order data) * and `list_users` (the rows are people). An editor who can moderate * comments has neither. * * @return true|WP_Error */ function openstation_my_wordpress_woo_customers_permission() { $orders = openstation_my_wordpress_woo_orders_permission(); if ( is_wp_error( $orders ) ) { return $orders; } if ( ! current_user_can( 'list_users' ) ) { return new WP_Error( 'openstation_woo_forbidden', __( 'Sorry, you are not allowed to view customers.', 'desktop-mode' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Shape a user as the row the site window's `user` entity kind reads — * the same field set `/wp/v2/users` returns, plus our two payloads. * * @param WP_User $user User. * @return array */ function openstation_my_wordpress_woo_customer_row( $user ) { $avatars = array(); foreach ( rest_get_avatar_sizes() as $size ) { $avatars[ (string) $size ] = get_avatar_url( $user->ID, array( 'size' => $size ) ); } return array( 'id' => (int) $user->ID, 'name' => $user->display_name, 'slug' => $user->user_nicename, 'description' => (string) get_user_meta( $user->ID, 'description', true ), 'link' => (string) get_author_posts_url( $user->ID ), 'avatar_urls' => $avatars, 'openstation_summary' => function_exists( 'openstation_my_wordpress_user_summary_payload' ) ? openstation_my_wordpress_user_summary_payload( $user->ID ) : array(), 'openstation_woo_customer' => openstation_my_wordpress_woo_customer_facts( $user->ID ), ); } /** * `GET /woocommerce/customers` — paginated, user-shaped customer list. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ function openstation_my_wordpress_woo_customers( $request ) { $per_page = max( 1, min( 100, (int) ( $request['per_page'] ?? 24 ) ) ); $page = max( 1, (int) ( $request['page'] ?? 1 ) ); $search = trim( (string) ( $request['search'] ?? '' ) ); $plan = openstation_my_wordpress_woo_customer_plan(); $capped = ! empty( $plan['capped'] ); $args = array( 'number' => $per_page, 'paged' => $page, 'fields' => 'all', ); if ( $capped ) { // Past the cap the plan holds no ids, so hand the ordering // back to the database: newest accounts first, which is the // only useful order left once bands are off. $args['role'] = 'customer'; $args['orderby'] = 'registered'; $args['order'] = 'DESC'; } else { $ids = (array) $plan['ids']; if ( empty( $ids ) ) { $response = rest_ensure_response( array() ); $response->header( 'X-WP-Total', '0' ); $response->header( 'X-WP-TotalPages', '1' ); return $response; } $args['include'] = $ids; // `include` + `orderby => include` replays the plan's order // verbatim, the same trick the catalogue uses with `post__in`. $args['orderby'] = 'include'; } if ( '' !== $search ) { $args['search'] = '*' . $search . '*'; $args['search_columns'] = array( 'user_login', 'user_email', 'user_nicename', 'display_name' ); // A search is a different question from "show me the roster", // and the plan's order would hide matches below the fold. // Ordering falls back to relevance-free display name, which is // at least stable. if ( ! $capped ) { $args['orderby'] = 'display_name'; $args['order'] = 'ASC'; } } /** * Filter the `WP_User_Query` args for the Customers section. * * `number` and `paged` are set by the paginator and will be * overwritten. * * **Status: Experimental** * * @param array $args Query args. * @param WP_REST_Request $request The request. */ $args = (array) apply_filters( 'openstation_my_wordpress_woo_customer_query_args', $args, $request ); $query = new WP_User_Query( $args ); $total = (int) $query->get_total(); $pages = $per_page > 0 ? (int) ceil( $total / $per_page ) : 1; $users = array(); foreach ( (array) $query->get_results() as $user ) { if ( $user instanceof WP_User ) { $users[] = $user; } } // The customer *facts* come from one cached aggregate, but the // generic user summary on each row is two indexed queries a piece // — 200 of them on a full page. Prefetch the page in two grouped // queries and every row below answers from memory. if ( function_exists( 'openstation_my_wordpress_user_summary_prime' ) ) { openstation_my_wordpress_user_summary_prime( array_map( static function ( $user ) { return (int) $user->ID; }, $users ) ); } $rows = array(); foreach ( $users as $user ) { $rows[] = openstation_my_wordpress_woo_customer_row( $user ); } $response = rest_ensure_response( $rows ); $response->header( 'X-WP-Total', (string) $total ); $response->header( 'X-WP-TotalPages', (string) max( 1, $pages ) ); // Same diagnostic contract the Orders route carries: an empty // folder with a confident count should be answerable from the // network tab, not guessed at. $response->header( 'X-Desktop-Mode-Woo-Customers-Mode', $capped ? 'capped' : 'ordered' ); return $response; } /** * `GET /woocommerce/customers/` — one user-shaped customer. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function openstation_my_wordpress_woo_customer( $request ) { $user = get_userdata( (int) $request['id'] ); if ( ! $user instanceof WP_User ) { return new WP_Error( 'openstation_woo_no_customer', __( 'Customer not found.', 'desktop-mode' ), array( 'status' => 404 ) ); } return rest_ensure_response( openstation_my_wordpress_woo_customer_row( $user ) ); } /* ------------------------------------------------------------------- * The customer summary — the right pane * ---------------------------------------------------------------- */ /** * The customer's most-bought product, resolved from their recent * orders. * * Bounded to the last 50 orders: this runs once per selection, and a * decade of order history would turn a preview pane into a page load. * * @param int $user_id User id. * @return array{label:string, editUrl:string, quantity:int}|null */ function openstation_my_wordpress_woo_customer_favourite( $user_id ) { $orders = wc_get_orders( array( 'customer_id' => (int) $user_id, 'limit' => 50, 'status' => openstation_my_wordpress_woo_paid_statuses(), 'orderby' => 'date', 'order' => 'DESC', 'return' => 'objects', ) ); $tally = array(); foreach ( (array) $orders as $maybe_order ) { $order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order; if ( ! $order instanceof WC_Abstract_Order ) { continue; } foreach ( $order->get_items() as $item ) { $product_id = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0; if ( $product_id <= 0 ) { continue; } if ( ! isset( $tally[ $product_id ] ) ) { $tally[ $product_id ] = array( 'label' => $item->get_name(), 'quantity' => 0, ); } $tally[ $product_id ]['quantity'] += (int) $item->get_quantity(); } } if ( empty( $tally ) ) { return null; } uasort( $tally, static function ( $a, $b ) { return $b['quantity'] <=> $a['quantity']; } ); $product_id = (int) array_key_first( $tally ); $top = $tally[ $product_id ]; // The link is gated, the fact isn't: someone who may read customer // money but not edit products should still be told what that // person buys — the name just stops being clickable. // `get_edit_post_link()` returns null without `edit_post`, and the // cast turns that into the empty string the client reads as "no // link"; the explicit check states the intent rather than leaving // it resting on a core side-effect. $can_edit = get_post( $product_id ) && current_user_can( 'edit_post', $product_id ); return array( 'label' => openstation_plain_text_title( $top['label'] ), 'quantity' => (int) $top['quantity'], 'editUrl' => $can_edit ? (string) get_edit_post_link( $product_id, 'raw' ) : '', ); } /** * A customer's most recent orders, shaped for a list. * * Bounded and only fetched for the one customer being looked at — the * list rows never touch this. * * @param int $user_id User id. * @param int $limit How many. * @return array[] */ function openstation_my_wordpress_woo_customer_recent_orders( $user_id, $limit = 8 ) { $orders = wc_get_orders( array( 'customer_id' => (int) $user_id, 'limit' => max( 1, (int) $limit ), 'orderby' => 'date', 'order' => 'DESC', 'return' => 'objects', ) ); $statuses = wc_get_order_statuses(); $rows = array(); foreach ( (array) $orders as $maybe_order ) { $order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order; if ( ! $order instanceof WC_Abstract_Order ) { continue; } $rows[] = array( 'id' => (int) $order->get_id(), 'number' => (string) $order->get_order_number(), 'status' => (string) $order->get_status(), 'statusLabel' => (string) ( $statuses[ 'wc-' . $order->get_status() ] ?? $order->get_status() ), 'date' => $order->get_date_created() ? $order->get_date_created()->date( 'c' ) : '', 'total' => openstation_my_wordpress_woo_price( $order->get_total(), $order->get_currency() ), 'items' => (int) $order->get_item_count(), 'editUrl' => method_exists( $order, 'get_edit_order_url' ) ? (string) $order->get_edit_order_url() : '', ); } return $rows; } /** * Merchant facts for one customer — the right-pane panel. * * @param int $id User id. * @return array|WP_Error */ function openstation_my_wordpress_woo_customer_summary( $id ) { $user = get_userdata( (int) $id ); if ( ! $user instanceof WP_User ) { return new WP_Error( 'openstation_woo_no_customer', __( 'Customer not found.', 'desktop-mode' ), array( 'status' => 404 ) ); } $facts = openstation_my_wordpress_woo_customer_facts( $user->ID ); $bands = array(); foreach ( openstation_my_wordpress_woo_customer_band_defs() as $band ) { $bands[ $band['id'] ] = (string) $band['label']; } // The most recent order, for the "last bought" line and the jump // into it. One query, only for the selected row. $recent = wc_get_orders( array( 'customer_id' => $user->ID, 'limit' => 1, 'orderby' => 'date', 'order' => 'DESC', 'return' => 'objects', ) ); $last = null; foreach ( (array) $recent as $maybe_order ) { $order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order; if ( $order instanceof WC_Abstract_Order ) { $last = $order; break; } } $customer = null; if ( class_exists( 'WC_Customer' ) ) { try { $customer = new WC_Customer( $user->ID ); } catch ( Exception $e ) { $customer = null; } } $location = ''; $billing = ''; $shipping = ''; $phone = ''; if ( $customer ) { $parts = array_filter( array( $customer->get_billing_city(), $customer->get_billing_country(), ) ); $location = implode( ', ', $parts ); $phone = (string) $customer->get_billing_phone(); // `WC_Customer` has no formatted-address accessor of its own, // so build the lines the way WooCommerce's order screen does. $format = static function ( array $address ) { if ( ! function_exists( 'WC' ) || ! WC()->countries ) { return ''; } return trim( wp_strip_all_tags( (string) WC()->countries->get_formatted_address( $address, ', ' ) ) ); }; $billing = $format( array( 'address_1' => $customer->get_billing_address_1(), 'address_2' => $customer->get_billing_address_2(), 'city' => $customer->get_billing_city(), 'state' => $customer->get_billing_state(), 'postcode' => $customer->get_billing_postcode(), 'country' => $customer->get_billing_country(), ) ); $shipping = $format( array( 'address_1' => $customer->get_shipping_address_1(), 'address_2' => $customer->get_shipping_address_2(), 'city' => $customer->get_shipping_city(), 'state' => $customer->get_shipping_state(), 'postcode' => $customer->get_shipping_postcode(), 'country' => $customer->get_shipping_country(), ) ); } return array( 'type' => 'customer', 'id' => (int) $user->ID, 'name' => openstation_plain_text_title( $user->display_name ), 'username' => $user->user_login, 'avatar' => (string) get_avatar_url( $user->ID, array( 'size' => 96 ) ), 'email' => $user->user_email, 'phone' => $phone, 'billing' => $billing, 'shipping' => $shipping, // Only the window asks for these; the preview pane's panel // ignores them. One payload, two consumers — cheaper than a // second route, and the window is where the depth belongs. 'recentOrders' => openstation_my_wordpress_woo_customer_recent_orders( $user->ID ), 'spendRaw' => (float) $facts['spendRaw'], 'band' => (string) $facts['band'], 'bandLabel' => $bands[ $facts['band'] ] ?? (string) $facts['band'], 'orders' => (int) $facts['orders'], 'spend' => (string) $facts['spend'], 'aov' => (string) $facts['aov'], 'firstOrder' => (string) $facts['firstOrder'], 'lastOrder' => (string) $facts['lastOrder'], 'daysSince' => $facts['daysSince'], 'lastOrderNo' => $last ? (string) $last->get_order_number() : '', 'lastOrderUrl' => $last && method_exists( $last, 'get_edit_order_url' ) ? (string) $last->get_edit_order_url() : '', 'lastOrderTotal' => $last ? openstation_my_wordpress_woo_price( $last->get_total(), $last->get_currency() ) : '', 'favourite' => openstation_my_wordpress_woo_customer_favourite( $user->ID ), 'location' => $location, 'registered' => '' !== $user->user_registered ? mysql2date( 'c', $user->user_registered, false ) : '', 'ordersUrl' => openstation_my_wordpress_woo_customer_orders_url( $user->ID ), 'profileUrl' => current_user_can( 'edit_user', $user->ID ) ? (string) get_edit_user_link( $user->ID ) : '', ); } /** * The admin URL listing this customer's orders — HPOS and legacy * storage put that screen in different places. * * @param int $user_id User id. * @return string */ function openstation_my_wordpress_woo_customer_orders_url( $user_id ) { $user_id = (int) $user_id; if ( openstation_my_wordpress_woo_hpos_enabled() ) { return admin_url( 'admin.php?page=wc-orders&_customer_user=' . $user_id ); } return admin_url( 'edit.php?post_type=shop_order&_customer_user=' . $user_id ); } /** * Add the `customer` type to the shared summary route. * * Joining through the route's own extension seam rather than editing * its switch keeps the whole Customers surface in one file — and * proves the seam works, since this is the first thing to use it. * * @param array|null $data Summary payload (untouched for other types). * @param string $type Summary type. * @param int $id Object id. * @return array|WP_Error|null */ function openstation_my_wordpress_woo_customer_summary_filter( $data, $type, $id ) { if ( 'customer' !== $type ) { return $data; } return openstation_my_wordpress_woo_customer_summary( $id ); } /** * Gate the `customer` summary type. The generic fallback checks * `edit_post` against the id, which for a user id is meaningless — * and, on a site where post and user ids collide, wrong. * * @param true|WP_Error|null $allowed Permission verdict so far. * @param string $type Summary type. * @param int $id Object id. * @return true|WP_Error|null */ function openstation_my_wordpress_woo_customer_summary_capability( $allowed, $type, $id ) { unset( $id ); if ( 'customer' !== $type ) { return $allowed; } return openstation_my_wordpress_woo_customers_permission(); } /** * Register the Customers routes. * * @return void */ function openstation_my_wordpress_woo_register_customer_routes() { if ( ! openstation_my_wordpress_woo_active() ) { return; } register_rest_route( 'desktop-mode/v1', '/woocommerce/customers', array( 'methods' => WP_REST_Server::READABLE, 'callback' => 'openstation_my_wordpress_woo_customers', 'permission_callback' => 'openstation_my_wordpress_woo_customers_permission', 'args' => array( 'page' => array( 'type' => 'integer', 'default' => 1, ), 'per_page' => array( 'type' => 'integer', 'default' => 24, ), 'search' => array( 'type' => 'string' ), ), ) ); register_rest_route( 'desktop-mode/v1', '/woocommerce/customers/(?P\d+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => 'openstation_my_wordpress_woo_customer', 'permission_callback' => 'openstation_my_wordpress_woo_customers_permission', 'args' => array( 'id' => array( 'type' => 'integer' ), ), ) ); } add_action( 'rest_api_init', 'openstation_my_wordpress_woo_register_customer_routes' ); /* ------------------------------------------------------------------- * The section * ---------------------------------------------------------------- */ /** * Append the Customers section to the Woo folder. * * Registered on the same filter as Orders and at the same priority, * so it lands next to it inside the folder rather than at the end of * the entity list. * * @param array[] $entities Entity descriptors. * @return array[] */ function openstation_my_wordpress_woo_customer_entity( $entities ) { if ( ! is_array( $entities ) || ! openstation_my_wordpress_woo_active() ) { return $entities; } if ( true !== openstation_my_wordpress_woo_customers_permission() ) { return $entities; } $group = openstation_my_wordpress_woo_group( array( 'id' => 'plugin:woocommerce', 'label' => 'WooCommerce', 'icon' => 'dashicons-admin-plugins', 'order' => 20, ), 'shop_order' ); $entities[] = array( 'id' => 'wc-customers', 'label' => __( 'Customers', 'desktop-mode' ), 'icon' => 'dashicons-groups', 'restPath' => 'desktop-mode/v1/woocommerce/customers', // Renders through the built-in user kind: avatar tiles, the // dossier pane, the footprint route, the drag-out seam. A // customer is a person before it is a row of money. 'kind' => 'user', // Keeps the facts payload from being stripped by `_fields`. 'listFields' => array( 'openstation_woo_customer' ), 'group' => $group['id'], 'groupLabel' => $group['label'], 'groupIcon' => $group['icon'], 'groupOrder' => $group['order'], ); return $entities; } /** * Add the people numbers to the Woo folder's Store panel. * * Guest revenue is here because it is the one figure the Customers * section structurally cannot show: an order with no account has no * tile to sit on. Reporting it as a line on the folder is the honest * alternative to letting it disappear. * * @param array $data Store totals. * @return array */ function openstation_my_wordpress_woo_customer_store_totals( $data ) { if ( ! is_array( $data ) || true !== openstation_my_wordpress_woo_customers_permission() ) { return $data; } $map = openstation_my_wordpress_woo_customer_spend_map(); $guest = $map[0] ?? null; $plan = openstation_my_wordpress_woo_customer_plan(); $data['customers'] = (int) ( $plan['customers'] ?? 0 ); // Past the ordering cap the plan holds no bands, so the band // counts are not zero — they are unknown. Saying so is the whole // point: a store with 40,000 customers reporting "0 VIPs" is a // wrong answer stated confidently, which is worse than no answer. $data['bandsCapped'] = ! empty( $plan['capped'] ); if ( empty( $data['bandsCapped'] ) ) { $data['vips'] = (int) ( ( $plan['counts'] ?? array() )['vip'] ?? 0 ); $data['lapsed'] = (int) ( ( $plan['counts'] ?? array() )['lapsed'] ?? 0 ); } $data['guestSpend'] = $guest && (float) $guest['spend'] > 0 ? openstation_my_wordpress_woo_price( (float) $guest['spend'] ) : ''; $data['guestOrders'] = $guest ? (int) $guest['orders'] : 0; return $data; } /** * Boot the Customers surface. * * @return void */ function openstation_my_wordpress_woo_customers_boot() { add_filter( 'openstation_my_wordpress_woo_store', 'openstation_my_wordpress_woo_customer_store_totals' ); add_filter( 'openstation_my_wordpress_entities', 'openstation_my_wordpress_woo_customer_entity', 5 ); add_filter( 'openstation_my_wordpress_woo_summary_type', 'openstation_my_wordpress_woo_customer_summary_filter', 10, 3 ); add_filter( 'openstation_my_wordpress_woo_summary_capability', 'openstation_my_wordpress_woo_customer_summary_capability', 10, 3 ); } openstation_my_wordpress_woo_customers_boot();