'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 register_rest_route('woonoow/v1', '/products', [ '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] ) ]; } $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(), ]; 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' => (string) ( $billing['first_name'] ?? '' ), 'last_name' => (string) ( $billing['last_name'] ?? '' ), 'email' => (string) ( $billing['email'] ?? '' ), 'phone' => (string) ( $billing['phone'] ?? '' ), 'address_1' => (string) ( $billing['address_1'] ?? '' ), 'address_2' => (string) ( $billing['address_2'] ?? '' ), 'city' => (string) ( $billing['city'] ?? '' ), 'state' => (string) ( $billing['state'] ?? '' ), 'postcode' => (string) ( $billing['postcode'] ?? '' ), 'country' => (string) ( $billing['country'] ?? '' ), ], 'billing' ); } // === Shipping === if ( is_array( $shipping ) ) { $order->set_address( [ 'first_name' => (string) ( $shipping['first_name'] ?? ( $shipping['name'] ?? '' ) ), 'last_name' => (string) ( $shipping['last_name'] ?? '' ), 'phone' => (string) ( $shipping['phone'] ?? '' ), 'address_1' => (string) ( $shipping['address_1'] ?? '' ), 'address_2' => (string) ( $shipping['address_2'] ?? '' ), 'city' => (string) ( $shipping['city'] ?? '' ), 'state' => (string) ( $shipping['state'] ?? '' ), 'postcode' => (string) ( $shipping['postcode'] ?? '' ), 'country' => (string) ( $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 */ } } // 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(); // 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 ) { error_log('[WooNooW] Shutdown hook firing - scheduling email for order #' . $order_id); self::schedule_order_email( $order_id, $status ); error_log('[WooNooW] Email scheduled successfully for order #' . $order_id); }, 999 ); } return new \WP_REST_Response( [ 'ok' => true, 'id' => $order->get_id() ], 200 ); } catch ( \Throwable $e ) { // Log the actual error for debugging error_log('[WooNooW] Order update failed: ' . $e->getMessage()); // 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 ) { error_log('[WooNooW] Skipping auto-schedule during API request for order #' . $order_id); return; } // Schedule email notification with 15s delay self::schedule_order_email( $order_id, $status_to ); error_log('[WooNooW] Order #' . $order_id . ' status changed: ' . $status_from . ' → ' . $status_to . ', email scheduled'); } /** * 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'] ) : ''; $register_member = (bool) ( $p['register_as_member'] ?? 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 is required for a healthy order $required_billing_fields = [ 'first_name' => __( 'Billing first name', 'woonoow' ), 'last_name' => __( 'Billing last name', 'woonoow' ), 'email' => __( 'Billing email', 'woonoow' ), ]; // Address fields only required for physical products if ( $has_physical_product ) { $required_billing_fields['address_1'] = __( 'Billing address', 'woonoow' ); $required_billing_fields['city'] = __( 'Billing city', 'woonoow' ); $required_billing_fields['postcode'] = __( 'Billing postcode', 'woonoow' ); $required_billing_fields['country'] = __( 'Billing country', 'woonoow' ); } foreach ( $required_billing_fields as $field => $label ) { if ( empty( $billing[ $field ] ) ) { /* translators: %s: field label */ $validation_errors[] = sprintf( __( '%s is required', 'woonoow' ), $label ); } } // 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' => (string) ( $billing['first_name'] ?? '' ), 'last_name' => (string) ( $billing['last_name'] ?? '' ), 'email' => (string) ( $billing['email'] ?? '' ), 'phone' => (string) ( $billing['phone'] ?? '' ), 'address_1' => (string) ( $billing['address_1'] ?? '' ), 'address_2' => (string) ( $billing['address_2'] ?? '' ), 'city' => (string) ( $billing['city'] ?? '' ), 'state' => (string) ( $billing['state'] ?? '' ), 'postcode' => (string) ( $billing['postcode'] ?? '' ), 'country' => (string) ( $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' => (string) ( $ship_addr['first_name'] ?? ( $ship_addr['name'] ?? '' ) ), 'last_name' => (string) ( $ship_addr['last_name'] ?? '' ), 'phone' => (string) ( $ship_addr['phone'] ?? '' ), 'address_1' => (string) ( $ship_addr['address_1'] ?? '' ), 'address_2' => (string) ( $ship_addr['address_2'] ?? '' ), 'city' => (string) ( $ship_addr['city'] ?? '' ), 'state' => (string) ( $ship_addr['state'] ?? '' ), 'postcode' => (string) ( $ship_addr['postcode'] ?? '' ), 'country' => (string) ( $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 ) { error_log( '[WooNooW] Coupon error: ' . $e->getMessage() ); } } // 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 if ( ! empty( $billing['first_name'] ) ) $customer->set_billing_first_name( $billing['first_name'] ); if ( ! empty( $billing['last_name'] ) ) $customer->set_billing_last_name( $billing['last_name'] ); if ( ! empty( $billing['email'] ) ) $customer->set_billing_email( $billing['email'] ); if ( ! empty( $billing['phone'] ) ) $customer->set_billing_phone( $billing['phone'] ); if ( ! empty( $billing['address_1'] ) ) $customer->set_billing_address_1( $billing['address_1'] ); if ( ! empty( $billing['city'] ) ) $customer->set_billing_city( $billing['city'] ); if ( ! empty( $billing['state'] ) ) $customer->set_billing_state( $billing['state'] ); if ( ! empty( $billing['postcode'] ) ) $customer->set_billing_postcode( $billing['postcode'] ); if ( ! empty( $billing['country'] ) ) $customer->set_billing_country( $billing['country'] ); // Update shipping address (if provided) if ( ! empty( $shipping ) && is_array( $shipping ) ) { if ( ! empty( $shipping['first_name'] ) ) $customer->set_shipping_first_name( $shipping['first_name'] ); if ( ! empty( $shipping['last_name'] ) ) $customer->set_shipping_last_name( $shipping['last_name'] ); if ( ! empty( $shipping['address_1'] ) ) $customer->set_shipping_address_1( $shipping['address_1'] ); if ( ! empty( $shipping['city'] ) ) $customer->set_shipping_city( $shipping['city'] ); if ( ! empty( $shipping['state'] ) ) $customer->set_shipping_state( $shipping['state'] ); if ( ! empty( $shipping['postcode'] ) ) $customer->set_shipping_postcode( $shipping['postcode'] ); if ( ! empty( $shipping['country'] ) ) $customer->set_shipping_country( $shipping['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 if ( ! empty( $billing['first_name'] ) ) $customer->set_billing_first_name( $billing['first_name'] ); if ( ! empty( $billing['last_name'] ) ) $customer->set_billing_last_name( $billing['last_name'] ); if ( ! empty( $billing['email'] ) ) $customer->set_billing_email( $billing['email'] ); if ( ! empty( $billing['phone'] ) ) $customer->set_billing_phone( $billing['phone'] ); if ( ! empty( $billing['address_1'] ) ) $customer->set_billing_address_1( $billing['address_1'] ); if ( ! empty( $billing['city'] ) ) $customer->set_billing_city( $billing['city'] ); if ( ! empty( $billing['state'] ) ) $customer->set_billing_state( $billing['state'] ); if ( ! empty( $billing['postcode'] ) ) $customer->set_billing_postcode( $billing['postcode'] ); if ( ! empty( $billing['country'] ) ) $customer->set_billing_country( $billing['country'] ); // Shipping address (if provided) if ( ! empty( $shipping ) && is_array( $shipping ) ) { if ( ! empty( $shipping['first_name'] ) ) $customer->set_shipping_first_name( $shipping['first_name'] ); if ( ! empty( $shipping['last_name'] ) ) $customer->set_shipping_last_name( $shipping['last_name'] ); if ( ! empty( $shipping['address_1'] ) ) $customer->set_shipping_address_1( $shipping['address_1'] ); if ( ! empty( $shipping['city'] ) ) $customer->set_shipping_city( $shipping['city'] ); if ( ! empty( $shipping['state'] ) ) $customer->set_shipping_state( $shipping['state'] ); if ( ! empty( $shipping['postcode'] ) ) $customer->set_shipping_postcode( $shipping['postcode'] ); if ( ! empty( $shipping['country'] ) ) $customer->set_shipping_country( $shipping['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 error_log('[WooNooW] Order creation failed: ' . $e->getMessage()); // 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 ) ) ); $args = [ 'limit' => $limit, 'status' => 'publish' ]; if ( $s ) { $args['s'] = $s; } $prods = wc_get_products( $args ); $rows = array_map( function( $p ) { return [ 'id' => $p->get_id(), 'name' => $p->get_name(), '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(), ]; }, $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 ) ) { 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'] ?? '' ); WC()->customer->set_shipping_address( $shipping['address_1'] ?? '' ); WC()->customer->set_shipping_address_2( $shipping['address_2'] ?? '' ); } // Calculate shipping WC()->cart->calculate_shipping(); WC()->cart->calculate_totals(); // Get available shipping packages and rates $packages = WC()->shipping()->get_packages(); $methods = []; foreach ( $packages as $package_key => $package ) { $rates = $package['rates'] ?? []; foreach ( $rates as $rate_id => $rate ) { /** @var \WC_Shipping_Rate $rate */ $methods[] = [ '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(), ]; } } // Clean up WC()->cart->empty_cart(); return new \WP_REST_Response( [ 'methods' => $methods, 'has_methods' => ! empty( $methods ), ], 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') && WC()->payment_gateways ) { $all_gateways = WC()->payment_gateways->payment_gateways(); // First, check if it's a direct gateway ID (e.g., tripay_bniva, bacs, cod) if ( isset( $all_gateways[ $payment_method_id ] ) ) { $gateway = $all_gateways[ $payment_method_id ]; // Check if this gateway has channels $channels = apply_filters( 'woonoow/payment_gateway_channels', [], $payment_method_id, $gateway ); // If no channels, return gateway title if ( empty( $channels ) ) { return $gateway->get_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 ) { // Extract gateway ID (e.g., "bacs" from "bacs_dwindi-ramadhana_0") $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 ] ) ) { error_log( '[WooNooW] Payment gateway not found: ' . $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' ) ) { error_log( '[WooNooW] Gateway does not have process_payment method: ' . $gateway_id ); 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' ); error_log( '[WooNooW] Processing payment for order #' . $order->get_id() . ' with gateway: ' . $gateway_id ); // 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' ) ); error_log( '[WooNooW] Payment processing succeeded for order #' . $order->get_id() ); } 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 ) ); error_log( '[WooNooW] Payment processing failed for order #' . $order->get_id() . ': ' . $message ); } $order->save(); } return $result; } catch ( \Throwable $e ) { error_log( '[WooNooW] Payment processing exception for order #' . $order->get_id() . ': ' . $e->getMessage() ); $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 ); } }