| @@ -35,18 +35,19 @@ | ||
| 35 | 35 | class OrderTools |
| 36 | 36 | { |
| 37 | 37 | public static function definitions() |
| 38 | 38 | { |
| 39 | - $orderStatuses = ContextTools::ENUMS['order_statuses']; | |
| 40 | - $paymentStatuses = ContextTools::ENUMS['payment_statuses']; | |
| 41 | - $shippingStatuses = ContextTools::ENUMS['shipping_statuses']; | |
| 39 | + $enums = ContextTools::enums(); | |
| 40 | + $orderStatuses = $enums['order_statuses']; | |
| 41 | + $paymentStatuses = $enums['payment_statuses']; | |
| 42 | + $shippingStatuses = $enums['shipping_statuses']; | |
| 42 | 43 | // change-order-status cannot set an order back to "no shipping required". |
| 43 | 44 | $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). | |
| 45 | + // Only the statuses core accepts for a manual change — a subset of the | |
| 46 | + // order_statuses enum used for filtering ('failed' is reached through a | |
| 47 | + // payment flow, not a manual set). | |
| 47 | 48 | $orderWritable = array_keys(Status::getEditableOrderStatuses()); |
| 48 | - $orderTypes = ContextTools::ENUMS['order_types']; | |
| 49 | + $orderTypes = $enums['order_types']; | |
| 49 | 50 | |
| 50 | 51 | return [ |
| 51 | 52 | 'fluent-cart/list-orders' => [ |
| 52 | 53 | 'label' => __('List Orders', 'fluent-cart'), |
| @@ -53,10 +54,10 @@ | ||
| 53 | 54 | 'description' => __('Find and filter orders. Returns compact rows (id, number, customer, total, statuses, date, plus an items list: each line item\'s product, title and quantity) — call get-order for the full money/refund breakdown. All filters optional; combine freely. For one customer\'s orders, pass customer_email or customer_id here. Money filters are in store currency (e.g. 49.99), not cents. For conditions these flat filters cannot express (OR groups, relative dates, transaction/UTM/label/license properties) pass advanced_filters — call get-search-schema entity=orders first (Pro).', 'fluent-cart'), |
| 54 | 55 | 'input_schema' => [ |
| 55 | 56 | 'type' => 'object', |
| 56 | 57 | 'properties' => [ |
| 57 | - 'status' => ['type' => 'string', 'enum' => $orderStatuses, 'description' => 'Order fulfillment/lifecycle status.'], | |
| 58 | - 'payment_status' => ['type' => 'string', 'enum' => $paymentStatuses], | |
| 58 | + 'status' => ['type' => 'string', 'enum' => $orderStatuses, 'description' => 'Order fulfillment/lifecycle status. To find refunded orders use payment_status (refunded / partially_refunded), NOT this field: refunds are recorded as payment state, and status=refunded only ever appears on stores migrated from WooCommerce. pending here means an unpaid store-managed renewal invoice, which is not the same as payment_status=pending; a COD order sits at status=on-hold with payment_status=pending.'], | |
| 59 | + 'payment_status' => ['type' => 'string', 'enum' => $paymentStatuses, 'description' => 'Money state of the order — this is where refunded, partially_refunded, authorized and payment_scheduled live.'], | |
| 59 | 60 | 'shipping_status' => ['type' => 'string', 'enum' => $shippingStatuses], |
| 60 | 61 | 'type' => ['type' => 'string', 'enum' => $orderTypes, 'description' => 'payment = first purchase, renewal = subscription renewal.'], |
| 61 | 62 | 'customer_id' => ['type' => 'integer'], |
| 62 | 63 | 'customer_email' => ['type' => 'string', 'description' => 'Exact email — the most reliable customer filter.'], |
| @@ -86,9 +87,13 @@ | ||
| 86 | 87 | ], |
| 87 | 88 | |
| 88 | 89 | 'fluent-cart/get-order' => [ |
| 89 | 90 | '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 | + 'description' => sprintf( | |
| 92 | + /* translators: %1$s: comma-separated include[] section names */ | |
| 93 | + __('Full detail for one order: money breakdown, line items, and customer by default. Add include[] for any of: %1$s. Identify the order by order_id (numeric, from list-orders) OR uuid OR invoice_no.', 'fluent-cart'), | |
| 94 | + implode(', ', self::includeSections()) | |
| 95 | + ), | |
| 91 | 96 | 'input_schema' => [ |
| 92 | 97 | 'type' => 'object', |
| 93 | 98 | 'properties' => [ |
| 94 | 99 | 'order_id' => ['type' => 'integer', 'description' => 'Numeric order id as returned by list-orders.'], |
| @@ -96,9 +101,9 @@ | ||
| 96 | 101 | 'invoice_no' => ['type' => 'string'], |
| 97 | 102 | 'include' => [ |
| 98 | 103 | 'type' => 'array', |
| 99 | 104 | 'description' => 'Optional heavier sections. items + customer are always included.', |
| 100 | - 'items' => ['type' => 'string', 'enum' => ['transactions', 'refunds', 'addresses', 'coupons', 'subscriptions']], | |
| 105 | + 'items' => ['type' => 'string', 'enum' => self::includeSections()], | |
| 101 | 106 | ], |
| 102 | 107 | 'fields' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Optional: return only these top-level keys to shrink the payload (order_id is always kept). e.g. status, payment_status, totals, items, customer. Applies after include[]. Omit for the full record.'], |
| 103 | 108 | ], |
| 104 | 109 | ], |
| @@ -396,9 +401,14 @@ | ||
| 396 | 401 | $customer = ($order->relationLoaded('customer') && $order->customer) ? $order->customer : null; |
| 397 | 402 | |
| 398 | 403 | return [ |
| 399 | 404 | 'order_id' => (int) $order->id, |
| 400 | - 'number' => $order->invoice_no ? $order->invoice_no : (string) $order->id, | |
| 405 | + // null, not the raw id: an invoice number is only assigned once the | |
| 406 | + // order is paid, and echoing the id here made unpaid orders look like | |
| 407 | + // they had a number in a different format from every other row (and | |
| 408 | + // disagreed with get-order, which already returns null). order_id is | |
| 409 | + // right above it for referencing the record. | |
| 410 | + 'number' => $order->invoice_no ? $order->invoice_no : null, | |
| 401 | 411 | 'label' => self::label($order, $customer), |
| 402 | 412 | 'status' => $order->status, |
| 403 | 413 | 'payment_status' => $order->payment_status, |
| 404 | 414 | 'shipping_status' => self::shippingStatusOut($order), |
| @@ -473,10 +483,13 @@ | ||
| 473 | 483 | |
| 474 | 484 | $data = [ |
| 475 | 485 | 'order_id' => (int) $order->id, |
| 476 | 486 | 'uuid' => $order->uuid, |
| 477 | - 'number' => $order->invoice_no, | |
| 478 | - 'receipt_number' => $order->receipt_number, | |
| 487 | + // Normalized to null when unassigned — the column stores '' for an | |
| 488 | + // order that has not been invoiced yet, and an empty string reads as | |
| 489 | + // "the number is blank" rather than "there is no number". | |
| 490 | + 'number' => $order->invoice_no ? $order->invoice_no : null, | |
| 491 | + 'receipt_number' => $order->receipt_number ? $order->receipt_number : null, | |
| 479 | 492 | 'status' => $order->status, |
| 480 | 493 | 'payment_status' => $order->payment_status, |
| 481 | 494 | 'shipping_status' => self::shippingStatusOut($order), |
| 482 | 495 | 'type' => $order->type, |
| @@ -505,13 +518,56 @@ | ||
| 505 | 518 | if (in_array('subscriptions', $include, true)) { |
| 506 | 519 | $data['subscriptions'] = self::subscriptionsBlock($order); |
| 507 | 520 | } |
| 508 | 521 | |
| 522 | + /** | |
| 523 | + * The assembled get-order payload, for add-on sections registered through | |
| 524 | + * fluent_cart/mcp_order_include_sections. Listeners should add their key | |
| 525 | + * only when it is present in $context['include']. | |
| 526 | + * | |
| 527 | + * @since 1.0.0 | |
| 528 | + * | |
| 529 | + * @param array $data the order payload | |
| 530 | + * @param array $context { order: Order, include: string[] } | |
| 531 | + */ | |
| 532 | + $data = apply_filters('fluent_cart/mcp_order_data', $data, [ | |
| 533 | + 'order' => $order, | |
| 534 | + 'include' => $include, | |
| 535 | + ]); | |
| 536 | + | |
| 509 | 537 | // fields projection runs last, so it can trim both the base record and any |
| 510 | 538 | // include[] sections; order_id is always kept. |
| 511 | 539 | $fields = isset($params['fields']) ? $params['fields'] : null; |
| 512 | 540 | |
| 513 | 541 | return MCPHelper::envelope(self::label($order, $order->customer), MCPHelper::pickFields($data, $fields, ['order_id'])); |
| 542 | + } | |
| 543 | + | |
| 544 | + /** | |
| 545 | + * The sections get-order's include[] accepts. Filterable so an integration | |
| 546 | + * that owns order-adjacent context (the CRM contact behind the buyer, for | |
| 547 | + * one) can offer it as an include rather than leaving the agent to guess | |
| 548 | + * which other tool holds it. | |
| 549 | + * | |
| 550 | + * A section added here MUST be populated by a listener on | |
| 551 | + * fluent_cart/mcp_order_data — an include the schema advertises but nothing | |
| 552 | + * fills is worse than no include at all. | |
| 553 | + * | |
| 554 | + * @return array | |
| 555 | + */ | |
| 556 | + private static function includeSections() | |
| 557 | + { | |
| 558 | + $sections = ['transactions', 'refunds', 'addresses', 'coupons', 'subscriptions']; | |
| 559 | + | |
| 560 | + /** | |
| 561 | + * Extra include[] section names for get-order. | |
| 562 | + * | |
| 563 | + * @since 1.0.0 | |
| 564 | + * | |
| 565 | + * @param array $sections section names offered in the include[] enum | |
| 566 | + */ | |
| 567 | + $sections = apply_filters('fluent_cart/mcp_order_include_sections', $sections); | |
| 568 | + | |
| 569 | + return array_values(array_unique(array_map('strval', (array) $sections))); | |
| 514 | 570 | } |
| 515 | 571 | |
| 516 | 572 | private static function resolveOrder($params) |
| 517 | 573 | { |