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

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

1,129 lines 51.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\Helpers\Status;
7 use FluentCart\App\Models\Order;
8 use FluentCart\App\Models\OrderTransaction;
9 use FluentCart\App\Modules\MCP\Support\MCPHelper;
10 use FluentCart\App\Modules\MCP\Support\PermissionGate;
11 use FluentCart\App\Modules\MCP\Support\WriteGuard;
12 use FluentCart\App\Services\Payments\Refund;
13 use FluentCart\Api\Resource\OrderResource;
14
15 /**
16 * Order tools — find orders, then load one fully.
17 *
18 * Read surface (this file): list-orders (compact, filterable), get-order
19 * (one order, include[]-driven), get-order-activity (the audit timeline).
20 *
21 * Parameter design notes for the agent's sake:
22 * - list-orders takes FLAT, enum-constrained filters (status, payment_status,
23 * …) rather than a freeform query object — the model negotiates against the
24 * schema at selection time, so flat + enum means fewer wrong calls.
25 * - Every filter is optional; omitting all returns the latest orders. Money
26 * filters (min_total/max_total) are in store-currency decimals, not cents —
27 * the agent thinks in dollars, we convert.
28 * - get-order accepts a numeric order_id (what list-orders returns) OR a
29 * uuid / invoice_no, so the agent never has to translate identifiers.
30 * - get-order is lean by default (items + customer); heavier sections
31 * (transactions, refunds, coupons, subscriptions, addresses) are opt-in via
32 * include[] so one order can't silently flood the context window.
33 */
34 class OrderTools
35 {
36 public static function definitions()
37 {
38 $orderStatuses = ContextTools::ENUMS['order_statuses'];
39 $paymentStatuses = ContextTools::ENUMS['payment_statuses'];
40 $shippingStatuses = ContextTools::ENUMS['shipping_statuses'];
41 // change-order-status cannot set an order back to "no shipping required".
42 $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).
46 $orderWritable = array_keys(Status::getEditableOrderStatuses());
47 $orderTypes = ContextTools::ENUMS['order_types'];
48
49 return [
50 'fluent-cart/list-orders' => [
51 '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'),
53 'input_schema' => [
54 'type' => 'object',
55 'properties' => [
56 'status' => ['type' => 'string', 'enum' => $orderStatuses, 'description' => 'Order fulfillment/lifecycle status.'],
57 'payment_status' => ['type' => 'string', 'enum' => $paymentStatuses],
58 'shipping_status' => ['type' => 'string', 'enum' => $shippingStatuses],
59 'type' => ['type' => 'string', 'enum' => $orderTypes, 'description' => 'payment = first purchase, renewal = subscription renewal.'],
60 'customer_id' => ['type' => 'integer'],
61 'customer_email' => ['type' => 'string', 'description' => 'Exact email — the most reliable customer filter.'],
62 'product_id' => ['type' => 'integer', 'description' => 'Orders containing this product.'],
63 'coupon_code' => ['type' => 'string'],
64 'country' => ['type' => 'string', 'description' => 'ISO-2 country code on the billing address.'],
65 'currency' => ['type' => 'string', 'description' => 'ISO currency code.'],
66 'min_total' => ['type' => 'number', 'description' => 'Minimum order total in store currency.'],
67 'max_total' => ['type' => 'number', 'description' => 'Maximum order total in store currency.'],
68 'created_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
69 'created_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
70 'mode' => ['type' => 'string', 'enum' => ['live', 'test'], 'description' => 'Defaults to all modes.'],
71 'search' => ['type' => 'string', 'description' => 'Matches invoice/receipt number, order uuid, and customer name/email.'],
72 'sort_by' => ['type' => 'string', 'enum' => ['id', 'created_at', 'completed_at', 'total_amount'], 'default' => 'id'],
73 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
74 'page' => ['type' => 'integer', 'default' => 1],
75 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 100.'],
76 ],
77 ],
78 'execute_callback' => [self::class, 'listOrders'],
79 'permission_callback' => function () {
80 return PermissionGate::can('orders/view');
81 },
82 'annotations' => ['readonly' => true],
83 ],
84
85 'fluent-cart/get-order' => [
86 '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'),
88 'input_schema' => [
89 'type' => 'object',
90 'properties' => [
91 'order_id' => ['type' => 'integer', 'description' => 'Numeric order id as returned by list-orders.'],
92 'uuid' => ['type' => 'string'],
93 'invoice_no' => ['type' => 'string'],
94 'include' => [
95 'type' => 'array',
96 'description' => 'Optional heavier sections. items + customer are always included.',
97 'items' => ['type' => 'string', 'enum' => ['transactions', 'refunds', 'addresses', 'coupons', 'subscriptions']],
98 ],
99 ],
100 ],
101 'execute_callback' => [self::class, 'getOrder'],
102 'permission_callback' => function () {
103 return PermissionGate::can('orders/view');
104 },
105 'annotations' => ['readonly' => true],
106 ],
107
108 'fluent-cart/get-order-activity' => [
109 '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'),
111 'input_schema' => [
112 'type' => 'object',
113 'properties' => [
114 'order_id' => ['type' => 'integer'],
115 'limit' => ['type' => 'integer', 'default' => 30, 'description' => 'Max 100.'],
116 ],
117 'required' => ['order_id'],
118 ],
119 'execute_callback' => [self::class, 'getOrderActivity'],
120 'permission_callback' => function () {
121 return PermissionGate::can('orders/view');
122 },
123 'annotations' => ['readonly' => true],
124 ],
125
126 'fluent-cart/change-order-status' => [
127 'label' => __('Change Order Status', 'fluent-cart'),
128 'description' => __('Change an order status or shipping status. Pass order_id and at least one of order_status or shipping_status. A no-op is returned if the order is already in that status. To refund, use refund-order instead.', 'fluent-cart'),
129 'input_schema' => [
130 'type' => 'object',
131 'properties' => [
132 'order_id' => ['type' => 'integer'],
133 'order_status' => ['type' => 'string', 'enum' => $orderWritable, 'description' => 'New order lifecycle status. Only these are manually settable; refunded/partial-refund come from the refund flow, draft/pending/failed from payment.'],
134 'shipping_status' => ['type' => 'string', 'enum' => $shippingWritable, 'description' => 'New shipping status. Setting shipped/delivered marks items fulfilled.'],
135 ],
136 'required' => ['order_id'],
137 ],
138 'execute_callback' => [self::class, 'changeOrderStatus'],
139 'permission_callback' => function () {
140 return PermissionGate::can('orders/manage_statuses');
141 },
142 ],
143
144 'fluent-cart/add-order-note' => [
145 'label' => __('Add Order Note', 'fluent-cart'),
146 'description' => __('Add an internal note to an order activity log. Visible to staff, not the customer.', 'fluent-cart'),
147 'input_schema' => [
148 'type' => 'object',
149 'properties' => [
150 'order_id' => ['type' => 'integer'],
151 'note' => ['type' => 'string', 'description' => 'Note text. Plain text or simple HTML.'],
152 ],
153 'required' => ['order_id', 'note'],
154 ],
155 'execute_callback' => [self::class, 'addOrderNote'],
156 'permission_callback' => function () {
157 return PermissionGate::can('orders/manage');
158 },
159 ],
160
161 'fluent-cart/refund-order' => [
162 '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'),
164 'input_schema' => [
165 'type' => 'object',
166 'properties' => [
167 'order_id' => ['type' => 'integer'],
168 'amount' => ['type' => 'number', 'description' => 'Amount to refund in store currency. Omit for the full remaining balance.'],
169 'transaction_id' => ['type' => 'integer', 'description' => 'Charge transaction to refund against. Omit to use the latest successful charge.'],
170 'reason' => ['type' => 'string'],
171 'dry_run' => ['type' => 'boolean', 'description' => 'Preview without refunding. Returns a confirm_token. Do this first.'],
172 'confirm_token' => ['type' => 'string', 'description' => 'From a prior dry_run. Required to execute.'],
173 'idempotency_key' => ['type' => 'string', 'description' => 'A unique string for this refund. Prevents double-refund on retry.'],
174 ],
175 'required' => ['order_id'],
176 ],
177 'execute_callback' => [self::class, 'refundOrder'],
178 'permission_callback' => function () {
179 return PermissionGate::can('orders/can_refund');
180 },
181 'annotations' => ['destructive' => true],
182 ],
183 ];
184 }
185
186 // -----------------------------------------------------------------
187 // list-orders
188 // -----------------------------------------------------------------
189
190 public static function listOrders($params = [])
191 {
192 $paging = MCPHelper::pagination($params);
193
194 // Eager-load customer plus a TRIMMED order_items relation — only the
195 // columns needed for a "what's in this order" preview, never the full
196 // money/refund/fulfillment row (that's get-order's job). formatRow caps
197 // the preview, so even a large multi-item order can't flood the payload.
198 // The product_id filter uses whereHas (a join), independent of this load.
199 $query = Order::query()->with([
200 'customer',
201 'order_items' => function ($q) {
202 $q->select(['id', 'order_id', 'post_id', 'post_title', 'title', 'quantity']);
203 },
204 ]);
205
206 $filterError = self::applyFilters($query, $params);
207 if (is_wp_error($filterError)) {
208 return $filterError;
209 }
210
211 $sortBy = self::allowed($params, 'sort_by', ['id', 'created_at', 'completed_at', 'total_amount'], 'id');
212 $sortType = strtoupper(isset($params['sort_type']) ? $params['sort_type'] : 'DESC') === 'ASC' ? 'ASC' : 'DESC';
213
214 // Deterministic total order: tie-break on id so identical calls and
215 // cursor paging never reshuffle rows.
216 $query->orderBy($sortBy, $sortType);
217 if ($sortBy !== 'id') {
218 $query->orderBy('id', 'DESC');
219 }
220
221 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
222 $total = self::total($paginator);
223
224 $rows = [];
225 foreach (MCPHelper::paginatorItems($paginator) as $order) {
226 $rows[] = self::formatRow($order);
227 }
228
229 return MCPHelper::envelope(
230 sprintf(
231 /* translators: %d: number of matching orders */
232 _n('%d order found.', '%d orders found.', $total, 'fluent-cart'),
233 $total
234 ),
235 ['orders' => $rows],
236 MCPHelper::pagingMeta($paginator)
237 );
238 }
239
240 private static function applyFilters($query, $params)
241 {
242 foreach (['status', 'payment_status', 'type', 'currency', 'mode'] as $col) {
243 if (!empty($params[$col])) {
244 $query->where($col, sanitize_text_field($params[$col]));
245 }
246 }
247
248 // shipping_status: the reported 'none' maps to the empty/NULL stored value.
249 if (!empty($params['shipping_status'])) {
250 $shipping = sanitize_text_field($params['shipping_status']);
251 if ($shipping === 'none') {
252 $query->where(function ($q) {
253 $q->whereNull('shipping_status')->orWhere('shipping_status', '');
254 });
255 } else {
256 $query->where('shipping_status', $shipping);
257 }
258 }
259
260 if (!empty($params['customer_id'])) {
261 $query->where('customer_id', (int) $params['customer_id']);
262 }
263
264 if (!empty($params['customer_email'])) {
265 $email = sanitize_email($params['customer_email']);
266 $query->whereHas('customer', function ($q) use ($email) {
267 $q->where('email', $email);
268 });
269 }
270
271 if (!empty($params['product_id'])) {
272 $productId = (int) $params['product_id'];
273 $query->whereHas('order_items', function ($q) use ($productId) {
274 $q->where('post_id', $productId);
275 });
276 }
277
278 if (!empty($params['coupon_code'])) {
279 $code = sanitize_text_field($params['coupon_code']);
280 $query->whereHas('appliedCoupons', function ($q) use ($code) {
281 $q->where('code', $code);
282 });
283 }
284
285 if (!empty($params['country'])) {
286 $country = sanitize_text_field($params['country']);
287 $query->whereHas('billing_address', function ($q) use ($country) {
288 $q->where('country', $country);
289 });
290 }
291
292 if (isset($params['min_total'])) {
293 $query->where('total_amount', '>=', Helper::toCent($params['min_total']));
294 }
295 if (isset($params['max_total'])) {
296 $query->where('total_amount', '<=', Helper::toCent($params['max_total']));
297 }
298
299 foreach (['created_after' => '>=', 'created_before' => '<='] as $field => $op) {
300 if (empty($params[$field])) {
301 continue;
302 }
303 $date = self::toDbDate($params[$field]);
304 if ($date === null) {
305 return self::invalidDateError($field);
306 }
307 $query->where('created_at', $op, $date);
308 }
309
310 if (!empty($params['search'])) {
311 $term = sanitize_text_field($params['search']);
312 $like = '%' . $term . '%';
313 $query->where(function ($q) use ($like) {
314 $q->where('invoice_no', 'LIKE', $like)
315 ->orWhere('receipt_number', 'LIKE', $like)
316 ->orWhere('uuid', 'LIKE', $like)
317 ->orWhereHas('customer', function ($cq) use ($like) {
318 $cq->where('email', 'LIKE', $like)
319 ->orWhere('first_name', 'LIKE', $like)
320 ->orWhere('last_name', 'LIKE', $like);
321 });
322 });
323 }
324 }
325
326 /**
327 * Refund timestamp. Falls back to the latest refund transaction's date when
328 * the order's own refunded_at column is empty but money was refunded — some
329 * refund paths don't stamp the column.
330 */
331 private static function refundedAt($order)
332 {
333 if ($order->refunded_at) {
334 return MCPHelper::toIso8601($order->refunded_at);
335 }
336 if ((int) $order->total_refund > 0) {
337 $txn = OrderTransaction::query()
338 ->where('order_id', $order->id)
339 ->where('transaction_type', 'refund')
340 ->orderBy('id', 'DESC')
341 ->first();
342 if ($txn && $txn->created_at) {
343 return MCPHelper::toIso8601($txn->created_at);
344 }
345 }
346 return null;
347 }
348
349 /**
350 * Report the shipping status, mapping an empty/NULL stored value to 'none'
351 * (no shipping required — e.g. digital orders) so the value is always a
352 * member of the advertised enum.
353 */
354 private static function shippingStatusOut($order)
355 {
356 return ($order->shipping_status !== null && $order->shipping_status !== '') ? $order->shipping_status : 'none';
357 }
358
359 /** Compact list row — only what's needed to scan and decide which to open. */
360 private static function formatRow($order)
361 {
362 $customer = ($order->relationLoaded('customer') && $order->customer) ? $order->customer : null;
363
364 return [
365 'order_id' => (int) $order->id,
366 'number' => $order->invoice_no ? $order->invoice_no : (string) $order->id,
367 'label' => self::label($order, $customer),
368 'status' => $order->status,
369 'payment_status' => $order->payment_status,
370 'shipping_status' => self::shippingStatusOut($order),
371 'type' => $order->type,
372 'total' => MCPHelper::moneyCompact($order->total_amount),
373 'customer' => $customer ? [
374 'id' => (int) $customer->id,
375 'name' => MCPHelper::personName($customer),
376 'email' => $customer->email,
377 ] : null,
378 'items' => self::itemsSummary($order),
379 'created_at' => MCPHelper::toIso8601($order->created_at),
380 ];
381 }
382
383 /**
384 * Compact "what was ordered" list for list rows: every line item as
385 * product_id, display title (incl. variation), and quantity — enough for the
386 * agent to recognize an order's contents without a get-order round-trip.
387 * Prices and refund/fulfillment detail stay in get-order. Uncapped: a single
388 * order won't realistically carry enough lines to bloat the payload.
389 */
390 private static function itemsSummary($order)
391 {
392 if (!$order->relationLoaded('order_items')) {
393 return [];
394 }
395
396 $items = [];
397 foreach ($order->order_items as $item) {
398 $items[] = [
399 'product_id' => (int) $item->post_id,
400 'title' => $item->getDisplayTitle(),
401 'quantity' => (int) $item->quantity,
402 ];
403 }
404
405 return $items;
406 }
407
408 /** Human-readable one-liner: "Order INV-1042 — Jane Doe — $89.00 — paid". */
409 private static function label($order, $customer)
410 {
411 $number = $order->invoice_no ? $order->invoice_no : ('#' . $order->id);
412 $name = $customer ? MCPHelper::personName($customer) : __('Guest', 'fluent-cart');
413 $total = MCPHelper::displayAmount((int) $order->total_amount, $order->currency);
414
415 return sprintf(
416 /* translators: 1: order number, 2: customer name, 3: order total, 4: payment status */
417 __('Order %1$s — %2$s — %3$s — %4$s', 'fluent-cart'),
418 $number,
419 $name,
420 $total,
421 $order->payment_status
422 );
423 }
424
425 // -----------------------------------------------------------------
426 // get-order
427 // -----------------------------------------------------------------
428
429 public static function getOrder($params = [])
430 {
431 $order = self::resolveOrder($params);
432 if (is_wp_error($order)) {
433 return $order;
434 }
435
436 $include = isset($params['include']) ? (array) $params['include'] : [];
437
438 $order->load('customer', 'order_items');
439
440 $data = [
441 'order_id' => (int) $order->id,
442 'uuid' => $order->uuid,
443 'number' => $order->invoice_no,
444 'receipt_number' => $order->receipt_number,
445 'status' => $order->status,
446 'payment_status' => $order->payment_status,
447 'shipping_status' => self::shippingStatusOut($order),
448 'type' => $order->type,
449 'mode' => $order->mode,
450 'currency' => $order->currency,
451 'totals' => self::totals($order),
452 'customer' => self::customerBlock($order),
453 'items' => self::itemsBlock($order),
454 'created_at' => MCPHelper::toIso8601($order->created_at),
455 'completed_at' => MCPHelper::toIso8601($order->completed_at),
456 'refunded_at' => self::refundedAt($order),
457 ];
458
459 if (in_array('addresses', $include, true)) {
460 $data['addresses'] = self::addressesBlock($order);
461 }
462 if (in_array('transactions', $include, true)) {
463 $data['transactions'] = self::transactionsBlock($order, false);
464 }
465 if (in_array('refunds', $include, true)) {
466 $data['refunds'] = self::transactionsBlock($order, true);
467 }
468 if (in_array('coupons', $include, true)) {
469 $data['coupons'] = self::couponsBlock($order);
470 }
471 if (in_array('subscriptions', $include, true)) {
472 $data['subscriptions'] = self::subscriptionsBlock($order);
473 }
474
475 return MCPHelper::envelope(self::label($order, $order->customer), $data);
476 }
477
478 private static function resolveOrder($params)
479 {
480 if (!empty($params['order_id'])) {
481 $order = Order::query()->where('id', (int) $params['order_id'])->first();
482 } elseif (!empty($params['uuid'])) {
483 $order = Order::query()->where('uuid', sanitize_text_field($params['uuid']))->first();
484 } elseif (!empty($params['invoice_no'])) {
485 $order = Order::query()->where('invoice_no', sanitize_text_field($params['invoice_no']))->first();
486 } else {
487 return MCPHelper::error(
488 'missing_identifier',
489 __('Provide order_id, uuid, or invoice_no.', 'fluent-cart'),
490 ['fields' => ['order_id', 'uuid', 'invoice_no'], 'hint' => 'Use list-orders to find an order_id.']
491 );
492 }
493
494 if (!$order) {
495 return MCPHelper::error('order_not_found', __('No order found for the given identifier.', 'fluent-cart'));
496 }
497
498 return $order;
499 }
500
501 /** Full money breakdown — every line a money object (decimal + cents + display). */
502 private static function totals($order)
503 {
504 $currency = $order->currency;
505 return [
506 'subtotal' => MCPHelper::money($order->subtotal, $currency),
507 'manual_discount_total' => MCPHelper::money($order->manual_discount_total, $currency),
508 'coupon_discount_total' => MCPHelper::money($order->coupon_discount_total, $currency),
509 'tax_total' => MCPHelper::money($order->tax_total, $currency),
510 'shipping_total' => MCPHelper::money($order->shipping_total, $currency),
511 'fee_total' => MCPHelper::money($order->fee_total, $currency),
512 'total_amount' => MCPHelper::money($order->total_amount, $currency),
513 'total_paid' => MCPHelper::money($order->total_paid, $currency),
514 'total_refund' => MCPHelper::money($order->total_refund, $currency),
515 ];
516 }
517
518 private static function customerBlock($order)
519 {
520 if (!$order->customer) {
521 return null;
522 }
523 $c = $order->customer;
524 return [
525 'id' => (int) $c->id,
526 'name' => MCPHelper::personName($c),
527 'email' => $c->email,
528 ];
529 }
530
531 private static function itemsBlock($order)
532 {
533 $items = [];
534 if (!$order->relationLoaded('order_items')) {
535 return $items;
536 }
537 foreach ($order->order_items as $item) {
538 $items[] = [
539 'id' => (int) $item->id,
540 'product_id' => (int) $item->post_id,
541 'variation_id' => (int) $item->object_id,
542 'title' => $item->post_title ? $item->post_title : $item->title,
543 'quantity' => (int) $item->quantity,
544 'fulfilled_qty' => (int) $item->fulfilled_quantity,
545 'unit_price' => MCPHelper::money($item->unit_price, $order->currency),
546 'line_total' => MCPHelper::money($item->line_total, $order->currency),
547 'refund_total' => MCPHelper::money($item->refund_total, $order->currency),
548 ];
549 }
550 return $items;
551 }
552
553 private static function addressesBlock($order)
554 {
555 $order->load('order_addresses');
556 $out = ['billing' => null, 'shipping' => null];
557 if (!$order->relationLoaded('order_addresses')) {
558 return $out;
559 }
560 foreach ($order->order_addresses as $addr) {
561 $block = [
562 'name' => $addr->name,
563 'address_1' => $addr->address_1,
564 'address_2' => $addr->address_2,
565 'city' => $addr->city,
566 'state' => $addr->state,
567 'postcode' => $addr->postcode,
568 'country' => $addr->country,
569 'phone' => $addr->phone,
570 'email' => $addr->email,
571 ];
572 if ($addr->type === 'shipping') {
573 $out['shipping'] = $block;
574 } else {
575 $out['billing'] = $block;
576 }
577 }
578 return $out;
579 }
580
581 private static function transactionsBlock($order, $refundsOnly)
582 {
583 $order->load('transactions');
584 $out = [];
585 if (!$order->relationLoaded('transactions')) {
586 return $out;
587 }
588 foreach ($order->transactions as $txn) {
589 $isRefund = $txn->transaction_type === 'refund';
590 if ($refundsOnly !== $isRefund) {
591 continue;
592 }
593 $currency = $txn->currency ? $txn->currency : $order->currency;
594 $out[] = [
595 'id' => (int) $txn->id,
596 'type' => $txn->transaction_type,
597 'status' => $txn->status,
598 'payment_method' => $txn->payment_method,
599 'amount' => MCPHelper::money($txn->total, $currency),
600 'card_last_4' => $txn->card_last_4,
601 'card_brand' => $txn->card_brand,
602 'vendor_charge_id' => $txn->vendor_charge_id,
603 'created_at' => MCPHelper::toIso8601($txn->created_at),
604 ];
605 }
606 return $out;
607 }
608
609 private static function couponsBlock($order)
610 {
611 $order->load('appliedCoupons');
612 $out = [];
613 if (!$order->relationLoaded('appliedCoupons')) {
614 return $out;
615 }
616 foreach ($order->appliedCoupons as $coupon) {
617 $out[] = [
618 'code' => $coupon->code,
619 'amount' => MCPHelper::money($coupon->amount, $order->currency),
620 ];
621 }
622 return $out;
623 }
624
625 private static function subscriptionsBlock($order)
626 {
627 $order->load('subscriptions');
628 $out = [];
629 if (!$order->relationLoaded('subscriptions')) {
630 return $out;
631 }
632 foreach ($order->subscriptions as $sub) {
633 $out[] = [
634 'id' => (int) $sub->id,
635 'status' => $sub->status,
636 'item_name' => $sub->item_name,
637 'recurring_total' => MCPHelper::money($sub->recurring_total, $order->currency),
638 'billing_interval' => $sub->billing_interval,
639 'next_billing_date' => MCPHelper::toIso8601($sub->next_billing_date),
640 ];
641 }
642 return $out;
643 }
644
645 // -----------------------------------------------------------------
646 // get-order-activity
647 // -----------------------------------------------------------------
648
649 public static function getOrderActivity($params = [])
650 {
651 if (empty($params['order_id'])) {
652 return MCPHelper::error('missing_identifier', __('order_id is required.', 'fluent-cart'));
653 }
654
655 $orderId = (int) $params['order_id'];
656 $limit = isset($params['limit']) ? min(max((int) $params['limit'], 1), 100) : 30;
657
658 $order = Order::query()->where('id', $orderId)->first();
659 if (!$order) {
660 return MCPHelper::error('order_not_found', __('No order found for the given order_id.', 'fluent-cart'));
661 }
662
663 $events = [];
664
665 // Logged activity: status changes, notes, emails — all written to
666 // fct_activity. Fetch up to $limit; the merge below trims to $limit total.
667 if (class_exists('\FluentCart\App\Models\Activity')) {
668 $rows = \FluentCart\App\Models\Activity::query()
669 ->where('module_id', $orderId)
670 ->where(function ($q) {
671 $q->where('module_type', Order::class)->orWhere('module_name', 'order');
672 })
673 ->orderBy('id', 'DESC')
674 ->limit($limit)
675 ->get();
676
677 foreach ($rows as $row) {
678 $events[] = [
679 '_sort' => (string) $row->created_at,
680 'event' => self::activityEvent($row),
681 'source' => 'activity',
682 'title' => $row->title,
683 'status' => $row->status,
684 'content' => MCPHelper::htmlToText($row->content),
685 'by' => $row->created_by,
686 'amount' => null,
687 'payment_method' => null,
688 'reference' => null,
689 'created_at' => MCPHelper::toIso8601($row->created_at),
690 ];
691 }
692 }
693
694 // Money events: charges and refunds from the transactions ledger. These
695 // are the payment/refund timeline entries the activity log doesn't carry.
696 $order->load('transactions');
697 if ($order->relationLoaded('transactions')) {
698 foreach ($order->transactions as $txn) {
699 $type = $txn->transaction_type ? $txn->transaction_type : 'charge';
700 $event = ($type === 'refund') ? 'refund' : (($type === 'charge') ? 'payment' : $type);
701 $events[] = [
702 '_sort' => (string) $txn->created_at,
703 'event' => $event,
704 'source' => 'transaction',
705 'title' => self::txnTitle($type, $txn),
706 'status' => $txn->status,
707 'content' => null,
708 'by' => null,
709 'amount' => MCPHelper::money($txn->total, $txn->currency ? $txn->currency : null),
710 'payment_method' => $txn->payment_method ? $txn->payment_method : null,
711 'reference' => $txn->vendor_charge_id ? $txn->vendor_charge_id : null,
712 'created_at' => MCPHelper::toIso8601($txn->created_at),
713 ];
714 }
715 }
716
717 // Merge both streams most-recent-first, then cap at $limit.
718 usort($events, function ($a, $b) {
719 return strcmp($b['_sort'], $a['_sort']);
720 });
721 $events = array_slice($events, 0, $limit);
722 foreach ($events as &$event) {
723 unset($event['_sort']);
724 }
725 unset($event);
726
727 return MCPHelper::envelope(
728 sprintf(
729 /* translators: 1: number of timeline entries, 2: order id */
730 _n('%1$d timeline entry for order #%2$d.', '%1$d timeline entries for order #%2$d.', count($events), 'fluent-cart'),
731 count($events),
732 $orderId
733 ),
734 ['timeline' => $events]
735 );
736 }
737
738 /** Classify an activity-log row into a coarse timeline event kind. */
739 private static function activityEvent($row)
740 {
741 $title = strtolower((string) $row->title);
742 if (strpos($title, 'email') !== false) {
743 return 'email';
744 }
745 if (strpos($title, 'status') !== false || strpos($title, 'refund') !== false) {
746 return 'status';
747 }
748 if ($row->log_type === 'api') {
749 return 'api';
750 }
751 return 'note';
752 }
753
754 /** Human-readable title for a transaction timeline entry. */
755 private static function txnTitle($type, $txn)
756 {
757 $method = $txn->payment_method ? $txn->payment_method : __('gateway', 'fluent-cart');
758 if ($type === 'refund') {
759 /* translators: 1: payment method, 2: status */
760 return sprintf(__('Refund via %1$s — %2$s', 'fluent-cart'), $method, $txn->status);
761 }
762 if ($type === 'charge') {
763 /* translators: 1: payment method, 2: status */
764 return sprintf(__('Payment via %1$s — %2$s', 'fluent-cart'), $method, $txn->status);
765 }
766 /* translators: 1: transaction type, 2: payment method, 3: status */
767 return sprintf(__('%1$s via %2$s — %3$s', 'fluent-cart'), $type, $method, $txn->status);
768 }
769
770 // -----------------------------------------------------------------
771 // change-order-status (write)
772 // -----------------------------------------------------------------
773
774 public static function changeOrderStatus($params = [])
775 {
776 if (empty($params['order_id'])) {
777 return MCPHelper::error('missing_identifier', __('order_id is required.', 'fluent-cart'));
778 }
779 $orderId = (int) $params['order_id'];
780 $order = Order::query()->where('id', $orderId)->first();
781 if (!$order) {
782 return MCPHelper::error('order_not_found', __('No order found for the given order_id.', 'fluent-cart'));
783 }
784
785 $targetOrderStatus = isset($params['order_status']) ? sanitize_text_field($params['order_status']) : null;
786 $targetShipStatus = isset($params['shipping_status']) ? sanitize_text_field($params['shipping_status']) : null;
787
788 if ($targetOrderStatus === null && $targetShipStatus === null) {
789 return MCPHelper::error('missing_param', __('Provide order_status and/or shipping_status.', 'fluent-cart'), ['fields' => ['order_status', 'shipping_status']]);
790 }
791
792 // Validate server-side against the statuses core actually accepts, so a
793 // client that ignores the advertised enum gets a precise error rather
794 // than a generic core rejection or a silent no-op.
795 $editableOrder = array_keys(Status::getEditableOrderStatuses());
796 if ($targetOrderStatus !== null && !in_array($targetOrderStatus, $editableOrder, true)) {
797 return MCPHelper::error(
798 'invalid_param',
799 sprintf(
800 /* translators: 1: rejected status, 2: allowed statuses */
801 __('order_status "%1$s" cannot be set manually. Allowed: %2$s.', 'fluent-cart'),
802 $targetOrderStatus,
803 implode(', ', $editableOrder)
804 ),
805 ['fields' => ['order_status'], 'allowed' => $editableOrder]
806 );
807 }
808 $editableShip = array_keys(Status::getEditableShippingStatuses());
809 if ($targetShipStatus !== null && !in_array($targetShipStatus, $editableShip, true)) {
810 return MCPHelper::error(
811 'invalid_param',
812 sprintf(
813 /* translators: 1: rejected status, 2: allowed statuses */
814 __('shipping_status "%1$s" is not settable. Allowed: %2$s.', 'fluent-cart'),
815 $targetShipStatus,
816 implode(', ', $editableShip)
817 ),
818 ['fields' => ['shipping_status'], 'allowed' => $editableShip]
819 );
820 }
821
822 $changed = [];
823 $noChange = [];
824 $notApplied = [];
825
826 if ($targetOrderStatus !== null) {
827 if ($order->status === $targetOrderStatus) {
828 $noChange[] = 'order_status';
829 } else {
830 $res = OrderResource::updateStatuses([
831 'order' => $order,
832 'action' => 'change_order_status',
833 'statuses' => ['order_status' => $targetOrderStatus],
834 ]);
835 if (is_wp_error($res)) {
836 return $res;
837 }
838 // Confirm the change actually took: core can no-op without error.
839 $order = Order::query()->where('id', $orderId)->first();
840 if ($order->status === $targetOrderStatus) {
841 $changed[] = 'order_status';
842 } else {
843 $notApplied[] = 'order_status';
844 }
845 }
846 }
847
848 if ($targetShipStatus !== null) {
849 $order = Order::query()->where('id', $orderId)->first();
850 if ($order->shipping_status === $targetShipStatus) {
851 $noChange[] = 'shipping_status';
852 } else {
853 $res = OrderResource::updateStatuses([
854 'order' => $order,
855 'action' => 'change_shipping_status',
856 'statuses' => ['shipping_status' => $targetShipStatus],
857 ]);
858 if (is_wp_error($res)) {
859 // Partial failure: report what already changed so the agent
860 // doesn't blindly re-apply the whole call (side effects fired).
861 if ($changed) {
862 $order = Order::query()->where('id', $orderId)->first();
863 return MCPHelper::error(
864 'partial_failure',
865 sprintf(
866 /* translators: 1: fields already changed, 2: error message */
867 __('Applied %1$s, but the shipping status change failed: %2$s. Do not re-run the whole call — retry only shipping_status.', 'fluent-cart'),
868 implode(', ', $changed),
869 $res->get_error_message()
870 ),
871 [
872 'order_id' => $orderId,
873 'changed' => $changed,
874 'failed' => ['field' => 'shipping_status', 'error' => $res->get_error_message()],
875 'status' => $order->status,
876 'shipping_status' => self::shippingStatusOut($order),
877 ]
878 );
879 }
880 return $res;
881 }
882 $order = Order::query()->where('id', $orderId)->first();
883 if (self::shippingStatusOut($order) === $targetShipStatus) {
884 $changed[] = 'shipping_status';
885 } else {
886 $notApplied[] = 'shipping_status';
887 }
888 }
889 }
890
891 $order = Order::query()->where('id', $orderId)->first();
892
893 $summary = $changed
894 ? sprintf(
895 /* translators: 1: fields changed, 2: order id */
896 __('Updated %1$s on order #%2$d.', 'fluent-cart'),
897 implode(', ', $changed),
898 $orderId
899 )
900 : __('No change — the order is already in the requested status.', 'fluent-cart');
901
902 return MCPHelper::envelope($summary, [
903 'order_id' => $orderId,
904 'status' => $order->status,
905 'shipping_status' => self::shippingStatusOut($order),
906 'changed' => $changed,
907 'no_change' => $noChange,
908 'not_applied' => $notApplied,
909 ]);
910 }
911
912 // -----------------------------------------------------------------
913 // add-order-note (write)
914 // -----------------------------------------------------------------
915
916 public static function addOrderNote($params = [])
917 {
918 if (empty($params['order_id']) || empty($params['note'])) {
919 return MCPHelper::error('missing_param', __('order_id and note are required.', 'fluent-cart'), ['fields' => ['order_id', 'note']]);
920 }
921 $orderId = (int) $params['order_id'];
922 $order = Order::query()->where('id', $orderId)->first();
923 if (!$order) {
924 return MCPHelper::error('order_not_found', __('No order found for the given order_id.', 'fluent-cart'));
925 }
926
927 $note = wp_kses_post($params['note']);
928
929 $log = fluent_cart_add_log(
930 __('Note added via AI assistant', 'fluent-cart'),
931 $note,
932 'info',
933 [
934 'module_name' => 'order',
935 'module_id' => $orderId,
936 'module_type' => Order::class,
937 'log_type' => 'activity',
938 ]
939 );
940
941 // Confirm the activity row was actually written before claiming success.
942 if (is_wp_error($log) || !is_object($log) || empty($log->id)) {
943 return MCPHelper::error(
944 'note_not_added',
945 __('The note could not be saved to the order activity log.', 'fluent-cart'),
946 ['order_id' => $orderId, 'retryable' => true]
947 );
948 }
949
950 return MCPHelper::envelope(
951 sprintf(
952 /* translators: %d: order id */
953 __('Note added to order #%d.', 'fluent-cart'),
954 $orderId
955 ),
956 ['order_id' => $orderId, 'note_id' => (int) $log->id, 'note' => MCPHelper::htmlToText($note)]
957 );
958 }
959
960 // -----------------------------------------------------------------
961 // refund-order (write, destructive — dry_run + idempotency)
962 // -----------------------------------------------------------------
963
964 public static function refundOrder($params = [])
965 {
966 if (empty($params['order_id'])) {
967 return MCPHelper::error('missing_identifier', __('order_id is required.', 'fluent-cart'));
968 }
969 $order = Order::query()->where('id', (int) $params['order_id'])->first();
970 if (!$order) {
971 return MCPHelper::error('order_not_found', __('No order found for the given order_id.', 'fluent-cart'));
972 }
973 if (!$order->canBeRefunded()) {
974 return MCPHelper::error('not_refundable', __('This order cannot be refunded in its current state.', 'fluent-cart'), ['current_state' => ['payment_status' => $order->payment_status]]);
975 }
976
977 $remaining = (int) $order->total_paid - (int) $order->total_refund;
978 if ($remaining <= 0) {
979 return MCPHelper::error('nothing_to_refund', __('There is no remaining refundable balance on this order.', 'fluent-cart'));
980 }
981
982 if (!empty($params['transaction_id'])) {
983 // Same constraints as the auto-select branch: an explicit id must
984 // still be a succeeded charge on this order, never a failed/pending/
985 // refund transaction.
986 $txn = OrderTransaction::query()
987 ->where('order_id', $order->id)
988 ->where('id', (int) $params['transaction_id'])
989 ->where('transaction_type', 'charge')
990 ->where('status', 'succeeded')
991 ->first();
992 } else {
993 $txn = OrderTransaction::query()
994 ->where('order_id', $order->id)
995 ->where('transaction_type', 'charge')
996 ->where('status', 'succeeded')
997 ->orderBy('id', 'DESC')
998 ->first();
999 }
1000 if (!$txn) {
1001 return MCPHelper::error('transaction_not_found', __('No refundable charge transaction was found on this order.', 'fluent-cart'));
1002 }
1003
1004 $amountCents = isset($params['amount']) ? Helper::toCent($params['amount']) : $remaining;
1005 if ($amountCents <= 0) {
1006 return MCPHelper::error('invalid_amount', __('Refund amount must be greater than zero.', 'fluent-cart'));
1007 }
1008 if ($amountCents > $remaining) {
1009 return MCPHelper::error(
1010 'refund_exceeds_remaining',
1011 sprintf(
1012 /* translators: 1: requested amount, 2: remaining refundable */
1013 __('Refund %1$s exceeds the remaining refundable balance %2$s.', 'fluent-cart'),
1014 MCPHelper::displayAmount($amountCents, $order->currency),
1015 MCPHelper::displayAmount($remaining, $order->currency)
1016 ),
1017 ['current_state' => ['refundable_cents' => $remaining]]
1018 );
1019 }
1020
1021 $tool = 'fluent-cart/refund-order';
1022 $entityKey = 'order:' . $order->id;
1023 // Bind the exact previewed mutation (amount + transaction) into the
1024 // fingerprint so a token minted for one amount can't confirm another.
1025 $fingerprint = 'paid:' . (int) $order->total_paid
1026 . '|refund:' . (int) $order->total_refund
1027 . '|amount:' . (int) $amountCents
1028 . '|txn:' . (int) $txn->id;
1029
1030 if (!empty($params['dry_run'])) {
1031 return MCPHelper::envelope(
1032 sprintf(
1033 /* translators: 1: amount to refund, 2: remaining refundable, 3: order id */
1034 __('Preview: refund %1$s of %2$s remaining on order #%3$d.', 'fluent-cart'),
1035 MCPHelper::displayAmount($amountCents, $order->currency),
1036 MCPHelper::displayAmount($remaining, $order->currency),
1037 (int) $order->id
1038 ),
1039 WriteGuard::preview($tool, $entityKey, $fingerprint, [
1040 'order_id' => (int) $order->id,
1041 'refundable' => MCPHelper::money($remaining, $order->currency),
1042 'amount' => MCPHelper::money($amountCents, $order->currency),
1043 'transaction' => ['id' => (int) $txn->id, 'payment_method' => $txn->payment_method, 'payment_mode' => $txn->payment_mode],
1044 'live_gateway_action' => WriteGuard::isLiveMode($txn->payment_mode),
1045 ])
1046 );
1047 }
1048
1049 $confirm = WriteGuard::confirm($tool, $entityKey, $fingerprint, isset($params['confirm_token']) ? $params['confirm_token'] : '');
1050 if (is_wp_error($confirm)) {
1051 return $confirm;
1052 }
1053
1054 // Real-money guard: a live refund needs explicit opt-in (test always OK).
1055 $liveGate = WriteGuard::liveGatewayAllowed($txn->payment_mode);
1056 if (is_wp_error($liveGate)) {
1057 return $liveGate;
1058 }
1059
1060 $reason = isset($params['reason']) ? sanitize_text_field($params['reason']) : '';
1061 $idemKey = isset($params['idempotency_key']) ? (string) $params['idempotency_key'] : '';
1062
1063 $result = WriteGuard::idempotent($tool, $entityKey, $idemKey, function () use ($txn, $amountCents, $reason) {
1064 return (new Refund())->processRefund($txn, $amountCents, ['reason' => $reason]);
1065 });
1066
1067 if (is_wp_error($result)) {
1068 return $result;
1069 }
1070
1071 $order = Order::query()->where('id', (int) $params['order_id'])->first();
1072
1073 return MCPHelper::envelope(
1074 sprintf(
1075 /* translators: 1: refunded amount, 2: order id */
1076 __('Refunded %1$s on order #%2$d.', 'fluent-cart'),
1077 MCPHelper::displayAmount($amountCents, $order->currency),
1078 (int) $order->id
1079 ),
1080 [
1081 'order_id' => (int) $order->id,
1082 'refunded' => MCPHelper::money($amountCents, $order->currency),
1083 'payment_status' => $order->payment_status,
1084 'total_refund' => MCPHelper::money($order->total_refund, $order->currency),
1085 'gateway_result' => is_array($result) ? array_intersect_key($result, array_flip(['vendor_refund_id', 'manual_refund'])) : null,
1086 ]
1087 );
1088 }
1089
1090 // -----------------------------------------------------------------
1091 // helpers
1092 // -----------------------------------------------------------------
1093
1094 private static function allowed($params, $key, array $allowed, $default)
1095 {
1096 $val = isset($params[$key]) ? $params[$key] : $default;
1097 return in_array($val, $allowed, true) ? $val : $default;
1098 }
1099
1100 private static function total($paginator)
1101 {
1102 return MCPHelper::paginatorTotal($paginator);
1103 }
1104
1105 private static function toDbDate($value)
1106 {
1107 try {
1108 return (new \DateTime((string) $value, new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
1109 } catch (\Exception $e) {
1110 // Return null so callers reject the input. An epoch fallback would
1111 // silently turn a typo'd date bound into an unbounded "match all".
1112 return null;
1113 }
1114 }
1115
1116 private static function invalidDateError($field)
1117 {
1118 return MCPHelper::error(
1119 'invalid_date',
1120 sprintf(
1121 /* translators: 1: field name */
1122 __('%1$s is not a valid date. Use YYYY-MM-DD or ISO 8601.', 'fluent-cart'),
1123 $field
1124 ),
1125 ['fields' => [$field]]
1126 );
1127 }
1128 }
1129