Files
WooNooW/includes/Api/CheckoutController.php
2026-01-29 11:54:42 +07:00

1105 lines
44 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace WooNooW\Api;
use WP_Error;
use WP_REST_Request;
use WC_Order;
use WC_Product;
use WC_Shipping_Zones;
use WC_Shipping_Rate;
if (!defined('ABSPATH')) {
exit;
}
class CheckoutController
{
/**
* Register REST routes for checkout quote & submit
*/
public static function register()
{
$namespace = 'woonoow/v1';
register_rest_route($namespace, '/checkout/quote', [
'methods' => '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'],
]);
// Public countries endpoint for customer checkout form
register_rest_route($namespace, '/countries', [
'methods' => 'GET',
'callback' => [new self(), 'get_countries'],
'permission_callback' => '__return_true', // Public - needed for checkout
]);
// Public order view endpoint for thank you page
register_rest_route($namespace, '/checkout/order/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [new self(), 'get_order'],
'permission_callback' => '__return_true', // Public, validated via order_key
'args' => [
'key' => [
'type' => 'string',
'required' => false,
],
],
]);
// Get available shipping rates for given address
register_rest_route($namespace, '/checkout/shipping-rates', [
'methods' => 'POST',
'callback' => [new self(), 'get_shipping_rates'],
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'],
]);
// Process payment for an existing order (e.g. renewal)
register_rest_route($namespace, '/checkout/pay-order/(?P<id>\d+)', [
'methods' => 'POST',
'callback' => [new self(), 'pay_order'],
'permission_callback' => '__return_true', // Validated via order key/owner in method
]);
}
/**
* 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(),
],
];
}
/**
* Public order view endpoint for thank you page
* Validates access via order_key (for guests) or logged-in customer ID
* GET /checkout/order/{id}?key=wc_order_xxx
*/
public function get_order(WP_REST_Request $r): array
{
$order_id = absint($r['id']);
$order_key = sanitize_text_field($r->get_param('key') ?? '');
if (!$order_id) {
return ['error' => __('Invalid order ID', 'woonoow')];
}
$order = wc_get_order($order_id);
if (!$order) {
return ['error' => __('Order not found', 'woonoow')];
}
// Validate access: order_key must match OR user must be logged in and own the order (or be admin)
$valid_key = $order_key && hash_equals($order->get_order_key(), $order_key);
$valid_owner = is_user_logged_in() && (
get_current_user_id() === $order->get_customer_id() ||
current_user_can('manage_woocommerce')
);
if (!$valid_key && !$valid_owner) {
return ['error' => __('Unauthorized access to order', 'woonoow')];
}
// Build order items
$items = [];
foreach ($order->get_items() as $item) {
$product = $item->get_product();
$items[] = [
'id' => $item->get_id(),
'product_id' => $product ? $product->get_id() : 0,
'name' => $item->get_name(),
'qty' => (int) $item->get_quantity(),
'price' => (float) $item->get_total() / max(1, $item->get_quantity()),
'total' => (float) $item->get_total(),
'image' => $product ? wp_get_attachment_image_url($product->get_image_id(), 'thumbnail') : null,
];
}
// Build shipping lines
$shipping_lines = [];
foreach ($order->get_shipping_methods() as $shipping_item) {
$shipping_lines[] = [
'id' => $shipping_item->get_id(),
'method_title' => $shipping_item->get_method_title(),
'method_id' => $shipping_item->get_method_id(),
'total' => wc_price($shipping_item->get_total()),
];
}
// Get tracking info from order meta (various plugins use different keys)
$tracking_number = $order->get_meta('_tracking_number')
?: $order->get_meta('_wc_shipment_tracking_items')
?: $order->get_meta('_rajaongkir_awb_number')
?: '';
$tracking_url = $order->get_meta('_tracking_url')
?: $order->get_meta('_rajaongkir_tracking_url')
?: '';
// Check for shipment tracking plugin format (array of tracking items)
if (is_array($tracking_number) && !empty($tracking_number)) {
$first_tracking = reset($tracking_number);
$tracking_number = $first_tracking['tracking_number'] ?? '';
$tracking_url = $first_tracking['tracking_url'] ?? $tracking_url;
}
return [
'ok' => true,
'id' => $order->get_id(),
'number' => $order->get_order_number(),
'status' => $order->get_status(),
'created_via' => $order->get_created_via(),
'subtotal' => (float) $order->get_subtotal(),
'discount_total' => (float) $order->get_discount_total(),
'shipping_total' => (float) $order->get_shipping_total(),
'tax_total' => (float) $order->get_total_tax(),
'total' => (float) $order->get_total(),
'currency' => $order->get_currency(),
'currency_symbol' => get_woocommerce_currency_symbol($order->get_currency()),
'payment_method' => $order->get_payment_method_title(),
'needs_shipping' => count($shipping_lines) > 0 || $order->needs_shipping_address(),
'shipping_lines' => $shipping_lines,
'tracking_number' => $tracking_number,
'tracking_url' => $tracking_url,
'billing' => [
'first_name' => $order->get_billing_first_name(),
'last_name' => $order->get_billing_last_name(),
'email' => $order->get_billing_email(),
'phone' => $order->get_billing_phone(),
],
'items' => $items,
'subscription' => $this->get_subscription_for_response($order),
'available_gateways' => $this->get_available_gateways_for_order($order),
];
}
private function get_subscription_for_response($order)
{
if (!class_exists('\WooNooW\Modules\Subscription\SubscriptionManager')) {
return null;
}
$sub = \WooNooW\Modules\Subscription\SubscriptionManager::get_by_order_id($order->get_id());
if (!$sub) return null;
return [
'id' => (int) $sub->id,
'status' => $sub->status,
'billing_period' => $sub->billing_period,
'billing_interval' => (int) $sub->billing_interval,
'start_date' => $sub->start_date,
'next_payment_date' => $sub->next_payment_date,
'end_date' => $sub->end_date,
'recurring_amount' => (float) $sub->recurring_amount,
];
}
/**
* 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()];
}
// Set customer ID if user is logged in
if (is_user_logged_in()) {
$user_id = get_current_user_id();
$order->set_customer_id($user_id);
// Update user's billing information from checkout data
if (!empty($payload['billing'])) {
$billing = $payload['billing'];
// Update first name and last name
if (!empty($billing['first_name'])) {
update_user_meta($user_id, 'first_name', sanitize_text_field($billing['first_name']));
update_user_meta($user_id, 'billing_first_name', sanitize_text_field($billing['first_name']));
}
if (!empty($billing['last_name'])) {
update_user_meta($user_id, 'last_name', sanitize_text_field($billing['last_name']));
update_user_meta($user_id, 'billing_last_name', sanitize_text_field($billing['last_name']));
}
// Update billing phone
if (!empty($billing['phone'])) {
update_user_meta($user_id, 'billing_phone', sanitize_text_field($billing['phone']));
}
// Update billing email
if (!empty($billing['email'])) {
update_user_meta($user_id, 'billing_email', sanitize_email($billing['email']));
}
}
} else {
// Guest checkout - check if auto-register is enabled
$customer_settings = \WooNooW\Compat\CustomerSettingsProvider::get_settings();
$auto_register = $customer_settings['auto_register_members'] ?? false;
if ($auto_register && !empty($payload['billing']['email'])) {
$email = sanitize_email($payload['billing']['email']);
// Check if user already exists
$existing_user = get_user_by('email', $email);
if ($existing_user) {
// User exists - link order to them
$order->set_customer_id($existing_user->ID);
} else {
// Create new user account
$password = wp_generate_password(12, true, true);
$userdata = [
'user_login' => $email,
'user_email' => $email,
'user_pass' => $password,
'first_name' => sanitize_text_field($payload['billing']['first_name'] ?? ''),
'last_name' => sanitize_text_field($payload['billing']['last_name'] ?? ''),
'display_name' => trim((sanitize_text_field($payload['billing']['first_name'] ?? '') . ' ' . sanitize_text_field($payload['billing']['last_name'] ?? ''))) ?: $email,
'role' => 'customer', // WooCommerce customer role
];
$new_user_id = wp_insert_user($userdata);
if (!is_wp_error($new_user_id)) {
// Link order to new user
$order->set_customer_id($new_user_id);
// Store temp password in user meta for email template
// The real password is already set via wp_insert_user
update_user_meta($new_user_id, '_woonoow_temp_password', $password);
// AUTO-LOGIN: Set authentication cookie so user is logged in after page reload
wp_set_auth_cookie($new_user_id, true);
wp_set_current_user($new_user_id);
// Set WooCommerce customer billing data
$customer = new \WC_Customer($new_user_id);
if (!empty($payload['billing']['first_name'])) $customer->set_billing_first_name(sanitize_text_field($payload['billing']['first_name']));
if (!empty($payload['billing']['last_name'])) $customer->set_billing_last_name(sanitize_text_field($payload['billing']['last_name']));
if (!empty($payload['billing']['email'])) $customer->set_billing_email(sanitize_email($payload['billing']['email']));
if (!empty($payload['billing']['phone'])) $customer->set_billing_phone(sanitize_text_field($payload['billing']['phone']));
if (!empty($payload['billing']['address_1'])) $customer->set_billing_address_1(sanitize_text_field($payload['billing']['address_1']));
if (!empty($payload['billing']['city'])) $customer->set_billing_city(sanitize_text_field($payload['billing']['city']));
if (!empty($payload['billing']['state'])) $customer->set_billing_state(sanitize_text_field($payload['billing']['state']));
if (!empty($payload['billing']['postcode'])) $customer->set_billing_postcode(sanitize_text_field($payload['billing']['postcode']));
if (!empty($payload['billing']['country'])) $customer->set_billing_country(sanitize_text_field($payload['billing']['country']));
$customer->save();
// Send new account email (WooCommerce will handle this automatically via hook)
do_action('woocommerce_created_customer', $new_user_id, $userdata, $password);
}
}
}
}
// 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 (besteffort 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 (besteffort 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);
} elseif (!empty($payload['shipping_cost']) && $payload['shipping_cost'] > 0) {
// Fallback: use shipping_cost directly from frontend
// This handles API-based shipping like Rajaongkir where WC zones don't apply
$item = new \WC_Order_Item_Shipping();
// Parse method ID from shipping_method (format: "method_id:instance_id" or "method_id:instance_id:variant")
$parts = explode(':', $payload['shipping_method']);
$method_id = $parts[0] ?? 'shipping';
$instance_id = isset($parts[1]) ? (int)$parts[1] : 0;
$item->set_props([
'method_title' => sanitize_text_field($payload['shipping_title'] ?? 'Shipping'),
'method_id' => sanitize_text_field($method_id),
'instance_id' => $instance_id,
'total' => floatval($payload['shipping_cost']),
]);
$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 (but not thankyou which outputs HTML)
do_action('woocommerce_checkout_create_order', $order->get_id(), $order);
$order->save();
if (!headers_sent()) {
header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1));
}
// Clear WooCommerce cart after successful order placement
// This ensures the cart page won't re-populate from server session
if (function_exists('WC') && WC()->cart) {
WC()->cart->empty_cart();
}
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(),
];
}
/**
* Process payment for an existing order
* POST /checkout/pay-order/{id}
*/
public function pay_order(WP_REST_Request $r): array
{
$order_id = absint($r['id']);
$order = wc_get_order($order_id);
if (!$order) {
return ['error' => __('Order not found', 'woonoow')];
}
// Validate access
$key = $r->get_param('key'); // optional if logged in
$valid_key = $key && hash_equals($order->get_order_key(), $key);
$valid_owner = is_user_logged_in() && (
get_current_user_id() === $order->get_customer_id() ||
current_user_can('manage_woocommerce')
);
if (!$valid_key && !$valid_owner) {
return ['error' => __('Unauthorized access', 'woonoow')];
}
if ($order->is_paid()) {
return ['error' => __('Order already paid', 'woonoow')];
}
$payment_method = wc_clean($r->get_param('payment_method'));
if (empty($payment_method)) {
return ['error' => __('Payment method required', 'woonoow')];
}
// Update payment method
$available = WC()->payment_gateways()->get_available_payment_gateways();
if (!isset($available[$payment_method])) {
return ['error' => __('Invalid payment method', 'woonoow')];
}
$gateway = $available[$payment_method];
$order->set_payment_method($gateway);
$order->save();
// Process payment
$result = $gateway->process_payment($order_id);
if (isset($result['result']) && $result['result'] === 'success') {
return [
'ok' => true,
'redirect' => $result['redirect'] ?? $order->get_checkout_order_received_url(),
];
}
return [
'error' => __('Payment failed', 'woonoow') . (isset($result['result']) ? ': ' . $result['result'] : ''),
'messages' => wc_get_notices('error'),
];
}
/**
* 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'] ?? [],
// New fields for dynamic rendering
'input_class' => $field['input_class'] ?? [],
'custom_attributes' => $field['custom_attributes'] ?? [],
'default' => $field['default'] ?? '',
// For searchable_select type
'search_endpoint' => $field['search_endpoint'] ?? null,
'search_param' => $field['search_param'] ?? 'search',
'min_chars' => $field['min_chars'] ?? 2,
];
}
}
// 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
* Plugins can extend this list via the 'woonoow_standard_checkout_field_keys' filter
*/
private function get_standard_field_keys(): array
{
$keys = [
'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',
];
/**
* Filter the list of standard checkout field keys.
* Plugins can add their own field keys to be recognized as "standard" (not custom).
*
* @param array $keys List of standard field keys
*/
return apply_filters('woonoow_standard_checkout_field_keys', $keys);
}
/** ----------------- 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 - set temporarily without saving to user profile
$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]));
}
}
}
// DO NOT save customer data - only use for quote calculation
// WC()->customer->save(); // REMOVED - should not update user profile during checkout
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']) : [];
$custom_fields = isset($json['custom_fields']) && is_array($json['custom_fields']) ? $json['custom_fields'] : [];
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,
// NEW: Added missing fields that were causing shipping to not be applied
'shipping_cost' => isset($json['shipping_cost']) ? (float) $json['shipping_cost'] : null,
'shipping_title' => isset($json['shipping_title']) ? sanitize_text_field($json['shipping_title']) : null,
'custom_fields' => $custom_fields,
'customer_note' => isset($json['customer_note']) ? sanitize_textarea_field($json['customer_note']) : '',
];
}
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;
}
/**
* Get countries and states for checkout form
* Public endpoint - no authentication required
*/
public function get_countries(): array
{
$wc_countries = WC()->countries;
// Get allowed selling countries
$allowed = $wc_countries->get_allowed_countries();
// Format for frontend
$countries = [];
foreach ($allowed as $code => $name) {
$countries[] = [
'code' => $code,
'name' => $name,
];
}
// Get states for all allowed countries
$states = [];
foreach (array_keys($allowed) as $country_code) {
$country_states = $wc_countries->get_states($country_code);
if (!empty($country_states) && is_array($country_states)) {
$states[$country_code] = $country_states;
}
}
// Get default country
$default_country = $wc_countries->get_base_country();
return [
'countries' => $countries,
'states' => $states,
'default_country' => $default_country,
];
}
/**
* Get available shipping rates for given address
* POST /checkout/shipping-rates
* Body: { shipping: { country, state, city, postcode, destination_id? }, items: [...] }
*/
public function get_shipping_rates(WP_REST_Request $r): array
{
$payload = $r->get_json_params();
$shipping = $payload['shipping'] ?? [];
$items = $payload['items'] ?? [];
$country = wc_clean($shipping['country'] ?? '');
$state = wc_clean($shipping['state'] ?? '');
$city = wc_clean($shipping['city'] ?? '');
$postcode = wc_clean($shipping['postcode'] ?? '');
if (empty($country)) {
return [
'ok' => true,
'rates' => [],
'message' => 'Country is required',
];
}
// Trigger hook for plugins to set session data (e.g., Rajaongkir destination_id)
do_action('woonoow/shipping/before_calculate', $shipping, $items);
// Set customer location for shipping calculation
if (WC()->customer) {
WC()->customer->set_shipping_country($country);
WC()->customer->set_shipping_state($state);
WC()->customer->set_shipping_city($city);
WC()->customer->set_shipping_postcode($postcode);
}
// Build package for shipping calculation
$contents = [];
$contents_cost = 0;
foreach ($items as $item) {
$product = wc_get_product($item['product_id'] ?? 0);
if (!$product) continue;
$qty = max(1, (int)($item['quantity'] ?? $item['qty'] ?? 1));
$price = (float) wc_get_price_to_display($product);
$contents[] = [
'data' => $product,
'quantity' => $qty,
'line_total' => $price * $qty,
];
$contents_cost += $price * $qty;
}
$package = [
'destination' => [
'country' => $country,
'state' => $state,
'city' => $city,
'postcode' => $postcode,
],
'contents' => $contents,
'contents_cost' => $contents_cost,
'applied_coupons' => [],
'user' => ['ID' => get_current_user_id()],
];
// Get matching shipping zone
$zone = WC_Shipping_Zones::get_zone_matching_package($package);
if (!$zone) {
return [
'ok' => true,
'rates' => [],
'message' => 'No shipping zone matches your location',
];
}
// Get enabled shipping methods from zone
$methods = $zone->get_shipping_methods(true);
$rates = [];
foreach ($methods as $method) {
// Check if method has rates (some methods like live rate need to calculate)
if (method_exists($method, 'get_rates_for_package')) {
$method_rates = $method->get_rates_for_package($package);
foreach ($method_rates as $rate) {
$rates[] = [
'id' => $rate->get_id(),
'label' => $rate->get_label(),
'cost' => (float) $rate->get_cost(),
'method_id' => $rate->get_method_id(),
'instance_id' => $rate->get_instance_id(),
];
}
} else {
// Fallback for simple methods
$method_id = $method->id . ':' . $method->get_instance_id();
$cost = 0;
// Try to get cost from method
if (isset($method->cost)) {
$cost = (float) $method->cost;
} elseif (method_exists($method, 'get_option')) {
$cost = (float) $method->get_option('cost', 0);
}
$rates[] = [
'id' => $method_id,
'label' => $method->get_title(),
'cost' => $cost,
'method_id' => $method->id,
'instance_id' => $method->get_instance_id(),
];
}
}
return [
'ok' => true,
'rates' => $rates,
'zone_name' => $zone->get_zone_name(),
];
}
private function get_available_gateways_for_order(WC_Order $order): array
{
// Mock cart for gateways that check cart total
if (!WC()->cart) {
WC()->initialize_cart();
}
// We can't easily bake the order into the cart, but many gateways just check 'needs_payment'
// or country.
$gateways = WC()->payment_gateways()->get_available_payment_gateways();
$results = [];
foreach ($gateways as $gateway) {
$results[] = [
'id' => $gateway->id,
'title' => $gateway->get_title() ?: $gateway->method_title ?: ucfirst($gateway->id), // Fallbacks
'description' => $gateway->get_description(),
'icon' => $gateway->get_icon(),
];
}
return $results;
}
}