'GET', 'callback' => [__CLASS__, 'index'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, 'args' => [ 'status' => ['type' => 'string', 'required' => false], // comma-separated 'page' => ['type' => 'integer', 'default' => 1], 'per_page' => ['type' => 'integer', 'default' => 25], 'date_start' => ['type' => 'string', 'required' => false], // YYYY-MM-DD or ISO8601 'date_end' => ['type' => 'string', 'required' => false], 'orderby' => ['type' => 'string', 'default' => 'date'], 'order' => ['type' => 'string', 'default' => 'desc'], // asc|desc ], ]); register_rest_route('woonoow/v1', '/orders/(?P\d+)', [ 'methods' => 'GET', 'callback' => [__CLASS__, 'show'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); register_rest_route('woonoow/v1', '/orders/(?P\d+)', [ 'methods' => 'PATCH', 'callback' => [__CLASS__, 'update'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, 'args' => [ 'items' => ['type' => 'array', 'required' => false], 'billing' => ['type' => 'object', 'required' => false], 'shipping' => ['type' => 'object', 'required' => false], 'status' => ['type' => 'string', 'required' => false], 'payment_method' => ['type' => 'string', 'required' => false], 'shipping_method' => ['type' => 'string', 'required' => false], 'coupons' => ['type' => 'array', 'required' => false], 'note' => ['type' => 'string', 'required' => false], ], ]); register_rest_route('woonoow/v1', '/orders', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'create'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, 'args' => [ 'items' => ['type' => 'array', 'required' => true], 'billing' => ['type' => 'object', 'required' => false], 'shipping' => ['type' => 'object', 'required' => false], 'status' => ['type' => 'string', 'required' => false], 'payment_method' => ['type' => 'string', 'required' => false], 'shipping_method' => ['type' => 'string', 'required' => false], // e.g. flat_rate:1 'coupons' => ['type' => 'array', 'required' => false], // array of codes 'note' => ['type' => 'string', 'required' => false], ], ]); register_rest_route('woonoow/v1', '/orders/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [__CLASS__, 'delete'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); // Lightweight product search to help build orders from admin UI // Changed from /products to /products/search to avoid conflict with ProductsController register_rest_route('woonoow/v1', '/products/search', [ 'methods' => 'GET', 'callback' => [__CLASS__, 'products'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, 'args' => [ 'search' => ['type' => 'string', 'required' => false], 'limit' => ['type' => 'integer', 'default' => 10], ], ]); register_rest_route('woonoow/v1', '/payments', [ 'methods' => 'GET', 'callback' => [__CLASS__, 'payments'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); register_rest_route('woonoow/v1', '/shippings', [ 'methods' => 'GET', 'callback' => [__CLASS__, 'shippings'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); // Calculate shipping rates for given address and cart register_rest_route('woonoow/v1', '/shipping/calculate', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'calculate_shipping'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, 'args' => [ 'items' => ['type' => 'array', 'required' => true], 'shipping' => ['type' => 'object', 'required' => true], ], ]); // Calculate order preview with taxes and totals register_rest_route('woonoow/v1', '/orders/preview', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'preview_order'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, 'args' => [ 'items' => ['type' => 'array', 'required' => true], 'billing' => ['type' => 'object', 'required' => false], 'shipping' => ['type' => 'object', 'required' => false], 'shipping_method' => ['type' => 'string', 'required' => false], 'coupons' => ['type' => 'array', 'required' => false], ], ]); // Retry payment processing for an order register_rest_route('woonoow/v1', '/orders/(?P\d+)/retry-payment', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'retry_payment'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); // Validate coupon code register_rest_route('woonoow/v1', '/coupons/validate', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'validate_coupon'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, 'args' => [ 'code' => [ 'required' => true, 'type' => 'string', ], 'items' => [ 'required' => false, 'type' => 'array', ], 'subtotal' => [ 'required' => false, 'type' => 'number', ], ], ]); register_rest_route('woonoow/v1', '/countries', [ 'methods' => 'GET', 'callback' => [__CLASS__, 'countries'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); // Customer search for autofill register_rest_route('woonoow/v1', '/customers/search', [ 'methods' => 'GET', 'callback' => [__CLASS__, 'search_customers'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, 'args' => [ 'email' => [ 'required' => false, 'type' => 'string', ], 'search' => [ 'required' => false, 'type' => 'string', ], ], ]); } /** * GET /woonoow/v1/orders */ public static function index(WP_REST_Request $req): WP_REST_Response { $page = max(1, absint($req->get_param('page') ?? 1)); $per_page = max(1, min(200, absint($req->get_param('per_page') ?? 20))); $orderby = sanitize_key($req->get_param('orderby') ?? 'date'); $order = strtoupper($req->get_param('order') ?? 'DESC'); if (! in_array($orderby, ['date', 'id', 'modified', 'total'], true)) $orderby = 'date'; if ($order !== 'ASC') $order = 'DESC'; $status = $req->get_param('status'); // If status is missing, include ALL Woo statuses. $statuses = $status ? array_map(function ($s) { $s = sanitize_key(trim($s)); return 'wc-' . preg_replace('/^wc-/', '', $s); }, explode(',', $status)) : array_keys(wc_get_order_statuses()); // --- Build date range (HPOS-safe, string values) --- $date_start = $req->get_param('date_start'); $date_end = $req->get_param('date_end'); $after_str = null; $before_str = null; if ($date_start) { // Expecting YYYY-MM-DD; build 00:00:00 UTC $after_ts = strtotime($date_start . ' 00:00:00'); if ($after_ts) $after_str = gmdate('Y-m-d H:i:s', $after_ts); } if ($date_end) { // Build 23:59:59 UTC $before_ts = strtotime($date_end . ' 23:59:59'); if ($before_ts) $before_str = gmdate('Y-m-d H:i:s', $before_ts); } $date_created = null; if ($after_str || $before_str) { $date_created = array_filter([ 'after' => $after_str, 'before' => $before_str, // note: do NOT include 'inclusive' to avoid Woo date arg coercion issues ]); } $args = [ 'paginate' => true, 'page' => $page, 'limit' => $per_page, 'status' => $statuses, 'orderby' => $orderby === 'total' ? 'total' : $orderby, // Woo supports 'total' in recent versions 'order' => $order, 'return' => 'ids', // HPOS friendly + fast ]; // Guard: Some Woo installs throw when passing array date ranges into OrdersTableQuery. // To be safe, only pass a single-bound filter directly. For two-sided ranges, we post-filter below. $pass_single_bound = $date_created && ( (isset($date_created['after']) && ! isset($date_created['before'])) || (isset($date_created['before']) && ! isset($date_created['after'])) ); if ($pass_single_bound) { $args['date_created'] = $date_created; // single bound only } // Optional simple ID search $search = $req->get_param('search'); if ($search && preg_match('/^#?(\\d+)$/', $search, $m)) { $args['include'] = [absint($m[1])]; } // Optional customer filter $customer_id = $req->get_param('customer_id'); if ($customer_id) { $args['customer_id'] = absint($customer_id); } $result = wc_get_orders($args); // If we had a two-sided date range, filter results manually to avoid OrdersTableQuery fatal. if ($date_created && isset($date_created['after']) && isset($date_created['before'])) { $after_ts = strtotime($date_created['after']); $before_ts = strtotime($date_created['before']); if ($after_ts && $before_ts) { $result->orders = array_values(array_filter($result->orders, function ($order_id) use ($after_ts, $before_ts) { $o = wc_get_order($order_id); if (! $o || ! $o->get_date_created()) return false; $t = $o->get_date_created()->getTimestamp(); return ($t >= $after_ts && $t <= $before_ts); })); $result->total = count($result->orders); } } $rows = array_map(function ($order_id) { $o = wc_get_order($order_id); // Customer name: prefer billing first/last; otherwise WP user's display name; otherwise null $first = trim((string) $o->get_billing_first_name()); $last = trim((string) $o->get_billing_last_name()); $name = trim($first . ' ' . $last); if ($name === '') { $uid = $o->get_user_id(); if ($uid) { $u = get_userdata($uid); if ($u && ! empty($u->display_name)) { $name = (string) $u->display_name; } } if ($name === '') { $name = null; // no email/Guest fallback per requirement } } // Items count (qty-based) and brief label $items = $o->get_items(); $items_count = 0; $brief_parts = []; $i = 0; foreach ($items as $it) { $qty = (int) $it->get_quantity(); $items_count += $qty; if ($i < 2) { $brief_parts[] = sprintf('%s ×%d', $it->get_name(), $qty); } $i++; } $items_brief = implode(', ', $brief_parts); $lines_total = count($items); $lines_preview = min(2, $lines_total); if ($lines_total > 2) { $items_brief .= sprintf(' +%d more', $lines_total - 2); } // Full list (name ×qty), used by hover card $full_parts = []; foreach ($items as $it_full) { $full_parts[] = sprintf('%s ×%d', $it_full->get_name(), (int) $it_full->get_quantity()); } $items_full = implode(', ', $full_parts); // Build site-local ISO and epoch seconds for created date $dt_created = $o->get_date_created(); $date_ts = $dt_created ? $dt_created->getTimestamp() : null; // epoch seconds (UTC) $tz_obj = wp_timezone(); $tz_str = wp_timezone_string(); $date_iso = null; if ($dt_created) { $local = clone $dt_created; $local = $local->setTimezone($tz_obj); $date_iso = $local->format(DATE_ATOM); // e.g. 2025-10-25T00:23:53+07:00 } return [ 'id' => $o->get_id(), 'number' => $o->get_order_number(), 'date' => $date_iso, 'date_ts' => $date_ts, 'tz' => $tz_str, 'status' => $o->get_status(), 'total' => (float) $o->get_total(), 'currency' => $o->get_currency(), 'currency_symbol' => get_woocommerce_currency_symbol($o->get_currency()), 'customer' => $name, 'items_count' => $items_count, 'items_brief' => $items_brief, 'items_full' => $items_full, 'lines_total' => $lines_total, 'lines_preview' => $lines_preview, ]; }, $result->orders); return new WP_REST_Response([ 'rows' => $rows, 'total' => (int) $result->total, 'page' => (int) $page, 'per_page' => (int) $per_page, ], 200); } /** * GET /woonoow/v1/orders/{id} */ public static function show(WP_REST_Request $req): WP_REST_Response { $id = absint($req['id']); if (! $id) return new WP_REST_Response(['error' => 'invalid_id'], 400); $order = wc_get_order($id); if (! $order) return new WP_REST_Response(['error' => 'not_found'], 404); $items = []; foreach ($order->get_items() as $item) { /** @var \WC_Order_Item_Product $item */ $product = $item->get_product(); $qty = (int) $item->get_quantity(); $subtotal = (float) $item->get_subtotal(); $total = (float) $item->get_total(); $product_id = $product ? $product->get_id() : 0; $unit_price = $qty > 0 ? (float) ($subtotal / $qty) : 0.0; $items[] = [ 'line_item_id' => $item->get_id(), 'id' => $item->get_id(), 'product_id' => $product_id, 'name' => $item->get_name(), 'sku' => $product ? $product->get_sku() : '', 'qty' => $qty, 'price' => $unit_price, 'subtotal' => $subtotal, 'total' => $total, 'subtotal_fmt' => wc_price($subtotal, ['currency' => $order->get_currency()]), 'total_fmt' => wc_price($total, ['currency' => $order->get_currency()]), 'virtual' => $product ? $product->is_virtual() : false, 'downloadable' => $product ? $product->is_downloadable() : false, 'regular_price' => $product ? (float) $product->get_regular_price() : 0, 'sale_price' => $product && $product->get_sale_price() ? (float) $product->get_sale_price() : null, ]; } $notes = []; foreach (wc_get_order_notes(['order_id' => $id, 'orderby' => 'date_created', 'order' => 'DESC']) as $note) { $notes[] = [ 'id' => $note->id, 'content' => $note->content, 'is_customer_note' => (bool) $note->customer_note, 'date' => $note->date_created ? $note->date_created->date(DATE_ATOM) : null, ]; } $coupon_codes = []; foreach ($order->get_items('coupon') as $citem) { $coupon_codes[] = (string) $citem->get_code(); } $dt_created = $order->get_date_created(); $date_ts = $dt_created ? $dt_created->getTimestamp() : null; $tz_obj = wp_timezone(); $tz_str = wp_timezone_string(); $date_iso = null; if ($dt_created) { $local = clone $dt_created; $local = $local->setTimezone($tz_obj); $date_iso = $local->format(DATE_ATOM); } // Defensive address formatting (Woo helpers may not be loaded in rare contexts) $billing_addr_arr = $order->get_address('billing'); $shipping_addr_arr = $order->get_address('shipping'); // Build raw billing/shipping fields for editing $billing_fields = [ 'first_name' => (string) $order->get_billing_first_name(), 'last_name' => (string) $order->get_billing_last_name(), 'email' => (string) $order->get_billing_email(), 'phone' => (string) $order->get_billing_phone(), 'address_1' => (string) $order->get_billing_address_1(), 'address_2' => (string) $order->get_billing_address_2(), 'city' => (string) $order->get_billing_city(), 'state' => (string) $order->get_billing_state(), 'postcode' => (string) $order->get_billing_postcode(), 'country' => (string) $order->get_billing_country(), ]; if ($billing_fields['first_name'] === '' && $billing_fields['last_name'] === '') { $uid = $order->get_user_id(); if ($uid) { $u = get_userdata($uid); if ($u && ! empty($u->display_name)) { $parts = preg_split('/\s+/', trim((string) $u->display_name), 2); $billing_fields['first_name'] = (string) ($parts[0] ?? ''); $billing_fields['last_name'] = (string) ($parts[1] ?? ''); } } } $shipping_fields = [ 'first_name' => (string) $order->get_shipping_first_name(), 'last_name' => (string) $order->get_shipping_last_name(), 'phone' => '', // Woo core doesn't store shipping phone by default 'address_1' => (string) $order->get_shipping_address_1(), 'address_2' => (string) $order->get_shipping_address_2(), 'city' => (string) $order->get_shipping_city(), 'state' => (string) $order->get_shipping_state(), 'postcode' => (string) $order->get_shipping_postcode(), 'country' => (string) $order->get_shipping_country(), ]; if (function_exists('wc') && is_object(wc()) && isset(wc()->countries) && method_exists(wc()->countries, 'get_formatted_address')) { $billing_addr_html = wc()->countries->get_formatted_address($billing_addr_arr); $shipping_addr_html = wc()->countries->get_formatted_address($shipping_addr_arr); } elseif (function_exists('wc_format_address')) { $billing_addr_html = \wc_format_address($billing_addr_arr); $shipping_addr_html = \wc_format_address($shipping_addr_arr); } else { // Fallback: simple HTML join $fmt = function ($a) { $parts = [ trim(($a['first_name'] ?? '') . ' ' . ($a['last_name'] ?? '')), $a['address_1'] ?? '', $a['address_2'] ?? '', trim((($a['city'] ?? '')) . (($a['state'] ?? '') ? ', ' . $a['state'] : '') . (($a['postcode'] ?? '') ? ' ' . $a['postcode'] : '')), $a['country'] ?? '', ]; $parts = array_values(array_filter($parts, fn($v) => $v !== '')); return implode('
', $parts); }; $billing_addr_html = $fmt($billing_addr_arr); $shipping_addr_html = $fmt($shipping_addr_arr); } $data = [ 'id' => $order->get_id(), 'number' => $order->get_order_number(), 'status' => $order->get_status(), 'currency' => $order->get_currency(), 'currency_symbol' => get_woocommerce_currency_symbol($order->get_currency()), 'currency_display' => get_woocommerce_currency_symbol($order->get_currency()), 'currency_pos' => get_option('woocommerce_currency_pos', 'left'), 'decimals' => wc_get_price_decimals(), 'decimal_sep' => wc_get_price_decimal_separator(), 'thousand_sep' => wc_get_price_thousand_separator(), 'date' => $date_iso, 'date_ts' => $date_ts, 'tz' => $tz_str, 'payment_method' => self::get_payment_method_title($order), 'payment_method_id' => $order->get_payment_method(), 'payment_meta' => self::get_payment_metadata($order), 'shipping_method' => self::get_shipping_method_title($order), 'shipping_method_id' => self::get_shipping_method_id($order), 'totals' => [ 'subtotal' => (float) $order->get_subtotal(), 'discount' => (float) $order->get_discount_total(), 'shipping' => (float) $order->get_shipping_total(), 'tax' => (float) $order->get_total_tax(), 'total' => (float) $order->get_total(), 'subtotal_fmt' => wc_price((float) $order->get_subtotal(), ['currency' => $order->get_currency()]), 'discount_fmt' => wc_price((float) $order->get_discount_total(), ['currency' => $order->get_currency()]), 'shipping_fmt' => wc_price((float) $order->get_shipping_total(), ['currency' => $order->get_currency()]), 'tax_fmt' => wc_price((float) $order->get_total_tax(), ['currency' => $order->get_currency()]), 'total_fmt' => wc_price((float) $order->get_total(), ['currency' => $order->get_currency()]), ], 'billing' => array_merge($billing_fields, [ 'name' => trim($billing_fields['first_name'] . ' ' . $billing_fields['last_name']), 'email' => $billing_fields['email'], 'phone' => $billing_fields['phone'], 'address_html' => $billing_addr_html, ]), 'shipping' => array_merge($shipping_fields, [ 'name' => trim($shipping_fields['first_name'] . ' ' . $shipping_fields['last_name']), 'address_html' => $shipping_addr_html, ]), 'items' => $items, 'notes' => $notes, 'coupons' => $coupon_codes, 'customer_note' => $order->get_customer_note(), 'meta' => self::get_order_meta_data($order), ]; // Get related subscription if (ModuleRegistry::is_enabled('subscription')) { if (class_exists('\WooNooW\Modules\Subscription\SubscriptionManager')) { $subscription = \WooNooW\Modules\Subscription\SubscriptionManager::get_by_order_id($id); if ($subscription) { $period_labels = [ 'day' => __('day', 'woonoow'), 'week' => __('week', 'woonoow'), 'month' => __('month', 'woonoow'), 'year' => __('year', 'woonoow'), ]; $interval = $subscription->billing_interval > 1 ? $subscription->billing_interval . ' ' : ''; $period = $period_labels[$subscription->billing_period] ?? $subscription->billing_period; if ($subscription->billing_interval > 1) { $period .= 's'; // Pluralize } $data['related_subscription'] = [ 'id' => (int) $subscription->id, 'status' => $subscription->status, 'next_payment_date' => $subscription->next_payment_date, 'billing_period' => $subscription->billing_period, 'billing_interval' => (int) $subscription->billing_interval, 'recurring_amount' => (float) $subscription->recurring_amount, 'billing_schedule' => sprintf(__('Every %s%s', 'woonoow'), $interval, $period), ]; } } } // Get related licenses if (ModuleRegistry::is_enabled('licensing')) { if (class_exists('\WooNooW\Modules\Licensing\LicenseManager')) { $licenses = \WooNooW\Modules\Licensing\LicenseManager::get_licenses_by_order($id); if (! empty($licenses)) { $data['related_licenses'] = array_map(function ($lic) { $product = wc_get_product($lic['product_id']); return [ 'id' => (int) $lic['id'], 'license_key' => $lic['license_key'], 'status' => $lic['status'], 'product_id' => (int) $lic['product_id'], 'product_name' => $product ? $product->get_name() : __('Unknown Product', 'woonoow'), ]; }, $licenses); } } } // Allow plugins to modify response (Level 1 compatibility) $data = apply_filters('woonoow/order_api_data', $data, $order, $req); return new WP_REST_Response($data, 200); } /** * PATCH /woonoow/v1/orders/{id} * Upserts line items, replaces coupon lines, updates addresses & meta, then recalculates totals. */ public static function update(WP_REST_Request $req): WP_REST_Response { if (! current_user_can('manage_woocommerce')) { return new \WP_REST_Response(['error' => 'forbidden'], 403); } $id = absint($req['id']); if (! $id) return new \WP_REST_Response(['error' => 'invalid_id'], 400); $order = wc_get_order($id); if (! $order) return new \WP_REST_Response(['error' => 'not_found'], 404); $p = $req->get_json_params(); $items = is_array($p['items'] ?? null) ? $p['items'] : null; // null means no change $billing = is_array($p['billing'] ?? null) ? $p['billing'] : null; $shipping = is_array($p['shipping'] ?? null) ? $p['shipping'] : null; $status = array_key_exists('status', $p) ? sanitize_text_field((string) $p['status']) : null; $payment_method = array_key_exists('payment_method', $p) ? sanitize_text_field((string) $p['payment_method']) : null; $shipping_method = array_key_exists('shipping_method', $p) ? sanitize_text_field((string) $p['shipping_method']) : null; $coupons = array_key_exists('coupons', $p) ? array_filter(array_map('sanitize_text_field', (array) $p['coupons'])) : null; $note = array_key_exists('customer_note', $p) ? wp_kses_post((string) $p['customer_note']) : null; try { // === Items upsert === if (is_array($items)) { // Map existing line items by id for quick lookup $existing_items = $order->get_items('line_item'); $existing_map = []; foreach ($existing_items as $it) { $existing_map[$it->get_id()] = $it; } $keep_ids = []; foreach ($items as $row) { $li_id = absint($row['line_item_id'] ?? 0); $qty = max(1, absint($row['qty'] ?? 1)); if ($li_id && isset($existing_map[$li_id])) { // Update quantity of existing line $existing_map[$li_id]->set_quantity($qty); $keep_ids[] = $li_id; } else { $pid = absint($row['product_id'] ?? 0); if ($pid) { $product = wc_get_product($pid); if ($product) { $order->add_product($product, $qty); } } } } // Delete removed items (present before, missing now) foreach ($existing_items as $it) { if (! in_array($it->get_id(), $keep_ids, true)) { $order->remove_item($it->get_id()); } } } // === Billing === if (is_array($billing)) { $order->set_address([ 'first_name' => self::sanitize_field($billing['first_name'] ?? ''), 'last_name' => self::sanitize_field($billing['last_name'] ?? ''), 'email' => self::sanitize_email_field($billing['email'] ?? ''), 'phone' => self::sanitize_phone($billing['phone'] ?? ''), 'address_1' => self::sanitize_field($billing['address_1'] ?? ''), 'address_2' => self::sanitize_field($billing['address_2'] ?? ''), 'city' => self::sanitize_field($billing['city'] ?? ''), 'state' => self::sanitize_field($billing['state'] ?? ''), 'postcode' => self::sanitize_field($billing['postcode'] ?? ''), 'country' => self::sanitize_field($billing['country'] ?? ''), ], 'billing'); } // === Shipping === if (is_array($shipping)) { $order->set_address([ 'first_name' => self::sanitize_field($shipping['first_name'] ?? ($shipping['name'] ?? '')), 'last_name' => self::sanitize_field($shipping['last_name'] ?? ''), 'phone' => self::sanitize_phone($shipping['phone'] ?? ''), 'address_1' => self::sanitize_field($shipping['address_1'] ?? ''), 'address_2' => self::sanitize_field($shipping['address_2'] ?? ''), 'city' => self::sanitize_field($shipping['city'] ?? ''), 'state' => self::sanitize_field($shipping['state'] ?? ''), 'postcode' => self::sanitize_field($shipping['postcode'] ?? ''), 'country' => self::sanitize_field($shipping['country'] ?? ''), ], 'shipping'); } // === Shipping method line === if (null !== $shipping_method) { // Remove existing shipping items foreach ($order->get_items('shipping') as $ship_item) { $order->remove_item($ship_item->get_id()); } if ($shipping_method) { $method_id = $shipping_method; $instance_id = null; if (strpos($shipping_method, ':') !== false) { list($method_id, $instance_str) = explode(':', $shipping_method, 2); $instance_id = absint($instance_str); } $ship_item = new \WC_Order_Item_Shipping(); $ship_item->set_method_id($method_id); if (null !== $instance_id && method_exists($ship_item, 'set_instance_id')) { $ship_item->set_instance_id($instance_id); } $ship_item->set_method_title($method_id); $ship_item->set_total(0); $order->add_item($ship_item); } } // === Payment method (info only) === if (null !== $payment_method) { if ($payment_method) { $order->set_payment_method($payment_method); } } // === Coupons: replace === if (is_array($coupons)) { // Remove existing coupon lines foreach ($order->get_items('coupon') as $citem) { $order->remove_item($citem->get_id()); } foreach ($coupons as $code) { try { $coupon = new \WC_Coupon($code); if ($coupon && $coupon->get_code()) { $ci = new \WC_Order_Item_Coupon(); $ci->set_code($coupon->get_code()); $order->add_item($ci); } } catch (\Throwable $e) { /* ignore invalid */ } } } // Customer note (visible to customer) - allow empty to clear note if (null !== $note) { $order->set_customer_note($note); } // Totals & status // Only recalculate if items/coupons/shipping changed; skip for status-only updates $needs_recalc = is_array($items) || is_array($coupons) || null !== $shipping_method; if ($needs_recalc) { $order->calculate_totals(true); } // PROFILING: Find what's causing the 30s delay if (null !== $status && $status !== '') { try { $order->set_status($status, 'Updated via API'); } catch (\Throwable $e) { /* ignore invalid */ } } // Update custom meta fields (Level 1 compatibility) if (isset($p['meta']) && is_array($p['meta'])) { self::update_order_meta_data($order, $p['meta']); } // SOLUTION: Block WooCommerce analytics tracking (pixel.wp.com) during save // This was causing 30s timeout on every order status change add_filter('pre_http_request', function ($preempt, $args, $url) { // Block WooCommerce analytics/tracking requests if (strpos($url, 'pixel.wp.com') !== false || strpos($url, 'stats.wp.com') !== false) { return new \WP_Error('http_request_blocked', 'WooCommerce analytics blocked during order save'); } return $preempt; }, PHP_INT_MAX, 3); $order->save(); // Allow plugins to perform additional updates (Level 1 compatibility) do_action('woonoow/order_updated', $order, $p, $req); // Clean up remove_all_filters('pre_http_request'); // Schedule email for later if (null !== $status && $status !== '') { $order_id = $order->get_id(); add_action('shutdown', function () use ($order_id, $status) { self::schedule_order_email($order_id, $status); }, 999); } return new \WP_REST_Response(['ok' => true, 'id' => $order->get_id()], 200); } catch (\Throwable $e) { // Log the actual error for debugging // Return user-friendly error message return new \WP_REST_Response([ 'error' => 'update_failed', 'message' => __('Unable to update order. Please check all fields and try again.', 'woonoow') ], 500); } } /** * Hook handler for order status changes. * Schedules email notification with 15s delay. * ONLY runs outside of API requests (API requests schedule manually). */ public static function on_order_status_changed($order_id, $status_from, $status_to, $order) { // Skip if we're in an API request (we schedule manually there) if (defined('REST_REQUEST') && REST_REQUEST) { return; } // Schedule email notification with 15s delay self::schedule_order_email($order_id, $status_to); } /** * Schedule order email notification with 15s delay (non-blocking). */ private static function schedule_order_email(int $order_id, string $status) { if (function_exists('as_schedule_single_action')) { // Use Action Scheduler (preferred) as_schedule_single_action(time() + 15, 'woonoow/send_order_email', ['order_id' => $order_id, 'status' => $status], 'woonoow-emails'); } else { // Fallback to wp-cron wp_schedule_single_event(time() + 15, 'woonoow/send_order_email', ['order_id' => $order_id, 'status' => $status]); } } /** * POST /woonoow/v1/orders */ public static function create(WP_REST_Request $req): WP_REST_Response { if (! current_user_can('manage_woocommerce')) { return new \WP_REST_Response(['error' => 'forbidden'], 403); } $p = $req->get_json_params(); $items = is_array($p['items'] ?? null) ? $p['items'] : []; $billing = is_array($p['billing'] ?? null) ? $p['billing'] : []; $shipping = is_array($p['shipping'] ?? null) ? $p['shipping'] : []; $status = sanitize_text_field($p['status'] ?? 'pending'); $payment_method = sanitize_text_field($p['payment_method'] ?? ''); $shipping_method = sanitize_text_field($p['shipping_method'] ?? ''); // e.g. flat_rate:1 $coupons = array_filter(array_map('sanitize_text_field', (array) ($p['coupons'] ?? []))); $note = isset($p['customer_note']) ? wp_kses_post((string) $p['customer_note']) : ''; // Get auto-register setting from customer settings (site-level) $customer_settings = \WooNooW\Compat\CustomerSettingsProvider::get_settings(); $register_member = $customer_settings['auto_register_members'] ?? false; // Validation: Collect all missing required fields $validation_errors = []; // 1. At least one product is required if (empty($items)) { $validation_errors[] = __('At least one product is required', 'woonoow'); } // 2. Check if all products are virtual/downloadable $has_physical_product = false; foreach ($items as $item) { $product_id = absint($item['product_id'] ?? 0); if ($product_id) { $product = wc_get_product($product_id); if ($product && ! $product->is_virtual() && ! $product->is_downloadable()) { $has_physical_product = true; break; } } } // 3. Billing information required based on checkout fields configuration // Get checkout field settings to respect hidden/required status from PHP snippets $checkout_fields = apply_filters('woonoow/checkout/fields', [], $items); // Helper to check if a billing field is required $is_field_required = function ($field_key) use ($checkout_fields) { foreach ($checkout_fields as $field) { if (isset($field['key']) && $field['key'] === $field_key) { // Field is not required if hidden or explicitly not required if (! empty($field['hidden']) || $field['type'] === 'hidden') { return false; } return ! empty($field['required']); } } // Default: core fields are required if not found in API return true; }; // Core billing fields - check against API configuration if ($is_field_required('billing_first_name') && empty($billing['first_name'])) { $validation_errors[] = __('Billing first name is required', 'woonoow'); } if ($is_field_required('billing_last_name') && empty($billing['last_name'])) { $validation_errors[] = __('Billing last name is required', 'woonoow'); } if ($is_field_required('billing_email') && empty($billing['email'])) { $validation_errors[] = __('Billing email is required', 'woonoow'); } // Address fields only required for physical products AND if not hidden if ($has_physical_product) { if ($is_field_required('billing_address_1') && empty($billing['address_1'])) { $validation_errors[] = __('Billing address is required', 'woonoow'); } if ($is_field_required('billing_city') && empty($billing['city'])) { $validation_errors[] = __('Billing city is required', 'woonoow'); } if ($is_field_required('billing_postcode') && empty($billing['postcode'])) { $validation_errors[] = __('Billing postcode is required', 'woonoow'); } if ($is_field_required('billing_country') && empty($billing['country'])) { $validation_errors[] = __('Billing country is required', 'woonoow'); } } // 3. Validate email format if provided if (! empty($billing['email']) && ! is_email($billing['email'])) { $validation_errors[] = __('Billing email is not valid', 'woonoow'); } // If there are validation errors, return them if (! empty($validation_errors)) { return new \WP_REST_Response([ 'error' => 'validation_failed', 'message' => __('Please complete all required fields', 'woonoow'), 'fields' => $validation_errors ], 400); } try { $order = wc_create_order(); // Items foreach ($items as $row) { $pid = absint($row['product_id'] ?? 0); $qty = max(1, absint($row['qty'] ?? 1)); if (! $pid) { continue; } $product = wc_get_product($pid); if (! $product) { continue; } $order->add_product($product, $qty); } // Billing if ($billing) { $order->set_address([ 'first_name' => self::sanitize_field($billing['first_name'] ?? ''), 'last_name' => self::sanitize_field($billing['last_name'] ?? ''), 'email' => self::sanitize_email_field($billing['email'] ?? ''), 'phone' => self::sanitize_phone($billing['phone'] ?? ''), 'address_1' => self::sanitize_field($billing['address_1'] ?? ''), 'address_2' => self::sanitize_field($billing['address_2'] ?? ''), 'city' => self::sanitize_field($billing['city'] ?? ''), 'state' => self::sanitize_field($billing['state'] ?? ''), 'postcode' => self::sanitize_field($billing['postcode'] ?? ''), 'country' => self::sanitize_field($billing['country'] ?? ''), ], 'billing'); } // Shipping: copy billing unless explicit ship_to_different $ship_to_different = (bool) ($shipping['ship_to_different'] ?? false); $ship_addr = $ship_to_different ? $shipping : $billing; if ($ship_addr) { $order->set_address([ 'first_name' => self::sanitize_field($ship_addr['first_name'] ?? ($ship_addr['name'] ?? '')), 'last_name' => self::sanitize_field($ship_addr['last_name'] ?? ''), 'phone' => self::sanitize_phone($ship_addr['phone'] ?? ''), 'address_1' => self::sanitize_field($ship_addr['address_1'] ?? ''), 'address_2' => self::sanitize_field($ship_addr['address_2'] ?? ''), 'city' => self::sanitize_field($ship_addr['city'] ?? ''), 'state' => self::sanitize_field($ship_addr['state'] ?? ''), 'postcode' => self::sanitize_field($ship_addr['postcode'] ?? ''), 'country' => self::sanitize_field($ship_addr['country'] ?? ''), ], 'shipping'); } // Shipping method - calculate using WooCommerce cart for accurate rates if ($shipping_method) { // Initialize cart if needed if (! WC()->cart) { wc_load_cart(); } if (! WC()->session) { WC()->session = new \WC_Session_Handler(); WC()->session->init(); } // Temporarily use cart to calculate shipping WC()->cart->empty_cart(); // Add items to cart foreach ($items as $row) { $pid = absint($row['product_id'] ?? 0); $qty = max(1, absint($row['qty'] ?? 1)); if ($pid) { WC()->cart->add_to_cart($pid, $qty); } } // Set shipping address for calculation if ($ship_addr) { WC()->customer->set_shipping_country($ship_addr['country'] ?? ''); WC()->customer->set_shipping_state($ship_addr['state'] ?? ''); WC()->customer->set_shipping_postcode($ship_addr['postcode'] ?? ''); WC()->customer->set_shipping_city($ship_addr['city'] ?? ''); WC()->customer->set_shipping_address($ship_addr['address_1'] ?? ''); } // Set chosen shipping method WC()->session->set('chosen_shipping_methods', [$shipping_method]); // Calculate shipping WC()->cart->calculate_shipping(); WC()->cart->calculate_totals(); // Get the calculated rate $packages = WC()->shipping()->get_packages(); $shipping_cost = 0; $shipping_title = $shipping_method; $shipping_taxes = []; foreach ($packages as $package) { if (isset($package['rates'][$shipping_method])) { $rate = $package['rates'][$shipping_method]; $shipping_cost = $rate->get_cost(); $shipping_title = $rate->get_label(); $shipping_taxes = $rate->get_taxes(); break; } } // Clean up cart WC()->cart->empty_cart(); // Parse method ID and instance ID $method_id = $shipping_method; $instance_id = null; if (strpos($shipping_method, ':') !== false) { list($method_id, $instance_str) = explode(':', $shipping_method, 2); $instance_id = absint($instance_str); } // Add shipping line item $ship_item = new \WC_Order_Item_Shipping(); $ship_item->set_method_id($method_id); if (null !== $instance_id && method_exists($ship_item, 'set_instance_id')) { $ship_item->set_instance_id($instance_id); } $ship_item->set_method_title($shipping_title); $ship_item->set_total($shipping_cost); // Set shipping taxes if available if (! empty($shipping_taxes)) { $ship_item->set_taxes(['total' => $shipping_taxes]); } $order->add_item($ship_item); } // Payment method if ($payment_method) { $order->set_payment_method($payment_method); } // Coupons foreach ($coupons as $code) { try { $coupon = new \WC_Coupon($code); if ($coupon && $coupon->get_code()) { // Apply coupon to order (this will calculate discount) $order->apply_coupon($coupon); } } catch (\Throwable $e) { } } // Customer note (visible to customer) if ($note) { $order->set_customer_note($note); } // Calculate totals (includes products, shipping, coupons, taxes) $order->calculate_totals(true); // Set status after totals try { $order->set_status($status, 'Set via API'); } catch (\Throwable $e) { /* ignore invalid */ } // Block WooCommerce analytics tracking during save (same as update method) add_filter('pre_http_request', function ($preempt, $args, $url) { if (strpos($url, 'pixel.wp.com') !== false || strpos($url, 'stats.wp.com') !== false) { return new \WP_Error('http_request_blocked', 'WooCommerce analytics blocked during order save'); } return $preempt; }, PHP_INT_MAX, 3); $order->save(); // Clean up remove_all_filters('pre_http_request'); // Process payment gateway if payment method is set and order is pending if ($payment_method && $status === 'pending') { self::process_payment_gateway($order, $payment_method); } // Link order to customer and update/create user account if (! empty($billing['email'])) { $email = sanitize_email($billing['email']); // Check if user already exists if (email_exists($email) || username_exists($email)) { // User exists - always link and update data $user = get_user_by('email', $email); if ($user) { // Upgrade subscriber to customer role (WooCommerce best practice) if (in_array('subscriber', (array) $user->roles, true)) { $user->set_role('customer'); } // Update customer billing & shipping data $customer = new \WC_Customer($user->ID); // Update billing address - sanitize and validate each field $first_name = self::sanitize_field($billing['first_name'] ?? ''); $last_name = self::sanitize_field($billing['last_name'] ?? ''); $email = self::sanitize_email_field($billing['email'] ?? ''); $phone = self::sanitize_phone($billing['phone'] ?? ''); $address_1 = self::sanitize_field($billing['address_1'] ?? ''); $address_2 = self::sanitize_field($billing['address_2'] ?? ''); $city = self::sanitize_field($billing['city'] ?? ''); $state = self::sanitize_field($billing['state'] ?? ''); $postcode = self::sanitize_field($billing['postcode'] ?? ''); $country = self::sanitize_field($billing['country'] ?? ''); // Only set if not empty (allow clearing fields by passing empty string) if ($first_name !== '') $customer->set_billing_first_name($first_name); if ($last_name !== '') $customer->set_billing_last_name($last_name); if ($email !== '') $customer->set_billing_email($email); if ($phone !== '') $customer->set_billing_phone($phone); if ($address_1 !== '') $customer->set_billing_address_1($address_1); if ($address_2 !== '') $customer->set_billing_address_2($address_2); if ($city !== '') $customer->set_billing_city($city); if ($state !== '') $customer->set_billing_state($state); if ($postcode !== '') $customer->set_billing_postcode($postcode); if ($country !== '') $customer->set_billing_country($country); // Update shipping address (if provided) if (! empty($shipping) && is_array($shipping)) { $s_first_name = self::sanitize_field($shipping['first_name'] ?? ''); $s_last_name = self::sanitize_field($shipping['last_name'] ?? ''); $s_address_1 = self::sanitize_field($shipping['address_1'] ?? ''); $s_address_2 = self::sanitize_field($shipping['address_2'] ?? ''); $s_city = self::sanitize_field($shipping['city'] ?? ''); $s_state = self::sanitize_field($shipping['state'] ?? ''); $s_postcode = self::sanitize_field($shipping['postcode'] ?? ''); $s_country = self::sanitize_field($shipping['country'] ?? ''); if ($s_first_name !== '') $customer->set_shipping_first_name($s_first_name); if ($s_last_name !== '') $customer->set_shipping_last_name($s_last_name); if ($s_address_1 !== '') $customer->set_shipping_address_1($s_address_1); if ($s_address_2 !== '') $customer->set_shipping_address_2($s_address_2); if ($s_city !== '') $customer->set_shipping_city($s_city); if ($s_state !== '') $customer->set_shipping_state($s_state); if ($s_postcode !== '') $customer->set_shipping_postcode($s_postcode); if ($s_country !== '') $customer->set_shipping_country($s_country); } $customer->save(); $order->set_customer_id($user->ID); $order->save(); } } elseif ($register_member) { // User doesn't exist - create only if checkbox is checked // Generate password $password = wp_generate_password(12, true, true); // Prepare user data $userdata = [ 'user_login' => $email, 'user_email' => $email, 'user_pass' => $password, 'first_name' => $billing['first_name'] ?? '', 'last_name' => $billing['last_name'] ?? '', 'display_name' => trim(($billing['first_name'] ?? '') . ' ' . ($billing['last_name'] ?? '')) ?: $email, 'role' => 'customer', // WooCommerce customer role ]; $user_id = wp_insert_user($userdata); if (! is_wp_error($user_id)) { // Set WooCommerce customer billing data $customer = new \WC_Customer($user_id); // Billing address - sanitize and validate each field $first_name = self::sanitize_field($billing['first_name'] ?? ''); $last_name = self::sanitize_field($billing['last_name'] ?? ''); $email = self::sanitize_email_field($billing['email'] ?? ''); $phone = self::sanitize_phone($billing['phone'] ?? ''); $address_1 = self::sanitize_field($billing['address_1'] ?? ''); $address_2 = self::sanitize_field($billing['address_2'] ?? ''); $city = self::sanitize_field($billing['city'] ?? ''); $state = self::sanitize_field($billing['state'] ?? ''); $postcode = self::sanitize_field($billing['postcode'] ?? ''); $country = self::sanitize_field($billing['country'] ?? ''); // Only set if not empty if ($first_name !== '') $customer->set_billing_first_name($first_name); if ($last_name !== '') $customer->set_billing_last_name($last_name); if ($email !== '') $customer->set_billing_email($email); if ($phone !== '') $customer->set_billing_phone($phone); if ($address_1 !== '') $customer->set_billing_address_1($address_1); if ($address_2 !== '') $customer->set_billing_address_2($address_2); if ($city !== '') $customer->set_billing_city($city); if ($state !== '') $customer->set_billing_state($state); if ($postcode !== '') $customer->set_billing_postcode($postcode); if ($country !== '') $customer->set_billing_country($country); // Shipping address (if provided) if (! empty($shipping) && is_array($shipping)) { $s_first_name = self::sanitize_field($shipping['first_name'] ?? ''); $s_last_name = self::sanitize_field($shipping['last_name'] ?? ''); $s_address_1 = self::sanitize_field($shipping['address_1'] ?? ''); $s_address_2 = self::sanitize_field($shipping['address_2'] ?? ''); $s_city = self::sanitize_field($shipping['city'] ?? ''); $s_state = self::sanitize_field($shipping['state'] ?? ''); $s_postcode = self::sanitize_field($shipping['postcode'] ?? ''); $s_country = self::sanitize_field($shipping['country'] ?? ''); if ($s_first_name !== '') $customer->set_shipping_first_name($s_first_name); if ($s_last_name !== '') $customer->set_shipping_last_name($s_last_name); if ($s_address_1 !== '') $customer->set_shipping_address_1($s_address_1); if ($s_address_2 !== '') $customer->set_shipping_address_2($s_address_2); if ($s_city !== '') $customer->set_shipping_city($s_city); if ($s_state !== '') $customer->set_shipping_state($s_state); if ($s_postcode !== '') $customer->set_shipping_postcode($s_postcode); if ($s_country !== '') $customer->set_shipping_country($s_country); } $customer->save(); // Link order to customer $order->set_customer_id($user_id); $order->save(); // Send new user notification (password reset link) wp_new_user_notification($user_id, null, 'user'); } } } // Schedule email notification with 15s delay (non-blocking) - on shutdown to avoid blocking add_action('shutdown', function () use ($order, $status) { self::schedule_order_email($order->get_id(), $status); }, 999); return new \WP_REST_Response([ 'ok' => true, 'id' => $order->get_id(), 'number' => $order->get_order_number(), ], 201); } catch (\Throwable $e) { // Log the actual error for debugging // Return user-friendly error message return new \WP_REST_Response([ 'error' => 'create_failed', 'message' => __('Unable to create order. Please check all required fields and try again.', 'woonoow') ], 500); } } /** * GET /woonoow/v1/products */ public static function products(WP_REST_Request $req): WP_REST_Response { $s = sanitize_text_field($req->get_param('search') ?? ''); $limit = max(1, min(20, absint($req->get_param('limit') ?? 10))); // Use WP_Query for proper search support (wc_get_products doesn't support 's' parameter) $args = [ 'post_type' => ['product'], 'post_status' => 'publish', 'posts_per_page' => $limit, ]; if ($s) { $args['s'] = $s; } $query = new \WP_Query($args); $prods = array_filter(array_map('wc_get_product', $query->posts)); $rows = array_map(function ($p) { $data = [ 'id' => $p->get_id(), 'name' => $p->get_name(), 'type' => $p->get_type(), 'price' => (float) $p->get_price(), 'regular_price' => (float) $p->get_regular_price(), 'sale_price' => $p->get_sale_price() ? (float) $p->get_sale_price() : null, 'sku' => $p->get_sku(), 'stock' => $p->get_stock_quantity(), 'virtual' => $p->is_virtual(), 'downloadable' => $p->is_downloadable(), 'variations' => [], ]; // If variable product, include variations if ($p->get_type() === 'variable') { $variation_ids = $p->get_children(); $parent_attributes = $p->get_attributes(); foreach ($variation_ids as $variation_id) { $variation = wc_get_product($variation_id); if (! $variation) continue; // Get variation attributes properly from parent attributes $formatted_attributes = []; foreach ($parent_attributes as $parent_attr) { if (! $parent_attr->get_variation()) { continue; // Skip non-variation attributes } $attr_name = $parent_attr->get_name(); $clean_name = $attr_name; // Get the variation's value for this attribute if (strpos($attr_name, 'pa_') === 0) { // Global/taxonomy attribute $clean_name = wc_attribute_label($attr_name); $value = $variation->get_attribute($attr_name); // Convert slug to term name if (! empty($value)) { $term = get_term_by('slug', $value, $attr_name); $value = $term ? $term->name : $value; } } else { // Custom attribute - WooCommerce stores as 'attribute_' + lowercase sanitized name $meta_key = 'attribute_' . sanitize_title($attr_name); $value = get_post_meta($variation_id, $meta_key, true); // Capitalize the attribute name for display $clean_name = ucfirst($attr_name); } $formatted_attributes[$clean_name] = $value; } $data['variations'][] = [ 'id' => $variation->get_id(), 'attributes' => $formatted_attributes, 'price' => (float) $variation->get_price(), 'regular_price' => (float) $variation->get_regular_price(), 'sale_price' => $variation->get_sale_price() ? (float) $variation->get_sale_price() : null, 'sku' => $variation->get_sku(), 'stock' => $variation->get_stock_quantity(), 'in_stock' => $variation->is_in_stock(), ]; } } return $data; }, $prods); return new WP_REST_Response(['rows' => $rows], 200); } public static function payments(WP_REST_Request $req): WP_REST_Response { $methods = []; if (function_exists('WC')) { $gateways = WC()->payment_gateways ? WC()->payment_gateways->get_available_payment_gateways() : []; foreach ($gateways as $id => $gw) { // Skip disabled gateways (double-check even though get_available_payment_gateways should filter) if (! isset($gw->enabled) || $gw->enabled !== 'yes') { continue; } $gateway_data = [ 'id' => (string) $id, 'title' => (string) ($gw->get_title() ?? $gw->title ?? $id), 'enabled' => true, 'channels' => [], ]; /** * Filter: woonoow/payment_gateway_channels * * Allows payment gateways to provide channel-level options (e.g., bank accounts). * If channels are provided, they will be shown instead of the gateway itself. * * @param array $channels Array of channel configurations * @param string $gateway_id Gateway ID * @param object $gateway Gateway instance * * Example: * add_filter('woonoow/payment_gateway_channels', function($channels, $gateway_id, $gateway) { * if ($gateway_id === 'bacs') { * $accounts = $gateway->get_option('account_details', []); * foreach ($accounts as $account) { * $channels[] = [ * 'id' => 'bacs_' . sanitize_title($account['account_name']), * 'title' => $account['bank_name'] . ' - ' . $account['account_name'], * 'meta' => $account, * ]; * } * } * return $channels; * }, 10, 3); */ $channels = apply_filters('woonoow/payment_gateway_channels', [], $id, $gw); // If gateway has channels, add them if (! empty($channels) && is_array($channels)) { $gateway_data['channels'] = $channels; } $methods[] = $gateway_data; } } return new \WP_REST_Response($methods, 200); } public static function shippings(WP_REST_Request $req): WP_REST_Response { $rows = []; if (! class_exists('\WC_Shipping_Zones')) { return new \WP_REST_Response($rows, 200); } $zones = \WC_Shipping_Zones::get_zones(); // Also include the “locations not covered by your other zones” pseudo-zone $zones[] = \WC_Shipping_Zones::get_zone_by('zone_id', 0)->get_data(); foreach ($zones as $zone) { $zone_obj = is_object($zone) ? $zone : new \WC_Shipping_Zone($zone['id'] ?? 0); foreach ($zone_obj->get_shipping_methods() as $m) { /** @var \WC_Shipping_Method $m */ // Skip disabled shipping methods if (! $m->is_enabled()) { continue; } $id = $m->id; $instance = method_exists($m, 'get_instance_id') ? $m->get_instance_id() : 0; $title = !empty($m->title) ? $m->title : (method_exists($m, 'get_method_title') ? $m->get_method_title() : $m->id); // Get shipping cost $cost = 0; if (method_exists($m, 'get_option')) { $cost = $m->get_option('cost', 0); } $rows[] = [ 'id' => $instance ? "{$id}:{$instance}" : $id, // e.g. "flat_rate:3" or "free_shipping" 'method' => $id, 'title' => (string) ($m->title ?? $m->get_method_title() ?? $id), // Use user's label first 'cost' => (float) $cost, // Shipping cost ]; } } // Deduplicate by id $rows = array_values(array_reduce($rows, function ($carry, $r) { $carry[$r['id']] = $r; return $carry; }, [])); return new \WP_REST_Response($rows, 200); } /** * Calculate shipping rates for given cart and address * POST /woonoow/v1/shipping/calculate */ public static function calculate_shipping(WP_REST_Request $req): \WP_REST_Response { $p = $req->get_json_params(); $items = is_array($p['items'] ?? null) ? $p['items'] : []; $shipping = is_array($p['shipping'] ?? null) ? $p['shipping'] : []; if (empty($items)) { return new \WP_REST_Response(['error' => 'items_required'], 400); } try { // Initialize WooCommerce cart and session if not already if (! WC()->cart) { wc_load_cart(); } if (! WC()->session) { WC()->session = new \WC_Session_Handler(); WC()->session->init(); } // Create a temporary cart to calculate shipping WC()->cart->empty_cart(); // Add items to cart foreach ($items as $item) { $product_id = absint($item['product_id'] ?? 0); $qty = max(1, absint($item['qty'] ?? 1)); if ($product_id) { WC()->cart->add_to_cart($product_id, $qty); } } // Set customer shipping address if (! empty($shipping)) { $country = $shipping['country'] ?? ''; $state = $shipping['state'] ?? ''; $postcode = $shipping['postcode'] ?? ''; $city = $shipping['city'] ?? ''; $address_1 = $shipping['address_1'] ?? ''; $address_2 = $shipping['address_2'] ?? ''; WC()->customer->set_shipping_country($country); WC()->customer->set_shipping_state($state); WC()->customer->set_shipping_postcode($postcode); WC()->customer->set_shipping_city($city); WC()->customer->set_shipping_address($address_1); WC()->customer->set_shipping_address_2($address_2); // Also set billing for tax calculation context WC()->customer->set_billing_country($country); WC()->customer->set_billing_state($state); WC()->customer->set_billing_postcode($postcode); WC()->customer->set_billing_city($city); /** * Allow shipping addons to prepare session/data before shipping calculation. * * This hook allows third-party shipping plugins (like Rajaongkir, Biteship, etc.) * to set any session variables or prepare data they need before WooCommerce * calculates shipping rates. * * @since 1.0.0 * @param array $shipping The shipping address data from frontend (country, state, city, postcode, address_1, etc.) * @param array $items The cart items being shipped */ do_action('woonoow/shipping/before_calculate', $shipping, $items ?? []); } // Calculate shipping WC()->cart->calculate_shipping(); WC()->cart->calculate_totals(); // Get available shipping packages and rates $packages = WC()->shipping()->get_packages(); $rates = []; foreach ($packages as $package_key => $package) { $package_rates = $package['rates'] ?? []; foreach ($package_rates as $rate_id => $rate) { /** @var \WC_Shipping_Rate $rate */ $rates[] = [ 'id' => $rate_id, 'method_id' => $rate->get_method_id(), 'instance_id' => $rate->get_instance_id(), 'label' => $rate->get_label(), 'cost' => (float) $rate->get_cost(), 'taxes' => $rate->get_taxes(), 'meta_data' => $rate->get_meta_data(), ]; } } // Fallback: If no rates from packages, manually calculate from matching zone if (empty($rates) && ! empty($shipping['country'])) { $package = [ 'destination' => [ 'country' => $shipping['country'] ?? '', 'state' => $shipping['state'] ?? '', 'postcode' => $shipping['postcode'] ?? '', 'city' => $shipping['city'] ?? '', ], 'contents' => WC()->cart->get_cart(), 'contents_cost' => WC()->cart->get_subtotal(), 'applied_coupons' => WC()->cart->get_applied_coupons(), 'user' => ['ID' => get_current_user_id()], ]; $zone = \WC_Shipping_Zones::get_zone_matching_package($package); if ($zone) { foreach ($zone->get_shipping_methods(true) as $method) { if (method_exists($method, 'get_rates_for_package')) { $method_rates = $method->get_rates_for_package($package); foreach ($method_rates as $rate_id => $rate) { $rates[] = [ 'id' => $rate_id, 'method_id' => $rate->get_method_id(), 'instance_id' => $rate->get_instance_id(), 'label' => $rate->get_label(), 'cost' => (float) $rate->get_cost(), 'taxes' => $rate->get_taxes(), 'meta_data' => $rate->get_meta_data(), ]; } } else { // Fallback for methods without get_rates_for_package (like Free Shipping) $method_id = $method->id . ':' . $method->instance_id; $rates[] = [ 'id' => $method_id, 'method_id' => $method->id, 'instance_id' => $method->instance_id, 'label' => $method->get_title(), 'cost' => $method->id === 'free_shipping' ? 0 : (float) ($method->cost ?? 0), 'taxes' => [], 'meta_data' => [], ]; } } } } // Clean up WC()->cart->empty_cart(); return new \WP_REST_Response([ 'methods' => $rates, // Keep as 'methods' for frontend compatibility 'has_methods' => ! empty($rates), 'debug' => [ 'packages_count' => count($packages), 'cart_items_count' => count(WC()->cart->get_cart()), 'address' => $shipping, ], ], 200); } catch (\Throwable $e) { // Clean up on error WC()->cart->empty_cart(); return new \WP_REST_Response([ 'error' => 'calculation_failed', 'message' => $e->getMessage(), ], 500); } } /** * Preview order totals (subtotal, shipping, tax, total) * POST /woonoow/v1/orders/preview */ public static function preview_order(WP_REST_Request $req): \WP_REST_Response { $p = $req->get_json_params(); $items = is_array($p['items'] ?? null) ? $p['items'] : []; $billing = is_array($p['billing'] ?? null) ? $p['billing'] : []; $shipping = is_array($p['shipping'] ?? null) ? $p['shipping'] : []; $shipping_method = sanitize_text_field($p['shipping_method'] ?? ''); $coupons = array_filter(array_map('sanitize_text_field', (array) ($p['coupons'] ?? []))); if (empty($items)) { return new \WP_REST_Response(['error' => 'items_required'], 400); } try { // Initialize WooCommerce cart and session if not already if (! WC()->cart) { wc_load_cart(); } if (! WC()->session) { WC()->session = new \WC_Session_Handler(); WC()->session->init(); } // Use WooCommerce cart for calculation WC()->cart->empty_cart(); // Add items foreach ($items as $item) { $product_id = absint($item['product_id'] ?? 0); $qty = max(1, absint($item['qty'] ?? 1)); if ($product_id) { WC()->cart->add_to_cart($product_id, $qty); } } // Set customer addresses for tax calculation if (! empty($billing)) { WC()->customer->set_billing_country($billing['country'] ?? ''); WC()->customer->set_billing_state($billing['state'] ?? ''); WC()->customer->set_billing_postcode($billing['postcode'] ?? ''); WC()->customer->set_billing_city($billing['city'] ?? ''); } if (! empty($shipping)) { WC()->customer->set_shipping_country($shipping['country'] ?? ''); WC()->customer->set_shipping_state($shipping['state'] ?? ''); WC()->customer->set_shipping_postcode($shipping['postcode'] ?? ''); WC()->customer->set_shipping_city($shipping['city'] ?? ''); } // Apply coupons foreach ($coupons as $code) { WC()->cart->apply_coupon($code); } // Set chosen shipping method if provided if ($shipping_method) { WC()->session->set('chosen_shipping_methods', [$shipping_method]); } // Calculate totals WC()->cart->calculate_shipping(); WC()->cart->calculate_totals(); // Get totals $result = [ 'subtotal' => (float) WC()->cart->get_subtotal(), 'subtotal_tax' => (float) WC()->cart->get_subtotal_tax(), 'discount_total' => (float) WC()->cart->get_discount_total(), 'discount_tax' => (float) WC()->cart->get_discount_tax(), 'shipping_total' => (float) WC()->cart->get_shipping_total(), 'shipping_tax' => (float) WC()->cart->get_shipping_tax(), 'cart_contents_tax' => (float) WC()->cart->get_cart_contents_tax(), 'fee_tax' => (float) WC()->cart->get_fee_tax(), 'total_tax' => (float) WC()->cart->get_total_tax(), 'total' => (float) WC()->cart->get_total('edit'), 'tax_display_cart' => get_option('woocommerce_tax_display_cart', 'excl'), 'prices_include_tax' => wc_prices_include_tax(), ]; // Clean up WC()->cart->empty_cart(); return new \WP_REST_Response($result, 200); } catch (\Throwable $e) { // Clean up on error WC()->cart->empty_cart(); return new \WP_REST_Response([ 'error' => 'preview_failed', 'message' => $e->getMessage(), ], 500); } } public static function countries(WP_REST_Request $req): \WP_REST_Response { $rows = []; $states = []; $default = ''; if (function_exists('wc') && wc() && isset(wc()->countries)) { $c = wc()->countries; // All countries and which ones are allowed for checkout $all = (array) $c->get_countries(); // [ 'ID' => 'Indonesia', ... ] $allowed = (array) $c->get_allowed_countries(); // obeys General ▶ Selling location(s) $states = (array) $c->get_states(); // [ 'US' => [ 'CA' => 'California', ... ], ... ] // Use only the allowed countries list; if empty, fall back to all. $use = ! empty($allowed) ? $allowed : $all; // shape: [ 'ID' => 'Indonesia', ... ] // Default country is the store base if present in the allowed set; otherwise first allowed. $base = wc_get_base_location(); // ['country' => 'ID', 'state' => 'JK'] $default = isset($base['country']) && array_key_exists($base['country'], $use) ? $base['country'] : (string) (array_key_first($use) ?: ''); // Filter states down to the allowed countries only if (! empty($states)) { $states = array_intersect_key($states, $use); } foreach ($use as $code => $name) { $rows[] = [ 'code' => (string) $code, 'name' => (string) $name, ]; } } // Shape: { countries: [{code,name}], states: { CODE: {STATE: Name} }, default_country: 'ID' } return new \WP_REST_Response([ 'countries' => $rows, 'states' => $states, 'default_country' => $default, ], 200); } /** * Send order email notification (runs async via Action Scheduler or wp-cron). * This is triggered 15 seconds after order status change to avoid blocking API responses. */ public static function send_order_email($args) { $order_id = absint($args['order_id'] ?? 0); $status = sanitize_text_field($args['status'] ?? ''); if (! $order_id || ! $status) { return; } $order = wc_get_order($order_id); if (! $order) { return; } // Trigger WooCommerce email notification for the status // This will call wp_mail() which is intercepted by WooEmailOverride do_action('woocommerce_order_status_' . $status, $order_id, $order); // Also trigger generic status change hook do_action('woocommerce_order_status_changed', $order_id, $order->get_status(), $status, $order); } /** * Delete an order (move to trash or permanently delete based on WooCommerce settings) */ public static function delete(WP_REST_Request $req): WP_REST_Response { $id = (int) $req['id']; if (! $id) { return new WP_REST_Response([ 'error' => __('Invalid order ID', 'woonoow'), ], 400); } $order = wc_get_order($id); if (! $order) { return new WP_REST_Response([ 'error' => __('Order not found', 'woonoow'), ], 404); } // Check if HPOS is enabled $using_hpos = OrderUtil::custom_orders_table_usage_is_enabled(); try { // For HPOS orders, use the delete method directly if ($using_hpos) { // Move to trash first (soft delete) $order->delete(false); // If you want to permanently delete instead, use: // $order->delete( true ); } else { // For legacy post-based orders, use wp_trash_post or wp_delete_post wp_trash_post($id); // For permanent deletion: // wp_delete_post( $id, true ); } // Log the deletion if (function_exists('wc_get_logger')) { $logger = wc_get_logger(); $logger->info( sprintf('Order #%d deleted via WooNooW API by user %d', $id, get_current_user_id()), ['source' => 'woonoow-orders'] ); } return new WP_REST_Response([ 'success' => true, 'id' => $id, 'message' => __('Order deleted successfully', 'woonoow'), ], 200); } catch (\Exception $e) { return new WP_REST_Response([ 'error' => __('Failed to delete order', 'woonoow'), 'message' => $e->getMessage(), ], 500); } } /** * Get payment method title from order * Handles both regular gateways and channel-based payments (e.g., bacs_dwindi-ramadhana_0) * * @param \WC_Order $order Order object * @return string Payment method title */ private static function get_payment_method_title($order): string { $payment_method_id = $order->get_payment_method(); if (empty($payment_method_id)) { return __('No payment method', 'woonoow'); } // Get ALL payment gateways (not just available ones) if (function_exists('WC')) { $gateways_handler = WC()->payment_gateways(); if ($gateways_handler) { $all_gateways = $gateways_handler->payment_gateways(); // First, check if it's a direct gateway ID if (isset($all_gateways[$payment_method_id])) { $gateway = $all_gateways[$payment_method_id]; return $gateway->get_title() ?: $gateway->method_title; } // If not a direct gateway, check if it's a channel ID (e.g., bacs_dwindi-ramadhana_0) if (strpos($payment_method_id, '_') !== false) { $parts = explode('_', $payment_method_id, 2); $gateway_id = $parts[0]; if (isset($all_gateways[$gateway_id])) { $gateway = $all_gateways[$gateway_id]; // Get channels for this gateway $channels = apply_filters('woonoow/payment_gateway_channels', [], $gateway_id, $gateway); // Find matching channel foreach ($channels as $channel) { if ($channel['id'] === $payment_method_id) { return $channel['title']; } } } } } } // Fallback to WooCommerce's get_payment_method_title() $title = $order->get_payment_method_title(); return $title ?: __('No payment method', 'woonoow'); } /** * Get shipping method title from order * Uses WC_Shipping_Zones to get the actual user-configured title * * @param \WC_Order $order Order object * @return string Shipping method title */ private static function get_shipping_method_title($order): string { $shipping_methods = $order->get_shipping_methods(); if (empty($shipping_methods)) { return __('No shipping method', 'woonoow'); } // Get first shipping method item $shipping_item = reset($shipping_methods); $method_id = $shipping_item->get_method_id(); $instance_id = $shipping_item->get_instance_id(); // Look up the actual shipping method from WC_Shipping_Zones if (class_exists('\WC_Shipping_Zones') && $instance_id) { $zones = \WC_Shipping_Zones::get_zones(); $zones[] = \WC_Shipping_Zones::get_zone_by('zone_id', 0)->get_data(); foreach ($zones as $zone) { $zone_obj = is_object($zone) ? $zone : new \WC_Shipping_Zone($zone['id'] ?? 0); foreach ($zone_obj->get_shipping_methods() as $method) { // Match by method_id and instance_id if ($method->id === $method_id && $method->get_instance_id() == $instance_id) { // Found it! Return the user's custom title return $method->title ?: $method->get_method_title() ?: $method_id; } } } } // Fallback: try to get title from the order item itself $title = $shipping_item->get_name(); if ($title && $title !== $method_id) { return $title; } // Last resort: return method ID return $method_id ?: __('No shipping method', 'woonoow'); } /** * Get shipping method ID from order * WooCommerce format: "method_id:instance_id" (e.g., "free_shipping:1") * * @param \WC_Order $order Order object * @return string Shipping method ID with instance */ private static function get_shipping_method_id($order): string { $shipping_methods = $order->get_shipping_methods(); if (empty($shipping_methods)) { return ''; } // Get first shipping method $shipping_method = reset($shipping_methods); $method_id = $shipping_method->get_method_id(); $instance_id = $shipping_method->get_instance_id(); // Return in WooCommerce format: method_id:instance_id return $instance_id ? "{$method_id}:{$instance_id}" : $method_id; } /** * GET /woonoow/v1/customers/search * Search customers by email or general search for autofill functionality */ public static function search_customers(WP_REST_Request $req): WP_REST_Response { $email = sanitize_email($req->get_param('email') ?? ''); $search = sanitize_text_field($req->get_param('search') ?? ''); // If email provided, search by email (exact match) if (! empty($email)) { $user = get_user_by('email', $email); if (! $user) { return new \WP_REST_Response(['found' => false], 200); } return new \WP_REST_Response(self::format_customer_data($user), 200); } // If search provided, search by name/email (multiple results) if (! empty($search)) { $users = get_users([ 'search' => '*' . $search . '*', 'search_columns' => ['user_login', 'user_email', 'display_name'], 'number' => 10, 'role__in' => ['customer', 'administrator', 'shop_manager', 'subscriber'], ]); $results = []; foreach ($users as $user) { $customer = new \WC_Customer($user->ID); $results[] = [ 'id' => $user->ID, 'email' => $user->user_email, 'name' => trim(($customer->get_billing_first_name() ?: $user->first_name) . ' ' . ($customer->get_billing_last_name() ?: $user->last_name)), 'first_name' => $customer->get_billing_first_name() ?: $user->first_name, 'last_name' => $customer->get_billing_last_name() ?: $user->last_name, ]; } return new \WP_REST_Response($results, 200); } return new \WP_REST_Response(['error' => 'email_or_search_required'], 400); } /** * Format customer data for autofill */ private static function format_customer_data($user): array { $customer = new \WC_Customer($user->ID); return [ 'found' => true, 'user_id' => $user->ID, 'email' => $user->user_email, 'first_name' => $customer->get_billing_first_name() ?: $user->first_name, 'last_name' => $customer->get_billing_last_name() ?: $user->last_name, 'phone' => $customer->get_billing_phone(), 'billing' => [ 'first_name' => $customer->get_billing_first_name(), 'last_name' => $customer->get_billing_last_name(), 'email' => $customer->get_billing_email() ?: $user->user_email, 'phone' => $customer->get_billing_phone(), 'address_1' => $customer->get_billing_address_1(), 'address_2' => $customer->get_billing_address_2(), 'city' => $customer->get_billing_city(), 'state' => $customer->get_billing_state(), 'postcode' => $customer->get_billing_postcode(), 'country' => $customer->get_billing_country(), ], 'shipping' => [ 'first_name' => $customer->get_shipping_first_name(), 'last_name' => $customer->get_shipping_last_name(), 'address_1' => $customer->get_shipping_address_1(), 'address_2' => $customer->get_shipping_address_2(), 'city' => $customer->get_shipping_city(), 'state' => $customer->get_shipping_state(), 'postcode' => $customer->get_shipping_postcode(), 'country' => $customer->get_shipping_country(), ], ]; } /** * Process payment gateway for an order * Calls the gateway's process_payment() method to create external transactions * * @param \WC_Order $order Order object * @param string $payment_method Payment method ID * @return array|\WP_Error Payment result or error */ private static function process_payment_gateway($order, $payment_method) { // Get all payment gateways if (! function_exists('WC') || ! WC()->payment_gateways) { return new \WP_Error('no_wc', __('WooCommerce not initialized', 'woonoow')); } // Initialize WooCommerce cart and session if not exists // Some gateways call WC()->cart->empty_cart() or WC()->session->set() after payment processing if (! WC()->cart) { WC()->initialize_cart(); } // Initialize session if not exists if (! WC()->session || ! WC()->session instanceof \WC_Session) { WC()->initialize_session(); } $gateways = WC()->payment_gateways->payment_gateways(); // Extract base gateway ID (handle channels like bacs_account_0) $gateway_id = $payment_method; if (strpos($payment_method, '_') !== false) { $parts = explode('_', $payment_method, 2); // Check if base gateway exists if (isset($gateways[$parts[0]])) { $gateway_id = $parts[0]; } } // Check if gateway exists if (! isset($gateways[$gateway_id])) { return new \WP_Error('gateway_not_found', sprintf(__('Payment gateway not found: %s', 'woonoow'), $gateway_id)); } $gateway = $gateways[$gateway_id]; // Check if gateway has process_payment method if (! method_exists($gateway, 'process_payment')) { return new \WP_Error('no_process_method', sprintf(__('Gateway does not support payment processing: %s', 'woonoow'), $gateway_id)); } try { // Mark as admin-created for gateways that need to know $order->update_meta_data('_woonoow_admin_created', true); $order->save(); // Set flag for gateways to detect admin context add_filter('woonoow/is_admin_order', '__return_true'); // Call gateway's process_payment method $result = $gateway->process_payment($order->get_id()); // Clean up filter remove_filter('woonoow/is_admin_order', '__return_true'); // Store result metadata if (is_array($result)) { if (isset($result['redirect'])) { $order->update_meta_data('_woonoow_payment_redirect', $result['redirect']); } if (isset($result['result']) && $result['result'] === 'success') { $order->add_order_note(__('Payment gateway processing completed via WooNooW', 'woonoow')); } elseif (isset($result['result']) && $result['result'] === 'failure') { $message = isset($result['message']) ? $result['message'] : __('Payment processing failed', 'woonoow'); $order->add_order_note(sprintf(__('Payment gateway error: %s', 'woonoow'), $message)); } $order->save(); } return $result; } catch (\Throwable $e) { $order->add_order_note(sprintf(__('Payment gateway exception: %s', 'woonoow'), $e->getMessage())); $order->save(); return new \WP_Error('gateway_exception', $e->getMessage()); } } /** * Get payment gateway metadata for display * Extracts common payment metadata like VA number, QR code, expiry, etc. * * @param \WC_Order $order Order object * @return array Payment metadata */ private static function get_payment_metadata($order): array { $meta = []; // Common metadata keys used by Indonesian payment gateways $meta_keys = apply_filters('woonoow/payment_meta_keys', [ // Tripay '_tripay_payment_pay_code' => __('Payment Code', 'woonoow'), '_tripay_payment_reference' => __('Reference', 'woonoow'), '_tripay_payment_expired_time' => __('Expires At', 'woonoow'), '_tripay_payment_amount' => __('Amount', 'woonoow'), '_tripay_payment_type' => __('Payment Type', 'woonoow'), // Duitku '_duitku_reference' => __('Reference', 'woonoow'), '_duitku_va_number' => __('VA Number', 'woonoow'), '_duitku_payment_url' => __('Payment URL', 'woonoow'), // Xendit '_xendit_invoice_id' => __('Invoice ID', 'woonoow'), '_xendit_invoice_url' => __('Invoice URL', 'woonoow'), '_xendit_expiry_date' => __('Expires At', 'woonoow'), // WooNooW '_woonoow_payment_redirect' => __('Payment URL', 'woonoow'), '_woonoow_admin_created' => __('Created via Admin', 'woonoow'), ], $order); foreach ($meta_keys as $key => $label) { $value = $order->get_meta($key); if (! empty($value)) { // Format timestamps if (strpos($key, 'expired_time') !== false || strpos($key, 'expiry') !== false) { if (is_numeric($value)) { $value = date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $value); } } // Format amounts (currency) if (strpos($key, 'amount') !== false && is_numeric($value)) { $value = wc_price($value, ['currency' => $order->get_currency()]); } // Format booleans if (is_bool($value)) { $value = $value ? __('Yes', 'woonoow') : __('No', 'woonoow'); } $meta[] = [ 'key' => $key, 'label' => $label, 'value' => $value, ]; } } return $meta; } /** * POST /woonoow/v1/orders/{id}/retry-payment * Retry payment gateway processing for an order */ public static function retry_payment(WP_REST_Request $req): WP_REST_Response { if (! current_user_can('manage_woocommerce')) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $id = absint($req['id']); if (! $id) { return new WP_REST_Response(['error' => 'invalid_id'], 400); } $order = wc_get_order($id); if (! $order) { return new WP_REST_Response(['error' => 'not_found'], 404); } $payment_method = $order->get_payment_method(); if (empty($payment_method)) { return new WP_REST_Response([ 'error' => 'no_payment_method', 'message' => __('Order has no payment method', 'woonoow') ], 400); } // Only allow retry for pending/on-hold orders $status = $order->get_status(); if (! in_array($status, ['pending', 'on-hold', 'failed'])) { return new WP_REST_Response([ 'error' => 'invalid_status', 'message' => sprintf( __('Cannot retry payment for order with status: %s', 'woonoow'), $status ) ], 400); } // Add order note $order->add_order_note(__('Payment retry requested via WooNooW Admin', 'woonoow')); // Block WooCommerce analytics tracking during ALL save operations (prevents 30s delay) // This needs to wrap the entire operation because process_payment_gateway() also calls save() add_filter('pre_http_request', function ($preempt, $args, $url) { if (strpos($url, 'pixel.wp.com') !== false || strpos($url, 'stats.wp.com') !== false) { return new \WP_Error('http_request_blocked', 'WooCommerce analytics blocked'); } return $preempt; }, PHP_INT_MAX, 3); $order->save(); // Trigger payment processing and capture result (this also calls save() internally) $result = self::process_payment_gateway($order, $payment_method); // Remove filter after ALL operations complete remove_all_filters('pre_http_request'); // Check if payment processing failed if (is_wp_error($result)) { return new WP_REST_Response([ 'error' => 'payment_failed', 'message' => $result->get_error_message() ], 400); } return new WP_REST_Response([ 'success' => true, 'message' => __('Payment processing retried', 'woonoow') ], 200); } /** * POST /woonoow/v1/coupons/validate * Validate a coupon code and calculate discount */ public static function validate_coupon(WP_REST_Request $req): WP_REST_Response { if (! current_user_can('manage_woocommerce')) { return new WP_REST_Response(['error' => 'forbidden'], 403); } $code = sanitize_text_field($req['code']); if (empty($code)) { return new WP_REST_Response([ 'valid' => false, 'error' => __('Coupon code is required', 'woonoow') ], 400); } // Get coupon $coupon = new \WC_Coupon($code); // Check if coupon exists if (! $coupon->get_id()) { return new WP_REST_Response([ 'valid' => false, 'error' => __('Coupon does not exist', 'woonoow') ], 200); } // Check if coupon is valid (not expired, usage limits, etc.) $validation = $coupon->is_valid(); if (is_wp_error($validation)) { return new WP_REST_Response([ 'valid' => false, 'error' => $validation->get_error_message() ], 200); } // Get coupon details $discount_type = $coupon->get_discount_type(); $amount = $coupon->get_amount(); $subtotal = floatval($req['subtotal'] ?? 0); // Calculate discount amount $discount_amount = 0; if ($discount_type === 'percent') { $discount_amount = ($subtotal * $amount) / 100; } elseif ($discount_type === 'fixed_cart') { $discount_amount = min($amount, $subtotal); } elseif ($discount_type === 'fixed_product') { // For product-level discounts, we'd need item details $discount_amount = $amount; } // Format response return new WP_REST_Response([ 'valid' => true, 'code' => $coupon->get_code(), 'discount_type' => $discount_type, 'amount' => $amount, 'discount_amount' => $discount_amount, 'description' => $coupon->get_description(), 'individual_use' => $coupon->get_individual_use(), 'free_shipping' => $coupon->get_free_shipping(), 'minimum_amount' => $coupon->get_minimum_amount(), 'maximum_amount' => $coupon->get_maximum_amount(), 'usage_count' => $coupon->get_usage_count(), 'usage_limit' => $coupon->get_usage_limit(), 'expiry_date' => $coupon->get_date_expires() ? $coupon->get_date_expires()->date('Y-m-d') : null, ], 200); } /** * Get order meta data for API exposure (Level 1 compatibility) * Filters out internal meta unless explicitly allowed * * @param \WC_Order $order * @return array */ private static function get_order_meta_data($order) { $meta_data = []; foreach ($order->get_meta_data() as $meta) { $key = $meta->key; $value = $meta->value; // Skip internal WooCommerce meta (starts with _wc_) if (strpos($key, '_wc_') === 0) { continue; } // Skip WooNooW internal meta if (strpos($key, '_woonoow_') === 0) { continue; } // Public meta (no underscore) - always expose if (strpos($key, '_') !== 0) { $meta_data[$key] = $value; continue; } // Private meta (starts with _) - check if allowed // Core has ZERO defaults - plugins register via filter $allowed_private = apply_filters('woonoow/order_allowed_private_meta', [], $order); if (in_array($key, $allowed_private, true)) { $meta_data[$key] = $value; } } return $meta_data; } /** * Update order meta data from API (Level 1 compatibility) * * @param \WC_Order $order * @param array $meta_updates */ private static function update_order_meta_data($order, $meta_updates) { // Get allowed updatable meta keys // Core has ZERO defaults - plugins register via filter $allowed = apply_filters('woonoow/order_updatable_meta', [], $order); foreach ($meta_updates as $key => $value) { // Skip internal WooCommerce meta if (strpos($key, '_wc_') === 0) { continue; } // Skip WooNooW internal meta if (strpos($key, '_woonoow_') === 0) { continue; } // Public meta (no underscore) - always allow if (strpos($key, '_') !== 0) { $order->update_meta_data($key, $value); continue; } // Private meta - check if allowed if (in_array($key, $allowed, true)) { $order->update_meta_data($key, $value); } } } }