| @@ -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 | { |