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 / ContextTools.php

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

667 lines 33.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\App;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Models\Order;
8 use FluentCart\App\Models\Customer;
9 use FluentCart\App\Models\Subscription;
10 use FluentCart\App\Modules\MCP\AbilitiesRegistrar;
11 use FluentCart\App\Modules\MCP\Support\MCPHelper;
12 use FluentCart\App\Modules\MCP\Support\PermissionGate;
13 use FluentCart\App\Services\DateTime\DateTime;
14 use FluentCart\App\Services\Permission\PermissionManager;
15
16 /**
17 * Discovery tools — the agent's entry point into a FluentCart store.
18 *
19 * `get-store-context` is the documented "call this first" tool. One call tells
20 * the agent who it is, what it's allowed to do, the store's money/time
21 * conventions, headline numbers, and every valid enum value — so it never has
22 * to guess a status string or invent a currency format. It's cached (60s) and
23 * invalidated when reference data changes, because it's called every session.
24 *
25 * `list-reference-data` is the on-demand lookup for the heavier reference lists
26 * (coupons, labels, tax/shipping config) the agent only sometimes needs — kept
27 * OUT of the context payload so the first call stays lean.
28 *
29 * Parameter philosophy: get-store-context takes nothing (zero friction, it's
30 * discovery). list-reference-data takes only `kinds[]` — the agent asks for
31 * exactly the lists it needs, and we return only the kinds its role can see.
32 */
33 class ContextTools
34 {
35 const CACHE_TTL = 60;
36
37 const CACHE_PREFIX = 'fluent_cart_mcp_context_';
38
39 // Baseline domain enums. The status families are re-read from the canonical
40 // Status helper at runtime by enums() — this literal is only the fallback for
41 // a family Status cannot answer for. Do not hand-maintain the status lists
42 // here: an enum the agent trusts but the column can never hold turns every
43 // filter built from it into a silent zero-row result.
44 const ENUMS = [
45 // Status::getOrderStatuses() plus PERSISTED_ONLY_ORDER_STATUSES — see that
46 // constant for why the canonical helper is not the whole set.
47 // 'partial-refund' is deliberately absent: unlike the others below, no code
48 // path writes it (the column COMMENT lists it, but nothing persists it).
49 'order_statuses' => ['draft', 'pending', 'processing', 'completed', 'on-hold', 'canceled', 'failed', 'refunded'],
50 // Kept in sync with Status::getPaymentStatuses(); 'authorized' (card
51 // authorized, not yet captured) and 'payment_scheduled' are valid
52 // persisted statuses and must be listed so clients can filter them.
53 'payment_statuses' => ['pending', 'paid', 'partially_paid', 'failed', 'refunded', 'partially_refunded', 'authorized', 'payment_scheduled'],
54 // 'none' = no shipping required (e.g. digital orders); reported when the
55 // stored value is empty. It is read-only — change-order-status won't set it.
56 'shipping_statuses' => ['none', 'unshipped', 'shipped', 'delivered', 'unshippable'],
57 'order_types' => ['payment', 'renewal', 'subscription'],
58 'subscription_statuses' => ['pending', 'active', 'failing', 'paused', 'expired', 'expiring', 'canceled', 'trialing', 'intended', 'past_due', 'completed'],
59 // installment = fixed-term split-pay plan (a lifetime license paid off in
60 // a finite number of charges, bill_times > 0); recurring = open-ended
61 // subscription (bill_times = 0). Derived from bill_times, never the title.
62 'plan_types' => ['installment', 'recurring'],
63 'billing_intervals' => ['daily', 'weekly', 'monthly', 'quarterly', 'half_yearly', 'yearly'],
64 'fulfillment_types' => ['physical', 'digital'],
65 'coupon_types' => ['fixed', 'percentage'],
66 'order_modes' => ['live', 'test'],
67 ];
68
69 /**
70 * Order statuses fct_orders.status genuinely holds that Status::getOrderStatuses()
71 * does NOT list, because that helper answers "what may an admin SET an order to",
72 * not "what can this column contain".
73 *
74 * An enum is wrong in two directions, and only one of them is loud. Listing a
75 * value the column can never hold gives the agent a filter that silently returns
76 * zero rows. OMITTING a value the column does hold is worse: those rows become
77 * unreachable, and because the value is missing from the input_schema enum the
78 * call is rejected outright, so the agent cannot even discover the rows exist.
79 *
80 * Each of these is written by a core path, verified in source:
81 * - draft: the column DEFAULT (database/Migrations/OrdersMigrator.php).
82 * - pending: every store-managed renewal invoice
83 * (StoreManagedRenewal/Services/RenewalService.php:113).
84 * - refunded: the WooCommerce migrator maps wc-refunded to it
85 * (WooCommerceMigrator/Services/OrderMigrationService.php).
86 *
87 * So a store using store-managed renewals, or migrated from WooCommerce, has rows
88 * the five-value helper cannot describe. Keep this list in step with the writers,
89 * not with the admin dropdown.
90 *
91 * Note COD is NOT one of them: a COD checkout creates the order as 'on-hold' and
92 * only its payment_status is pending. Cod::maybeUpdatePayments() looks like an
93 * order-status writer but has no callers.
94 */
95 const PERSISTED_ONLY_ORDER_STATUSES = ['draft', 'pending', 'refunded'];
96
97 /**
98 * The enums the agent is told to trust, with every status family re-read from
99 * the canonical Status helper so this payload can never drift from what the
100 * columns actually hold (a drifted enum is worse than a missing one — the
101 * agent builds a valid-looking filter that always returns zero rows).
102 *
103 * Status::get*Statuses() are themselves filtered, so a Pro/add-on status
104 * registered through those hooks shows up here automatically.
105 *
106 * @return array
107 */
108 public static function enums()
109 {
110 $enums = self::ENUMS;
111
112 $live = [
113 'order_statuses' => [Status::class, 'getOrderStatuses'],
114 'payment_statuses' => [Status::class, 'getPaymentStatuses'],
115 'shipping_statuses' => [Status::class, 'getShippingStatuses'],
116 'subscription_statuses' => [Status::class, 'getSubscriptionStatuses'],
117 ];
118
119 foreach ($live as $key => $callable) {
120 try {
121 $values = array_values(array_map('strval', array_keys((array) call_user_func($callable))));
122 } catch (\Throwable $e) {
123 // Keep the baseline rather than shipping an empty enum: an empty
124 // list reads as "no valid values" and blocks every filter.
125 continue;
126 }
127 if (!$values) {
128 continue;
129 }
130 // 'none' is an MCP-only reported value (empty stored shipping status)
131 // that Status has no constant for — re-add it after the live overlay.
132 if ($key === 'shipping_statuses') {
133 array_unshift($values, 'none');
134 }
135 // Statuses the column holds that the helper does not list. Unioned, not
136 // overwritten: getOrderStatuses() is the settable list, so overwriting
137 // would drop 'pending'/'draft'/'refunded' and make those real rows
138 // unfilterable. See PERSISTED_ONLY_ORDER_STATUSES.
139 if ($key === 'order_statuses') {
140 $values = array_merge($values, self::PERSISTED_ONLY_ORDER_STATUSES);
141 }
142 $enums[$key] = array_values(array_unique($values));
143 }
144
145 return $enums;
146 }
147
148 // Payment statuses that count as realized revenue. Centralized so every
149 // tool (context, reports, aggregates) agrees on what "paid" means.
150 const PAID_STATUSES = ['paid', 'partially_paid', 'partially_refunded'];
151
152 /**
153 * Ability definitions for this domain. The registrar merges every tool
154 * class's definitions(), so a tool's schema lives next to its code.
155 */
156 public static function definitions()
157 {
158 return [
159 'fluent-cart/get-store-context' => [
160 'label' => __('Get Store Context', 'fluent-cart'),
161 'description' => __('START HERE — call once per session. Returns who you are and your permissions, the store currency/timezone conventions, headline stats, every valid enum value (order/payment/shipping/subscription statuses, intervals, types), and usage guidelines. Use this before any other tool so you never guess a status string or money format.', 'fluent-cart'),
162 'input_schema' => [
163 'type' => 'object',
164 'properties' => new \stdClass(),
165 ],
166 'execute_callback' => [self::class, 'getContext'],
167 'permission_callback' => function () {
168 return PermissionGate::can('dashboard_stats/view') || PermissionGate::canAny(PermissionGate::readRoleCaps());
169 },
170 'annotations' => ['readonly' => true],
171 ],
172
173 'fluent-cart/list-reference-data' => [
174 'label' => __('List Reference Data', 'fluent-cart'),
175 'description' => __('On-demand lookup lists kept out of get-store-context to keep it lean: coupons, labels, gateways, tax_classes, shipping_zones, product_categories. Pass kinds[] with only what you need. Kinds your role cannot see are reported in meta.warnings, not dropped silently. The coupons kind is a capped snapshot (newest 200, each with times_used) — to filter by status/code, paginate, or find usable-now coupons, use list-coupons instead.', 'fluent-cart'),
176 'input_schema' => [
177 'type' => 'object',
178 'properties' => [
179 'kinds' => [
180 'type' => 'array',
181 'description' => 'Which reference lists to return.',
182 'items' => ['type' => 'string', 'enum' => ['coupons', 'labels', 'gateways', 'tax_classes', 'shipping_zones', 'product_categories']],
183 ],
184 ],
185 'required' => ['kinds'],
186 ],
187 'execute_callback' => [self::class, 'listReferenceData'],
188 'permission_callback' => function () {
189 return PermissionGate::canAny(PermissionGate::readRoleCaps());
190 },
191 'annotations' => ['readonly' => true],
192 ],
193 ];
194 }
195
196 public static function getContext($params = [])
197 {
198 $userId = get_current_user_id();
199 $cacheKey = self::CACHE_PREFIX . $userId;
200
201 $cached = get_transient($cacheKey);
202 if (is_array($cached)) {
203 return $cached;
204 }
205
206 $context = self::buildContext($userId);
207 set_transient($cacheKey, $context, self::CACHE_TTL);
208
209 return $context;
210 }
211
212 private static function buildContext($userId)
213 {
214 $user = get_user_by('ID', $userId);
215 $isAdmin = $user && user_can($user, 'manage_options');
216
217 $you = [
218 'wp_user_id' => (int) $userId,
219 'name' => $user ? $user->display_name : null,
220 'email' => $user ? $user->user_email : null,
221 'is_admin' => (bool) $isAdmin,
222 'permissions' => array_values((array) PermissionManager::getUserPermissions()),
223 ];
224
225 $store = [
226 'name' => get_bloginfo('name'),
227 'url' => site_url(),
228 'version' => defined('FLUENTCART_VERSION') ? FLUENTCART_VERSION : null,
229 // Must agree with the App::isProActive() check the Pro-gated paths
230 // (advanced_filters, get-search-schema) actually run — a false here on
231 // a Pro store makes an agent skip the whole advanced-search surface.
232 'pro_active' => App::isProActive(),
233 'currency' => MCPHelper::currencyContext(),
234 'timezone' => wp_timezone_string(),
235 'current_time' => MCPHelper::toIso8601(DateTime::gmtNow()),
236 // Named for the capability rather than the licence, so an agent does
237 // not have to infer what pro_active buys it before spending a call on
238 // get-search-schema (which rejects outright without Pro).
239 'advanced_search' => App::isProActive() ? 'available' : 'unavailable',
240 ];
241
242 // Headline stats are dashboard data: gate them on dashboard_stats/view so
243 // a narrow read role can still get context (enums, currency, permissions)
244 // without seeing store-wide revenue/order/customer numbers.
245 $canStats = PermissionGate::can('dashboard_stats/view');
246 $stats = $canStats ? self::buildStats() : null;
247
248 return MCPHelper::envelope(
249 $canStats ? self::summary($stats) : __('Store context loaded.', 'fluent-cart'),
250 [
251 'you' => $you,
252 'store' => $store,
253 'stats' => $stats,
254 'enums' => apply_filters('fluent_cart/mcp_enums', self::enums()),
255 'reference_kinds' => self::referenceKinds(),
256 'tool_index' => self::toolIndex(),
257 'guidelines' => self::guidelines(),
258 ]
259 );
260 }
261
262 /**
263 * Headline numbers. Each metric is isolated in safeCount/safeSum so one
264 * failing query (e.g. a model that doesn't exist on a given install) yields
265 * null for that stat instead of breaking the whole discovery call.
266 */
267 private static function buildStats()
268 {
269 $since30 = DateTime::gmtNow()->modify('-30 days')->format('Y-m-d H:i:s');
270
271 return [
272 'orders_total' => self::safeCount(function () {
273 return Order::query()->count();
274 }),
275 'orders_last_30d' => self::safeCount(function () use ($since30) {
276 return Order::query()->where('created_at', '>=', $since30)->count();
277 }),
278 'revenue_last_30d' => self::safeMoney(function () use ($since30) {
279 return (int) Order::query()
280 ->whereIn('payment_status', self::PAID_STATUSES)
281 ->where('created_at', '>=', $since30)
282 ->sum('total_paid');
283 }),
284 'customers_total' => self::safeCount(function () {
285 return Customer::query()->count();
286 }),
287 'active_subscriptions' => self::safeCount(function () {
288 return Subscription::query()->where('status', 'active')->count();
289 }),
290 'products_published' => self::safeCount(function () {
291 if (!class_exists('\FluentCart\App\Models\Product')) {
292 return null;
293 }
294 // post_type is pinned by the model's global scope; a literal
295 // here (and the wrong singular one) would match nothing.
296 return \FluentCart\App\Models\Product::query()
297 ->where('post_status', 'publish')
298 ->count();
299 }),
300 ];
301 }
302
303 private static function safeCount(callable $fn)
304 {
305 try {
306 $val = $fn();
307 return $val === null ? null : (int) $val;
308 } catch (\Throwable $e) {
309 return null;
310 }
311 }
312
313 private static function safeMoney(callable $fn)
314 {
315 try {
316 return MCPHelper::money((int) $fn());
317 } catch (\Throwable $e) {
318 return null;
319 }
320 }
321
322 /* translators: %1$s: revenue amount, %2$d: orders in 30 days, %3$d: total customers */
323 private static function summary($stats)
324 {
325 $rev30 = isset($stats['revenue_last_30d']['display']) ? $stats['revenue_last_30d']['display'] : '';
326 $orders30 = isset($stats['orders_last_30d']) ? (int) $stats['orders_last_30d'] : 0;
327 $customers = isset($stats['customers_total']) ? (int) $stats['customers_total'] : 0;
328
329 return sprintf(
330 /* translators: %1$s: 30-day revenue, %2$d: 30-day order count, %3$d: total customers */
331 __('Store snapshot — last 30 days: %1$s across %2$d orders; %3$d customers total.', 'fluent-cart'),
332 $rev30,
333 $orders30,
334 $customers
335 );
336 }
337
338 /** Tells the agent what `kinds` it can pass to list-reference-data. */
339 private static function referenceKinds()
340 {
341 return ['coupons', 'labels', 'gateways', 'tax_classes', 'shipping_zones', 'product_categories'];
342 }
343
344 /**
345 * Task → tool routing table so an agent picks the right ability among ~30
346 * without trial and error, grouped by intent (discovery / find / load /
347 * analytics / write).
348 *
349 * Derived from the LIVE registry so a newly registered tool can never
350 * silently go missing — each is annotated with a curated "reach for this
351 * when…" hint, and any tool without one still appears under its label.
352 * Filterable so pro / add-on tools can slot themselves in.
353 */
354 private static function toolIndex()
355 {
356 // [category, one-line "use this when…"], keyed by ability name.
357 $hints = [
358 'fluent-cart/get-store-context' => ['discovery', 'Call first — identity, permissions, currency, enums, headline stats, and this index.'],
359 'fluent-cart/list-reference-data' => ['discovery', 'Resolve names to ids: coupons, labels, gateways, tax classes, shipping zones, product categories.'],
360 'fluent-cart/get-search-schema' => ['discovery', 'The advanced_filters reference for one entity — every filterable property, operators, value formats. Call before building an advanced search.'],
361 'fluent-cart/list-orders' => ['find', 'Find orders by status / payment / customer / product / date.'],
362 'fluent-cart/list-customers' => ['find', 'Find customers by name / email / location / LTV.'],
363 'fluent-cart/list-products' => ['find', 'Find products by title / category / price.'],
364 'fluent-cart/list-subscriptions' => ['find', 'Find subscriptions by status / plan / product; summary_only for a fast aggregate.'],
365 'fluent-cart/list-coupons' => ['find', 'Find coupons by status / code, with usage counts.'],
366 'fluent-cart/list-transactions' => ['find', 'The payment ledger across records — refunds last week, failed charges for dunning, one customer\'s payment history.'],
367 'fluent-cart/get-inventory' => ['find', 'Products at or below their stock threshold, or out of stock.'],
368 'fluent-cart/get-order' => ['load', 'One order in full; include[] transactions / refunds / addresses / coupons / subscriptions.'],
369 'fluent-cart/get-order-activity' => ['load', 'The audit timeline for one order.'],
370 'fluent-cart/get-customer' => ['load', 'One customer profile; include[] orders / subscriptions.'],
371 'fluent-cart/get-product' => ['load', 'One product with variations; include[] sales / downloads.'],
372 'fluent-cart/get-subscription' => ['load', 'One subscription; include[] transactions / labels.'],
373 'fluent-cart/get-product-financials' => ['load', 'One product\'s money: one-time + installment + recurring, MRR / ARR, payment schedule.'],
374 'fluent-cart/get-sales-report' => ['analytics', 'The headline revenue number for a period, against the prior period.'],
375 'fluent-cart/get-sales-trend' => ['analytics', 'Revenue / order time series by hour / day / week / month.'],
376 'fluent-cart/get-top-products' => ['analytics', 'Best sellers by revenue or units.'],
377 'fluent-cart/get-refund-report' => ['analytics', 'Refund count, rate and amount for a period.'],
378 'fluent-cart/get-upcoming-payments' => ['analytics', 'Forward renewal cohort and at-risk revenue.'],
379 'fluent-cart/query-orders' => ['analytics', 'Flexible order metrics by dimension — revenue by payment_status / order_type / month.'],
380 'fluent-cart/query-products' => ['analytics', 'Product-line analytics — discount / margin leakage, by product / variation / order_type.'],
381 'fluent-cart/query-customers' => ['analytics', 'Customer analytics by country / state / status / cohort.'],
382 'fluent-cart/query-subscriptions' => ['analytics', 'Subscription analytics — contract vs recurring value, churn basis.'],
383 'fluent-cart/query-sources' => ['analytics', 'UTM attribution — revenue by source / medium / campaign.'],
384 'fluent-cart/change-order-status' => ['write', 'Set an order or shipping status.'],
385 'fluent-cart/add-order-note' => ['write', 'Add an internal note to an order.'],
386 'fluent-cart/refund-order' => ['write', 'Refund via the gateway — call dry_run first.'],
387 'fluent-cart/upsert-customer' => ['write', 'Create or update a customer.'],
388 'fluent-cart/change-subscription-status' => ['write', 'Cancel a subscription — call dry_run first.'],
389 'fluent-cart/manage-coupon' => ['write', 'Create, update or deactivate a coupon.'],
390 'fluent-cart/apply-labels' => ['write', 'Add or remove labels on an order / customer / subscription.'],
391 ];
392
393 // Preserve intent order; empty groups are dropped below.
394 $index = ['discovery' => [], 'find' => [], 'load' => [], 'analytics' => [], 'write' => [], 'other' => []];
395
396 foreach (AbilitiesRegistrar::getDefinitions() as $name => $def) {
397 $category = isset($hints[$name]) ? $hints[$name][0] : 'other';
398 $hint = isset($hints[$name]) ? $hints[$name][1] : (isset($def['label']) ? $def['label'] : $name);
399 $short = strpos($name, 'fluent-cart/') === 0 ? substr($name, strlen('fluent-cart/')) : $name;
400
401 $index[$category][$short] = $hint;
402 }
403
404 $index = array_filter($index, function ($group) {
405 return !empty($group);
406 });
407
408 return apply_filters('fluent_cart/mcp_tool_index', $index);
409 }
410
411 private static function guidelines()
412 {
413 $default = 'Call get-store-context once per session. Consult the tool_index in this payload to pick the right tool for a task, then use list-* and query-* tools to find and aggregate records and get-* tools to load one record fully. '
414 . 'Money is returned as both a number (amount) and a formatted string (display) — quote display, compare amount. '
415 . 'Dates are ISO-8601 UTC; pass a relative range (e.g. last_30_days) or explicit start_date/end_date to report tools. '
416 . 'Use the exact enum values from this payload — never invent a status. '
417 . 'Reports never sum across currencies; filter by one currency if the store has several. '
418 // Stated as a fact about THIS store, not a generic "requires Pro":
419 // an agent that reads the generic form still builds the filter and
420 // only discovers the gate when the call is rejected.
421 . (App::isProActive()
422 ? 'When a list tool\'s named filters cannot express a segmentation (OR groups, relative dates, per-property operators, relation properties like transactions/UTM/labels), call get-search-schema for the entity and pass advanced_filters to its list tool. '
423 : 'Advanced search is UNAVAILABLE on this store (store.advanced_search = unavailable): FluentCart Pro is not active, so get-search-schema and the advanced_filters parameter will be rejected. Do not build advanced_filters — use the named filters on the list-* tools and the query-* tools for aggregation. ')
424 . 'Writes (refund-order, change-subscription-status:cancel) require a dry_run preview first.';
425
426 return apply_filters('fluent_cart/mcp_guidelines', $default);
427 }
428
429 /**
430 * `list-reference-data` — heavier lookup lists, fetched on demand.
431 *
432 * @param array $params { kinds: string[] } — which lists to return. Each
433 * kind is gated by its own capability; kinds the
434 * caller can't see are reported in `skipped`, not
435 * silently dropped, so the agent knows why.
436 */
437 public static function listReferenceData($params = [])
438 {
439 $kinds = isset($params['kinds']) ? (array) $params['kinds'] : [];
440 if (!$kinds) {
441 return MCPHelper::error(
442 'missing_kinds',
443 __('Provide one or more kinds. Valid: coupons, labels, gateways, tax_classes, shipping_zones, product_categories.', 'fluent-cart'),
444 ['valid_kinds' => self::referenceKinds()]
445 );
446 }
447
448 $gate = [
449 'coupons' => 'coupons/view',
450 'labels' => 'labels/view',
451 'gateways' => 'dashboard_stats/view',
452 'tax_classes' => 'store/settings',
453 'shipping_zones' => 'store/settings',
454 'product_categories' => 'products/view',
455 ];
456
457 $data = [];
458 $skipped = [];
459
460 foreach ($kinds as $kind) {
461 if (!isset($gate[$kind])) {
462 $skipped[$kind] = 'unknown_kind';
463 continue;
464 }
465 if (!PermissionGate::can($gate[$kind])) {
466 $skipped[$kind] = 'forbidden: requires ' . $gate[$kind];
467 continue;
468 }
469 $data[$kind] = self::fetchReferenceKind($kind);
470 }
471
472 $meta = $skipped ? ['warnings' => self::skipWarnings($skipped)] : [];
473
474 return MCPHelper::envelope(
475 sprintf(
476 /* translators: %d: number of reference lists returned */
477 _n('Returned %d reference list.', 'Returned %d reference lists.', count($data), 'fluent-cart'),
478 count($data)
479 ),
480 $data,
481 $meta
482 );
483 }
484
485 private static function skipWarnings($skipped)
486 {
487 $out = [];
488 foreach ($skipped as $kind => $reason) {
489 $out[] = $kind . ': ' . $reason;
490 }
491 return $out;
492 }
493
494 /**
495 * Each kind is fetched behind a class_exists guard so a model that isn't
496 * present on a given install returns [] rather than fataling.
497 */
498 private static function fetchReferenceKind($kind)
499 {
500 try {
501 if ($kind === 'coupons' && class_exists('\FluentCart\App\Models\Coupon')) {
502 $coupons = \FluentCart\App\Models\Coupon::query()
503 ->select(['id', 'code', 'title', 'type', 'amount', 'status', 'use_count'])
504 ->orderBy('id', 'DESC')
505 ->limit(200)
506 ->get();
507 $out = [];
508 foreach ($coupons as $c) {
509 // Match list-coupons: numeric amount; fixed coupons stored in
510 // cents are reported in store currency, percentage as-is.
511 $amount = ($c->type === 'fixed')
512 ? 0 + \FluentCart\App\Helpers\Helper::toDecimalWithoutComma((int) $c->amount)
513 : (is_numeric($c->amount) ? 0 + $c->amount : $c->amount);
514 $out[] = [
515 'id' => (int) $c->id,
516 'code' => $c->code,
517 'title' => $c->title,
518 'type' => $c->type,
519 'amount' => $amount,
520 'status' => $c->status,
521 // Usage count so "how many times was code X used" is
522 // answerable without a second call. Alias times_used matches
523 // list-coupons.
524 'use_count' => (int) $c->use_count,
525 'times_used' => (int) $c->use_count,
526 ];
527 }
528 return $out;
529 }
530
531 if ($kind === 'labels' && class_exists('\FluentCart\App\Models\Label')) {
532 // fct_label stores a single (maybe-serialized) `value` column —
533 // it may hold a plain title string or an array {title,color,…}.
534 // Labels are user-created and can grow large; cap like coupons
535 // so kinds[]=labels can't trigger an unbounded read/response.
536 $labels = \FluentCart\App\Models\Label::query()->orderBy('id', 'ASC')->limit(200)->get();
537 $out = [];
538 foreach ($labels as $label) {
539 $val = $label->value;
540 $entry = ['id' => (int) $label->id];
541 if (is_array($val)) {
542 $entry['title'] = isset($val['title']) ? $val['title'] : (isset($val['value']) ? $val['value'] : null);
543 if (isset($val['color'])) {
544 $entry['color'] = $val['color'];
545 }
546 } else {
547 $entry['title'] = $val;
548 }
549 $out[] = $entry;
550 }
551 return $out;
552 }
553
554 if ($kind === 'tax_classes' && class_exists('\FluentCart\App\Models\TaxClass')) {
555 // fct_tax_classes labels its name column `title`, not `name`.
556 return \FluentCart\App\Models\TaxClass::query()
557 ->select(['id', 'title'])
558 ->get()
559 ->toArray();
560 }
561
562 if ($kind === 'shipping_zones' && class_exists('\FluentCart\App\Models\ShippingZone')) {
563 // fct_shipping_zones labels its name column `name`, not `title`.
564 return \FluentCart\App\Models\ShippingZone::query()
565 ->select(['id', 'name', 'region'])
566 ->get()
567 ->toArray();
568 }
569
570 if ($kind === 'gateways') {
571 return self::enabledGateways();
572 }
573
574 if ($kind === 'product_categories') {
575 return self::productCategories();
576 }
577 } catch (\Throwable $e) {
578 return [];
579 }
580
581 return [];
582 }
583
584 /**
585 * Active payment gateways. Each gateway stores its own settings (there is no
586 * single payment_settings option), so we read the registered gateway
587 * instances from the GatewayManager and keep the ones with is_active=yes.
588 */
589 private static function enabledGateways()
590 {
591 $managerClass = '\FluentCart\App\Modules\PaymentMethods\Core\GatewayManager';
592 if (!class_exists($managerClass) || !method_exists($managerClass, 'getInstance')) {
593 return [];
594 }
595
596 try {
597 $gateways = $managerClass::getInstance()->all();
598 } catch (\Throwable $e) {
599 return [];
600 }
601
602 $out = [];
603 foreach ((array) $gateways as $gateway) {
604 if (!is_object($gateway) || !method_exists($gateway, 'getMeta')) {
605 continue;
606 }
607
608 $settings = (isset($gateway->settings) && is_object($gateway->settings) && method_exists($gateway->settings, 'get'))
609 ? (array) $gateway->settings->get()
610 : [];
611
612 $isActive = isset($settings['is_active'])
613 ? ($settings['is_active'] === 'yes')
614 : !empty($gateway->getMeta('status'));
615 if (!$isActive) {
616 continue;
617 }
618
619 $meta = (array) $gateway->getMeta();
620 $route = isset($meta['route']) ? $meta['route'] : null;
621 $out[] = [
622 'key' => $route,
623 'title' => isset($meta['title']) ? $meta['title'] : $route,
624 'mode' => isset($settings['payment_mode'])
625 ? $settings['payment_mode']
626 : (isset($settings['checkout_mode']) ? $settings['checkout_mode'] : null),
627 ];
628 }
629 return $out;
630 }
631
632 /** Product categories from the WP taxonomy (best-effort across naming). */
633 private static function productCategories()
634 {
635 foreach (['fluent-cart-category', 'product_cat', 'fluent_cart_category'] as $taxonomy) {
636 if (!taxonomy_exists($taxonomy)) {
637 continue;
638 }
639 $terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'number' => 200]);
640 if (is_wp_error($terms)) {
641 continue;
642 }
643 $out = [];
644 foreach ($terms as $term) {
645 $out[] = ['id' => (int) $term->term_id, 'name' => $term->name, 'slug' => $term->slug, 'count' => (int) $term->count];
646 }
647 return $out;
648 }
649 return [];
650 }
651
652 /**
653 * Clear the cached context for all users. Hooked from MCPInit onto the
654 * events that change anything the context payload reports.
655 */
656 public static function invalidateCache()
657 {
658 global $wpdb;
659
660 $like = $wpdb->esc_like('_transient_' . self::CACHE_PREFIX) . '%';
661 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
662
663 $like = $wpdb->esc_like('_transient_timeout_' . self::CACHE_PREFIX) . '%';
664 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
665 }
666 }
667