Files
WooNooW/includes/Api/CheckoutController.php
dwindown 28bbce5434 feat: Tax settings + Checkout fields - Full implementation
##  TAX SETTINGS - COMPLETE

### Backend (TaxController.php):
-  GET /settings/tax - Get all tax settings
-  POST /settings/tax/toggle - Enable/disable tax
-  GET /settings/tax/suggested - Smart suggestions based on selling locations
-  POST /settings/tax/rates - Create tax rate
-  PUT /settings/tax/rates/{id} - Update tax rate
-  DELETE /settings/tax/rates/{id} - Delete tax rate

**Predefined Rates:**
- Indonesia: 11% (PPN)
- Malaysia: 6% (SST)
- Singapore: 9% (GST)
- Thailand: 7% (VAT)
- Philippines: 12% (VAT)
- Vietnam: 10% (VAT)
- + Australia, NZ, UK, Germany, France, Italy, Spain, Canada

**Smart Detection:**
- Reads WooCommerce "Selling location(s)" setting
- If specific countries selected → Show those rates
- If sell to all → Show store base country rate
- Zero re-selection needed!

### Frontend (Tax.tsx):
-  Toggle to enable/disable tax
-  Suggested rates card (based on selling locations)
-  Quick "Add Rate" button for suggested rates
-  Tax rates list with Edit/Delete
-  Add/Edit tax rate dialog
-  Display settings (prices include tax, shop/cart display)
-  Link to WooCommerce advanced settings

**User Flow:**
1. Enable tax toggle
2. See: "🇮🇩 Indonesia: 11% (PPN)" [Add Rate]
3. Click Add Rate
4. Done! Tax working.

##  CHECKOUT FIELDS - COMPLETE

### Backend (CheckoutController.php):
-  POST /checkout/fields - Get fields with all filters applied

**Features:**
- Listens to WooCommerce `woocommerce_checkout_fields` filter
- Respects addon hide/show logic:
  - Checks `hidden` class
  - Checks `enabled` flag
  - Checks `hide` class
- Respects digital-only products logic (hides shipping)
- Returns field metadata:
  - required, hidden, type, options, priority
  - Flags custom fields (from addons)
  - Includes validation rules

**How It Works:**
1. Addon adds field via filter
2. API applies all filters
3. Returns fields with metadata
4. Frontend renders dynamically

**Example:**
```php
// Indonesian Shipping Addon
add_filter('woocommerce_checkout_fields', function($fields) {
    $fields['shipping']['shipping_subdistrict'] = [
        'required' => true,
        'type' => 'select',
        'options' => get_subdistricts(),
    ];
    return $fields;
});
```

WooNooW automatically:
- Fetches field
- Sees required=true
- Renders it
- Validates it

## Benefits:

**Tax:**
- Zero learning curve (30 seconds setup)
- No re-selecting countries
- Smart suggestions
- Scales for single/multi-country

**Checkout Fields:**
- Addon responsibility (not hardcoded)
- Works with ANY addon
- Respects hide/show logic
- Preserves digital-only logic
- Future-proof

## Next: Frontend integration for checkout fields
2025-11-10 12:23:44 +07:00

550 lines
22 KiB
PHP
Raw Permalink 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()];
}
// 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
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;
}
}