Files
WooNooW/includes/Api/CheckoutController.php
Dwindi Ramadhana 100f9cce55 feat: implement multiple saved addresses with modal selector in checkout
- Add AddressController with full CRUD API for saved addresses
- Implement address management UI in My Account > Addresses
- Add modal-based address selector in checkout (Tokopedia-style)
- Hide checkout forms when saved address is selected
- Add search functionality in address modal
- Auto-select default addresses on page load
- Fix variable products to show 'Select Options' instead of 'Add to Cart'
- Add admin toggle for multiple addresses feature
- Clean up debug logs and fix TypeScript errors
2025-12-26 01:16:11 +07:00

581 lines
23 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' ],
]);
}
/**
* 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()];
}
// 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']));
}
}
}
// 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);
}
}
// 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));
}
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 - 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']) : [];
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;
}
}