| @@ -5,8 +5,9 @@ | ||
| 5 | 5 | use FluentCart\App\Helpers\Helper; |
| 6 | 6 | use FluentCart\App\Helpers\Status; |
| 7 | 7 | use FluentCart\App\Models\Order; |
| 8 | 8 | use FluentCart\App\Models\OrderTransaction; |
| 9 | +use FluentCart\App\Modules\MCP\Support\AdvancedSearch; | |
| 9 | 10 | use FluentCart\App\Modules\MCP\Support\MCPHelper; |
| 10 | 11 | use FluentCart\App\Modules\MCP\Support\PermissionGate; |
| 11 | 12 | use FluentCart\App\Modules\MCP\Support\WriteGuard; |
| 12 | 13 | use FluentCart\App\Services\Payments\Refund; |
| @@ -34,28 +35,29 @@ | ||
| 34 | 35 | class OrderTools |
| 35 | 36 | { |
| 36 | 37 | public static function definitions() |
| 37 | 38 | { |
| 38 | - $orderStatuses = ContextTools::ENUMS['order_statuses']; | |
| 39 | - $paymentStatuses = ContextTools::ENUMS['payment_statuses']; | |
| 40 | - $shippingStatuses = ContextTools::ENUMS['shipping_statuses']; | |
| 39 | + $enums = ContextTools::enums(); | |
| 40 | + $orderStatuses = $enums['order_statuses']; | |
| 41 | + $paymentStatuses = $enums['payment_statuses']; | |
| 42 | + $shippingStatuses = $enums['shipping_statuses']; | |
| 41 | 43 | // change-order-status cannot set an order back to "no shipping required". |
| 42 | 44 | $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). | |
| 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). | |
| 46 | 48 | $orderWritable = array_keys(Status::getEditableOrderStatuses()); |
| 47 | - $orderTypes = ContextTools::ENUMS['order_types']; | |
| 49 | + $orderTypes = $enums['order_types']; | |
| 48 | 50 | |
| 49 | 51 | return [ |
| 50 | 52 | 'fluent-cart/list-orders' => [ |
| 51 | 53 | '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'), | |
| 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'), | |
| 53 | 55 | 'input_schema' => [ |
| 54 | 56 | 'type' => 'object', |
| 55 | 57 | 'properties' => [ |
| 56 | - 'status' => ['type' => 'string', 'enum' => $orderStatuses, 'description' => 'Order fulfillment/lifecycle status.'], | |
| 57 | - '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.'], | |
| 58 | 60 | 'shipping_status' => ['type' => 'string', 'enum' => $shippingStatuses], |
| 59 | 61 | 'type' => ['type' => 'string', 'enum' => $orderTypes, 'description' => 'payment = first purchase, renewal = subscription renewal.'], |
| 60 | 62 | 'customer_id' => ['type' => 'integer'], |
| 61 | 63 | 'customer_email' => ['type' => 'string', 'description' => 'Exact email — the most reliable customer filter.'], |
| @@ -68,10 +70,12 @@ | ||
| 68 | 70 | 'created_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'], |
| 69 | 71 | 'created_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'], |
| 70 | 72 | 'mode' => ['type' => 'string', 'enum' => ['live', 'test'], 'description' => 'Defaults to all modes.'], |
| 71 | 73 | 'search' => ['type' => 'string', 'description' => 'Matches invoice/receipt number, order uuid, and customer name/email.'], |
| 74 | + 'advanced_filters' => ['type' => 'array', 'items' => ['type' => ['object', 'array']], 'description' => 'Pro: condition groups {property, operator, value} — outer array = OR groups, inner = AND. Call get-search-schema entity=orders FIRST for properties/operators/format. AND-combines with the other filters here. An empty array means no advanced filter.'], | |
| 72 | 75 | 'sort_by' => ['type' => 'string', 'enum' => ['id', 'created_at', 'completed_at', 'total_amount'], 'default' => 'id'], |
| 73 | 76 | 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'], |
| 77 | + 'fields' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Optional: return only these row keys to shrink the payload (order_id is always kept). Available: number, label, status, payment_status, shipping_status, type, total, customer, items, created_at. Omit for the full row.'], | |
| 74 | 78 | 'page' => ['type' => 'integer', 'default' => 1], |
| 75 | 79 | 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 100.'], |
| 76 | 80 | ], |
| 77 | 81 | ], |
| @@ -83,9 +87,13 @@ | ||
| 83 | 87 | ], |
| 84 | 88 | |
| 85 | 89 | 'fluent-cart/get-order' => [ |
| 86 | 90 | 'label' => __('Get Order', 'fluent-cart'), |
| 87 | - 'description' => __('Full detail for one order: money breakdown, line items, and customer by default. Add include[] for transactions, refunds, addresses, coupons, subscriptions. Identify the order by order_id (numeric, from list-orders) OR uuid OR invoice_no.', 'fluent-cart'), | |
| 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 | + ), | |
| 88 | 96 | 'input_schema' => [ |
| 89 | 97 | 'type' => 'object', |
| 90 | 98 | 'properties' => [ |
| 91 | 99 | 'order_id' => ['type' => 'integer', 'description' => 'Numeric order id as returned by list-orders.'], |
| @@ -93,10 +101,11 @@ | ||
| 93 | 101 | 'invoice_no' => ['type' => 'string'], |
| 94 | 102 | 'include' => [ |
| 95 | 103 | 'type' => 'array', |
| 96 | 104 | 'description' => 'Optional heavier sections. items + customer are always included.', |
| 97 | - 'items' => ['type' => 'string', 'enum' => ['transactions', 'refunds', 'addresses', 'coupons', 'subscriptions']], | |
| 105 | + 'items' => ['type' => 'string', 'enum' => self::includeSections()], | |
| 98 | 106 | ], |
| 107 | + 'fields' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Optional: return only these top-level keys to shrink the payload (order_id is always kept). e.g. status, payment_status, totals, items, customer. Applies after include[]. Omit for the full record.'], | |
| 99 | 108 | ], |
| 100 | 109 | ], |
| 101 | 110 | 'execute_callback' => [self::class, 'getOrder'], |
| 102 | 111 | 'permission_callback' => function () { |
| @@ -106,9 +115,9 @@ | ||
| 106 | 115 | ], |
| 107 | 116 | |
| 108 | 117 | 'fluent-cart/get-order-activity' => [ |
| 109 | 118 | 'label' => __('Get Order Activity', 'fluent-cart'), |
| 110 | - 'description' => __('Audit timeline for one order — status changes, payments, refunds, notes, emails sent: who did what and when. Use after get-order when you need history, not just current state.', 'fluent-cart'), | |
| 119 | + 'description' => __('Audit timeline for one order — status changes, payments, refunds, notes, emails sent: who did what and when. Refund and payment rows carry the amount (backfilled onto activity rows from the matching transaction), so you need not cross-reference. Use after get-order when you need history, not just current state.', 'fluent-cart'), | |
| 111 | 120 | 'input_schema' => [ |
| 112 | 121 | 'type' => 'object', |
| 113 | 122 | 'properties' => [ |
| 114 | 123 | 'order_id' => ['type' => 'integer'], |
| @@ -138,8 +147,12 @@ | ||
| 138 | 147 | 'execute_callback' => [self::class, 'changeOrderStatus'], |
| 139 | 148 | 'permission_callback' => function () { |
| 140 | 149 | return PermissionGate::can('orders/manage_statuses'); |
| 141 | 150 | }, |
| 151 | + // Mutates, but reversible (a status can be set back) and no-op | |
| 152 | + // aware, so not destructive. Setting the same status twice is a | |
| 153 | + // no-op — idempotent. | |
| 154 | + 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true], | |
| 142 | 155 | ], |
| 143 | 156 | |
| 144 | 157 | 'fluent-cart/add-order-note' => [ |
| 145 | 158 | 'label' => __('Add Order Note', 'fluent-cart'), |
| @@ -155,13 +168,16 @@ | ||
| 155 | 168 | 'execute_callback' => [self::class, 'addOrderNote'], |
| 156 | 169 | 'permission_callback' => function () { |
| 157 | 170 | return PermissionGate::can('orders/manage'); |
| 158 | 171 | }, |
| 172 | + // Appends a note (mutating, not destructive). Each call adds a | |
| 173 | + // new note, so it is NOT idempotent. | |
| 174 | + 'annotations' => ['readonly' => false, 'destructive' => false], | |
| 159 | 175 | ], |
| 160 | 176 | |
| 161 | 177 | 'fluent-cart/refund-order' => [ |
| 162 | 178 | 'label' => __('Refund Order', 'fluent-cart'), |
| 163 | - 'description' => __('Refund an order through its payment gateway. ALWAYS call with dry_run:true first to preview the refundable amount and receive a confirm_token, then call again with that confirm_token plus an idempotency_key to execute. Without an idempotency_key a repeated execute could double-refund. amount is in store currency; omit to refund the full remaining balance. The preview reports payment_mode and live_gateway_action; executing a LIVE refund requires the operator to opt in (test-mode always works).', 'fluent-cart'), | |
| 179 | + 'description' => __('Refund an order through its gateway. ALWAYS call dry_run:true first to preview the refundable amount and get a confirm_token, then call again with that confirm_token plus an idempotency_key to execute — without the key a repeated execute could double-refund. amount is in store currency; omit for the full remaining balance. The preview reports payment_mode and live_gateway_action; a LIVE refund requires operator opt-in, and test-mode always works.', 'fluent-cart'), | |
| 164 | 180 | 'input_schema' => [ |
| 165 | 181 | 'type' => 'object', |
| 166 | 182 | 'properties' => [ |
| 167 | 183 | 'order_id' => ['type' => 'integer'], |
| @@ -177,9 +193,11 @@ | ||
| 177 | 193 | 'execute_callback' => [self::class, 'refundOrder'], |
| 178 | 194 | 'permission_callback' => function () { |
| 179 | 195 | return PermissionGate::can('orders/can_refund'); |
| 180 | 196 | }, |
| 181 | - 'annotations' => ['destructive' => true], | |
| 197 | + // Moves money via the gateway — the destructive write. readonly:false | |
| 198 | + // is explicit so a client never mistakes it for a preview-only tool. | |
| 199 | + 'annotations' => ['readonly' => false, 'destructive' => true], | |
| 182 | 200 | ], |
| 183 | 201 | ]; |
| 184 | 202 | } |
| 185 | 203 | |
| @@ -190,14 +208,29 @@ | ||
| 190 | 208 | public static function listOrders($params = []) |
| 191 | 209 | { |
| 192 | 210 | $paging = MCPHelper::pagination($params); |
| 193 | 211 | |
| 212 | + // advanced_filters routes through the admin filter engine (validated | |
| 213 | + // first — a bad condition errors, never silently drops); the named | |
| 214 | + // filters below then AND onto the same query either way. | |
| 215 | + $advWarnings = []; | |
| 216 | + if (!empty($params['advanced_filters'])) { | |
| 217 | + $built = AdvancedSearch::buildQuery('orders', $params['advanced_filters']); | |
| 218 | + if (is_wp_error($built)) { | |
| 219 | + return $built; | |
| 220 | + } | |
| 221 | + $query = $built['query']; | |
| 222 | + $advWarnings = $built['warnings']; | |
| 223 | + } else { | |
| 224 | + $query = Order::query(); | |
| 225 | + } | |
| 226 | + | |
| 194 | 227 | // Eager-load customer plus a TRIMMED order_items relation — only the |
| 195 | 228 | // columns needed for a "what's in this order" preview, never the full |
| 196 | 229 | // money/refund/fulfillment row (that's get-order's job). formatRow caps |
| 197 | 230 | // the preview, so even a large multi-item order can't flood the payload. |
| 198 | 231 | // The product_id filter uses whereHas (a join), independent of this load. |
| 199 | - $query = Order::query()->with([ | |
| 232 | + $query->with([ | |
| 200 | 233 | 'customer', |
| 201 | 234 | 'order_items' => function ($q) { |
| 202 | 235 | $q->select(['id', 'order_id', 'post_id', 'post_title', 'title', 'quantity']); |
| 203 | 236 | }, |
| @@ -220,13 +253,19 @@ | ||
| 220 | 253 | |
| 221 | 254 | $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']); |
| 222 | 255 | $total = self::total($paginator); |
| 223 | 256 | |
| 224 | - $rows = []; | |
| 257 | + $fields = isset($params['fields']) ? $params['fields'] : null; | |
| 258 | + $rows = []; | |
| 225 | 259 | foreach (MCPHelper::paginatorItems($paginator) as $order) { |
| 226 | - $rows[] = self::formatRow($order); | |
| 260 | + $rows[] = MCPHelper::pickFields(self::formatRow($order), $fields, ['order_id']); | |
| 227 | 261 | } |
| 228 | 262 | |
| 263 | + $meta = MCPHelper::pagingMeta($paginator); | |
| 264 | + if ($advWarnings) { | |
| 265 | + $meta['warnings'] = $advWarnings; | |
| 266 | + } | |
| 267 | + | |
| 229 | 268 | return MCPHelper::envelope( |
| 230 | 269 | sprintf( |
| 231 | 270 | /* translators: %d: number of matching orders */ |
| 232 | 271 | _n('%d order found.', '%d orders found.', $total, 'fluent-cart'), |
| @@ -232,9 +271,9 @@ | ||
| 232 | 271 | _n('%d order found.', '%d orders found.', $total, 'fluent-cart'), |
| 233 | 272 | $total |
| 234 | 273 | ), |
| 235 | 274 | ['orders' => $rows], |
| 236 | - MCPHelper::pagingMeta($paginator) | |
| 275 | + $meta | |
| 237 | 276 | ); |
| 238 | 277 | } |
| 239 | 278 | |
| 240 | 279 | private static function applyFilters($query, $params) |
| @@ -362,9 +401,14 @@ | ||
| 362 | 401 | $customer = ($order->relationLoaded('customer') && $order->customer) ? $order->customer : null; |
| 363 | 402 | |
| 364 | 403 | return [ |
| 365 | 404 | 'order_id' => (int) $order->id, |
| 366 | - '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, | |
| 367 | 411 | 'label' => self::label($order, $customer), |
| 368 | 412 | 'status' => $order->status, |
| 369 | 413 | 'payment_status' => $order->payment_status, |
| 370 | 414 | 'shipping_status' => self::shippingStatusOut($order), |
| @@ -439,10 +483,13 @@ | ||
| 439 | 483 | |
| 440 | 484 | $data = [ |
| 441 | 485 | 'order_id' => (int) $order->id, |
| 442 | 486 | 'uuid' => $order->uuid, |
| 443 | - 'number' => $order->invoice_no, | |
| 444 | - '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, | |
| 445 | 492 | 'status' => $order->status, |
| 446 | 493 | 'payment_status' => $order->payment_status, |
| 447 | 494 | 'shipping_status' => self::shippingStatusOut($order), |
| 448 | 495 | 'type' => $order->type, |
| @@ -471,11 +518,58 @@ | ||
| 471 | 518 | if (in_array('subscriptions', $include, true)) { |
| 472 | 519 | $data['subscriptions'] = self::subscriptionsBlock($order); |
| 473 | 520 | } |
| 474 | 521 | |
| 475 | - return MCPHelper::envelope(self::label($order, $order->customer), $data); | |
| 522 | + /** | |
| 523 | + * The assembled get-order payload, for add-on sections registered through | |
| 524 | + * fluent_cart/mcp_order_include_sections. Listeners should add their key | |
| 525 | + * only when it is present in $context['include']. | |
| 526 | + * | |
| 527 | + * @since 1.0.0 | |
| 528 | + * | |
| 529 | + * @param array $data the order payload | |
| 530 | + * @param array $context { order: Order, include: string[] } | |
| 531 | + */ | |
| 532 | + $data = apply_filters('fluent_cart/mcp_order_data', $data, [ | |
| 533 | + 'order' => $order, | |
| 534 | + 'include' => $include, | |
| 535 | + ]); | |
| 536 | + | |
| 537 | + // fields projection runs last, so it can trim both the base record and any | |
| 538 | + // include[] sections; order_id is always kept. | |
| 539 | + $fields = isset($params['fields']) ? $params['fields'] : null; | |
| 540 | + | |
| 541 | + return MCPHelper::envelope(self::label($order, $order->customer), MCPHelper::pickFields($data, $fields, ['order_id'])); | |
| 476 | 542 | } |
| 477 | 543 | |
| 544 | + /** | |
| 545 | + * The sections get-order's include[] accepts. Filterable so an integration | |
| 546 | + * that owns order-adjacent context (the CRM contact behind the buyer, for | |
| 547 | + * one) can offer it as an include rather than leaving the agent to guess | |
| 548 | + * which other tool holds it. | |
| 549 | + * | |
| 550 | + * A section added here MUST be populated by a listener on | |
| 551 | + * fluent_cart/mcp_order_data — an include the schema advertises but nothing | |
| 552 | + * fills is worse than no include at all. | |
| 553 | + * | |
| 554 | + * @return array | |
| 555 | + */ | |
| 556 | + private static function includeSections() | |
| 557 | + { | |
| 558 | + $sections = ['transactions', 'refunds', 'addresses', 'coupons', 'subscriptions']; | |
| 559 | + | |
| 560 | + /** | |
| 561 | + * Extra include[] section names for get-order. | |
| 562 | + * | |
| 563 | + * @since 1.0.0 | |
| 564 | + * | |
| 565 | + * @param array $sections section names offered in the include[] enum | |
| 566 | + */ | |
| 567 | + $sections = apply_filters('fluent_cart/mcp_order_include_sections', $sections); | |
| 568 | + | |
| 569 | + return array_values(array_unique(array_map('strval', (array) $sections))); | |
| 570 | + } | |
| 571 | + | |
| 478 | 572 | private static function resolveOrder($params) |
| 479 | 573 | { |
| 480 | 574 | if (!empty($params['order_id'])) { |
| 481 | 575 | $order = Order::query()->where('id', (int) $params['order_id'])->first(); |
| @@ -676,8 +770,9 @@ | ||
| 676 | 770 | |
| 677 | 771 | foreach ($rows as $row) { |
| 678 | 772 | $events[] = [ |
| 679 | 773 | '_sort' => (string) $row->created_at, |
| 774 | + '_ts' => self::toTs($row->created_at), | |
| 680 | 775 | 'event' => self::activityEvent($row), |
| 681 | 776 | 'source' => 'activity', |
| 682 | 777 | 'title' => $row->title, |
| 683 | 778 | 'status' => $row->status, |
| @@ -693,14 +788,19 @@ | ||
| 693 | 788 | |
| 694 | 789 | // Money events: charges and refunds from the transactions ledger. These |
| 695 | 790 | // are the payment/refund timeline entries the activity log doesn't carry. |
| 696 | 791 | $order->load('transactions'); |
| 792 | + $refundTxns = []; | |
| 793 | + $chargeTxns = []; | |
| 697 | 794 | if ($order->relationLoaded('transactions')) { |
| 698 | 795 | foreach ($order->transactions as $txn) { |
| 699 | - $type = $txn->transaction_type ? $txn->transaction_type : 'charge'; | |
| 700 | - $event = ($type === 'refund') ? 'refund' : (($type === 'charge') ? 'payment' : $type); | |
| 796 | + $type = $txn->transaction_type ? $txn->transaction_type : 'charge'; | |
| 797 | + $event = ($type === 'refund') ? 'refund' : (($type === 'charge') ? 'payment' : $type); | |
| 798 | + $amount = MCPHelper::money($txn->total, $txn->currency ? $txn->currency : null); | |
| 799 | + $ts = self::toTs($txn->created_at); | |
| 701 | 800 | $events[] = [ |
| 702 | 801 | '_sort' => (string) $txn->created_at, |
| 802 | + '_ts' => $ts, | |
| 703 | 803 | 'event' => $event, |
| 704 | 804 | 'source' => 'transaction', |
| 705 | 805 | 'title' => self::txnTitle($type, $txn), |
| 706 | 806 | 'status' => $txn->status, |
| @@ -705,16 +805,40 @@ | ||
| 705 | 805 | 'title' => self::txnTitle($type, $txn), |
| 706 | 806 | 'status' => $txn->status, |
| 707 | 807 | 'content' => null, |
| 708 | 808 | 'by' => null, |
| 709 | - 'amount' => MCPHelper::money($txn->total, $txn->currency ? $txn->currency : null), | |
| 809 | + 'amount' => $amount, | |
| 710 | 810 | 'payment_method' => $txn->payment_method ? $txn->payment_method : null, |
| 711 | 811 | 'reference' => $txn->vendor_charge_id ? $txn->vendor_charge_id : null, |
| 712 | 812 | 'created_at' => MCPHelper::toIso8601($txn->created_at), |
| 713 | 813 | ]; |
| 814 | + if ($type === 'refund') { | |
| 815 | + $refundTxns[] = ['ts' => $ts, 'amount' => $amount]; | |
| 816 | + } elseif ($type === 'charge') { | |
| 817 | + $chargeTxns[] = ['ts' => $ts, 'amount' => $amount]; | |
| 818 | + } | |
| 714 | 819 | } |
| 715 | 820 | } |
| 716 | 821 | |
| 822 | + // Activity rows about a refund/payment don't store the amount (the Activity | |
| 823 | + // model has no amount column), so a consumer previously had to cross- | |
| 824 | + // reference the transaction rows. Backfill each such row from the money | |
| 825 | + // event it mirrors — the closest refund/charge transaction on this order by | |
| 826 | + // time — since the activity log is written seconds after its transaction in | |
| 827 | + // the same request, so the amount is known and no cross-reference is needed. | |
| 828 | + foreach ($events as &$moneyRow) { | |
| 829 | + if ($moneyRow['source'] !== 'activity' || $moneyRow['amount'] !== null) { | |
| 830 | + continue; | |
| 831 | + } | |
| 832 | + $kind = self::activityMoneyKind($moneyRow['title']); | |
| 833 | + if ($kind === 'refund') { | |
| 834 | + $moneyRow['amount'] = self::nearestTxnAmount($moneyRow['_ts'], $refundTxns); | |
| 835 | + } elseif ($kind === 'payment') { | |
| 836 | + $moneyRow['amount'] = self::nearestTxnAmount($moneyRow['_ts'], $chargeTxns); | |
| 837 | + } | |
| 838 | + } | |
| 839 | + unset($moneyRow); | |
| 840 | + | |
| 717 | 841 | // Merge both streams most-recent-first, then cap at $limit. |
| 718 | 842 | usort($events, function ($a, $b) { |
| 719 | 843 | return strcmp($b['_sort'], $a['_sort']); |
| 720 | 844 | }); |
| @@ -719,9 +843,9 @@ | ||
| 719 | 843 | return strcmp($b['_sort'], $a['_sort']); |
| 720 | 844 | }); |
| 721 | 845 | $events = array_slice($events, 0, $limit); |
| 722 | 846 | foreach ($events as &$event) { |
| 723 | - unset($event['_sort']); | |
| 847 | + unset($event['_sort'], $event['_ts']); | |
| 724 | 848 | } |
| 725 | 849 | unset($event); |
| 726 | 850 | |
| 727 | 851 | return MCPHelper::envelope( |
| @@ -748,8 +872,64 @@ | ||
| 748 | 872 | if ($row->log_type === 'api') { |
| 749 | 873 | return 'api'; |
| 750 | 874 | } |
| 751 | 875 | return 'note'; |
| 876 | + } | |
| 877 | + | |
| 878 | + /** | |
| 879 | + * Classify an activity row's money kind from its title so its amount can be | |
| 880 | + * backfilled from the matching transaction. Title-only (not content) to avoid | |
| 881 | + * false positives like a note that merely mentions "refund". | |
| 882 | + */ | |
| 883 | + private static function activityMoneyKind($title) | |
| 884 | + { | |
| 885 | + $t = strtolower((string) $title); | |
| 886 | + if (strpos($t, 'refund') !== false) { | |
| 887 | + return 'refund'; | |
| 888 | + } | |
| 889 | + if (strpos($t, 'payment') !== false || strpos($t, 'charge') !== false || strpos($t, 'captured') !== false) { | |
| 890 | + return 'payment'; | |
| 891 | + } | |
| 892 | + return null; | |
| 893 | + } | |
| 894 | + | |
| 895 | + /** | |
| 896 | + * Amount of the transaction closest in time to $ts, from a pool of | |
| 897 | + * ['ts' => int|null, 'amount' => money] entries. Returns null if $ts is unknown | |
| 898 | + * or the pool is empty. Refund activity rows match only refund transactions and | |
| 899 | + * payment rows only charges, so the nearest by time is the right money event. | |
| 900 | + */ | |
| 901 | + private static function nearestTxnAmount($ts, array $pool) | |
| 902 | + { | |
| 903 | + if ($ts === null || !$pool) { | |
| 904 | + return null; | |
| 905 | + } | |
| 906 | + $best = null; | |
| 907 | + $bestDiff = null; | |
| 908 | + foreach ($pool as $entry) { | |
| 909 | + if ($entry['ts'] === null) { | |
| 910 | + continue; | |
| 911 | + } | |
| 912 | + $diff = abs($entry['ts'] - $ts); | |
| 913 | + if ($bestDiff === null || $diff < $bestDiff) { | |
| 914 | + $bestDiff = $diff; | |
| 915 | + $best = $entry['amount']; | |
| 916 | + } | |
| 917 | + } | |
| 918 | + return $best; | |
| 919 | + } | |
| 920 | + | |
| 921 | + /** Parse a stored GMT datetime to a UTC unix timestamp; null on empty/zero-date. */ | |
| 922 | + private static function toTs($value) | |
| 923 | + { | |
| 924 | + if (!$value || strpos((string) $value, '0000-00-00') === 0) { | |
| 925 | + return null; | |
| 926 | + } | |
| 927 | + try { | |
| 928 | + return (new \DateTime((string) $value, new \DateTimeZone('UTC')))->getTimestamp(); | |
| 929 | + } catch (\Exception $e) { | |
| 930 | + return null; | |
| 931 | + } | |
| 752 | 932 | } |
| 753 | 933 | |
| 754 | 934 | /** Human-readable title for a transaction timeline entry. */ |
| 755 | 935 | private static function txnTitle($type, $txn) |