PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Modules/MCP/Tools/CustomerTools.php +197 -30 1.5.3 → 1.6.5 View file →
@@ -3,8 +3,9 @@
3 3 namespace FluentCart\App\Modules\MCP\Tools;
4 4
5 5 use FluentCart\App\Helpers\Helper;
6 6 use FluentCart\App\Models\Customer;
7 +use FluentCart\App\Modules\MCP\Support\AdvancedSearch;
7 8 use FluentCart\App\Modules\MCP\Support\MCPHelper;
8 9 use FluentCart\App\Modules\MCP\Support\PermissionGate;
9 10 use FluentCart\Api\Resource\CustomerResource;
10 11
@@ -27,22 +28,23 @@
27 28 {
28 29 return [
29 30 'fluent-cart/list-customers' => [
30 31 'label' => __('List Customers', 'fluent-cart'),
31 - 'description' => __('Find and filter customers. Compact rows with LTV, order count, AOV, and location. For one customer\'s full history use get-customer. min_ltv is in store currency (e.g. 500), not cents.', 'fluent-cart'),
32 + 'description' => __('Find and filter customers. Compact rows with LTV, order count, AOV, and location. For one customer\'s full history use get-customer. min_ltv is in store currency (e.g. 500), not cents. For conditions these flat filters cannot express (OR groups, buyers of a specific product/variation, relative purchase-date windows, labels) pass advanced_filters — call get-search-schema entity=customers first (Pro).', 'fluent-cart'),
32 33 'input_schema' => [
33 34 'type' => 'object',
34 35 'properties' => [
35 36 'search' => ['type' => 'string', 'description' => 'Matches name or email.'],
36 37 'status' => ['type' => 'string', 'enum' => ['active', 'archived']],
37 - 'country' => ['type' => 'string', 'description' => 'ISO-2 country code.'],
38 - 'state' => ['type' => 'string'],
39 - 'city' => ['type' => 'string'],
38 + 'country' => ['type' => 'string', 'description' => 'ISO-2 country code. Matches the customer PROFILE country, which is captured once when the customer record is created and never refreshed — it can differ from the billing address (see location_source on each row). For geography taken from the address on the order, filter list-orders by country instead.'],
39 + 'state' => ['type' => 'string', 'description' => 'Customer profile state — same caveat as country.'],
40 + 'city' => ['type' => 'string', 'description' => 'Customer profile city — same caveat as country.'],
40 41 'min_ltv' => ['type' => 'number', 'description' => 'Minimum lifetime value in store currency.'],
41 42 'min_purchase_count' => ['type' => 'integer'],
42 43 'first_purchase_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
43 44 'last_purchase_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
44 45 'last_purchase_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
46 + 'advanced_filters' => ['type' => 'array', 'items' => ['type' => ['object', 'array']], 'description' => 'Pro: condition groups {property, operator, value} — outer array = OR groups, inner = AND. Call get-search-schema entity=customers FIRST for properties/operators/format. AND-combines with the other filters here. An empty array means no advanced filter.'],
45 47 'sort_by' => ['type' => 'string', 'enum' => ['id', 'ltv', 'purchase_count', 'last_purchase_date', 'created_at'], 'default' => 'ltv'],
46 48 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
47 49 'page' => ['type' => 'integer', 'default' => 1],
48 50 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 100.'],
@@ -56,9 +58,13 @@
56 58 ],
57 59
58 60 'fluent-cart/get-customer' => [
59 61 'label' => __('Get Customer', 'fluent-cart'),
60 - 'description' => __('Full profile + metrics for one customer. Identify by customer_id OR email. Add include[] for orders, subscriptions, addresses, labels, notes. Use with_orders_limit to bound order history.', 'fluent-cart'),
62 + 'description' => sprintf(
63 + /* translators: %1$s: comma-separated include[] section names */
64 + __('Full profile + metrics for one customer. Identify by customer_id OR email. Add include[] for any of: %1$s (orders carry their line items: product id, title, quantity; subscriptions carry plan_type so installment plans are distinguishable from recurring ones). Use with_orders_limit to bound order history.', 'fluent-cart'),
65 + implode(', ', self::includeSections())
66 + ),
61 67 'input_schema' => [
62 68 'type' => 'object',
63 69 'properties' => [
64 70 'customer_id' => ['type' => 'integer'],
@@ -64,9 +70,9 @@
64 70 'customer_id' => ['type' => 'integer'],
65 71 'email' => ['type' => 'string'],
66 72 'include' => [
67 73 'type' => 'array',
68 - 'items' => ['type' => 'string', 'enum' => ['orders', 'subscriptions', 'addresses', 'labels', 'notes']],
74 + 'items' => ['type' => 'string', 'enum' => self::includeSections()],
69 75 'description' => 'Optional sections. Profile + metrics are always returned.',
70 76 ],
71 77 'with_orders_limit' => ['type' => 'integer', 'default' => 10, 'description' => 'Cap on orders when include has orders. Max 50.'],
72 78 ],
@@ -108,8 +114,36 @@
108 114 ],
109 115 ];
110 116 }
111 117
118 + /**
119 + * The sections get-customer's include[] accepts. Filterable so an add-on that
120 + * owns its own customer-scoped entity (Pro's licences, for one) can offer it
121 + * as an include instead of forcing the agent into a second list-* call and a
122 + * manual join by customer.
123 + *
124 + * A section added here MUST be populated by a listener on
125 + * fluent_cart/mcp_customer_data — an include the schema advertises but
126 + * nothing fills is worse than no include at all.
127 + *
128 + * @return array
129 + */
130 + private static function includeSections()
131 + {
132 + $sections = ['orders', 'subscriptions', 'addresses', 'labels', 'notes'];
133 +
134 + /**
135 + * Extra include[] section names for get-customer.
136 + *
137 + * @since 1.0.0
138 + *
139 + * @param array $sections section names offered in the include[] enum
140 + */
141 + $sections = apply_filters('fluent_cart/mcp_customer_include_sections', $sections);
142 +
143 + return array_values(array_unique(array_map('strval', (array) $sections)));
144 + }
145 +
112 146 public static function upsertCustomer($params = [])
113 147 {
114 148 $ifExists = (isset($params['if_exists']) && in_array($params['if_exists'], ['merge', 'skip', 'error'], true)) ? $params['if_exists'] : 'merge';
115 149
@@ -215,10 +249,24 @@
215 249
216 250 public static function listCustomers($params = [])
217 251 {
218 252 $paging = MCPHelper::pagination($params);
219 - $query = Customer::query();
220 253
254 + // advanced_filters routes through the admin filter engine (validated
255 + // first — a bad condition errors, never silently drops); the named
256 + // filters below then AND onto the same query either way.
257 + $advWarnings = [];
258 + if (!empty($params['advanced_filters'])) {
259 + $built = AdvancedSearch::buildQuery('customers', $params['advanced_filters']);
260 + if (is_wp_error($built)) {
261 + return $built;
262 + }
263 + $query = $built['query'];
264 + $advWarnings = $built['warnings'];
265 + } else {
266 + $query = Customer::query();
267 + }
268 +
221 269 if (!empty($params['search'])) {
222 270 $like = '%' . sanitize_text_field($params['search']) . '%';
223 271 $query->where(function ($q) use ($like) {
224 272 $q->where('email', 'LIKE', $like)
@@ -261,8 +309,12 @@
261 309 if ($sortBy !== 'id') {
262 310 $query->orderBy('id', 'DESC');
263 311 }
264 312
313 + // Eager-load both address relations the location string reads, so a page
314 + // of rows costs two extra queries instead of two per row.
315 + $query->with(['primary_billing_address', 'billing_address']);
316 +
265 317 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
266 318 $total = self::total($paginator);
267 319
268 320 $rows = [];
@@ -269,8 +321,13 @@
269 321 foreach (MCPHelper::paginatorItems($paginator) as $customer) {
270 322 $rows[] = self::formatRow($customer);
271 323 }
272 324
325 + $meta = MCPHelper::pagingMeta($paginator);
326 + if ($advWarnings) {
327 + $meta['warnings'] = $advWarnings;
328 + }
329 +
273 330 return MCPHelper::envelope(
274 331 sprintf(
275 332 /* translators: %d: number of matching customers */
276 333 _n('%d customer found.', '%d customers found.', $total, 'fluent-cart'),
@@ -276,16 +333,17 @@
276 333 _n('%d customer found.', '%d customers found.', $total, 'fluent-cart'),
277 334 $total
278 335 ),
279 336 ['customers' => $rows],
280 - MCPHelper::pagingMeta($paginator)
337 + $meta
281 338 );
282 339 }
283 340
284 341 private static function formatRow($customer)
285 342 {
286 - $ltv = (int) $customer->ltv;
287 - $count = (int) $customer->purchase_count;
343 + $ltv = (int) $customer->ltv;
344 + $count = (int) $customer->purchase_count;
345 + $location = self::locationBlock($customer);
288 346
289 347 return [
290 348 'customer_id' => (int) $customer->id,
291 349 'label' => self::label($customer, $ltv),
@@ -294,9 +352,10 @@
294 352 'status' => $customer->status,
295 353 'ltv' => MCPHelper::moneyCompact($ltv),
296 354 'purchase_count' => $count,
297 355 'aov' => MCPHelper::moneyCompact(self::aovCents($ltv, $count)),
298 - 'location' => self::location($customer),
356 + 'location' => $location['location'],
357 + 'location_source' => $location['location_source'],
299 358 'last_purchase_date' => MCPHelper::toIso8601($customer->last_purchase_date),
300 359 ];
301 360 }
302 361
@@ -322,19 +381,21 @@
322 381 if (is_wp_error($customer)) {
323 382 return $customer;
324 383 }
325 384
326 - $include = isset($params['include']) ? (array) $params['include'] : [];
327 - $ltv = (int) $customer->ltv;
328 - $count = (int) $customer->purchase_count;
385 + $include = isset($params['include']) ? (array) $params['include'] : [];
386 + $ltv = (int) $customer->ltv;
387 + $count = (int) $customer->purchase_count;
388 + $location = self::locationBlock($customer);
329 389
330 390 $data = [
331 - 'customer_id' => (int) $customer->id,
332 - 'name' => MCPHelper::personName($customer),
333 - 'email' => $customer->email,
334 - 'status' => $customer->status,
335 - 'wp_user_id' => $customer->user_id ? (int) $customer->user_id : null,
336 - 'location' => self::location($customer),
391 + 'customer_id' => (int) $customer->id,
392 + 'name' => MCPHelper::personName($customer),
393 + 'email' => $customer->email,
394 + 'status' => $customer->status,
395 + 'wp_user_id' => $customer->user_id ? (int) $customer->user_id : null,
396 + 'location' => $location['location'],
397 + 'location_source' => $location['location_source'],
337 398 'metrics' => [
338 399 'ltv' => MCPHelper::money($ltv),
339 400 'purchase_count' => $count,
340 401 'aov' => MCPHelper::money(self::aovCents($ltv, $count)),
@@ -360,8 +421,23 @@
360 421 if (in_array('notes', $include, true)) {
361 422 $data['notes'] = MCPHelper::htmlToText($customer->notes);
362 423 }
363 424
425 + /**
426 + * The assembled get-customer payload, for add-on sections registered
427 + * through fluent_cart/mcp_customer_include_sections. Listeners should add
428 + * their key only when it is present in $context['include'].
429 + *
430 + * @since 1.0.0
431 + *
432 + * @param array $data the customer payload
433 + * @param array $context { customer: Customer, include: string[] }
434 + */
435 + $data = apply_filters('fluent_cart/mcp_customer_data', $data, [
436 + 'customer' => $customer,
437 + 'include' => $include,
438 + ]);
439 +
364 440 return MCPHelper::envelope(self::label($customer, $ltv), $data);
365 441 }
366 442
367 443 private static function resolve($params)
@@ -413,17 +489,37 @@
413 489 }
414 490
415 491 private static function orders($customer, $limit)
416 492 {
417 - $orders = $customer->orders()->orderBy('id', 'DESC')->limit($limit)->get();
493 + // Trimmed order_items eager load, same as list-orders: without the items
494 + // there is no way to tell WHAT the customer bought from this view, which
495 + // is the whole point of a customer's order history.
496 + $orders = $customer->orders()
497 + ->with(['order_items' => function ($q) {
498 + $q->select(['id', 'order_id', 'post_id', 'post_title', 'title', 'quantity']);
499 + }])
500 + ->orderBy('id', 'DESC')->limit($limit)->get();
418 501 $out = [];
419 502 foreach ($orders as $order) {
503 + $items = [];
504 + if ($order->relationLoaded('order_items')) {
505 + foreach ($order->order_items as $item) {
506 + $items[] = [
507 + 'product_id' => (int) $item->post_id,
508 + 'title' => $item->getDisplayTitle(),
509 + 'quantity' => (int) $item->quantity,
510 + ];
511 + }
512 + }
420 513 $out[] = [
421 514 'order_id' => (int) $order->id,
422 - 'number' => $order->invoice_no ? $order->invoice_no : (string) $order->id,
515 + // null until an invoice number is assigned — same contract as
516 + // list-orders and get-order.
517 + 'number' => $order->invoice_no ? $order->invoice_no : null,
423 518 'status' => $order->status,
424 519 'payment_status' => $order->payment_status,
425 520 'total' => MCPHelper::moneyCompact($order->total_amount),
521 + 'items' => $items,
426 522 'created_at' => MCPHelper::toIso8601($order->created_at),
427 523 ];
428 524 }
429 525 return $out;
@@ -428,20 +524,34 @@
428 524 }
429 525 return $out;
430 526 }
431 527
528 + /**
529 + * Nested subscription rows. plan_type and the installment counters are the
530 + * same derivation list-subscriptions / get-subscription use (bill_times > 0),
531 + * carried here because otherwise the only way to tell a lifetime licence paid
532 + * in N installments from a genuine recurring plan on this payload was to
533 + * string-match "(Split Pay)" in item_name — the two are structurally
534 + * identical without it, and they are opposite kinds of revenue.
535 + */
432 536 private static function subscriptions($customer)
433 537 {
434 538 $subs = $customer->subscriptions()->orderBy('id', 'DESC')->get();
435 539 $out = [];
436 540 foreach ($subs as $sub) {
541 + $isInstallment = $sub->isInstallment();
437 542 $out[] = [
438 - 'id' => (int) $sub->id,
439 - 'status' => $sub->status,
440 - 'item_name' => $sub->item_name,
441 - 'recurring_total' => MCPHelper::moneyCompact($sub->recurring_total),
442 - 'billing_interval' => $sub->billing_interval,
443 - 'next_billing_date' => MCPHelper::toIso8601($sub->next_billing_date),
543 + 'id' => (int) $sub->id,
544 + 'status' => $sub->status,
545 + 'item_name' => $sub->item_name,
546 + 'plan_type' => $isInstallment ? 'installment' : 'recurring',
547 + 'recurring_total' => MCPHelper::moneyCompact($sub->recurring_total),
548 + 'billing_interval' => $sub->billing_interval,
549 + 'next_billing_date' => MCPHelper::toIso8601($sub->next_billing_date),
550 + 'bill_times' => (int) $sub->bill_times,
551 + 'installments_paid' => (int) $sub->bill_count,
552 + 'installments_remaining' => $sub->installmentsRemaining(),
553 + 'total_contract_value' => $isInstallment ? MCPHelper::moneyCompact($sub->totalContractValue()) : null,
444 554 ];
445 555 }
446 556 return $out;
447 557 }
@@ -458,12 +568,69 @@
458 568 }
459 569 return $out;
460 570 }
461 571
462 - private static function location($customer)
572 + /**
573 + * The convenience location string, derived from the customer's primary
574 + * billing address rather than the fct_customers city/state/country columns.
575 + *
576 + * Those columns are a WRITE-ONCE snapshot: CustomerResource::create() seeds
577 + * them when the customer row is first inserted and nothing refreshes them
578 + * afterwards (CheckoutApi::updateExistingCustomer() deliberately touches only
579 + * user_id), and the value seeded at checkout can be the country the frontend
580 + * GUESSED from the browser timezone before the buyer typed anything. The
581 + * billing address is the value the buyer actually entered, so it wins; the
582 + * profile columns are the fallback for a customer with no address row.
583 + *
584 + * In practice the two rarely conflict — on the 2,320-customer reference store
585 + * they never do (zero rows where both are set and differ). What this ordering
586 + * buys is correctness when a stale snapshot DOES diverge, plus coverage: 7
587 + * customers there have an address but no profile value, so reading the profile
588 + * alone would report no location for a customer whose address is on file.
589 + * location_source names which source produced the string, so an agent seeing
590 + * `location` next to an `addresses` include can tell whether they should agree.
591 + *
592 + * @return array { location: string|null, location_source: string|null }
593 + */
594 + private static function locationBlock($customer)
463 595 {
596 + $address = self::billingAddress($customer);
597 + if ($address) {
598 + $parts = array_filter([$address->city, $address->state, $address->country]);
599 + if ($parts) {
600 + return ['location' => implode(', ', $parts), 'location_source' => 'billing_address'];
601 + }
602 + }
603 +
464 604 $parts = array_filter([$customer->city, $customer->state, $customer->country]);
465 - return $parts ? implode(', ', $parts) : null;
605 + if ($parts) {
606 + return ['location' => implode(', ', $parts), 'location_source' => 'customer_profile'];
607 + }
608 +
609 + return ['location' => null, 'location_source' => null];
610 + }
611 +
612 + /**
613 + * The customer's primary billing address, falling back to any billing address
614 + * when none is flagged primary (real stores have both). Relations are checked
615 + * before loading so a caller that eager-loaded them (list-customers) pays no
616 + * per-row query.
617 + */
618 + private static function billingAddress($customer)
619 + {
620 + if (!$customer->relationLoaded('primary_billing_address')) {
621 + $customer->load('primary_billing_address');
622 + }
623 + if ($customer->primary_billing_address) {
624 + return $customer->primary_billing_address;
625 + }
626 +
627 + if (!$customer->relationLoaded('billing_address')) {
628 + $customer->load('billing_address');
629 + }
630 + $addresses = $customer->billing_address;
631 +
632 + return ($addresses && count($addresses)) ? $addresses[0] : null;
466 633 }
467 634
468 635 private static function aovCents($ltvCents, $count)
469 636 {