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

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

675 lines 31.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\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. 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 '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' => 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 ),
67 'input_schema' => [
68 'type' => 'object',
69 'properties' => [
70 'customer_id' => ['type' => 'integer'],
71 'email' => ['type' => 'string'],
72 'include' => [
73 'type' => 'array',
74 'items' => ['type' => 'string', 'enum' => self::includeSections()],
75 'description' => 'Optional sections. Profile + metrics are always returned.',
76 ],
77 'with_orders_limit' => ['type' => 'integer', 'default' => 10, 'description' => 'Cap on orders when include has orders. Max 50.'],
78 ],
79 ],
80 'execute_callback' => [self::class, 'getCustomer'],
81 'permission_callback' => function () {
82 return PermissionGate::can('customers/view');
83 },
84 'annotations' => ['readonly' => true],
85 ],
86
87 'fluent-cart/upsert-customer' => [
88 'label' => __('Create or Update Customer', 'fluent-cart'),
89 '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'),
90 'input_schema' => [
91 'type' => 'object',
92 'properties' => [
93 'customer_id' => ['type' => 'integer'],
94 'email' => ['type' => 'string', 'description' => 'Required to create; used to match on update.'],
95 'new_email' => ['type' => 'string', 'description' => 'Rename an existing customer in place.'],
96 'first_name' => ['type' => 'string'],
97 'last_name' => ['type' => 'string'],
98 'status' => ['type' => 'string', 'enum' => ['active', 'archived']],
99 'city' => ['type' => 'string'],
100 'state' => ['type' => 'string'],
101 'country' => ['type' => 'string', 'description' => 'ISO-2 country code.'],
102 'postcode' => ['type' => 'string'],
103 'if_exists' => ['type' => 'string', 'enum' => ['merge', 'skip', 'error'], 'default' => 'merge'],
104 ],
105 ],
106 'execute_callback' => [self::class, 'upsertCustomer'],
107 'permission_callback' => function () {
108 return PermissionGate::can('customers/manage');
109 },
110 // Upsert by id/email with if_exists — repeating the same call
111 // converges to the same record (idempotent). Archives rather than
112 // hard-deletes, so not destructive.
113 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true],
114 ],
115 ];
116 }
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
146 public static function upsertCustomer($params = [])
147 {
148 $ifExists = (isset($params['if_exists']) && in_array($params['if_exists'], ['merge', 'skip', 'error'], true)) ? $params['if_exists'] : 'merge';
149
150 // Reject invalid emails up front — sanitize_email() silently returns ''
151 // for garbage input, which would otherwise create an empty-email customer.
152 foreach (['email', 'new_email'] as $emailField) {
153 if (isset($params[$emailField]) && $params[$emailField] !== '') {
154 $clean = sanitize_email($params[$emailField]);
155 if (!$clean || !is_email($clean)) {
156 return MCPHelper::error(
157 'invalid_email',
158 sprintf(
159 /* translators: 1: field name */
160 __('The provided %1$s is not a valid email address.', 'fluent-cart'),
161 $emailField
162 ),
163 ['fields' => [$emailField]]
164 );
165 }
166 }
167 }
168
169 $existing = null;
170 if (!empty($params['customer_id'])) {
171 $existing = Customer::query()->where('id', (int) $params['customer_id'])->first();
172 if (!$existing) {
173 return MCPHelper::error('customer_not_found', __('No customer found for the given customer_id.', 'fluent-cart'));
174 }
175 } elseif (!empty($params['email'])) {
176 $existing = Customer::query()->where('email', sanitize_email($params['email']))->first();
177 } else {
178 return MCPHelper::error('missing_identifier', __('Provide customer_id to update, or email to create or match.', 'fluent-cart'), ['fields' => ['customer_id', 'email']]);
179 }
180
181 $fields = self::writableFields($params);
182
183 if ($existing) {
184 if ($ifExists === 'skip') {
185 return MCPHelper::envelope(__('Customer already exists; left unchanged.', 'fluent-cart'), ['customer_id' => (int) $existing->id, 'action' => 'skipped']);
186 }
187 if ($ifExists === 'error') {
188 return MCPHelper::error('customer_exists', __('A customer with this identifier already exists.', 'fluent-cart'), ['customer_id' => (int) $existing->id]);
189 }
190 if (!empty($params['new_email'])) {
191 $newEmail = sanitize_email($params['new_email']);
192 $taken = Customer::query()->where('email', $newEmail)->where('id', '!=', $existing->id)->first();
193 if ($taken) {
194 return MCPHelper::error('email_taken', __('Another customer already uses that email.', 'fluent-cart'));
195 }
196 $fields['email'] = $newEmail;
197 }
198 if ($fields) {
199 $existing->fill($fields);
200 $existing->save();
201 }
202 $existing = Customer::query()->where('id', $existing->id)->first();
203 return MCPHelper::envelope(
204 self::label($existing, (int) $existing->ltv),
205 ['customer_id' => (int) $existing->id, 'action' => 'updated', 'name' => MCPHelper::personName($existing), 'email' => $existing->email, 'status' => $existing->status]
206 );
207 }
208
209 if (empty($params['email'])) {
210 return MCPHelper::error('missing_param', __('email is required to create a customer.', 'fluent-cart'));
211 }
212 $fields['email'] = sanitize_email($params['email']);
213 if (empty($fields['status'])) {
214 $fields['status'] = 'active';
215 }
216
217 // Delegate to the resource layer: it normalizes the name, links an
218 // existing WP user via user_id, and uses firstOrCreate so a concurrent
219 // create matches rather than duplicating — none of which a raw
220 // Customer::create() does.
221 $result = CustomerResource::create($fields);
222 if (is_wp_error($result)) {
223 return MCPHelper::error('customer_create_failed', $result->get_error_message(), ['retryable' => true]);
224 }
225 $customer = is_array($result) && isset($result['data']) ? $result['data'] : null;
226 if (!is_object($customer) || empty($customer->id)) {
227 return MCPHelper::error('customer_create_failed', __('Customer creation failed.', 'fluent-cart'));
228 }
229
230 return MCPHelper::envelope(
231 self::label($customer, (int) $customer->ltv),
232 ['customer_id' => (int) $customer->id, 'action' => 'created', 'name' => MCPHelper::personName($customer), 'email' => $customer->email, 'status' => $customer->status]
233 );
234 }
235
236 private static function writableFields($params)
237 {
238 $out = [];
239 foreach (['first_name', 'last_name', 'status', 'city', 'state', 'country', 'postcode'] as $f) {
240 if (isset($params[$f])) {
241 $out[$f] = sanitize_text_field($params[$f]);
242 }
243 }
244 if (isset($out['status']) && !in_array($out['status'], ['active', 'archived'], true)) {
245 unset($out['status']);
246 }
247 return $out;
248 }
249
250 public static function listCustomers($params = [])
251 {
252 $paging = MCPHelper::pagination($params);
253
254 // advanced_filters routes through the admin filter engine (validated
255 // first — a bad condition errors, never silently drops); the named
256 // filters below then AND onto the same query either way.
257 $advWarnings = [];
258 if (!empty($params['advanced_filters'])) {
259 $built = AdvancedSearch::buildQuery('customers', $params['advanced_filters']);
260 if (is_wp_error($built)) {
261 return $built;
262 }
263 $query = $built['query'];
264 $advWarnings = $built['warnings'];
265 } else {
266 $query = Customer::query();
267 }
268
269 if (!empty($params['search'])) {
270 $like = '%' . sanitize_text_field($params['search']) . '%';
271 $query->where(function ($q) use ($like) {
272 $q->where('email', 'LIKE', $like)
273 ->orWhere('first_name', 'LIKE', $like)
274 ->orWhere('last_name', 'LIKE', $like);
275 });
276 }
277
278 foreach (['status', 'country', 'state', 'city'] as $col) {
279 if (!empty($params[$col])) {
280 $query->where($col, sanitize_text_field($params[$col]));
281 }
282 }
283
284 if (isset($params['min_ltv'])) {
285 $query->where('ltv', '>=', Helper::toCent($params['min_ltv']));
286 }
287 if (isset($params['min_purchase_count'])) {
288 $query->where('purchase_count', '>=', (int) $params['min_purchase_count']);
289 }
290 $dateFilters = [
291 'first_purchase_after' => ['first_purchase_date', '>='],
292 'last_purchase_after' => ['last_purchase_date', '>='],
293 'last_purchase_before' => ['last_purchase_date', '<='],
294 ];
295 foreach ($dateFilters as $field => $spec) {
296 if (empty($params[$field])) {
297 continue;
298 }
299 $date = self::toDbDate($params[$field]);
300 if ($date === null) {
301 return self::invalidDateError($field);
302 }
303 $query->where($spec[0], $spec[1], $date);
304 }
305
306 $sortBy = self::allowed($params, 'sort_by', ['id', 'ltv', 'purchase_count', 'last_purchase_date', 'created_at'], 'ltv');
307 $sortType = strtoupper(isset($params['sort_type']) ? $params['sort_type'] : 'DESC') === 'ASC' ? 'ASC' : 'DESC';
308 $query->orderBy($sortBy, $sortType);
309 if ($sortBy !== 'id') {
310 $query->orderBy('id', 'DESC');
311 }
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
317 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
318 $total = self::total($paginator);
319
320 $rows = [];
321 foreach (MCPHelper::paginatorItems($paginator) as $customer) {
322 $rows[] = self::formatRow($customer);
323 }
324
325 $meta = MCPHelper::pagingMeta($paginator);
326 if ($advWarnings) {
327 $meta['warnings'] = $advWarnings;
328 }
329
330 return MCPHelper::envelope(
331 sprintf(
332 /* translators: %d: number of matching customers */
333 _n('%d customer found.', '%d customers found.', $total, 'fluent-cart'),
334 $total
335 ),
336 ['customers' => $rows],
337 $meta
338 );
339 }
340
341 private static function formatRow($customer)
342 {
343 $ltv = (int) $customer->ltv;
344 $count = (int) $customer->purchase_count;
345 $location = self::locationBlock($customer);
346
347 return [
348 'customer_id' => (int) $customer->id,
349 'label' => self::label($customer, $ltv),
350 'name' => MCPHelper::personName($customer),
351 'email' => $customer->email,
352 'status' => $customer->status,
353 'ltv' => MCPHelper::moneyCompact($ltv),
354 'purchase_count' => $count,
355 'aov' => MCPHelper::moneyCompact(self::aovCents($ltv, $count)),
356 'location' => $location['location'],
357 'location_source' => $location['location_source'],
358 'last_purchase_date' => MCPHelper::toIso8601($customer->last_purchase_date),
359 ];
360 }
361
362 private static function label($customer, $ltvCents)
363 {
364 $name = MCPHelper::personName($customer);
365 if (!$name) {
366 $name = $customer->email;
367 }
368
369 return sprintf(
370 /* translators: 1: customer name, 2: email, 3: lifetime value */
371 __('%1$s [%2$s] — LTV %3$s', 'fluent-cart'),
372 $name,
373 $customer->email,
374 MCPHelper::displayAmount($ltvCents)
375 );
376 }
377
378 public static function getCustomer($params = [])
379 {
380 $customer = self::resolve($params);
381 if (is_wp_error($customer)) {
382 return $customer;
383 }
384
385 $include = isset($params['include']) ? (array) $params['include'] : [];
386 $ltv = (int) $customer->ltv;
387 $count = (int) $customer->purchase_count;
388 $location = self::locationBlock($customer);
389
390 $data = [
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'],
398 'metrics' => [
399 'ltv' => MCPHelper::money($ltv),
400 'purchase_count' => $count,
401 'aov' => MCPHelper::money(self::aovCents($ltv, $count)),
402 'first_purchase_date' => MCPHelper::toIso8601($customer->first_purchase_date),
403 'last_purchase_date' => MCPHelper::toIso8601($customer->last_purchase_date),
404 ],
405 'created_at' => MCPHelper::toIso8601($customer->created_at),
406 ];
407
408 if (in_array('addresses', $include, true)) {
409 $data['addresses'] = self::addresses($customer);
410 }
411 if (in_array('orders', $include, true)) {
412 $limit = isset($params['with_orders_limit']) ? min(max((int) $params['with_orders_limit'], 1), 50) : 10;
413 $data['orders'] = self::orders($customer, $limit);
414 }
415 if (in_array('subscriptions', $include, true)) {
416 $data['subscriptions'] = self::subscriptions($customer);
417 }
418 if (in_array('labels', $include, true)) {
419 $data['labels'] = self::labels($customer);
420 }
421 if (in_array('notes', $include, true)) {
422 $data['notes'] = MCPHelper::htmlToText($customer->notes);
423 }
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
440 return MCPHelper::envelope(self::label($customer, $ltv), $data);
441 }
442
443 private static function resolve($params)
444 {
445 if (!empty($params['customer_id'])) {
446 $customer = Customer::query()->where('id', (int) $params['customer_id'])->first();
447 } elseif (!empty($params['email'])) {
448 $customer = Customer::query()->where('email', sanitize_email($params['email']))->first();
449 } else {
450 return MCPHelper::error('missing_identifier', __('Provide customer_id or email.', 'fluent-cart'), ['fields' => ['customer_id', 'email']]);
451 }
452
453 if (!$customer) {
454 return MCPHelper::error('customer_not_found', __('No customer found for the given identifier.', 'fluent-cart'));
455 }
456
457 return $customer;
458 }
459
460 private static function addresses($customer)
461 {
462 $customer->load('billing_address', 'shipping_address');
463 return [
464 'billing' => self::addressList($customer->billing_address),
465 'shipping' => self::addressList($customer->shipping_address),
466 ];
467 }
468
469 private static function addressList($addresses)
470 {
471 if (!$addresses) {
472 return [];
473 }
474 $out = [];
475 foreach ($addresses as $addr) {
476 $out[] = [
477 'name' => $addr->name,
478 'address_1' => $addr->address_1,
479 'address_2' => $addr->address_2,
480 'city' => $addr->city,
481 'state' => $addr->state,
482 'postcode' => $addr->postcode,
483 'country' => $addr->country,
484 'phone' => $addr->phone,
485 'is_primary' => (bool) $addr->is_primary,
486 ];
487 }
488 return $out;
489 }
490
491 private static function orders($customer, $limit)
492 {
493 // Trimmed order_items eager load, same as list-orders: without the items
494 // there is no way to tell WHAT the customer bought from this view, which
495 // is the whole point of a customer's order history.
496 $orders = $customer->orders()
497 ->with(['order_items' => function ($q) {
498 $q->select(['id', 'order_id', 'post_id', 'post_title', 'title', 'quantity']);
499 }])
500 ->orderBy('id', 'DESC')->limit($limit)->get();
501 $out = [];
502 foreach ($orders as $order) {
503 $items = [];
504 if ($order->relationLoaded('order_items')) {
505 foreach ($order->order_items as $item) {
506 $items[] = [
507 'product_id' => (int) $item->post_id,
508 'title' => $item->getDisplayTitle(),
509 'quantity' => (int) $item->quantity,
510 ];
511 }
512 }
513 $out[] = [
514 'order_id' => (int) $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,
518 'status' => $order->status,
519 'payment_status' => $order->payment_status,
520 'total' => MCPHelper::moneyCompact($order->total_amount),
521 'items' => $items,
522 'created_at' => MCPHelper::toIso8601($order->created_at),
523 ];
524 }
525 return $out;
526 }
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 */
536 private static function subscriptions($customer)
537 {
538 $subs = $customer->subscriptions()->orderBy('id', 'DESC')->get();
539 $out = [];
540 foreach ($subs as $sub) {
541 $isInstallment = $sub->isInstallment();
542 $out[] = [
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,
554 ];
555 }
556 return $out;
557 }
558
559 private static function labels($customer)
560 {
561 $customer->load('labels');
562 $out = [];
563 if (!$customer->relationLoaded('labels')) {
564 return $out;
565 }
566 foreach ($customer->labels as $label) {
567 $out[] = ['id' => (int) $label->id, 'title' => $label->title];
568 }
569 return $out;
570 }
571
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)
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
604 $parts = array_filter([$customer->city, $customer->state, $customer->country]);
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;
633 }
634
635 private static function aovCents($ltvCents, $count)
636 {
637 return $count > 0 ? (int) round($ltvCents / $count) : 0;
638 }
639
640 private static function allowed($params, $key, array $allowed, $default)
641 {
642 $val = isset($params[$key]) ? $params[$key] : $default;
643 return in_array($val, $allowed, true) ? $val : $default;
644 }
645
646 private static function total($paginator)
647 {
648 return MCPHelper::paginatorTotal($paginator);
649 }
650
651 private static function toDbDate($value)
652 {
653 try {
654 return (new \DateTime((string) $value, new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
655 } catch (\Exception $e) {
656 // Return null so callers reject the input. An epoch fallback would
657 // silently turn a typo'd date bound into an unbounded "match all".
658 return null;
659 }
660 }
661
662 private static function invalidDateError($field)
663 {
664 return MCPHelper::error(
665 'invalid_date',
666 sprintf(
667 /* translators: 1: field name */
668 __('%1$s is not a valid date. Use YYYY-MM-DD or ISO 8601.', 'fluent-cart'),
669 $field
670 ),
671 ['fields' => [$field]]
672 );
673 }
674 }
675