PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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
← All changes | app/Modules/MCP/Tools/OrderTools.php +207 -27 1.5.0 → 1.6.5 View file →
@@ -5,8 +5,9 @@
5 5 use FluentCart\App\Helpers\Helper;
6 6 use FluentCart\App\Helpers\Status;
7 7 use FluentCart\App\Models\Order;
8 8 use FluentCart\App\Models\OrderTransaction;
9 +use FluentCart\App\Modules\MCP\Support\AdvancedSearch;
9 10 use FluentCart\App\Modules\MCP\Support\MCPHelper;
10 11 use FluentCart\App\Modules\MCP\Support\PermissionGate;
11 12 use FluentCart\App\Modules\MCP\Support\WriteGuard;
12 13 use FluentCart\App\Services\Payments\Refund;
@@ -34,28 +35,29 @@
34 35 class OrderTools
35 36 {
36 37 public static function definitions()
37 38 {
38 - $orderStatuses = ContextTools::ENUMS['order_statuses'];
39 - $paymentStatuses = ContextTools::ENUMS['payment_statuses'];
40 - $shippingStatuses = ContextTools::ENUMS['shipping_statuses'];
39 + $enums = ContextTools::enums();
40 + $orderStatuses = $enums['order_statuses'];
41 + $paymentStatuses = $enums['payment_statuses'];
42 + $shippingStatuses = $enums['shipping_statuses'];
41 43 // change-order-status cannot set an order back to "no shipping required".
42 44 $shippingWritable = array_values(array_diff($shippingStatuses, ['none']));
43 - // Only the statuses core actually accepts for a manual change — a subset
44 - // of the full order_statuses enum used for filtering (no draft/pending/
45 - // refunded/partial-refund: those are reached via payment/refund flows).
45 + // Only the statuses core accepts for a manual change — a subset of the
46 + // order_statuses enum used for filtering ('failed' is reached through a
47 + // payment flow, not a manual set).
46 48 $orderWritable = array_keys(Status::getEditableOrderStatuses());
47 - $orderTypes = ContextTools::ENUMS['order_types'];
49 + $orderTypes = $enums['order_types'];
48 50
49 51 return [
50 52 'fluent-cart/list-orders' => [
51 53 'label' => __('List Orders', 'fluent-cart'),
52 - 'description' => __('Find and filter orders. Returns compact rows (id, number, customer, total, statuses, date, plus an items list: each line item\'s product, title and quantity) — call get-order for the full money/refund breakdown. All filters optional; combine freely. For one customer\'s orders, pass customer_email or customer_id here. Money filters are in store currency (e.g. 49.99), not cents.', 'fluent-cart'),
54 + 'description' => __('Find and filter orders. Returns compact rows (id, number, customer, total, statuses, date, plus an items list: each line item\'s product, title and quantity) — call get-order for the full money/refund breakdown. All filters optional; combine freely. For one customer\'s orders, pass customer_email or customer_id here. Money filters are in store currency (e.g. 49.99), not cents. For conditions these flat filters cannot express (OR groups, relative dates, transaction/UTM/label/license properties) pass advanced_filters — call get-search-schema entity=orders first (Pro).', 'fluent-cart'),
53 55 'input_schema' => [
54 56 'type' => 'object',
55 57 'properties' => [
56 - 'status' => ['type' => 'string', 'enum' => $orderStatuses, 'description' => 'Order fulfillment/lifecycle status.'],
57 - 'payment_status' => ['type' => 'string', 'enum' => $paymentStatuses],
58 + 'status' => ['type' => 'string', 'enum' => $orderStatuses, 'description' => 'Order fulfillment/lifecycle status. To find refunded orders use payment_status (refunded / partially_refunded), NOT this field: refunds are recorded as payment state, and status=refunded only ever appears on stores migrated from WooCommerce. pending here means an unpaid store-managed renewal invoice, which is not the same as payment_status=pending; a COD order sits at status=on-hold with payment_status=pending.'],
59 + 'payment_status' => ['type' => 'string', 'enum' => $paymentStatuses, 'description' => 'Money state of the order — this is where refunded, partially_refunded, authorized and payment_scheduled live.'],
58 60 'shipping_status' => ['type' => 'string', 'enum' => $shippingStatuses],
59 61 'type' => ['type' => 'string', 'enum' => $orderTypes, 'description' => 'payment = first purchase, renewal = subscription renewal.'],
60 62 'customer_id' => ['type' => 'integer'],
61 63 'customer_email' => ['type' => 'string', 'description' => 'Exact email — the most reliable customer filter.'],
@@ -68,10 +70,12 @@
68 70 'created_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
69 71 'created_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
70 72 'mode' => ['type' => 'string', 'enum' => ['live', 'test'], 'description' => 'Defaults to all modes.'],
71 73 'search' => ['type' => 'string', 'description' => 'Matches invoice/receipt number, order uuid, and customer name/email.'],
74 + '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=orders FIRST for properties/operators/format. AND-combines with the other filters here. An empty array means no advanced filter.'],
72 75 'sort_by' => ['type' => 'string', 'enum' => ['id', 'created_at', 'completed_at', 'total_amount'], 'default' => 'id'],
73 76 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
77 + 'fields' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Optional: return only these row keys to shrink the payload (order_id is always kept). Available: number, label, status, payment_status, shipping_status, type, total, customer, items, created_at. Omit for the full row.'],
74 78 'page' => ['type' => 'integer', 'default' => 1],
75 79 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 100.'],
76 80 ],
77 81 ],
@@ -83,9 +87,13 @@
83 87 ],
84 88
85 89 'fluent-cart/get-order' => [
86 90 'label' => __('Get Order', 'fluent-cart'),
87 - 'description' => __('Full detail for one order: money breakdown, line items, and customer by default. Add include[] for transactions, refunds, addresses, coupons, subscriptions. Identify the order by order_id (numeric, from list-orders) OR uuid OR invoice_no.', 'fluent-cart'),
91 + 'description' => sprintf(
92 + /* translators: %1$s: comma-separated include[] section names */
93 + __('Full detail for one order: money breakdown, line items, and customer by default. Add include[] for any of: %1$s. Identify the order by order_id (numeric, from list-orders) OR uuid OR invoice_no.', 'fluent-cart'),
94 + implode(', ', self::includeSections())
95 + ),
88 96 'input_schema' => [
89 97 'type' => 'object',
90 98 'properties' => [
91 99 'order_id' => ['type' => 'integer', 'description' => 'Numeric order id as returned by list-orders.'],
@@ -93,10 +101,11 @@
93 101 'invoice_no' => ['type' => 'string'],
94 102 'include' => [
95 103 'type' => 'array',
96 104 'description' => 'Optional heavier sections. items + customer are always included.',
97 - 'items' => ['type' => 'string', 'enum' => ['transactions', 'refunds', 'addresses', 'coupons', 'subscriptions']],
105 + 'items' => ['type' => 'string', 'enum' => self::includeSections()],
98 106 ],
107 + 'fields' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Optional: return only these top-level keys to shrink the payload (order_id is always kept). e.g. status, payment_status, totals, items, customer. Applies after include[]. Omit for the full record.'],
99 108 ],
100 109 ],
101 110 'execute_callback' => [self::class, 'getOrder'],
102 111 'permission_callback' => function () {
@@ -106,9 +115,9 @@
106 115 ],
107 116
108 117 'fluent-cart/get-order-activity' => [
109 118 'label' => __('Get Order Activity', 'fluent-cart'),
110 - 'description' => __('Audit timeline for one order — status changes, payments, refunds, notes, emails sent: who did what and when. Use after get-order when you need history, not just current state.', 'fluent-cart'),
119 + 'description' => __('Audit timeline for one order — status changes, payments, refunds, notes, emails sent: who did what and when. Refund and payment rows carry the amount (backfilled onto activity rows from the matching transaction), so you need not cross-reference. Use after get-order when you need history, not just current state.', 'fluent-cart'),
111 120 'input_schema' => [
112 121 'type' => 'object',
113 122 'properties' => [
114 123 'order_id' => ['type' => 'integer'],
@@ -138,8 +147,12 @@
138 147 'execute_callback' => [self::class, 'changeOrderStatus'],
139 148 'permission_callback' => function () {
140 149 return PermissionGate::can('orders/manage_statuses');
141 150 },
151 + // Mutates, but reversible (a status can be set back) and no-op
152 + // aware, so not destructive. Setting the same status twice is a
153 + // no-op — idempotent.
154 + 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true],
142 155 ],
143 156
144 157 'fluent-cart/add-order-note' => [
145 158 'label' => __('Add Order Note', 'fluent-cart'),
@@ -155,13 +168,16 @@
155 168 'execute_callback' => [self::class, 'addOrderNote'],
156 169 'permission_callback' => function () {
157 170 return PermissionGate::can('orders/manage');
158 171 },
172 + // Appends a note (mutating, not destructive). Each call adds a
173 + // new note, so it is NOT idempotent.
174 + 'annotations' => ['readonly' => false, 'destructive' => false],
159 175 ],
160 176
161 177 'fluent-cart/refund-order' => [
162 178 'label' => __('Refund Order', 'fluent-cart'),
163 - 'description' => __('Refund an order through its payment gateway. ALWAYS call with dry_run:true first to preview the refundable amount and receive a confirm_token, then call again with that confirm_token plus an idempotency_key to execute. Without an idempotency_key a repeated execute could double-refund. amount is in store currency; omit to refund the full remaining balance. The preview reports payment_mode and live_gateway_action; executing a LIVE refund requires the operator to opt in (test-mode always works).', 'fluent-cart'),
179 + 'description' => __('Refund an order through its gateway. ALWAYS call dry_run:true first to preview the refundable amount and get a confirm_token, then call again with that confirm_token plus an idempotency_key to execute — without the key a repeated execute could double-refund. amount is in store currency; omit for the full remaining balance. The preview reports payment_mode and live_gateway_action; a LIVE refund requires operator opt-in, and test-mode always works.', 'fluent-cart'),
164 180 'input_schema' => [
165 181 'type' => 'object',
166 182 'properties' => [
167 183 'order_id' => ['type' => 'integer'],
@@ -177,9 +193,11 @@
177 193 'execute_callback' => [self::class, 'refundOrder'],
178 194 'permission_callback' => function () {
179 195 return PermissionGate::can('orders/can_refund');
180 196 },
181 - 'annotations' => ['destructive' => true],
197 + // Moves money via the gateway — the destructive write. readonly:false
198 + // is explicit so a client never mistakes it for a preview-only tool.
199 + 'annotations' => ['readonly' => false, 'destructive' => true],
182 200 ],
183 201 ];
184 202 }
185 203
@@ -190,14 +208,29 @@
190 208 public static function listOrders($params = [])
191 209 {
192 210 $paging = MCPHelper::pagination($params);
193 211
212 + // advanced_filters routes through the admin filter engine (validated
213 + // first — a bad condition errors, never silently drops); the named
214 + // filters below then AND onto the same query either way.
215 + $advWarnings = [];
216 + if (!empty($params['advanced_filters'])) {
217 + $built = AdvancedSearch::buildQuery('orders', $params['advanced_filters']);
218 + if (is_wp_error($built)) {
219 + return $built;
220 + }
221 + $query = $built['query'];
222 + $advWarnings = $built['warnings'];
223 + } else {
224 + $query = Order::query();
225 + }
226 +
194 227 // Eager-load customer plus a TRIMMED order_items relation — only the
195 228 // columns needed for a "what's in this order" preview, never the full
196 229 // money/refund/fulfillment row (that's get-order's job). formatRow caps
197 230 // the preview, so even a large multi-item order can't flood the payload.
198 231 // The product_id filter uses whereHas (a join), independent of this load.
199 - $query = Order::query()->with([
232 + $query->with([
200 233 'customer',
201 234 'order_items' => function ($q) {
202 235 $q->select(['id', 'order_id', 'post_id', 'post_title', 'title', 'quantity']);
203 236 },
@@ -220,13 +253,19 @@
220 253
221 254 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
222 255 $total = self::total($paginator);
223 256
224 - $rows = [];
257 + $fields = isset($params['fields']) ? $params['fields'] : null;
258 + $rows = [];
225 259 foreach (MCPHelper::paginatorItems($paginator) as $order) {
226 - $rows[] = self::formatRow($order);
260 + $rows[] = MCPHelper::pickFields(self::formatRow($order), $fields, ['order_id']);
227 261 }
228 262
263 + $meta = MCPHelper::pagingMeta($paginator);
264 + if ($advWarnings) {
265 + $meta['warnings'] = $advWarnings;
266 + }
267 +
229 268 return MCPHelper::envelope(
230 269 sprintf(
231 270 /* translators: %d: number of matching orders */
232 271 _n('%d order found.', '%d orders found.', $total, 'fluent-cart'),
@@ -232,9 +271,9 @@
232 271 _n('%d order found.', '%d orders found.', $total, 'fluent-cart'),
233 272 $total
234 273 ),
235 274 ['orders' => $rows],
236 - MCPHelper::pagingMeta($paginator)
275 + $meta
237 276 );
238 277 }
239 278
240 279 private static function applyFilters($query, $params)
@@ -362,9 +401,14 @@
362 401 $customer = ($order->relationLoaded('customer') && $order->customer) ? $order->customer : null;
363 402
364 403 return [
365 404 'order_id' => (int) $order->id,
366 - 'number' => $order->invoice_no ? $order->invoice_no : (string) $order->id,
405 + // null, not the raw id: an invoice number is only assigned once the
406 + // order is paid, and echoing the id here made unpaid orders look like
407 + // they had a number in a different format from every other row (and
408 + // disagreed with get-order, which already returns null). order_id is
409 + // right above it for referencing the record.
410 + 'number' => $order->invoice_no ? $order->invoice_no : null,
367 411 'label' => self::label($order, $customer),
368 412 'status' => $order->status,
369 413 'payment_status' => $order->payment_status,
370 414 'shipping_status' => self::shippingStatusOut($order),
@@ -439,10 +483,13 @@
439 483
440 484 $data = [
441 485 'order_id' => (int) $order->id,
442 486 'uuid' => $order->uuid,
443 - 'number' => $order->invoice_no,
444 - 'receipt_number' => $order->receipt_number,
487 + // Normalized to null when unassigned — the column stores '' for an
488 + // order that has not been invoiced yet, and an empty string reads as
489 + // "the number is blank" rather than "there is no number".
490 + 'number' => $order->invoice_no ? $order->invoice_no : null,
491 + 'receipt_number' => $order->receipt_number ? $order->receipt_number : null,
445 492 'status' => $order->status,
446 493 'payment_status' => $order->payment_status,
447 494 'shipping_status' => self::shippingStatusOut($order),
448 495 'type' => $order->type,
@@ -471,11 +518,58 @@
471 518 if (in_array('subscriptions', $include, true)) {
472 519 $data['subscriptions'] = self::subscriptionsBlock($order);
473 520 }
474 521
475 - return MCPHelper::envelope(self::label($order, $order->customer), $data);
522 + /**
523 + * The assembled get-order payload, for add-on sections registered through
524 + * fluent_cart/mcp_order_include_sections. Listeners should add their key
525 + * only when it is present in $context['include'].
526 + *
527 + * @since 1.0.0
528 + *
529 + * @param array $data the order payload
530 + * @param array $context { order: Order, include: string[] }
531 + */
532 + $data = apply_filters('fluent_cart/mcp_order_data', $data, [
533 + 'order' => $order,
534 + 'include' => $include,
535 + ]);
536 +
537 + // fields projection runs last, so it can trim both the base record and any
538 + // include[] sections; order_id is always kept.
539 + $fields = isset($params['fields']) ? $params['fields'] : null;
540 +
541 + return MCPHelper::envelope(self::label($order, $order->customer), MCPHelper::pickFields($data, $fields, ['order_id']));
476 542 }
477 543
544 + /**
545 + * The sections get-order's include[] accepts. Filterable so an integration
546 + * that owns order-adjacent context (the CRM contact behind the buyer, for
547 + * one) can offer it as an include rather than leaving the agent to guess
548 + * which other tool holds it.
549 + *
550 + * A section added here MUST be populated by a listener on
551 + * fluent_cart/mcp_order_data — an include the schema advertises but nothing
552 + * fills is worse than no include at all.
553 + *
554 + * @return array
555 + */
556 + private static function includeSections()
557 + {
558 + $sections = ['transactions', 'refunds', 'addresses', 'coupons', 'subscriptions'];
559 +
560 + /**
561 + * Extra include[] section names for get-order.
562 + *
563 + * @since 1.0.0
564 + *
565 + * @param array $sections section names offered in the include[] enum
566 + */
567 + $sections = apply_filters('fluent_cart/mcp_order_include_sections', $sections);
568 +
569 + return array_values(array_unique(array_map('strval', (array) $sections)));
570 + }
571 +
478 572 private static function resolveOrder($params)
479 573 {
480 574 if (!empty($params['order_id'])) {
481 575 $order = Order::query()->where('id', (int) $params['order_id'])->first();
@@ -676,8 +770,9 @@
676 770
677 771 foreach ($rows as $row) {
678 772 $events[] = [
679 773 '_sort' => (string) $row->created_at,
774 + '_ts' => self::toTs($row->created_at),
680 775 'event' => self::activityEvent($row),
681 776 'source' => 'activity',
682 777 'title' => $row->title,
683 778 'status' => $row->status,
@@ -693,14 +788,19 @@
693 788
694 789 // Money events: charges and refunds from the transactions ledger. These
695 790 // are the payment/refund timeline entries the activity log doesn't carry.
696 791 $order->load('transactions');
792 + $refundTxns = [];
793 + $chargeTxns = [];
697 794 if ($order->relationLoaded('transactions')) {
698 795 foreach ($order->transactions as $txn) {
699 - $type = $txn->transaction_type ? $txn->transaction_type : 'charge';
700 - $event = ($type === 'refund') ? 'refund' : (($type === 'charge') ? 'payment' : $type);
796 + $type = $txn->transaction_type ? $txn->transaction_type : 'charge';
797 + $event = ($type === 'refund') ? 'refund' : (($type === 'charge') ? 'payment' : $type);
798 + $amount = MCPHelper::money($txn->total, $txn->currency ? $txn->currency : null);
799 + $ts = self::toTs($txn->created_at);
701 800 $events[] = [
702 801 '_sort' => (string) $txn->created_at,
802 + '_ts' => $ts,
703 803 'event' => $event,
704 804 'source' => 'transaction',
705 805 'title' => self::txnTitle($type, $txn),
706 806 'status' => $txn->status,
@@ -705,16 +805,40 @@
705 805 'title' => self::txnTitle($type, $txn),
706 806 'status' => $txn->status,
707 807 'content' => null,
708 808 'by' => null,
709 - 'amount' => MCPHelper::money($txn->total, $txn->currency ? $txn->currency : null),
809 + 'amount' => $amount,
710 810 'payment_method' => $txn->payment_method ? $txn->payment_method : null,
711 811 'reference' => $txn->vendor_charge_id ? $txn->vendor_charge_id : null,
712 812 'created_at' => MCPHelper::toIso8601($txn->created_at),
713 813 ];
814 + if ($type === 'refund') {
815 + $refundTxns[] = ['ts' => $ts, 'amount' => $amount];
816 + } elseif ($type === 'charge') {
817 + $chargeTxns[] = ['ts' => $ts, 'amount' => $amount];
818 + }
714 819 }
715 820 }
716 821
822 + // Activity rows about a refund/payment don't store the amount (the Activity
823 + // model has no amount column), so a consumer previously had to cross-
824 + // reference the transaction rows. Backfill each such row from the money
825 + // event it mirrors — the closest refund/charge transaction on this order by
826 + // time — since the activity log is written seconds after its transaction in
827 + // the same request, so the amount is known and no cross-reference is needed.
828 + foreach ($events as &$moneyRow) {
829 + if ($moneyRow['source'] !== 'activity' || $moneyRow['amount'] !== null) {
830 + continue;
831 + }
832 + $kind = self::activityMoneyKind($moneyRow['title']);
833 + if ($kind === 'refund') {
834 + $moneyRow['amount'] = self::nearestTxnAmount($moneyRow['_ts'], $refundTxns);
835 + } elseif ($kind === 'payment') {
836 + $moneyRow['amount'] = self::nearestTxnAmount($moneyRow['_ts'], $chargeTxns);
837 + }
838 + }
839 + unset($moneyRow);
840 +
717 841 // Merge both streams most-recent-first, then cap at $limit.
718 842 usort($events, function ($a, $b) {
719 843 return strcmp($b['_sort'], $a['_sort']);
720 844 });
@@ -719,9 +843,9 @@
719 843 return strcmp($b['_sort'], $a['_sort']);
720 844 });
721 845 $events = array_slice($events, 0, $limit);
722 846 foreach ($events as &$event) {
723 - unset($event['_sort']);
847 + unset($event['_sort'], $event['_ts']);
724 848 }
725 849 unset($event);
726 850
727 851 return MCPHelper::envelope(
@@ -748,8 +872,64 @@
748 872 if ($row->log_type === 'api') {
749 873 return 'api';
750 874 }
751 875 return 'note';
876 + }
877 +
878 + /**
879 + * Classify an activity row's money kind from its title so its amount can be
880 + * backfilled from the matching transaction. Title-only (not content) to avoid
881 + * false positives like a note that merely mentions "refund".
882 + */
883 + private static function activityMoneyKind($title)
884 + {
885 + $t = strtolower((string) $title);
886 + if (strpos($t, 'refund') !== false) {
887 + return 'refund';
888 + }
889 + if (strpos($t, 'payment') !== false || strpos($t, 'charge') !== false || strpos($t, 'captured') !== false) {
890 + return 'payment';
891 + }
892 + return null;
893 + }
894 +
895 + /**
896 + * Amount of the transaction closest in time to $ts, from a pool of
897 + * ['ts' => int|null, 'amount' => money] entries. Returns null if $ts is unknown
898 + * or the pool is empty. Refund activity rows match only refund transactions and
899 + * payment rows only charges, so the nearest by time is the right money event.
900 + */
901 + private static function nearestTxnAmount($ts, array $pool)
902 + {
903 + if ($ts === null || !$pool) {
904 + return null;
905 + }
906 + $best = null;
907 + $bestDiff = null;
908 + foreach ($pool as $entry) {
909 + if ($entry['ts'] === null) {
910 + continue;
911 + }
912 + $diff = abs($entry['ts'] - $ts);
913 + if ($bestDiff === null || $diff < $bestDiff) {
914 + $bestDiff = $diff;
915 + $best = $entry['amount'];
916 + }
917 + }
918 + return $best;
919 + }
920 +
921 + /** Parse a stored GMT datetime to a UTC unix timestamp; null on empty/zero-date. */
922 + private static function toTs($value)
923 + {
924 + if (!$value || strpos((string) $value, '0000-00-00') === 0) {
925 + return null;
926 + }
927 + try {
928 + return (new \DateTime((string) $value, new \DateTimeZone('UTC')))->getTimestamp();
929 + } catch (\Exception $e) {
930 + return null;
931 + }
752 932 }
753 933
754 934 /** Human-readable title for a transaction timeline entry. */
755 935 private static function txnTitle($type, $txn)