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

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

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