PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.4
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Modules / MCP / Tools / OrderTools.php

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

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