PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.4
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
fluent-cart / app / Modules / MCP / Tools / CustomerTools.php

CustomerTools.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.4, at app/Modules/MCP/Tools/CustomerTools.php

547 lines 24.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\MCP\Tools;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\Models\Customer;
7 use FluentCart\App\Modules\MCP\Support\AdvancedSearch;
8 use FluentCart\App\Modules\MCP\Support\MCPHelper;
9 use FluentCart\App\Modules\MCP\Support\PermissionGate;
10 use FluentCart\Api\Resource\CustomerResource;
11
12 /**
13 * Customer tools — find customers, then load one's 360° view.
14 *
15 * Parameter design:
16 * - list-customers filters on the metrics owners actually segment by (LTV,
17 * purchase count, location, first/last purchase window). LTV/min_ltv are in
18 * store currency, not cents.
19 * - AOV is computed (ltv ÷ purchase_count) rather than read from the stored
20 * column, so it's always internally consistent with the LTV we show.
21 * - get-customer is lean by default (profile + metrics); orders, subscriptions,
22 * addresses, labels, notes are opt-in via include[]. with_orders_limit
23 * bounds the order history so a whale's account can't flood context.
24 */
25 class CustomerTools
26 {
27 public static function definitions()
28 {
29 return [
30 'fluent-cart/list-customers' => [
31 'label' => __('List Customers', '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'),
33 'input_schema' => [
34 'type' => 'object',
35 'properties' => [
36 'search' => ['type' => 'string', 'description' => 'Matches name or email.'],
37 'status' => ['type' => 'string', 'enum' => ['active', 'archived']],
38 'country' => ['type' => 'string', 'description' => 'ISO-2 country code.'],
39 'state' => ['type' => 'string'],
40 'city' => ['type' => 'string'],
41 'min_ltv' => ['type' => 'number', 'description' => 'Minimum lifetime value in store currency.'],
42 'min_purchase_count' => ['type' => 'integer'],
43 'first_purchase_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
44 'last_purchase_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
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.'],
47 'sort_by' => ['type' => 'string', 'enum' => ['id', 'ltv', 'purchase_count', 'last_purchase_date', 'created_at'], 'default' => 'ltv'],
48 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
49 'page' => ['type' => 'integer', 'default' => 1],
50 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 100.'],
51 ],
52 ],
53 'execute_callback' => [self::class, 'listCustomers'],
54 'permission_callback' => function () {
55 return PermissionGate::can('customers/view');
56 },
57 'annotations' => ['readonly' => true],
58 ],
59
60 'fluent-cart/get-customer' => [
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'),
63 'input_schema' => [
64 'type' => 'object',
65 'properties' => [
66 'customer_id' => ['type' => 'integer'],
67 'email' => ['type' => 'string'],
68 'include' => [
69 'type' => 'array',
70 'items' => ['type' => 'string', 'enum' => ['orders', 'subscriptions', 'addresses', 'labels', 'notes']],
71 'description' => 'Optional sections. Profile + metrics are always returned.',
72 ],
73 'with_orders_limit' => ['type' => 'integer', 'default' => 10, 'description' => 'Cap on orders when include has orders. Max 50.'],
74 ],
75 ],
76 'execute_callback' => [self::class, 'getCustomer'],
77 'permission_callback' => function () {
78 return PermissionGate::can('customers/view');
79 },
80 'annotations' => ['readonly' => true],
81 ],
82
83 'fluent-cart/upsert-customer' => [
84 'label' => __('Create or Update Customer', 'fluent-cart'),
85 'description' => __('Create a customer or update an existing one. Identify by customer_id to update, or email to create or match. On create, email is required. Only the fields you pass change. Set status to archived to deactivate; there is no hard delete. Use new_email to rename. if_exists handles a matched email: merge updates, skip leaves it, error returns a conflict.', 'fluent-cart'),
86 'input_schema' => [
87 'type' => 'object',
88 'properties' => [
89 'customer_id' => ['type' => 'integer'],
90 'email' => ['type' => 'string', 'description' => 'Required to create; used to match on update.'],
91 'new_email' => ['type' => 'string', 'description' => 'Rename an existing customer in place.'],
92 'first_name' => ['type' => 'string'],
93 'last_name' => ['type' => 'string'],
94 'status' => ['type' => 'string', 'enum' => ['active', 'archived']],
95 'city' => ['type' => 'string'],
96 'state' => ['type' => 'string'],
97 'country' => ['type' => 'string', 'description' => 'ISO-2 country code.'],
98 'postcode' => ['type' => 'string'],
99 'if_exists' => ['type' => 'string', 'enum' => ['merge', 'skip', 'error'], 'default' => 'merge'],
100 ],
101 ],
102 'execute_callback' => [self::class, 'upsertCustomer'],
103 'permission_callback' => function () {
104 return PermissionGate::can('customers/manage');
105 },
106 // Upsert by id/email with if_exists — repeating the same call
107 // converges to the same record (idempotent). Archives rather than
108 // hard-deletes, so not destructive.
109 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true],
110 ],
111 ];
112 }
113
114 public static function upsertCustomer($params = [])
115 {
116 $ifExists = (isset($params['if_exists']) && in_array($params['if_exists'], ['merge', 'skip', 'error'], true)) ? $params['if_exists'] : 'merge';
117
118 // Reject invalid emails up front — sanitize_email() silently returns ''
119 // for garbage input, which would otherwise create an empty-email customer.
120 foreach (['email', 'new_email'] as $emailField) {
121 if (isset($params[$emailField]) && $params[$emailField] !== '') {
122 $clean = sanitize_email($params[$emailField]);
123 if (!$clean || !is_email($clean)) {
124 return MCPHelper::error(
125 'invalid_email',
126 sprintf(
127 /* translators: 1: field name */
128 __('The provided %1$s is not a valid email address.', 'fluent-cart'),
129 $emailField
130 ),
131 ['fields' => [$emailField]]
132 );
133 }
134 }
135 }
136
137 $existing = null;
138 if (!empty($params['customer_id'])) {
139 $existing = Customer::query()->where('id', (int) $params['customer_id'])->first();
140 if (!$existing) {
141 return MCPHelper::error('customer_not_found', __('No customer found for the given customer_id.', 'fluent-cart'));
142 }
143 } elseif (!empty($params['email'])) {
144 $existing = Customer::query()->where('email', sanitize_email($params['email']))->first();
145 } else {
146 return MCPHelper::error('missing_identifier', __('Provide customer_id to update, or email to create or match.', 'fluent-cart'), ['fields' => ['customer_id', 'email']]);
147 }
148
149 $fields = self::writableFields($params);
150
151 if ($existing) {
152 if ($ifExists === 'skip') {
153 return MCPHelper::envelope(__('Customer already exists; left unchanged.', 'fluent-cart'), ['customer_id' => (int) $existing->id, 'action' => 'skipped']);
154 }
155 if ($ifExists === 'error') {
156 return MCPHelper::error('customer_exists', __('A customer with this identifier already exists.', 'fluent-cart'), ['customer_id' => (int) $existing->id]);
157 }
158 if (!empty($params['new_email'])) {
159 $newEmail = sanitize_email($params['new_email']);
160 $taken = Customer::query()->where('email', $newEmail)->where('id', '!=', $existing->id)->first();
161 if ($taken) {
162 return MCPHelper::error('email_taken', __('Another customer already uses that email.', 'fluent-cart'));
163 }
164 $fields['email'] = $newEmail;
165 }
166 if ($fields) {
167 $existing->fill($fields);
168 $existing->save();
169 }
170 $existing = Customer::query()->where('id', $existing->id)->first();
171 return MCPHelper::envelope(
172 self::label($existing, (int) $existing->ltv),
173 ['customer_id' => (int) $existing->id, 'action' => 'updated', 'name' => MCPHelper::personName($existing), 'email' => $existing->email, 'status' => $existing->status]
174 );
175 }
176
177 if (empty($params['email'])) {
178 return MCPHelper::error('missing_param', __('email is required to create a customer.', 'fluent-cart'));
179 }
180 $fields['email'] = sanitize_email($params['email']);
181 if (empty($fields['status'])) {
182 $fields['status'] = 'active';
183 }
184
185 // Delegate to the resource layer: it normalizes the name, links an
186 // existing WP user via user_id, and uses firstOrCreate so a concurrent
187 // create matches rather than duplicating — none of which a raw
188 // Customer::create() does.
189 $result = CustomerResource::create($fields);
190 if (is_wp_error($result)) {
191 return MCPHelper::error('customer_create_failed', $result->get_error_message(), ['retryable' => true]);
192 }
193 $customer = is_array($result) && isset($result['data']) ? $result['data'] : null;
194 if (!is_object($customer) || empty($customer->id)) {
195 return MCPHelper::error('customer_create_failed', __('Customer creation failed.', 'fluent-cart'));
196 }
197
198 return MCPHelper::envelope(
199 self::label($customer, (int) $customer->ltv),
200 ['customer_id' => (int) $customer->id, 'action' => 'created', 'name' => MCPHelper::personName($customer), 'email' => $customer->email, 'status' => $customer->status]
201 );
202 }
203
204 private static function writableFields($params)
205 {
206 $out = [];
207 foreach (['first_name', 'last_name', 'status', 'city', 'state', 'country', 'postcode'] as $f) {
208 if (isset($params[$f])) {
209 $out[$f] = sanitize_text_field($params[$f]);
210 }
211 }
212 if (isset($out['status']) && !in_array($out['status'], ['active', 'archived'], true)) {
213 unset($out['status']);
214 }
215 return $out;
216 }
217
218 public static function listCustomers($params = [])
219 {
220 $paging = MCPHelper::pagination($params);
221
222 // advanced_filters routes through the admin filter engine (validated
223 // first — a bad condition errors, never silently drops); the named
224 // filters below then AND onto the same query either way.
225 $advWarnings = [];
226 if (!empty($params['advanced_filters'])) {
227 $built = AdvancedSearch::buildQuery('customers', $params['advanced_filters']);
228 if (is_wp_error($built)) {
229 return $built;
230 }
231 $query = $built['query'];
232 $advWarnings = $built['warnings'];
233 } else {
234 $query = Customer::query();
235 }
236
237 if (!empty($params['search'])) {
238 $like = '%' . sanitize_text_field($params['search']) . '%';
239 $query->where(function ($q) use ($like) {
240 $q->where('email', 'LIKE', $like)
241 ->orWhere('first_name', 'LIKE', $like)
242 ->orWhere('last_name', 'LIKE', $like);
243 });
244 }
245
246 foreach (['status', 'country', 'state', 'city'] as $col) {
247 if (!empty($params[$col])) {
248 $query->where($col, sanitize_text_field($params[$col]));
249 }
250 }
251
252 if (isset($params['min_ltv'])) {
253 $query->where('ltv', '>=', Helper::toCent($params['min_ltv']));
254 }
255 if (isset($params['min_purchase_count'])) {
256 $query->where('purchase_count', '>=', (int) $params['min_purchase_count']);
257 }
258 $dateFilters = [
259 'first_purchase_after' => ['first_purchase_date', '>='],
260 'last_purchase_after' => ['last_purchase_date', '>='],
261 'last_purchase_before' => ['last_purchase_date', '<='],
262 ];
263 foreach ($dateFilters as $field => $spec) {
264 if (empty($params[$field])) {
265 continue;
266 }
267 $date = self::toDbDate($params[$field]);
268 if ($date === null) {
269 return self::invalidDateError($field);
270 }
271 $query->where($spec[0], $spec[1], $date);
272 }
273
274 $sortBy = self::allowed($params, 'sort_by', ['id', 'ltv', 'purchase_count', 'last_purchase_date', 'created_at'], 'ltv');
275 $sortType = strtoupper(isset($params['sort_type']) ? $params['sort_type'] : 'DESC') === 'ASC' ? 'ASC' : 'DESC';
276 $query->orderBy($sortBy, $sortType);
277 if ($sortBy !== 'id') {
278 $query->orderBy('id', 'DESC');
279 }
280
281 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
282 $total = self::total($paginator);
283
284 $rows = [];
285 foreach (MCPHelper::paginatorItems($paginator) as $customer) {
286 $rows[] = self::formatRow($customer);
287 }
288
289 $meta = MCPHelper::pagingMeta($paginator);
290 if ($advWarnings) {
291 $meta['warnings'] = $advWarnings;
292 }
293
294 return MCPHelper::envelope(
295 sprintf(
296 /* translators: %d: number of matching customers */
297 _n('%d customer found.', '%d customers found.', $total, 'fluent-cart'),
298 $total
299 ),
300 ['customers' => $rows],
301 $meta
302 );
303 }
304
305 private static function formatRow($customer)
306 {
307 $ltv = (int) $customer->ltv;
308 $count = (int) $customer->purchase_count;
309
310 return [
311 'customer_id' => (int) $customer->id,
312 'label' => self::label($customer, $ltv),
313 'name' => MCPHelper::personName($customer),
314 'email' => $customer->email,
315 'status' => $customer->status,
316 'ltv' => MCPHelper::moneyCompact($ltv),
317 'purchase_count' => $count,
318 'aov' => MCPHelper::moneyCompact(self::aovCents($ltv, $count)),
319 'location' => self::location($customer),
320 'last_purchase_date' => MCPHelper::toIso8601($customer->last_purchase_date),
321 ];
322 }
323
324 private static function label($customer, $ltvCents)
325 {
326 $name = MCPHelper::personName($customer);
327 if (!$name) {
328 $name = $customer->email;
329 }
330
331 return sprintf(
332 /* translators: 1: customer name, 2: email, 3: lifetime value */
333 __('%1$s [%2$s] — LTV %3$s', 'fluent-cart'),
334 $name,
335 $customer->email,
336 MCPHelper::displayAmount($ltvCents)
337 );
338 }
339
340 public static function getCustomer($params = [])
341 {
342 $customer = self::resolve($params);
343 if (is_wp_error($customer)) {
344 return $customer;
345 }
346
347 $include = isset($params['include']) ? (array) $params['include'] : [];
348 $ltv = (int) $customer->ltv;
349 $count = (int) $customer->purchase_count;
350
351 $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),
358 'metrics' => [
359 'ltv' => MCPHelper::money($ltv),
360 'purchase_count' => $count,
361 'aov' => MCPHelper::money(self::aovCents($ltv, $count)),
362 'first_purchase_date' => MCPHelper::toIso8601($customer->first_purchase_date),
363 'last_purchase_date' => MCPHelper::toIso8601($customer->last_purchase_date),
364 ],
365 'created_at' => MCPHelper::toIso8601($customer->created_at),
366 ];
367
368 if (in_array('addresses', $include, true)) {
369 $data['addresses'] = self::addresses($customer);
370 }
371 if (in_array('orders', $include, true)) {
372 $limit = isset($params['with_orders_limit']) ? min(max((int) $params['with_orders_limit'], 1), 50) : 10;
373 $data['orders'] = self::orders($customer, $limit);
374 }
375 if (in_array('subscriptions', $include, true)) {
376 $data['subscriptions'] = self::subscriptions($customer);
377 }
378 if (in_array('labels', $include, true)) {
379 $data['labels'] = self::labels($customer);
380 }
381 if (in_array('notes', $include, true)) {
382 $data['notes'] = MCPHelper::htmlToText($customer->notes);
383 }
384
385 return MCPHelper::envelope(self::label($customer, $ltv), $data);
386 }
387
388 private static function resolve($params)
389 {
390 if (!empty($params['customer_id'])) {
391 $customer = Customer::query()->where('id', (int) $params['customer_id'])->first();
392 } elseif (!empty($params['email'])) {
393 $customer = Customer::query()->where('email', sanitize_email($params['email']))->first();
394 } else {
395 return MCPHelper::error('missing_identifier', __('Provide customer_id or email.', 'fluent-cart'), ['fields' => ['customer_id', 'email']]);
396 }
397
398 if (!$customer) {
399 return MCPHelper::error('customer_not_found', __('No customer found for the given identifier.', 'fluent-cart'));
400 }
401
402 return $customer;
403 }
404
405 private static function addresses($customer)
406 {
407 $customer->load('billing_address', 'shipping_address');
408 return [
409 'billing' => self::addressList($customer->billing_address),
410 'shipping' => self::addressList($customer->shipping_address),
411 ];
412 }
413
414 private static function addressList($addresses)
415 {
416 if (!$addresses) {
417 return [];
418 }
419 $out = [];
420 foreach ($addresses as $addr) {
421 $out[] = [
422 'name' => $addr->name,
423 'address_1' => $addr->address_1,
424 'address_2' => $addr->address_2,
425 'city' => $addr->city,
426 'state' => $addr->state,
427 'postcode' => $addr->postcode,
428 'country' => $addr->country,
429 'phone' => $addr->phone,
430 'is_primary' => (bool) $addr->is_primary,
431 ];
432 }
433 return $out;
434 }
435
436 private static function orders($customer, $limit)
437 {
438 // Trimmed order_items eager load, same as list-orders: without the items
439 // there is no way to tell WHAT the customer bought from this view, which
440 // is the whole point of a customer's order history.
441 $orders = $customer->orders()
442 ->with(['order_items' => function ($q) {
443 $q->select(['id', 'order_id', 'post_id', 'post_title', 'title', 'quantity']);
444 }])
445 ->orderBy('id', 'DESC')->limit($limit)->get();
446 $out = [];
447 foreach ($orders as $order) {
448 $items = [];
449 if ($order->relationLoaded('order_items')) {
450 foreach ($order->order_items as $item) {
451 $items[] = [
452 'product_id' => (int) $item->post_id,
453 'title' => $item->getDisplayTitle(),
454 'quantity' => (int) $item->quantity,
455 ];
456 }
457 }
458 $out[] = [
459 'order_id' => (int) $order->id,
460 'number' => $order->invoice_no ? $order->invoice_no : (string) $order->id,
461 'status' => $order->status,
462 'payment_status' => $order->payment_status,
463 'total' => MCPHelper::moneyCompact($order->total_amount),
464 'items' => $items,
465 'created_at' => MCPHelper::toIso8601($order->created_at),
466 ];
467 }
468 return $out;
469 }
470
471 private static function subscriptions($customer)
472 {
473 $subs = $customer->subscriptions()->orderBy('id', 'DESC')->get();
474 $out = [];
475 foreach ($subs as $sub) {
476 $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),
483 ];
484 }
485 return $out;
486 }
487
488 private static function labels($customer)
489 {
490 $customer->load('labels');
491 $out = [];
492 if (!$customer->relationLoaded('labels')) {
493 return $out;
494 }
495 foreach ($customer->labels as $label) {
496 $out[] = ['id' => (int) $label->id, 'title' => $label->title];
497 }
498 return $out;
499 }
500
501 private static function location($customer)
502 {
503 $parts = array_filter([$customer->city, $customer->state, $customer->country]);
504 return $parts ? implode(', ', $parts) : null;
505 }
506
507 private static function aovCents($ltvCents, $count)
508 {
509 return $count > 0 ? (int) round($ltvCents / $count) : 0;
510 }
511
512 private static function allowed($params, $key, array $allowed, $default)
513 {
514 $val = isset($params[$key]) ? $params[$key] : $default;
515 return in_array($val, $allowed, true) ? $val : $default;
516 }
517
518 private static function total($paginator)
519 {
520 return MCPHelper::paginatorTotal($paginator);
521 }
522
523 private static function toDbDate($value)
524 {
525 try {
526 return (new \DateTime((string) $value, new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
527 } catch (\Exception $e) {
528 // Return null so callers reject the input. An epoch fallback would
529 // silently turn a typo'd date bound into an unbounded "match all".
530 return null;
531 }
532 }
533
534 private static function invalidDateError($field)
535 {
536 return MCPHelper::error(
537 'invalid_date',
538 sprintf(
539 /* translators: 1: field name */
540 __('%1$s is not a valid date. Use YYYY-MM-DD or ISO 8601.', 'fluent-cart'),
541 $field
542 ),
543 ['fields' => [$field]]
544 );
545 }
546 }
547