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

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

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