get_json_params(); if (!is_array($params)) { $params = []; } $external_order_id = isset($params['external_order_id']) ? sanitize_text_field((string) $params['external_order_id']) : ''; if ($external_order_id === '') { return new WP_Error( 'twokcs_missing_external_order_id', __('external_order_id is required.', '2khat-catalog-sync'), ['status' => 400] ); } $existing_id = $this->find_order_id_by_external_id($external_order_id); if ($existing_id) { return new WP_Error( 'twokcs_duplicate_external_order_id', __('An order with this external_order_id already exists.', '2khat-catalog-sync'), [ 'status' => 409, 'existing_order_id' => $existing_id, ] ); } $line_items = isset($params['line_items']) && is_array($params['line_items']) ? $params['line_items'] : []; if (empty($line_items)) { return new WP_Error( 'twokcs_missing_line_items', __('line_items is required.', '2khat-catalog-sync'), ['status' => 400] ); } $resolved_items = []; foreach ($line_items as $index => $item) { if (!is_array($item)) { return new WP_Error( 'twokcs_invalid_line_item', sprintf( /* translators: %d: line item index */ __('Invalid line item at index %d.', '2khat-catalog-sync'), (int) $index ), ['status' => 400] ); } $resolved = $this->resolve_line_item($item, (int) $index); if (is_wp_error($resolved)) { return $resolved; } $resolved_items[] = $resolved; } $status = isset($params['status']) ? sanitize_text_field((string) $params['status']) : 'processing'; $status = $this->normalize_status($status); if ($status === '') { $status = 'processing'; } $order = wc_create_order([ 'created_via' => 'twokcs', 'status' => 'pending', ]); if (is_wp_error($order) || !$order instanceof WC_Order) { return new WP_Error( 'twokcs_order_create_failed', __('Failed to create order.', '2khat-catalog-sync'), ['status' => 500] ); } foreach ($resolved_items as $resolved) { $this->add_line_item($order, $resolved); } if (!empty($params['billing']) && is_array($params['billing'])) { $order->set_address($this->sanitize_address($params['billing']), 'billing'); } if (!empty($params['shipping']) && is_array($params['shipping'])) { $order->set_address($this->sanitize_address($params['shipping']), 'shipping'); } if (!empty($params['customer_note'])) { $order->set_customer_note(sanitize_textarea_field((string) $params['customer_note'])); } $order->update_meta_data(Twokcs_Constants::EXTERNAL_ORDER_ID_META, $external_order_id); $this->apply_meta_data($order, $params); $order->calculate_totals(); $order->update_status($status, __('Order created via 2khat.', '2khat-catalog-sync'), true); return new WP_REST_Response($this->prepare_order_response($order), 201); } /** * Cancel a whole 2khat order. * * Cancelling an already cancelled order is a no-op and returns 200. * WooCommerce restores reduced stock when the status changes to cancelled. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function cancel(WP_REST_Request $request) { $params = $request->get_json_params(); if (!is_array($params)) { $params = []; } $order = $this->resolve_order($params); if (is_wp_error($order)) { return $order; } if ($order->get_status() === 'cancelled') { return new WP_REST_Response($this->prepare_order_response($order), 200); } if (!in_array($order->get_status(), Twokcs_Constants::EDITABLE_ORDER_STATUSES, true)) { return new WP_Error( 'twokcs_order_not_cancellable', sprintf( /* translators: %s: order status */ __('Order with status "%s" cannot be cancelled.', '2khat-catalog-sync'), $order->get_status() ), ['status' => 409] ); } $note = __('Order cancelled via 2khat.', '2khat-catalog-sync'); $reason = $this->get_reason($params); if ($reason !== '') { $note .= ' ' . $reason; } $order->update_status('cancelled', $note, true); return new WP_REST_Response($this->prepare_order_response($order), 200); } /** * Update line items of a 2khat order. * * Each entry in line_items is one of: * - `{ id, quantity }` to change the quantity of an existing line item (0 removes it) * - `{ product_id, variation_id?, quantity }` to add a new line item * * All changes are validated before any is applied. Existing items keep * their original unit price; new items use the current product price. * Stock is adjusted when the order has already reduced stock. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function update(WP_REST_Request $request) { $params = $request->get_json_params(); if (!is_array($params)) { $params = []; } $order = $this->resolve_order($params); if (is_wp_error($order)) { return $order; } if (!in_array($order->get_status(), Twokcs_Constants::EDITABLE_ORDER_STATUSES, true)) { return new WP_Error( 'twokcs_order_not_editable', sprintf( /* translators: %s: order status */ __('Order with status "%s" cannot be updated.', '2khat-catalog-sync'), $order->get_status() ), ['status' => 409] ); } $line_items = isset($params['line_items']) && is_array($params['line_items']) ? $params['line_items'] : []; if (empty($line_items)) { return new WP_Error( 'twokcs_missing_line_items', __('line_items is required.', '2khat-catalog-sync'), ['status' => 400] ); } $stock_reduced = (bool) $order->get_data_store()->get_stock_reduced($order->get_id()); $existing_items = $order->get_items('line_item'); $changes = []; $additions = []; $seen_item_ids = []; foreach ($line_items as $index => $item) { if (!is_array($item)) { return new WP_Error( 'twokcs_invalid_line_item', sprintf( /* translators: %d: line item index */ __('Invalid line item at index %d.', '2khat-catalog-sync'), (int) $index ), ['status' => 400] ); } $item_id = $this->find_line_item_id($existing_items, $item, (int) $index); if (is_wp_error($item_id)) { return $item_id; } if ($item_id === null) { continue; } if ($item_id === 0) { $resolved = $this->resolve_line_item($item, (int) $index); if (is_wp_error($resolved)) { return $resolved; } $additions[] = $resolved; continue; } if (isset($seen_item_ids[$item_id])) { return new WP_Error( 'twokcs_invalid_line_item', sprintf( /* translators: %d: order line item ID */ __('Line item %d is listed more than once.', '2khat-catalog-sync'), $item_id ), ['status' => 400] ); } $seen_item_ids[$item_id] = true; $quantity = isset($item['quantity']) ? (int) $item['quantity'] : -1; if ($quantity < 0) { return new WP_Error( 'twokcs_invalid_line_item', sprintf( /* translators: %d: line item index */ __('Line item at index %d must have quantity of 0 or more.', '2khat-catalog-sync'), (int) $index ), ['status' => 400] ); } $order_item = $existing_items[$item_id]; $old_quantity = (int) $order_item->get_quantity(); if ($quantity === $old_quantity) { continue; } if ($quantity > $old_quantity) { $product = $order_item->get_product(); $needed = $stock_reduced ? $quantity - $old_quantity : $quantity; if (!$product || !$product->is_in_stock() || !$product->has_enough_stock($needed)) { return new WP_Error( 'twokcs_invalid_line_item', sprintf( /* translators: %d: order line item ID */ __('Not enough stock to increase line item %d.', '2khat-catalog-sync'), $item_id ), ['status' => 400] ); } } $changes[$item_id] = $quantity; } $remaining = count($existing_items) + count($additions); foreach ($changes as $quantity) { if ($quantity === 0) { $remaining--; } } if ($remaining < 1) { return new WP_Error( 'twokcs_order_empty', __('An order must keep at least one line item. Use the cancel endpoint to cancel the whole order.', '2khat-catalog-sync'), ['status' => 400] ); } if (empty($changes) && empty($additions)) { return new WP_REST_Response($this->prepare_update_response($order, $order->get_total()), 200); } $this->load_admin_functions(); $previous_total = $order->get_total(); $note_lines = []; foreach ($changes as $item_id => $quantity) { $order_item = $existing_items[$item_id]; $old_quantity = (int) $order_item->get_quantity(); if ($quantity === 0) { wc_maybe_adjust_line_item_product_stock($order_item, 0); $order->remove_item($item_id); $note_lines[] = sprintf('%s: %d → 0 (removed)', $order_item->get_name(), $old_quantity); continue; } $unit_subtotal = (float) $order_item->get_subtotal() / $old_quantity; $unit_total = (float) $order_item->get_total() / $old_quantity; $order_item->set_quantity($quantity); $order_item->set_subtotal(wc_format_decimal($unit_subtotal * $quantity)); $order_item->set_total(wc_format_decimal($unit_total * $quantity)); $order_item->save(); wc_maybe_adjust_line_item_product_stock($order_item); $note_lines[] = sprintf('%s: %d → %d', $order_item->get_name(), $old_quantity, $quantity); } foreach ($additions as $resolved) { $new_item = $this->add_line_item($order, $resolved); if ($new_item instanceof WC_Order_Item_Product) { wc_maybe_adjust_line_item_product_stock($new_item); } $note_lines[] = sprintf('%s: 0 → %d (added)', $resolved['product']->get_name(), $resolved['quantity']); } $order->calculate_totals(); $note = __('Order items updated via 2khat:', '2khat-catalog-sync') . "\n" . implode("\n", $note_lines); $reason = $this->get_reason($params); if ($reason !== '') { $note .= "\n" . $reason; } $order->add_order_note($note); return new WP_REST_Response($this->prepare_update_response($order, $previous_total), 200); } /** * List orders with optional id, external_order_id, and status filters. * * Always returns the catalog list shape: count, max_pages, results. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function track(WP_REST_Request $request) { $ids = $this->parse_id_list($request->get_param('id')); $external_order_id = sanitize_text_field((string) $request->get_param('external_order_id')); $status_filter = sanitize_text_field((string) $request->get_param('status')); $per_page = $this->get_per_page($request); $page = $this->get_page($request); $include_ids = null; if ($external_order_id !== '') { $external_id = $this->find_order_id_by_external_id($external_order_id); if (!$external_id) { return new WP_REST_Response($this->empty_list_response(), 200); } if (!empty($ids) && !in_array($external_id, $ids, true)) { if (count($ids) > 1) { return new WP_REST_Response($this->empty_list_response(), 200); } return new WP_Error( 'twokcs_ambiguous_order_lookup', __('id and external_order_id refer to different orders.', '2khat-catalog-sync'), ['status' => 400] ); } $include_ids = [$external_id]; } elseif (!empty($ids)) { $include_ids = $ids; } $query_args = [ 'limit' => $per_page, 'page' => $page, 'paginate' => true, 'orderby' => 'ID', 'order' => 'DESC', 'return' => 'objects', ]; if ($include_ids !== null) { $query_args['include'] = $include_ids; $query_args['limit'] = count($include_ids); $query_args['page'] = 1; } if ($status_filter !== '') { $query_args['status'] = $this->normalize_status($status_filter); } $results = wc_get_orders($query_args); $orders = []; $total = 0; if (is_object($results) && isset($results->orders)) { $orders = $results->orders; $total = isset($results->total) ? (int) $results->total : count($orders); } elseif (is_array($results)) { $orders = $results; $total = count($orders); } $items = []; foreach ($orders as $order) { if ($order instanceof WC_Order) { $items[] = $this->prepare_order_response($order); } } $max_pages = $total > 0 ? (int) ceil($total / $per_page) : 0; if ($include_ids !== null) { $max_pages = $total > 0 ? 1 : 0; } return new WP_REST_Response( [ 'count' => $total, 'max_pages' => $max_pages, 'results' => $items, ], 200 ); } /** * Empty catalog-shaped list response. * * @return array{count: int, max_pages: int, results: array} */ private function empty_list_response() { return [ 'count' => 0, 'max_pages' => 0, 'results' => [], ]; } /** * Parse a single id or comma-separated id list, capped at MAX_PER_PAGE. * * @param mixed $value Raw `id` query value. * @return int[] */ private function parse_id_list($value) { $ids = array_filter( array_map('intval', explode(',', (string) $value)), function ($id) { return $id > 0; } ); return array_slice(array_values(array_unique($ids)), 0, Twokcs_Constants::MAX_PER_PAGE); } /** * @param WP_REST_Request $request Request object. * @return int */ private function get_per_page(WP_REST_Request $request) { $per_page = (int) $request->get_param('per_page'); if ($per_page < 1) { $per_page = Twokcs_Constants::DEFAULT_PER_PAGE; } return min($per_page, Twokcs_Constants::MAX_PER_PAGE); } /** * @param WP_REST_Request $request Request object. * @return int */ private function get_page(WP_REST_Request $request) { $page = (int) $request->get_param('page'); return max($page, 1); } /** * Resolve and validate a single line item. * * @param array $item Line item payload. * @param int $index Index for error messages. * @return array{product: WC_Product, quantity: int}|WP_Error */ private function resolve_line_item(array $item, $index) { $product_id = isset($item['product_id']) ? (int) $item['product_id'] : 0; $variation_id = isset($item['variation_id']) ? (int) $item['variation_id'] : 0; $quantity = isset($item['quantity']) ? (int) $item['quantity'] : 0; if ($quantity < 1) { return new WP_Error( 'twokcs_invalid_line_item', sprintf( /* translators: %d: line item index */ __('Line item at index %d must have quantity greater than 0.', '2khat-catalog-sync'), $index ), ['status' => 400] ); } $lookup_id = $variation_id > 0 ? $variation_id : $product_id; if ($lookup_id < 1) { return new WP_Error( 'twokcs_invalid_line_item', sprintf( /* translators: %d: line item index */ __('Line item at index %d requires product_id or variation_id.', '2khat-catalog-sync'), $index ), ['status' => 400] ); } $product = wc_get_product($lookup_id); if (!$product) { return new WP_Error( 'twokcs_product_not_found', sprintf( /* translators: %d: product or variation ID */ __('Product %d not found.', '2khat-catalog-sync'), $lookup_id ), ['status' => 404] ); } if ($product->get_status() !== 'publish') { return new WP_Error( 'twokcs_product_not_found', sprintf( /* translators: %d: product or variation ID */ __('Product %d not found.', '2khat-catalog-sync'), $lookup_id ), ['status' => 404] ); } if (!$product->is_in_stock() || !$product->has_enough_stock($quantity)) { return new WP_Error( 'twokcs_invalid_line_item', sprintf( /* translators: %d: product or variation ID */ __('Product %d is out of stock.', '2khat-catalog-sync'), $lookup_id ), ['status' => 400] ); } return [ 'product' => $product, 'quantity' => $quantity, 'external_item_id' => $this->get_external_item_id($item), ]; } /** * Add a resolved line item to the order, tagging it with external_item_id. * * @param WC_Order $order Order object. * @param array $resolved Result of resolve_line_item(). * @return WC_Order_Item_Product|false */ private function add_line_item(WC_Order $order, array $resolved) { $item_id = $order->add_product($resolved['product'], $resolved['quantity']); $item = $order->get_item($item_id); if (!$item instanceof WC_Order_Item_Product) { return false; } if ($resolved['external_item_id'] !== '') { $item->update_meta_data(Twokcs_Constants::EXTERNAL_ITEM_ID_META, $resolved['external_item_id']); $item->save(); } return $item; } /** * Platform line item id from a line item payload. * * @param array $item Line item payload. * @return string */ private function get_external_item_id(array $item) { return isset($item['external_item_id']) ? sanitize_text_field((string) $item['external_item_id']) : ''; } /** * Find the existing order line item an update entry refers to. * * Matching order: * 1. `id` — WooCommerce line item id (must exist) * 2. `external_item_id` — line item tagged with the same platform id * 3. For orders created before external_item_id was stored: the single * untagged line item with the same product_id / variation_id * * Returns 0 when the entry should add a new line item, and null when an * unknown external_item_id has quantity 0 (nothing to remove), so callers * can always send the full item list. * * @param WC_Order_Item_Product[] $existing_items Line items keyed by id. * @param array $item Update entry. * @param int $index Index for error messages. * @return int|null|WP_Error */ private function find_line_item_id(array $existing_items, array $item, $index) { if (!empty($item['id'])) { $item_id = (int) $item['id']; if (!isset($existing_items[$item_id])) { return new WP_Error( 'twokcs_line_item_not_found', sprintf( /* translators: %d: order line item ID */ __('Line item %d does not belong to this order.', '2khat-catalog-sync'), $item_id ), ['status' => 404] ); } return $item_id; } $external_item_id = $this->get_external_item_id($item); if ($external_item_id === '') { return 0; } foreach ($existing_items as $item_id => $order_item) { if ((string) $order_item->get_meta(Twokcs_Constants::EXTERNAL_ITEM_ID_META) === $external_item_id) { return (int) $item_id; } } $product_id = isset($item['product_id']) ? (int) $item['product_id'] : 0; $variation_id = isset($item['variation_id']) ? (int) $item['variation_id'] : 0; $matches = []; if ($product_id > 0 || $variation_id > 0) { foreach ($existing_items as $item_id => $order_item) { if ((string) $order_item->get_meta(Twokcs_Constants::EXTERNAL_ITEM_ID_META) !== '') { continue; } if ($variation_id > 0 ? (int) $order_item->get_variation_id() === $variation_id : (int) $order_item->get_product_id() === $product_id) { $matches[] = (int) $item_id; } } } if (count($matches) === 1) { return $matches[0]; } if (count($matches) > 1) { return new WP_Error( 'twokcs_ambiguous_line_item', sprintf( /* translators: %d: line item index */ __('Line item at index %d matches more than one order line item; use id instead.', '2khat-catalog-sync'), $index ), ['status' => 400] ); } // Unknown external_item_id: a positive quantity adds it as a new item. if (isset($item['quantity']) && (int) $item['quantity'] > 0) { return 0; } return null; } /** * Sanitize billing/shipping address fields. * * @param array $address Raw address. * @return array */ private function sanitize_address(array $address) { $clean = []; foreach ($this->address_fields as $field) { if (!isset($address[$field])) { continue; } $value = (string) $address[$field]; $clean[$field] = ($field === 'email') ? sanitize_email($value) : sanitize_text_field($value); } return $clean; } /** * Apply optional meta_data from the create payload. * * @param WC_Order $order Order object. * @param array $params Request params. */ private function apply_meta_data(WC_Order $order, array $params) { if (empty($params['meta_data']) || !is_array($params['meta_data'])) { return; } foreach ($params['meta_data'] as $meta) { if (!is_array($meta) || empty($meta['key'])) { continue; } $key = (string) $meta['key']; if ($key === Twokcs_Constants::EXTERNAL_ORDER_ID_META) { continue; } // Allow public keys and our own _twokcs_* namespace; skip other protected keys. if (strpos($key, '_') === 0 && strpos($key, '_twokcs_') !== 0) { continue; } $value = isset($meta['value']) ? $meta['value'] : ''; if (is_scalar($value) || $value === null) { $order->update_meta_data(sanitize_text_field($key), is_string($value) ? sanitize_text_field($value) : $value); } else { $order->update_meta_data(sanitize_text_field($key), $value); } } } /** * Resolve a 2khat order from `id` and/or `external_order_id` in the body. * * Only orders carrying the external order id meta are returned, so other * store orders cannot be changed through these endpoints. * * @param array $params Request params. * @return WC_Order|WP_Error */ private function resolve_order(array $params) { $id = isset($params['id']) ? (int) $params['id'] : 0; $external_order_id = isset($params['external_order_id']) ? sanitize_text_field((string) $params['external_order_id']) : ''; if ($id < 1 && $external_order_id === '') { return new WP_Error( 'twokcs_missing_order_reference', __('id or external_order_id is required.', '2khat-catalog-sync'), ['status' => 400] ); } if ($external_order_id !== '') { $external_id = $this->find_order_id_by_external_id($external_order_id); if ($external_id && $id > 0 && $external_id !== $id) { return new WP_Error( 'twokcs_ambiguous_order_lookup', __('id and external_order_id refer to different orders.', '2khat-catalog-sync'), ['status' => 400] ); } $id = $external_id ? $external_id : 0; } $order = $id > 0 ? wc_get_order($id) : false; if (!$order instanceof WC_Order || (string) $order->get_meta(Twokcs_Constants::EXTERNAL_ORDER_ID_META) === '') { return new WP_Error( 'twokcs_order_not_found', __('Order not found.', '2khat-catalog-sync'), ['status' => 404] ); } return $order; } /** * Optional free-text reason from the request body. * * @param array $params Request params. * @return string */ private function get_reason(array $params) { return isset($params['reason']) ? sanitize_textarea_field((string) $params['reason']) : ''; } /** * Load WooCommerce admin helpers (wc_maybe_adjust_line_item_product_stock) * which are not included on REST requests. */ private function load_admin_functions() { if (!function_exists('wc_maybe_adjust_line_item_product_stock')) { require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php'; } } /** * Order response for updates, including the total before the change. * * @param WC_Order $order Order object. * @param string $previous_total Order total before the update. * @return array */ private function prepare_update_response(WC_Order $order, $previous_total) { $response = $this->prepare_order_response($order); $response['previous_total'] = wc_format_decimal($previous_total, wc_get_price_decimals()); return $response; } /** * Find a WC order ID by external order id meta. * * @param string $external_order_id External order id. * @return int|null */ private function find_order_id_by_external_id($external_order_id) { $orders = wc_get_orders([ 'limit' => 1, 'return' => 'ids', 'meta_key' => Twokcs_Constants::EXTERNAL_ORDER_ID_META, 'meta_value' => $external_order_id, ]); if (empty($orders)) { return null; } return (int) $orders[0]; } /** * Normalize WC status (strip optional wc- prefix). * * @param string $status Status string. * @return string */ private function normalize_status($status) { $status = strtolower(trim((string) $status)); if (strpos($status, 'wc-') === 0) { $status = substr($status, 3); } return $status; } /** * Build compact order response payload. * * @param WC_Order $order Order object. * @return array */ private function prepare_order_response(WC_Order $order) { $tracking = $this->resolve_tracking($order); $shipping_methods = []; foreach ($order->get_shipping_methods() as $method) { $shipping_methods[] = $method->get_name(); } $line_items = []; foreach ($order->get_items('line_item') as $item_id => $item) { $line_items[] = [ 'id' => (int) $item_id, 'external_item_id' => (string) $item->get_meta(Twokcs_Constants::EXTERNAL_ITEM_ID_META), 'product_id' => (int) $item->get_product_id(), 'variation_id' => (int) $item->get_variation_id(), 'name' => $item->get_name(), 'quantity' => (int) $item->get_quantity(), 'total' => $item->get_total(), ]; } $date_created = $order->get_date_created(); $date_modified = $order->get_date_modified(); return [ 'id' => $order->get_id(), 'status' => $order->get_status(), 'external_order_id' => (string) $order->get_meta(Twokcs_Constants::EXTERNAL_ORDER_ID_META), 'tracking_code' => $tracking['tracking_code'], 'tracking_provider' => $tracking['tracking_provider'], 'shipping_method' => !empty($shipping_methods) ? implode(', ', $shipping_methods) : null, 'date_created' => $date_created ? $date_created->date('c') : null, 'date_modified' => $date_modified ? $date_modified->date('c') : null, 'total' => $order->get_total(), 'currency' => $order->get_currency(), 'line_items' => $line_items, ]; } /** * Resolve shipment tracking code/provider from common meta sources. * * @param WC_Order $order Order object. * @return array{tracking_code: string|null, tracking_provider: string|null} */ private function resolve_tracking(WC_Order $order) { $shipment_items = $order->get_meta('_wc_shipment_tracking_items'); if (is_array($shipment_items) && !empty($shipment_items)) { $first = reset($shipment_items); if (is_array($first)) { $code = isset($first['tracking_number']) ? (string) $first['tracking_number'] : ''; $provider = ''; if (!empty($first['tracking_provider'])) { $provider = (string) $first['tracking_provider']; } elseif (!empty($first['custom_tracking_provider'])) { $provider = (string) $first['custom_tracking_provider']; } if ($code !== '') { return [ 'tracking_code' => $code, 'tracking_provider' => $provider !== '' ? $provider : null, ]; } } } $meta_keys = [ '_tracking_number', 'tracking_number', '_tracking_code', 'tracking_code', ]; foreach ($meta_keys as $key) { $value = $order->get_meta($key); if (is_string($value) && $value !== '') { $provider = $order->get_meta('_tracking_provider'); if (!is_string($provider) || $provider === '') { $provider = $order->get_meta('tracking_provider'); } return [ 'tracking_code' => $value, 'tracking_provider' => (is_string($provider) && $provider !== '') ? $provider : null, ]; } } return [ 'tracking_code' => null, 'tracking_provider' => null, ]; } }