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 +154 -26 1.5.4 → 1.6.5 View file →
@@ -34,11 +34,11 @@
34 34 'type' => 'object',
35 35 'properties' => [
36 36 'search' => ['type' => 'string', 'description' => 'Matches name or email.'],
37 37 'status' => ['type' => 'string', 'enum' => ['active', 'archived']],
38 - 'country' => ['type' => 'string', 'description' => 'ISO-2 country code.'],
39 - 'state' => ['type' => 'string'],
40 - '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.'],
41 41 'min_ltv' => ['type' => 'number', 'description' => 'Minimum lifetime value in store currency.'],
42 42 'min_purchase_count' => ['type' => 'integer'],
43 43 'first_purchase_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
44 44 'last_purchase_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
@@ -58,9 +58,13 @@
58 58 ],
59 59
60 60 'fluent-cart/get-customer' => [
61 61 'label' => __('Get Customer', 'fluent-cart'),
62 - 'description' => __('Full profile + metrics for one customer. Identify by customer_id OR email. Add include[] for orders (each order row carries its line items: product id, title, quantity), 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 + ),
63 67 'input_schema' => [
64 68 'type' => 'object',
65 69 'properties' => [
66 70 'customer_id' => ['type' => 'integer'],
@@ -66,9 +70,9 @@
66 70 'customer_id' => ['type' => 'integer'],
67 71 'email' => ['type' => 'string'],
68 72 'include' => [
69 73 'type' => 'array',
70 - 'items' => ['type' => 'string', 'enum' => ['orders', 'subscriptions', 'addresses', 'labels', 'notes']],
74 + 'items' => ['type' => 'string', 'enum' => self::includeSections()],
71 75 'description' => 'Optional sections. Profile + metrics are always returned.',
72 76 ],
73 77 'with_orders_limit' => ['type' => 'integer', 'default' => 10, 'description' => 'Cap on orders when include has orders. Max 50.'],
74 78 ],
@@ -110,8 +114,36 @@
110 114 ],
111 115 ];
112 116 }
113 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 +
114 146 public static function upsertCustomer($params = [])
115 147 {
116 148 $ifExists = (isset($params['if_exists']) && in_array($params['if_exists'], ['merge', 'skip', 'error'], true)) ? $params['if_exists'] : 'merge';
117 149
@@ -277,8 +309,12 @@
277 309 if ($sortBy !== 'id') {
278 310 $query->orderBy('id', 'DESC');
279 311 }
280 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 +
281 317 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
282 318 $total = self::total($paginator);
283 319
284 320 $rows = [];
@@ -303,10 +339,11 @@
303 339 }
304 340
305 341 private static function formatRow($customer)
306 342 {
307 - $ltv = (int) $customer->ltv;
308 - $count = (int) $customer->purchase_count;
343 + $ltv = (int) $customer->ltv;
344 + $count = (int) $customer->purchase_count;
345 + $location = self::locationBlock($customer);
309 346
310 347 return [
311 348 'customer_id' => (int) $customer->id,
312 349 'label' => self::label($customer, $ltv),
@@ -315,9 +352,10 @@
315 352 'status' => $customer->status,
316 353 'ltv' => MCPHelper::moneyCompact($ltv),
317 354 'purchase_count' => $count,
318 355 'aov' => MCPHelper::moneyCompact(self::aovCents($ltv, $count)),
319 - 'location' => self::location($customer),
356 + 'location' => $location['location'],
357 + 'location_source' => $location['location_source'],
320 358 'last_purchase_date' => MCPHelper::toIso8601($customer->last_purchase_date),
321 359 ];
322 360 }
323 361
@@ -343,19 +381,21 @@
343 381 if (is_wp_error($customer)) {
344 382 return $customer;
345 383 }
346 384
347 - $include = isset($params['include']) ? (array) $params['include'] : [];
348 - $ltv = (int) $customer->ltv;
349 - $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);
350 389
351 390 $data = [
352 - 'customer_id' => (int) $customer->id,
353 - 'name' => MCPHelper::personName($customer),
354 - 'email' => $customer->email,
355 - 'status' => $customer->status,
356 - 'wp_user_id' => $customer->user_id ? (int) $customer->user_id : null,
357 - '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'],
358 398 'metrics' => [
359 399 'ltv' => MCPHelper::money($ltv),
360 400 'purchase_count' => $count,
361 401 'aov' => MCPHelper::money(self::aovCents($ltv, $count)),
@@ -381,8 +421,23 @@
381 421 if (in_array('notes', $include, true)) {
382 422 $data['notes'] = MCPHelper::htmlToText($customer->notes);
383 423 }
384 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 +
385 440 return MCPHelper::envelope(self::label($customer, $ltv), $data);
386 441 }
387 442
388 443 private static function resolve($params)
@@ -456,9 +511,11 @@
456 511 }
457 512 }
458 513 $out[] = [
459 514 'order_id' => (int) $order->id,
460 - '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,
461 518 'status' => $order->status,
462 519 'payment_status' => $order->payment_status,
463 520 'total' => MCPHelper::moneyCompact($order->total_amount),
464 521 'items' => $items,
@@ -467,20 +524,34 @@
467 524 }
468 525 return $out;
469 526 }
470 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 + */
471 536 private static function subscriptions($customer)
472 537 {
473 538 $subs = $customer->subscriptions()->orderBy('id', 'DESC')->get();
474 539 $out = [];
475 540 foreach ($subs as $sub) {
541 + $isInstallment = $sub->isInstallment();
476 542 $out[] = [
477 - 'id' => (int) $sub->id,
478 - 'status' => $sub->status,
479 - 'item_name' => $sub->item_name,
480 - 'recurring_total' => MCPHelper::moneyCompact($sub->recurring_total),
481 - 'billing_interval' => $sub->billing_interval,
482 - '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,
483 554 ];
484 555 }
485 556 return $out;
486 557 }
@@ -497,12 +568,69 @@
497 568 }
498 569 return $out;
499 570 }
500 571
501 - 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)
502 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 +
503 604 $parts = array_filter([$customer->city, $customer->state, $customer->country]);
504 - 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;
505 633 }
506 634
507 635 private static function aovCents($ltvCents, $count)
508 636 {