feat(orders): Add WooCommerce-native calculation endpoints

## Problem:
1. No shipping service options (UPS Ground, UPS Express, etc.)
2. Tax not calculated (11% PPN not showing)
3. Manual cost calculation instead of using WooCommerce core

## Root Cause:
Current implementation manually sets shipping costs from static config:
```php
$shipping_cost = $method->get_option( 'cost', 0 );
$ship_item->set_total( $shipping_cost );
```

This doesn't work for:
- Live rate methods (UPS, FedEx) - need dynamic calculation
- Tax calculation - WooCommerce needs proper context
- Service-level rates (UPS Ground vs Express)

## Solution: Use WooCommerce Native Calculation

### New Endpoints:

1. **POST /woonoow/v1/shipping/calculate**
   - Calculates real-time shipping rates
   - Uses WooCommerce cart + customer address
   - Returns all available methods with costs
   - Supports live rate plugins (UPS, FedEx)
   - Returns service-level options

2. **POST /woonoow/v1/orders/preview**
   - Previews order totals before creation
   - Calculates: subtotal, shipping, tax, discounts, total
   - Uses WooCommerce cart engine
   - Respects tax settings and rates
   - Applies coupons correctly

### How It Works:

```php
// Temporarily use WooCommerce cart
WC()->cart->empty_cart();
WC()->cart->add_to_cart( $product_id, $qty );
WC()->customer->set_shipping_address( $address );
WC()->cart->calculate_shipping();
WC()->cart->calculate_totals();

// Get calculated rates
$packages = WC()->shipping()->get_packages();
foreach ( $packages as $package ) {
    $rates = $package['rates']; // UPS Ground, UPS Express, etc.
}

// Get totals with tax
$total = WC()->cart->get_total();
$tax = WC()->cart->get_total_tax();
```

### Benefits:
-  Live shipping rates work
-  Service-level options appear
-  Tax calculated correctly
-  Coupons applied properly
-  Uses WooCommerce core logic
-  No reinventing the wheel

### Next Steps (Frontend):
1. Call `/shipping/calculate` when address changes
2. Show service options in dropdown
3. Call `/orders/preview` to show totals with tax
4. Update UI to display tax breakdown
This commit is contained in:
dwindown
2025-11-10 15:53:58 +07:00
parent a487baa61d
commit 619fe45055

View File

@@ -97,6 +97,31 @@ class OrdersController {
'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<id>\d+)/retry-payment', [
'methods' => 'POST',
@@ -1197,6 +1222,176 @@ class OrdersController {
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 {
// 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 {
// 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 = [];