PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.0
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.0, at app/Modules/MCP/Tools/CustomerTools.php

504 lines 22.2 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 ],
105 ];
106 }
107
108 public static function upsertCustomer($params = [])
109 {
110 $ifExists = (isset($params['if_exists']) && in_array($params['if_exists'], ['merge', 'skip', 'error'], true)) ? $params['if_exists'] : 'merge';
111
112 // Reject invalid emails up front — sanitize_email() silently returns ''
113 // for garbage input, which would otherwise create an empty-email customer.
114 foreach (['email', 'new_email'] as $emailField) {
115 if (isset($params[$emailField]) && $params[$emailField] !== '') {
116 $clean = sanitize_email($params[$emailField]);
117 if (!$clean || !is_email($clean)) {
118 return MCPHelper::error(
119 'invalid_email',
120 sprintf(
121 /* translators: 1: field name */
122 __('The provided %1$s is not a valid email address.', 'fluent-cart'),
123 $emailField
124 ),
125 ['fields' => [$emailField]]
126 );
127 }
128 }
129 }
130
131 $existing = null;
132 if (!empty($params['customer_id'])) {
133 $existing = Customer::query()->where('id', (int) $params['customer_id'])->first();
134 if (!$existing) {
135 return MCPHelper::error('customer_not_found', __('No customer found for the given customer_id.', 'fluent-cart'));
136 }
137 } elseif (!empty($params['email'])) {
138 $existing = Customer::query()->where('email', sanitize_email($params['email']))->first();
139 } else {
140 return MCPHelper::error('missing_identifier', __('Provide customer_id to update, or email to create or match.', 'fluent-cart'), ['fields' => ['customer_id', 'email']]);
141 }
142
143 $fields = self::writableFields($params);
144
145 if ($existing) {
146 if ($ifExists === 'skip') {
147 return MCPHelper::envelope(__('Customer already exists; left unchanged.', 'fluent-cart'), ['customer_id' => (int) $existing->id, 'action' => 'skipped']);
148 }
149 if ($ifExists === 'error') {
150 return MCPHelper::error('customer_exists', __('A customer with this identifier already exists.', 'fluent-cart'), ['customer_id' => (int) $existing->id]);
151 }
152 if (!empty($params['new_email'])) {
153 $newEmail = sanitize_email($params['new_email']);
154 $taken = Customer::query()->where('email', $newEmail)->where('id', '!=', $existing->id)->first();
155 if ($taken) {
156 return MCPHelper::error('email_taken', __('Another customer already uses that email.', 'fluent-cart'));
157 }
158 $fields['email'] = $newEmail;
159 }
160 if ($fields) {
161 $existing->fill($fields);
162 $existing->save();
163 }
164 $existing = Customer::query()->where('id', $existing->id)->first();
165 return MCPHelper::envelope(
166 self::label($existing, (int) $existing->ltv),
167 ['customer_id' => (int) $existing->id, 'action' => 'updated', 'name' => MCPHelper::personName($existing), 'email' => $existing->email, 'status' => $existing->status]
168 );
169 }
170
171 if (empty($params['email'])) {
172 return MCPHelper::error('missing_param', __('email is required to create a customer.', 'fluent-cart'));
173 }
174 $fields['email'] = sanitize_email($params['email']);
175 if (empty($fields['status'])) {
176 $fields['status'] = 'active';
177 }
178
179 // Delegate to the resource layer: it normalizes the name, links an
180 // existing WP user via user_id, and uses firstOrCreate so a concurrent
181 // create matches rather than duplicating — none of which a raw
182 // Customer::create() does.
183 $result = CustomerResource::create($fields);
184 if (is_wp_error($result)) {
185 return MCPHelper::error('customer_create_failed', $result->get_error_message(), ['retryable' => true]);
186 }
187 $customer = is_array($result) && isset($result['data']) ? $result['data'] : null;
188 if (!is_object($customer) || empty($customer->id)) {
189 return MCPHelper::error('customer_create_failed', __('Customer creation failed.', 'fluent-cart'));
190 }
191
192 return MCPHelper::envelope(
193 self::label($customer, (int) $customer->ltv),
194 ['customer_id' => (int) $customer->id, 'action' => 'created', 'name' => MCPHelper::personName($customer), 'email' => $customer->email, 'status' => $customer->status]
195 );
196 }
197
198 private static function writableFields($params)
199 {
200 $out = [];
201 foreach (['first_name', 'last_name', 'status', 'city', 'state', 'country', 'postcode'] as $f) {
202 if (isset($params[$f])) {
203 $out[$f] = sanitize_text_field($params[$f]);
204 }
205 }
206 if (isset($out['status']) && !in_array($out['status'], ['active', 'archived'], true)) {
207 unset($out['status']);
208 }
209 return $out;
210 }
211
212 public static function listCustomers($params = [])
213 {
214 $paging = MCPHelper::pagination($params);
215 $query = Customer::query();
216
217 if (!empty($params['search'])) {
218 $like = '%' . sanitize_text_field($params['search']) . '%';
219 $query->where(function ($q) use ($like) {
220 $q->where('email', 'LIKE', $like)
221 ->orWhere('first_name', 'LIKE', $like)
222 ->orWhere('last_name', 'LIKE', $like);
223 });
224 }
225
226 foreach (['status', 'country', 'state', 'city'] as $col) {
227 if (!empty($params[$col])) {
228 $query->where($col, sanitize_text_field($params[$col]));
229 }
230 }
231
232 if (isset($params['min_ltv'])) {
233 $query->where('ltv', '>=', Helper::toCent($params['min_ltv']));
234 }
235 if (isset($params['min_purchase_count'])) {
236 $query->where('purchase_count', '>=', (int) $params['min_purchase_count']);
237 }
238 $dateFilters = [
239 'first_purchase_after' => ['first_purchase_date', '>='],
240 'last_purchase_after' => ['last_purchase_date', '>='],
241 'last_purchase_before' => ['last_purchase_date', '<='],
242 ];
243 foreach ($dateFilters as $field => $spec) {
244 if (empty($params[$field])) {
245 continue;
246 }
247 $date = self::toDbDate($params[$field]);
248 if ($date === null) {
249 return self::invalidDateError($field);
250 }
251 $query->where($spec[0], $spec[1], $date);
252 }
253
254 $sortBy = self::allowed($params, 'sort_by', ['id', 'ltv', 'purchase_count', 'last_purchase_date', 'created_at'], 'ltv');
255 $sortType = strtoupper(isset($params['sort_type']) ? $params['sort_type'] : 'DESC') === 'ASC' ? 'ASC' : 'DESC';
256 $query->orderBy($sortBy, $sortType);
257 if ($sortBy !== 'id') {
258 $query->orderBy('id', 'DESC');
259 }
260
261 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
262 $total = self::total($paginator);
263
264 $rows = [];
265 foreach (MCPHelper::paginatorItems($paginator) as $customer) {
266 $rows[] = self::formatRow($customer);
267 }
268
269 return MCPHelper::envelope(
270 sprintf(
271 /* translators: %d: number of matching customers */
272 _n('%d customer found.', '%d customers found.', $total, 'fluent-cart'),
273 $total
274 ),
275 ['customers' => $rows],
276 MCPHelper::pagingMeta($paginator)
277 );
278 }
279
280 private static function formatRow($customer)
281 {
282 $ltv = (int) $customer->ltv;
283 $count = (int) $customer->purchase_count;
284
285 return [
286 'customer_id' => (int) $customer->id,
287 'label' => self::label($customer, $ltv),
288 'name' => MCPHelper::personName($customer),
289 'email' => $customer->email,
290 'status' => $customer->status,
291 'ltv' => MCPHelper::moneyCompact($ltv),
292 'purchase_count' => $count,
293 'aov' => MCPHelper::moneyCompact(self::aovCents($ltv, $count)),
294 'location' => self::location($customer),
295 'last_purchase_date' => MCPHelper::toIso8601($customer->last_purchase_date),
296 ];
297 }
298
299 private static function label($customer, $ltvCents)
300 {
301 $name = MCPHelper::personName($customer);
302 if (!$name) {
303 $name = $customer->email;
304 }
305
306 return sprintf(
307 /* translators: 1: customer name, 2: email, 3: lifetime value */
308 __('%1$s [%2$s] — LTV %3$s', 'fluent-cart'),
309 $name,
310 $customer->email,
311 MCPHelper::displayAmount($ltvCents)
312 );
313 }
314
315 public static function getCustomer($params = [])
316 {
317 $customer = self::resolve($params);
318 if (is_wp_error($customer)) {
319 return $customer;
320 }
321
322 $include = isset($params['include']) ? (array) $params['include'] : [];
323 $ltv = (int) $customer->ltv;
324 $count = (int) $customer->purchase_count;
325
326 $data = [
327 'customer_id' => (int) $customer->id,
328 'name' => MCPHelper::personName($customer),
329 'email' => $customer->email,
330 'status' => $customer->status,
331 'wp_user_id' => $customer->user_id ? (int) $customer->user_id : null,
332 'location' => self::location($customer),
333 'metrics' => [
334 'ltv' => MCPHelper::money($ltv),
335 'purchase_count' => $count,
336 'aov' => MCPHelper::money(self::aovCents($ltv, $count)),
337 'first_purchase_date' => MCPHelper::toIso8601($customer->first_purchase_date),
338 'last_purchase_date' => MCPHelper::toIso8601($customer->last_purchase_date),
339 ],
340 'created_at' => MCPHelper::toIso8601($customer->created_at),
341 ];
342
343 if (in_array('addresses', $include, true)) {
344 $data['addresses'] = self::addresses($customer);
345 }
346 if (in_array('orders', $include, true)) {
347 $limit = isset($params['with_orders_limit']) ? min(max((int) $params['with_orders_limit'], 1), 50) : 10;
348 $data['orders'] = self::orders($customer, $limit);
349 }
350 if (in_array('subscriptions', $include, true)) {
351 $data['subscriptions'] = self::subscriptions($customer);
352 }
353 if (in_array('labels', $include, true)) {
354 $data['labels'] = self::labels($customer);
355 }
356 if (in_array('notes', $include, true)) {
357 $data['notes'] = MCPHelper::htmlToText($customer->notes);
358 }
359
360 return MCPHelper::envelope(self::label($customer, $ltv), $data);
361 }
362
363 private static function resolve($params)
364 {
365 if (!empty($params['customer_id'])) {
366 $customer = Customer::query()->where('id', (int) $params['customer_id'])->first();
367 } elseif (!empty($params['email'])) {
368 $customer = Customer::query()->where('email', sanitize_email($params['email']))->first();
369 } else {
370 return MCPHelper::error('missing_identifier', __('Provide customer_id or email.', 'fluent-cart'), ['fields' => ['customer_id', 'email']]);
371 }
372
373 if (!$customer) {
374 return MCPHelper::error('customer_not_found', __('No customer found for the given identifier.', 'fluent-cart'));
375 }
376
377 return $customer;
378 }
379
380 private static function addresses($customer)
381 {
382 $customer->load('billing_address', 'shipping_address');
383 return [
384 'billing' => self::addressList($customer->billing_address),
385 'shipping' => self::addressList($customer->shipping_address),
386 ];
387 }
388
389 private static function addressList($addresses)
390 {
391 if (!$addresses) {
392 return [];
393 }
394 $out = [];
395 foreach ($addresses as $addr) {
396 $out[] = [
397 'name' => $addr->name,
398 'address_1' => $addr->address_1,
399 'address_2' => $addr->address_2,
400 'city' => $addr->city,
401 'state' => $addr->state,
402 'postcode' => $addr->postcode,
403 'country' => $addr->country,
404 'phone' => $addr->phone,
405 'is_primary' => (bool) $addr->is_primary,
406 ];
407 }
408 return $out;
409 }
410
411 private static function orders($customer, $limit)
412 {
413 $orders = $customer->orders()->orderBy('id', 'DESC')->limit($limit)->get();
414 $out = [];
415 foreach ($orders as $order) {
416 $out[] = [
417 'order_id' => (int) $order->id,
418 'number' => $order->invoice_no ? $order->invoice_no : (string) $order->id,
419 'status' => $order->status,
420 'payment_status' => $order->payment_status,
421 'total' => MCPHelper::moneyCompact($order->total_amount),
422 'created_at' => MCPHelper::toIso8601($order->created_at),
423 ];
424 }
425 return $out;
426 }
427
428 private static function subscriptions($customer)
429 {
430 $subs = $customer->subscriptions()->orderBy('id', 'DESC')->get();
431 $out = [];
432 foreach ($subs as $sub) {
433 $out[] = [
434 'id' => (int) $sub->id,
435 'status' => $sub->status,
436 'item_name' => $sub->item_name,
437 'recurring_total' => MCPHelper::moneyCompact($sub->recurring_total),
438 'billing_interval' => $sub->billing_interval,
439 'next_billing_date' => MCPHelper::toIso8601($sub->next_billing_date),
440 ];
441 }
442 return $out;
443 }
444
445 private static function labels($customer)
446 {
447 $customer->load('labels');
448 $out = [];
449 if (!$customer->relationLoaded('labels')) {
450 return $out;
451 }
452 foreach ($customer->labels as $label) {
453 $out[] = ['id' => (int) $label->id, 'title' => $label->title];
454 }
455 return $out;
456 }
457
458 private static function location($customer)
459 {
460 $parts = array_filter([$customer->city, $customer->state, $customer->country]);
461 return $parts ? implode(', ', $parts) : null;
462 }
463
464 private static function aovCents($ltvCents, $count)
465 {
466 return $count > 0 ? (int) round($ltvCents / $count) : 0;
467 }
468
469 private static function allowed($params, $key, array $allowed, $default)
470 {
471 $val = isset($params[$key]) ? $params[$key] : $default;
472 return in_array($val, $allowed, true) ? $val : $default;
473 }
474
475 private static function total($paginator)
476 {
477 return MCPHelper::paginatorTotal($paginator);
478 }
479
480 private static function toDbDate($value)
481 {
482 try {
483 return (new \DateTime((string) $value, new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
484 } catch (\Exception $e) {
485 // Return null so callers reject the input. An epoch fallback would
486 // silently turn a typo'd date bound into an unbounded "match all".
487 return null;
488 }
489 }
490
491 private static function invalidDateError($field)
492 {
493 return MCPHelper::error(
494 'invalid_date',
495 sprintf(
496 /* translators: 1: field name */
497 __('%1$s is not a valid date. Use YYYY-MM-DD or ISO 8601.', 'fluent-cart'),
498 $field
499 ),
500 ['fields' => [$field]]
501 );
502 }
503 }
504