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

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

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