'POST', 'callback' => [ new self(), 'quote' ], 'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], // consider nonce check later ]); register_rest_route($namespace, '/checkout/submit', [ 'methods' => 'POST', 'callback' => [ new self(), 'submit' ], 'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], // consider capability/nonce ]); register_rest_route($namespace, '/checkout/fields', [ 'methods' => 'POST', 'callback' => [ new self(), 'get_fields' ], 'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], ]); } /** * Build a quote for the given payload: * { * items: [{ product_id, variation_id?, qty, meta?[] }], * billing: {...}, * shipping: {..., ship_to_different?}, * coupons: ["CODE"], * shipping_method: "flat_rate:1" | "free_shipping:3" | ... * } */ public function quote(WP_REST_Request $r): array { $__t0 = microtime(true); $payload = $this->sanitize_payload($r); // Allow a fully accurate quote using Woo's Cart mechanics (slower, but parity) $useAccurate = (bool) apply_filters('woonoow/quote/accurate', false); if ($useAccurate) { $resp = $this->accurate_quote_via_wc_cart($payload); if (!headers_sent()) { header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1)); } return $resp; } // Build a transient cart-like calculation using Woo helpers. // We will sum line totals and attempt shipping/tax estimates. $lines = []; $subtotal = 0.0; $discount = 0.0; // coupons not applied to calculations in this v0 (placeholder) $shipping_total = 0.0; $tax_total = 0.0; foreach ($payload['items'] as $line) { $product = $this->load_product($line); if ($product instanceof WP_Error) { return ['error' => $product->get_error_message()]; } $qty = max(1, (int)($line['qty'] ?? 1)); $price = (float) wc_get_price_to_display($product); $line_total = $price * $qty; $lines[] = [ 'product_id' => $product->get_id(), 'name' => $product->get_name(), 'qty' => $qty, 'price' => $price, 'line_total' => $line_total, ]; $subtotal += $line_total; } // --- simple coupon handling (fast path) --- if (!empty($payload['coupons'])) { foreach ($payload['coupons'] as $code) { $coupon = new \WC_Coupon($code); if ($coupon->get_id()) { $type = $coupon->get_discount_type(); // 'percent', 'fixed_cart', etc. $amount = (float) $coupon->get_amount(); if ($type === 'percent') { $discount += ($amount / 100.0) * $subtotal; } elseif ($type === 'fixed_cart') { $discount += min($amount, $subtotal); } // NOTE: fixed_product & restrictions are ignored in FastQuote } } } $discount = min($discount, $subtotal); // Simple shipping estimate using zones (optional, best-effort). if (!empty($payload['shipping']['postcode']) && !empty($payload['shipping']['country'])) { $shipping_total = $this->estimate_shipping($payload['shipping'], $payload['shipping_method'] ?? null); } $grand = max(0, $subtotal - $discount + $shipping_total + $tax_total); if (!headers_sent()) { header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1)); } return [ 'ok' => true, 'items' => $lines, 'totals' => [ 'subtotal' => wc_format_decimal($subtotal, wc_get_price_decimals()), 'discount_total' => wc_format_decimal($discount, wc_get_price_decimals()), 'shipping_total' => wc_format_decimal($shipping_total, wc_get_price_decimals()), 'tax_total' => wc_format_decimal($tax_total, wc_get_price_decimals()), 'grand_total' => wc_format_decimal($grand, wc_get_price_decimals()), 'currency' => get_woocommerce_currency(), 'currency_symbol' => get_woocommerce_currency_symbol(), '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(), ], ]; } /** * Submit an order: * { * items: [{ product_id, variation_id?, qty, meta?[] }], * billing: {...}, * shipping: {..., ship_to_different?}, * coupons: ["CODE"], * shipping_method: "flat_rate:1", * payment_method: "cod" | "bacs" | ... * } */ public function submit(WP_REST_Request $r): array { $__t0 = microtime(true); $payload = $this->sanitize_payload($r); if (empty($payload['items'])) { return ['error' => __('No items provided', 'woonoow')]; } // Create order $order = wc_create_order(); if (is_wp_error($order)) { return ['error' => $order->get_error_message()]; } // Add items foreach ($payload['items'] as $line) { $product = $this->load_product($line); if (is_wp_error($product)) { return ['error' => $product->get_error_message()]; } $qty = max(1, (int)($line['qty'] ?? 1)); $args = []; if (!empty($line['meta']) && is_array($line['meta'])) { $args['item_meta_array'] = $line['meta']; } $order->add_product($product, $qty, $args); } // Addresses if (!empty($payload['billing'])) { $order->set_address($this->only_address_fields($payload['billing']), 'billing'); } if (!empty($payload['shipping'])) { $ship = $payload['shipping']; // If ship_to_different is false, copy billing if (empty($ship['ship_to_different'])) { $ship = $this->only_address_fields($payload['billing'] ?: []); } $order->set_address($this->only_address_fields($ship), 'shipping'); } // Coupons (best‑effort using Woo Coupon objects) if (!empty($payload['coupons']) && is_array($payload['coupons'])) { foreach ($payload['coupons'] as $code) { try { $coupon = new \WC_Coupon(wc_clean(wp_unslash($code))); if ($coupon->get_id()) { $order->apply_coupon($coupon); } } catch (\Throwable $e) { // ignore invalid in v0 } } } // Shipping (best‑effort estimate) if (!empty($payload['shipping_method'])) { $rate = $this->find_shipping_rate_for_order($order, $payload['shipping_method']); if ($rate instanceof WC_Shipping_Rate) { $item = new \WC_Order_Item_Shipping(); $item->set_props([ 'method_title' => $rate->get_label(), 'method_id' => $rate->get_method_id(), 'instance_id' => $rate->get_instance_id(), 'total' => $rate->get_cost(), 'taxes' => $rate->get_taxes(), ]); $order->add_item($item); } } // Payment method if (!empty($payload['payment_method'])) { $order->set_payment_method($payload['payment_method']); } // Totals $order->calculate_totals(); // Mirror Woo hooks so extensions still work do_action('woocommerce_checkout_create_order', $order->get_id(), $order); do_action('woocommerce_thankyou', $order->get_id()); $order->save(); if (!headers_sent()) { header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1)); } return [ 'ok' => true, 'order_id' => $order->get_id(), 'order_key' => $order->get_order_key(), 'status' => $order->get_status(), 'pay_url' => $order->get_checkout_payment_url(), 'thankyou_url' => $order->get_checkout_order_received_url(), ]; } /** * Get checkout fields with all filters applied * Accepts: { items: [...], is_digital_only?: bool } * Returns fields with required, hidden, etc. based on addons + cart context */ public function get_fields(WP_REST_Request $r): array { $json = $r->get_json_params(); $items = isset($json['items']) && is_array($json['items']) ? $json['items'] : []; $is_digital_only = isset($json['is_digital_only']) ? (bool) $json['is_digital_only'] : false; // Initialize WooCommerce checkout if not already if (!WC()->checkout()) { WC()->initialize_session(); WC()->initialize_cart(); } // Get checkout fields with all filters applied $fields = WC()->checkout()->get_checkout_fields(); $formatted = []; foreach ($fields as $fieldset_key => $fieldset) { foreach ($fieldset as $key => $field) { // Check if field should be hidden $hidden = false; // Hide shipping fields if digital only (your existing logic) if ($is_digital_only && $fieldset_key === 'shipping') { $hidden = true; } // Check if addon/filter explicitly hides this field if (isset($field['class']) && is_array($field['class'])) { if (in_array('hidden', $field['class']) || in_array('hide', $field['class'])) { $hidden = true; } } // Respect 'enabled' flag if set by addons if (isset($field['enabled']) && !$field['enabled']) { $hidden = true; } $formatted[] = [ 'key' => $key, 'fieldset' => $fieldset_key, // billing, shipping, account, order 'type' => $field['type'] ?? 'text', 'label' => $field['label'] ?? '', 'placeholder' => $field['placeholder'] ?? '', 'required' => $field['required'] ?? false, 'hidden' => $hidden, 'class' => $field['class'] ?? [], 'priority' => $field['priority'] ?? 10, 'options' => $field['options'] ?? null, // For select fields 'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields 'autocomplete'=> $field['autocomplete'] ?? '', 'validate' => $field['validate'] ?? [], ]; } } // Sort by priority usort($formatted, function($a, $b) { return $a['priority'] <=> $b['priority']; }); return [ 'ok' => true, 'fields' => $formatted, 'is_digital_only' => $is_digital_only, ]; } /** * Get list of standard WooCommerce field keys */ private function get_standard_field_keys(): array { return [ 'billing_first_name', 'billing_last_name', 'billing_company', 'billing_country', 'billing_address_1', 'billing_address_2', 'billing_city', 'billing_state', 'billing_postcode', 'billing_phone', 'billing_email', 'shipping_first_name', 'shipping_last_name', 'shipping_company', 'shipping_country', 'shipping_address_1', 'shipping_address_2', 'shipping_city', 'shipping_state', 'shipping_postcode', 'order_comments', ]; } /** ----------------- Helpers ----------------- **/ private function accurate_quote_via_wc_cart(array $payload): array { if (!WC()->customer) { WC()->customer = new \WC_Customer(get_current_user_id(), true); } if (!WC()->cart) { WC()->cart = new \WC_Cart(); } // Address context for taxes/shipping rules $ship = !empty($payload['shipping']) ? $payload['shipping'] : $payload['billing']; if (!empty($payload['billing'])) { foreach (['country','state','postcode','city','address_1','address_2'] as $k) { $setter = 'set_billing_' . $k; if (method_exists(WC()->customer, $setter) && isset($payload['billing'][$k])) { WC()->customer->{$setter}(wc_clean($payload['billing'][$k])); } } } if (!empty($ship)) { foreach (['country','state','postcode','city','address_1','address_2'] as $k) { $setter = 'set_shipping_' . $k; if (method_exists(WC()->customer, $setter) && isset($ship[$k])) { WC()->customer->{$setter}(wc_clean($ship[$k])); } } } WC()->customer->save(); WC()->cart->empty_cart(true); foreach ($payload['items'] as $line) { $product = $this->load_product($line); if (is_wp_error($product)) { return ['error' => $product->get_error_message()]; } WC()->cart->add_to_cart($product->get_id(), max(1, (int) ($line['qty'] ?? 1))); } if (!empty($payload['coupons'])) { foreach ($payload['coupons'] as $code) { try { WC()->cart->apply_coupon($code); } catch (\Throwable $e) { // ignore invalid in v0 } } } WC()->cart->calculate_totals(); $subtotal = (float) WC()->cart->get_subtotal(); $discount = (float) WC()->cart->get_discount_total(); $shipping_total = (float) WC()->cart->get_shipping_total(); $tax_total = (float) WC()->cart->get_total_tax(); $grand = (float) WC()->cart->get_total('edit'); $lines = []; foreach (WC()->cart->get_cart() as $ci) { $p = $ci['data']; $qty = (int) $ci['quantity']; $price = (float) wc_get_price_to_display($p); $lines[] = [ 'product_id' => $p->get_id(), 'name' => $p->get_name(), 'qty' => $qty, 'price' => $price, 'line_total' => $price * $qty, ]; } return [ 'ok' => true, 'items' => $lines, 'totals' => [ 'subtotal' => wc_format_decimal($subtotal, wc_get_price_decimals()), 'discount_total' => wc_format_decimal($discount, wc_get_price_decimals()), 'shipping_total' => wc_format_decimal($shipping_total, wc_get_price_decimals()), 'tax_total' => wc_format_decimal($tax_total, wc_get_price_decimals()), 'grand_total' => wc_format_decimal($grand, wc_get_price_decimals()), 'currency' => get_woocommerce_currency(), 'currency_symbol' => get_woocommerce_currency_symbol(), '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(), ], ]; } private function sanitize_payload(WP_REST_Request $r): array { $json = $r->get_json_params(); $items = isset($json['items']) && is_array($json['items']) ? $json['items'] : []; $billing = isset($json['billing']) && is_array($json['billing']) ? $json['billing'] : []; $shipping = isset($json['shipping']) && is_array($json['shipping']) ? $json['shipping'] : []; $coupons = isset($json['coupons']) && is_array($json['coupons']) ? array_map('wc_clean', $json['coupons']) : []; return [ 'items' => array_map(function ($i) { return [ 'product_id' => isset($i['product_id']) ? (int) $i['product_id'] : 0, 'variation_id' => isset($i['variation_id']) ? (int) $i['variation_id'] : 0, 'qty' => isset($i['qty']) ? (int) $i['qty'] : 1, 'meta' => isset($i['meta']) && is_array($i['meta']) ? $i['meta'] : [], ]; }, $items), 'billing' => $billing, 'shipping'=> $shipping, 'coupons' => $coupons, 'shipping_method' => isset($json['shipping_method']) ? wc_clean($json['shipping_method']) : null, 'payment_method' => isset($json['payment_method']) ? wc_clean($json['payment_method']) : null, ]; } private function load_product(array $line) { $pid = (int)($line['variation_id'] ?? 0) ?: (int)($line['product_id'] ?? 0); if (!$pid) { return new WP_Error('bad_item', __('Invalid product id', 'woonoow')); } $product = wc_get_product($pid); if (!$product || !$product->is_purchasable()) { return new WP_Error('bad_item', __('Product not purchasable', 'woonoow')); } return $product; } private function only_address_fields(array $src): array { $keys = ['first_name','last_name','company','address_1','address_2','city','state','postcode','country','email','phone']; $out = []; foreach ($keys as $k) { if (isset($src[$k])) $out[$k] = wc_clean(wp_unslash($src[$k])); } return $out; } private function estimate_shipping(array $address, ?string $chosen_method): float { $country = wc_clean($address['country'] ?? ''); $postcode = wc_clean($address['postcode'] ?? ''); $state = wc_clean($address['state'] ?? ''); $city = wc_clean($address['city'] ?? ''); $cache_key = 'wnw_ship_' . md5(json_encode([$country, $state, $postcode, $city, (string) $chosen_method])); $cached = wp_cache_get($cache_key, 'woonoow'); if ($cached !== false) { return (float) $cached; } if (!$country) return 0.0; $packages = [[ 'destination' => compact('country','state','postcode','city'), 'contents_cost' => 0, // not exact in v0 'contents' => [], 'applied_coupons' => [], 'user' => ['ID' => get_current_user_id()], ]]; $zone = \WC_Shipping_Zones::get_zone_matching_package($packages[0]); $methods = $zone ? $zone->get_shipping_methods(true) : []; $cost = 0.0; foreach ($methods as $method) { if (!empty($chosen_method)) { $id = $method->id . ':' . $method->get_instance_id(); if ($id === $chosen_method && method_exists($method, 'get_rates_for_package')) { $rates = $method->get_rates_for_package($packages[0]); if (!empty($rates)) { $rate = reset($rates); $cost = (float) $rate->get_cost(); break; } } } } wp_cache_set($cache_key, $cost, 'woonoow', 60); // cache for 60 seconds return $cost; } private function find_shipping_rate_for_order(WC_Order $order, string $chosen) { $shipping = $order->get_address('shipping'); $packages = [[ 'destination' => [ 'country' => $shipping['country'] ?? '', 'state' => $shipping['state'] ?? '', 'postcode' => $shipping['postcode'] ?? '', 'city' => $shipping['city'] ?? '', ], 'contents' => [], 'applied_coupons' => [], 'user' => ['ID' => $order->get_user_id()], ]]; $zone = \WC_Shipping_Zones::get_zone_matching_package($packages[0]); if (!$zone) return null; foreach ($zone->get_shipping_methods(true) as $method) { $id = $method->id . ':' . $method->get_instance_id(); if ($id === $chosen && method_exists($method, 'get_rates_for_package')) { $rates = $method->get_rates_for_package($packages[0]); if (!empty($rates)) { return reset($rates); } } } return null; } }