Files
WooNooW/includes/Api/OrdersController.php
dwindown 03ef9e3f24 docs: Document Rajaongkir integration issue and add session support
## Discovery 

Rajaongkir plugin uses a completely different approach:
- Removes standard WooCommerce city/state fields
- Adds custom destination dropdown with Select2 search
- Stores destination in WooCommerce session (not address fields)
- Reads from session during shipping calculation

## Root Cause of Issues:

### 1. Same rates for different provinces
- OrderForm sends: city="Bandung", state="Jawa Barat"
- Rajaongkir ignores these fields
- Rajaongkir reads: WC()->session->get("selected_destination_id")
- Session empty → Uses cached/default rates

### 2. No Rajaongkir API hits
- No destination_id in session
- Rajaongkir can't calculate without destination
- Returns empty or cached rates

## Backend Fix ( DONE):

Added Rajaongkir session support in calculate_shipping:
```php
// Support for Rajaongkir plugin
if ( $country === 'ID' && ! empty( $shipping['destination_id'] ) ) {
    WC()->session->set( 'selected_destination_id', $shipping['destination_id'] );
    WC()->session->set( 'selected_destination_label', $shipping['destination_label'] );
}
```

## Frontend Fix (TODO):

Need to add Rajaongkir destination field:
1. Add destination search component (Select2/Combobox)
2. Search Rajaongkir API for locations
3. Pass destination_id to backend
4. Backend sets session before calculate_shipping()

## Documentation:

Created RAJAONGKIR_INTEGRATION.md with:
- How Rajaongkir works
- Why our implementation fails
- Complete solution steps
- Testing checklist

## Next Steps:

1. Add Rajaongkir search endpoint to OrdersController
2. Create destination search component in OrderForm
3. Pass destination_id in shipping data
4. Test with real Rajaongkir API
2025-11-10 18:56:41 +07:00

2114 lines
85 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_REST_Request;
use WP_REST_Response;
use Automattic\WooCommerce\Utilities\OrderUtil;
class OrdersController {
public static function init() {
// Register action hook for delayed email sending
add_action( 'woonoow/send_order_email', [ __CLASS__, 'send_order_email' ], 10, 1 );
// Hook into order status changes to schedule our delayed emails
add_action( 'woocommerce_order_status_changed', [ __CLASS__, 'on_order_status_changed' ], 10, 4 );
}
public static function register() {
register_rest_route('woonoow/v1', '/orders', [
'methods' => 'GET',
'callback' => [__CLASS__, 'index'],
'permission_callback' => function () { return current_user_can('manage_woocommerce'); },
'args' => [
'status' => ['type' => 'string', 'required' => false], // comma-separated
'page' => ['type' => 'integer', 'default' => 1],
'per_page' => ['type' => 'integer', 'default' => 25],
'date_start' => ['type' => 'string', 'required' => false], // YYYY-MM-DD or ISO8601
'date_end' => ['type' => 'string', 'required' => false],
'orderby' => ['type' => 'string', 'default' => 'date'],
'order' => ['type' => 'string', 'default' => 'desc'], // asc|desc
],
]);
register_rest_route('woonoow/v1', '/orders/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'show'],
'permission_callback' => function () { return current_user_can('manage_woocommerce'); },
]);
register_rest_route('woonoow/v1', '/orders/(?P<id>\d+)', [
'methods' => 'PATCH',
'callback' => [__CLASS__, 'update'],
'permission_callback' => function () { return current_user_can('manage_woocommerce'); },
'args' => [
'items' => [ 'type' => 'array', 'required' => false ],
'billing' => [ 'type' => 'object', 'required' => false ],
'shipping' => [ 'type' => 'object', 'required' => false ],
'status' => [ 'type' => 'string', 'required' => false ],
'payment_method' => [ 'type' => 'string', 'required' => false ],
'shipping_method' => [ 'type' => 'string', 'required' => false ],
'coupons' => [ 'type' => 'array', 'required' => false ],
'note' => [ 'type' => 'string', 'required' => false ],
],
]);
register_rest_route('woonoow/v1', '/orders', [
'methods' => 'POST',
'callback' => [__CLASS__, 'create'],
'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 ],
'status' => [ 'type' => 'string', 'required' => false ],
'payment_method' => [ 'type' => 'string', 'required' => false ],
'shipping_method' => [ 'type' => 'string', 'required' => false ], // e.g. flat_rate:1
'coupons' => [ 'type' => 'array', 'required' => false ], // array of codes
'note' => [ 'type' => 'string', 'required' => false ],
],
]);
register_rest_route('woonoow/v1', '/orders/(?P<id>\d+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'delete'],
'permission_callback' => function () { return current_user_can('manage_woocommerce'); },
]);
// Lightweight product search to help build orders from admin UI
register_rest_route('woonoow/v1', '/products', [
'methods' => 'GET',
'callback' => [__CLASS__, 'products'],
'permission_callback' => function () { return current_user_can('manage_woocommerce'); },
'args' => [
'search' => [ 'type' => 'string', 'required' => false ],
'limit' => [ 'type' => 'integer', 'default' => 10 ],
],
]);
register_rest_route('woonoow/v1', '/payments', [
'methods' => 'GET',
'callback' => [__CLASS__, 'payments'],
'permission_callback' => function () { return current_user_can('manage_woocommerce'); },
]);
register_rest_route('woonoow/v1', '/shippings', [
'methods' => 'GET',
'callback' => [__CLASS__, 'shippings'],
'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',
'callback' => [__CLASS__, 'retry_payment'],
'permission_callback' => function () { return current_user_can('manage_woocommerce'); },
]);
// Validate coupon code
register_rest_route('woonoow/v1', '/coupons/validate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'validate_coupon'],
'permission_callback' => function () { return current_user_can('manage_woocommerce'); },
'args' => [
'code' => [
'required' => true,
'type' => 'string',
],
'items' => [
'required' => false,
'type' => 'array',
],
'subtotal' => [
'required' => false,
'type' => 'number',
],
],
]);
register_rest_route('woonoow/v1', '/countries', [
'methods' => 'GET',
'callback' => [__CLASS__, 'countries'],
'permission_callback' => function () { return current_user_can('manage_woocommerce'); },
]);
// Customer search for autofill
register_rest_route('woonoow/v1', '/customers/search', [
'methods' => 'GET',
'callback' => [__CLASS__, 'search_customers'],
'permission_callback' => function () { return current_user_can('manage_woocommerce'); },
'args' => [
'email' => [
'required' => false,
'type' => 'string',
],
'search' => [
'required' => false,
'type' => 'string',
],
],
]);
}
/**
* GET /woonoow/v1/orders
*/
public static function index( WP_REST_Request $req ) : WP_REST_Response {
$page = max( 1, absint( $req->get_param( 'page' ) ?? 1 ) );
$per_page = max( 1, min( 200, absint( $req->get_param( 'per_page' ) ?? 20 ) ) );
$orderby = sanitize_key( $req->get_param( 'orderby' ) ?? 'date' );
$order = strtoupper( $req->get_param( 'order' ) ?? 'DESC' );
if ( ! in_array( $orderby, [ 'date', 'id', 'modified', 'total' ], true ) ) $orderby = 'date';
if ( $order !== 'ASC' ) $order = 'DESC';
$status = $req->get_param( 'status' );
// If status is missing, include ALL Woo statuses.
$statuses = $status
? array_map( function( $s ) { $s = sanitize_key( trim( $s ) ); return 'wc-' . preg_replace( '/^wc-/', '', $s ); }, explode( ',', $status ) )
: array_keys( wc_get_order_statuses() );
// --- Build date range (HPOS-safe, string values) ---
$date_start = $req->get_param( 'date_start' );
$date_end = $req->get_param( 'date_end' );
$after_str = null;
$before_str = null;
if ( $date_start ) {
// Expecting YYYY-MM-DD; build 00:00:00 UTC
$after_ts = strtotime( $date_start . ' 00:00:00' );
if ( $after_ts ) $after_str = gmdate( 'Y-m-d H:i:s', $after_ts );
}
if ( $date_end ) {
// Build 23:59:59 UTC
$before_ts = strtotime( $date_end . ' 23:59:59' );
if ( $before_ts ) $before_str = gmdate( 'Y-m-d H:i:s', $before_ts );
}
$date_created = null;
if ( $after_str || $before_str ) {
$date_created = array_filter( [
'after' => $after_str,
'before' => $before_str,
// note: do NOT include 'inclusive' to avoid Woo date arg coercion issues
] );
}
$args = [
'paginate' => true,
'page' => $page,
'limit' => $per_page,
'status' => $statuses,
'orderby' => $orderby === 'total' ? 'total' : $orderby, // Woo supports 'total' in recent versions
'order' => $order,
'return' => 'ids', // HPOS friendly + fast
];
// Guard: Some Woo installs throw when passing array date ranges into OrdersTableQuery.
// To be safe, only pass a single-bound filter directly. For two-sided ranges, we post-filter below.
$pass_single_bound = $date_created && (
( isset($date_created['after']) && ! isset($date_created['before']) ) ||
( isset($date_created['before']) && ! isset($date_created['after']) )
);
if ( $pass_single_bound ) {
$args['date_created'] = $date_created; // single bound only
}
// Optional simple ID search
$search = $req->get_param( 'search' );
if ( $search && preg_match( '/^#?(\\d+)$/', $search, $m ) ) {
$args['include'] = [ absint( $m[1] ) ];
}
$result = wc_get_orders( $args );
// If we had a two-sided date range, filter results manually to avoid OrdersTableQuery fatal.
if ( $date_created && isset($date_created['after']) && isset($date_created['before']) ) {
$after_ts = strtotime( $date_created['after'] );
$before_ts = strtotime( $date_created['before'] );
if ( $after_ts && $before_ts ) {
$result->orders = array_values( array_filter( $result->orders, function( $order_id ) use ( $after_ts, $before_ts ) {
$o = wc_get_order( $order_id );
if ( ! $o || ! $o->get_date_created() ) return false;
$t = $o->get_date_created()->getTimestamp();
return ( $t >= $after_ts && $t <= $before_ts );
}));
$result->total = count( $result->orders );
}
}
$rows = array_map( function( $order_id ) {
$o = wc_get_order( $order_id );
// Customer name: prefer billing first/last; otherwise WP user's display name; otherwise null
$first = trim( (string) $o->get_billing_first_name() );
$last = trim( (string) $o->get_billing_last_name() );
$name = trim( $first . ' ' . $last );
if ( $name === '' ) {
$uid = $o->get_user_id();
if ( $uid ) {
$u = get_userdata( $uid );
if ( $u && ! empty( $u->display_name ) ) {
$name = (string) $u->display_name;
}
}
if ( $name === '' ) {
$name = null; // no email/Guest fallback per requirement
}
}
// Items count (qty-based) and brief label
$items = $o->get_items();
$items_count = 0;
$brief_parts = [];
$i = 0;
foreach ( $items as $it ) {
$qty = (int) $it->get_quantity();
$items_count += $qty;
if ( $i < 2 ) {
$brief_parts[] = sprintf( '%s ×%d', $it->get_name(), $qty );
}
$i++;
}
$items_brief = implode( ', ', $brief_parts );
$lines_total = count( $items );
$lines_preview = min( 2, $lines_total );
if ( $lines_total > 2 ) {
$items_brief .= sprintf( ' +%d more', $lines_total - 2 );
}
// Full list (name ×qty), used by hover card
$full_parts = [];
foreach ( $items as $it_full ) {
$full_parts[] = sprintf( '%s ×%d', $it_full->get_name(), (int) $it_full->get_quantity() );
}
$items_full = implode( ', ', $full_parts );
// Build site-local ISO and epoch seconds for created date
$dt_created = $o->get_date_created();
$date_ts = $dt_created ? $dt_created->getTimestamp() : null; // epoch seconds (UTC)
$tz_obj = wp_timezone();
$tz_str = wp_timezone_string();
$date_iso = null;
if ( $dt_created ) {
$local = clone $dt_created;
$local = $local->setTimezone( $tz_obj );
$date_iso = $local->format( DATE_ATOM ); // e.g. 2025-10-25T00:23:53+07:00
}
return [
'id' => $o->get_id(),
'number' => $o->get_order_number(),
'date' => $date_iso,
'date_ts' => $date_ts,
'tz' => $tz_str,
'status' => $o->get_status(),
'total' => (float) $o->get_total(),
'currency' => $o->get_currency(),
'currency_symbol' => get_woocommerce_currency_symbol( $o->get_currency() ),
'customer' => $name,
'items_count' => $items_count,
'items_brief' => $items_brief,
'items_full' => $items_full,
'lines_total' => $lines_total,
'lines_preview' => $lines_preview,
];
}, $result->orders );
return new WP_REST_Response( [
'rows' => $rows,
'total' => (int) $result->total,
'page' => (int) $page,
'per_page' => (int) $per_page,
], 200 );
}
/**
* GET /woonoow/v1/orders/{id}
*/
public static function show( WP_REST_Request $req ) : WP_REST_Response {
$id = absint( $req['id'] );
if ( ! $id ) return new WP_REST_Response( [ 'error' => 'invalid_id' ], 400 );
$order = wc_get_order( $id );
if ( ! $order ) return new WP_REST_Response( [ 'error' => 'not_found' ], 404 );
$items = [];
foreach ( $order->get_items() as $item ) {
/** @var \WC_Order_Item_Product $item */
$product = $item->get_product();
$qty = (int) $item->get_quantity();
$subtotal = (float) $item->get_subtotal();
$total = (float) $item->get_total();
$product_id = $product ? $product->get_id() : 0;
$unit_price = $qty > 0 ? (float) ( $subtotal / $qty ) : 0.0;
$items[] = [
'line_item_id' => $item->get_id(),
'id' => $item->get_id(),
'product_id' => $product_id,
'name' => $item->get_name(),
'sku' => $product ? $product->get_sku() : '',
'qty' => $qty,
'price' => $unit_price,
'subtotal' => $subtotal,
'total' => $total,
'subtotal_fmt' => wc_price( $subtotal, [ 'currency' => $order->get_currency() ] ),
'total_fmt' => wc_price( $total, [ 'currency' => $order->get_currency() ] ),
'virtual' => $product ? $product->is_virtual() : false,
'downloadable' => $product ? $product->is_downloadable() : false,
'regular_price' => $product ? (float) $product->get_regular_price() : 0,
'sale_price' => $product && $product->get_sale_price() ? (float) $product->get_sale_price() : null,
];
}
$notes = [];
foreach ( wc_get_order_notes( [ 'order_id' => $id, 'orderby' => 'date_created', 'order' => 'DESC' ] ) as $note ) {
$notes[] = [
'id' => $note->id,
'content' => $note->content,
'is_customer_note' => (bool) $note->customer_note,
'date' => $note->date_created ? $note->date_created->date( DATE_ATOM ) : null,
];
}
$coupon_codes = [];
foreach ( $order->get_items( 'coupon' ) as $citem ) {
$coupon_codes[] = (string) $citem->get_code();
}
$dt_created = $order->get_date_created();
$date_ts = $dt_created ? $dt_created->getTimestamp() : null;
$tz_obj = wp_timezone();
$tz_str = wp_timezone_string();
$date_iso = null;
if ( $dt_created ) {
$local = clone $dt_created;
$local = $local->setTimezone( $tz_obj );
$date_iso = $local->format( DATE_ATOM );
}
// Defensive address formatting (Woo helpers may not be loaded in rare contexts)
$billing_addr_arr = $order->get_address( 'billing' );
$shipping_addr_arr = $order->get_address( 'shipping' );
// Build raw billing/shipping fields for editing
$billing_fields = [
'first_name' => (string) $order->get_billing_first_name(),
'last_name' => (string) $order->get_billing_last_name(),
'email' => (string) $order->get_billing_email(),
'phone' => (string) $order->get_billing_phone(),
'address_1' => (string) $order->get_billing_address_1(),
'address_2' => (string) $order->get_billing_address_2(),
'city' => (string) $order->get_billing_city(),
'state' => (string) $order->get_billing_state(),
'postcode' => (string) $order->get_billing_postcode(),
'country' => (string) $order->get_billing_country(),
];
if ( $billing_fields['first_name'] === '' && $billing_fields['last_name'] === '' ) {
$uid = $order->get_user_id();
if ( $uid ) {
$u = get_userdata( $uid );
if ( $u && ! empty( $u->display_name ) ) {
$parts = preg_split('/\s+/', trim( (string) $u->display_name ), 2);
$billing_fields['first_name'] = (string) ( $parts[0] ?? '' );
$billing_fields['last_name'] = (string) ( $parts[1] ?? '' );
}
}
}
$shipping_fields = [
'first_name' => (string) $order->get_shipping_first_name(),
'last_name' => (string) $order->get_shipping_last_name(),
'phone' => '', // Woo core doesn't store shipping phone by default
'address_1' => (string) $order->get_shipping_address_1(),
'address_2' => (string) $order->get_shipping_address_2(),
'city' => (string) $order->get_shipping_city(),
'state' => (string) $order->get_shipping_state(),
'postcode' => (string) $order->get_shipping_postcode(),
'country' => (string) $order->get_shipping_country(),
];
if ( function_exists('wc') && is_object( wc() ) && isset( wc()->countries ) && method_exists( wc()->countries, 'get_formatted_address' ) ) {
$billing_addr_html = wc()->countries->get_formatted_address( $billing_addr_arr );
$shipping_addr_html = wc()->countries->get_formatted_address( $shipping_addr_arr );
} elseif ( function_exists('wc_format_address') ) {
$billing_addr_html = \wc_format_address( $billing_addr_arr );
$shipping_addr_html = \wc_format_address( $shipping_addr_arr );
} else {
// Fallback: simple HTML join
$fmt = function( $a ) {
$parts = [
trim( ($a['first_name'] ?? '') . ' ' . ($a['last_name'] ?? '') ),
$a['address_1'] ?? '',
$a['address_2'] ?? '',
trim( (($a['city'] ?? '')) . ( ($a['state']??'') ? ', ' . $a['state'] : '' ) . ( ($a['postcode']??'') ? ' ' . $a['postcode'] : '' ) ),
$a['country'] ?? '',
];
$parts = array_values( array_filter( $parts, fn($v) => $v !== '' ) );
return implode('<br>', $parts);
};
$billing_addr_html = $fmt( $billing_addr_arr );
$shipping_addr_html = $fmt( $shipping_addr_arr );
}
$data = [
'id' => $order->get_id(),
'number' => $order->get_order_number(),
'status' => $order->get_status(),
'currency' => $order->get_currency(),
'currency_symbol' => get_woocommerce_currency_symbol( $order->get_currency() ),
'currency_display' => get_woocommerce_currency_symbol( $order->get_currency() ),
'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(),
'date' => $date_iso,
'date_ts' => $date_ts,
'tz' => $tz_str,
'payment_method' => self::get_payment_method_title($order),
'payment_method_id' => $order->get_payment_method(),
'payment_meta' => self::get_payment_metadata($order),
'shipping_method' => self::get_shipping_method_title($order),
'shipping_method_id' => self::get_shipping_method_id($order),
'totals' => [
'subtotal' => (float) $order->get_subtotal(),
'discount' => (float) $order->get_discount_total(),
'shipping' => (float) $order->get_shipping_total(),
'tax' => (float) $order->get_total_tax(),
'total' => (float) $order->get_total(),
'subtotal_fmt' => wc_price( (float) $order->get_subtotal(), [ 'currency' => $order->get_currency() ] ),
'discount_fmt' => wc_price( (float) $order->get_discount_total(), [ 'currency' => $order->get_currency() ] ),
'shipping_fmt' => wc_price( (float) $order->get_shipping_total(), [ 'currency' => $order->get_currency() ] ),
'tax_fmt' => wc_price( (float) $order->get_total_tax(), [ 'currency' => $order->get_currency() ] ),
'total_fmt' => wc_price( (float) $order->get_total(), [ 'currency' => $order->get_currency() ] ),
],
'billing' => array_merge( $billing_fields, [
'name' => trim( $billing_fields['first_name'] . ' ' . $billing_fields['last_name'] ),
'email' => $billing_fields['email'],
'phone' => $billing_fields['phone'],
'address_html' => $billing_addr_html,
]),
'shipping' => array_merge( $shipping_fields, [
'name' => trim( $shipping_fields['first_name'] . ' ' . $shipping_fields['last_name'] ),
'address_html' => $shipping_addr_html,
]),
'items' => $items,
'notes' => $notes,
'coupons' => $coupon_codes,
'customer_note' => $order->get_customer_note(),
];
return new WP_REST_Response( $data, 200 );
}
/**
* PATCH /woonoow/v1/orders/{id}
* Upserts line items, replaces coupon lines, updates addresses & meta, then recalculates totals.
*/
public static function update( WP_REST_Request $req ) : WP_REST_Response {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return new \WP_REST_Response( [ 'error' => 'forbidden' ], 403 );
}
$id = absint( $req['id'] );
if ( ! $id ) return new \WP_REST_Response( [ 'error' => 'invalid_id' ], 400 );
$order = wc_get_order( $id );
if ( ! $order ) return new \WP_REST_Response( [ 'error' => 'not_found' ], 404 );
$p = $req->get_json_params();
$items = is_array( $p['items'] ?? null ) ? $p['items'] : null; // null means no change
$billing = is_array( $p['billing'] ?? null ) ? $p['billing'] : null;
$shipping = is_array( $p['shipping'] ?? null ) ? $p['shipping'] : null;
$status = array_key_exists('status', $p) ? sanitize_text_field( (string) $p['status'] ) : null;
$payment_method = array_key_exists('payment_method', $p) ? sanitize_text_field( (string) $p['payment_method'] ) : null;
$shipping_method = array_key_exists('shipping_method', $p) ? sanitize_text_field( (string) $p['shipping_method'] ) : null;
$coupons = array_key_exists('coupons', $p) ? array_filter( array_map( 'sanitize_text_field', (array) $p['coupons'] ) ) : null;
$note = array_key_exists('customer_note', $p) ? wp_kses_post( (string) $p['customer_note'] ) : null;
try {
// === Items upsert ===
if ( is_array( $items ) ) {
// Map existing line items by id for quick lookup
$existing_items = $order->get_items( 'line_item' );
$existing_map = [];
foreach ( $existing_items as $it ) { $existing_map[ $it->get_id() ] = $it; }
$keep_ids = [];
foreach ( $items as $row ) {
$li_id = absint( $row['line_item_id'] ?? 0 );
$qty = max( 1, absint( $row['qty'] ?? 1 ) );
if ( $li_id && isset( $existing_map[$li_id] ) ) {
// Update quantity of existing line
$existing_map[$li_id]->set_quantity( $qty );
$keep_ids[] = $li_id;
} else {
$pid = absint( $row['product_id'] ?? 0 );
if ( $pid ) {
$product = wc_get_product( $pid );
if ( $product ) {
$order->add_product( $product, $qty );
}
}
}
}
// Delete removed items (present before, missing now)
foreach ( $existing_items as $it ) {
if ( ! in_array( $it->get_id(), $keep_ids, true ) ) {
$order->remove_item( $it->get_id() );
}
}
}
// === Billing ===
if ( is_array( $billing ) ) {
$order->set_address( [
'first_name' => (string) ( $billing['first_name'] ?? '' ),
'last_name' => (string) ( $billing['last_name'] ?? '' ),
'email' => (string) ( $billing['email'] ?? '' ),
'phone' => (string) ( $billing['phone'] ?? '' ),
'address_1' => (string) ( $billing['address_1'] ?? '' ),
'address_2' => (string) ( $billing['address_2'] ?? '' ),
'city' => (string) ( $billing['city'] ?? '' ),
'state' => (string) ( $billing['state'] ?? '' ),
'postcode' => (string) ( $billing['postcode'] ?? '' ),
'country' => (string) ( $billing['country'] ?? '' ),
], 'billing' );
}
// === Shipping ===
if ( is_array( $shipping ) ) {
$order->set_address( [
'first_name' => (string) ( $shipping['first_name'] ?? ( $shipping['name'] ?? '' ) ),
'last_name' => (string) ( $shipping['last_name'] ?? '' ),
'phone' => (string) ( $shipping['phone'] ?? '' ),
'address_1' => (string) ( $shipping['address_1'] ?? '' ),
'address_2' => (string) ( $shipping['address_2'] ?? '' ),
'city' => (string) ( $shipping['city'] ?? '' ),
'state' => (string) ( $shipping['state'] ?? '' ),
'postcode' => (string) ( $shipping['postcode'] ?? '' ),
'country' => (string) ( $shipping['country'] ?? '' ),
], 'shipping' );
}
// === Shipping method line ===
if ( null !== $shipping_method ) {
// Remove existing shipping items
foreach ( $order->get_items( 'shipping' ) as $ship_item ) { $order->remove_item( $ship_item->get_id() ); }
if ( $shipping_method ) {
$method_id = $shipping_method;
$instance_id = null;
if ( strpos( $shipping_method, ':' ) !== false ) {
list( $method_id, $instance_str ) = explode( ':', $shipping_method, 2 );
$instance_id = absint( $instance_str );
}
$ship_item = new \WC_Order_Item_Shipping();
$ship_item->set_method_id( $method_id );
if ( null !== $instance_id && method_exists( $ship_item, 'set_instance_id' ) ) {
$ship_item->set_instance_id( $instance_id );
}
$ship_item->set_method_title( $method_id );
$ship_item->set_total( 0 );
$order->add_item( $ship_item );
}
}
// === Payment method (info only) ===
if ( null !== $payment_method ) {
if ( $payment_method ) { $order->set_payment_method( $payment_method ); }
}
// === Coupons: replace ===
if ( is_array( $coupons ) ) {
// Remove existing coupon lines
foreach ( $order->get_items( 'coupon' ) as $citem ) { $order->remove_item( $citem->get_id() ); }
foreach ( $coupons as $code ) {
try {
$coupon = new \WC_Coupon( $code );
if ( $coupon && $coupon->get_code() ) {
$ci = new \WC_Order_Item_Coupon();
$ci->set_code( $coupon->get_code() );
$order->add_item( $ci );
}
} catch ( \Throwable $e ) { /* ignore invalid */ }
}
}
// Customer note (visible to customer) - allow empty to clear note
if ( null !== $note ) {
$order->set_customer_note( $note );
}
// Totals & status
// Only recalculate if items/coupons/shipping changed; skip for status-only updates
$needs_recalc = is_array( $items ) || is_array( $coupons ) || null !== $shipping_method;
if ( $needs_recalc ) {
$order->calculate_totals( true );
}
// PROFILING: Find what's causing the 30s delay
if ( null !== $status && $status !== '' ) {
try {
$order->set_status( $status, 'Updated via API' );
} catch ( \Throwable $e ) {
/* ignore invalid */
}
}
// SOLUTION: Block WooCommerce analytics tracking (pixel.wp.com) during save
// This was causing 30s timeout on every order status change
add_filter( 'pre_http_request', function( $preempt, $args, $url ) {
// Block WooCommerce analytics/tracking requests
if ( strpos( $url, 'pixel.wp.com' ) !== false || strpos( $url, 'stats.wp.com' ) !== false ) {
return new \WP_Error( 'http_request_blocked', 'WooCommerce analytics blocked during order save' );
}
return $preempt;
}, PHP_INT_MAX, 3 );
$order->save();
// Clean up
remove_all_filters( 'pre_http_request' );
// Schedule email for later
if ( null !== $status && $status !== '' ) {
$order_id = $order->get_id();
add_action( 'shutdown', function() use ( $order_id, $status ) {
error_log('[WooNooW] Shutdown hook firing - scheduling email for order #' . $order_id);
self::schedule_order_email( $order_id, $status );
error_log('[WooNooW] Email scheduled successfully for order #' . $order_id);
}, 999 );
}
return new \WP_REST_Response( [ 'ok' => true, 'id' => $order->get_id() ], 200 );
} catch ( \Throwable $e ) {
// Log the actual error for debugging
error_log('[WooNooW] Order update failed: ' . $e->getMessage());
// Return user-friendly error message
return new \WP_REST_Response( [
'error' => 'update_failed',
'message' => __( 'Unable to update order. Please check all fields and try again.', 'woonoow' )
], 500 );
}
}
/**
* Hook handler for order status changes.
* Schedules email notification with 15s delay.
* ONLY runs outside of API requests (API requests schedule manually).
*/
public static function on_order_status_changed( $order_id, $status_from, $status_to, $order ) {
// Skip if we're in an API request (we schedule manually there)
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
error_log('[WooNooW] Skipping auto-schedule during API request for order #' . $order_id);
return;
}
// Schedule email notification with 15s delay
self::schedule_order_email( $order_id, $status_to );
error_log('[WooNooW] Order #' . $order_id . ' status changed: ' . $status_from . ' → ' . $status_to . ', email scheduled');
}
/**
* Schedule order email notification with 15s delay (non-blocking).
*/
private static function schedule_order_email( int $order_id, string $status ) {
if ( function_exists( 'as_schedule_single_action' ) ) {
// Use Action Scheduler (preferred)
as_schedule_single_action( time() + 15, 'woonoow/send_order_email', [ 'order_id' => $order_id, 'status' => $status ], 'woonoow-emails' );
} else {
// Fallback to wp-cron
wp_schedule_single_event( time() + 15, 'woonoow/send_order_email', [ 'order_id' => $order_id, 'status' => $status ] );
}
}
/**
* POST /woonoow/v1/orders
*/
public static function create( WP_REST_Request $req ) : WP_REST_Response {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return new \WP_REST_Response( [ 'error' => 'forbidden' ], 403 );
}
$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'] : [];
$status = sanitize_text_field( $p['status'] ?? 'pending' );
$payment_method = sanitize_text_field( $p['payment_method'] ?? '' );
$shipping_method = sanitize_text_field( $p['shipping_method'] ?? '' ); // e.g. flat_rate:1
$coupons = array_filter( array_map( 'sanitize_text_field', (array) ( $p['coupons'] ?? [] ) ) );
$note = isset( $p['customer_note'] ) ? wp_kses_post( (string) $p['customer_note'] ) : '';
$register_member = (bool) ( $p['register_as_member'] ?? false );
// Validation: Collect all missing required fields
$validation_errors = [];
// 1. At least one product is required
if ( empty( $items ) ) {
$validation_errors[] = __( 'At least one product is required', 'woonoow' );
}
// 2. Check if all products are virtual/downloadable
$has_physical_product = false;
foreach ( $items as $item ) {
$product_id = absint( $item['product_id'] ?? 0 );
if ( $product_id ) {
$product = wc_get_product( $product_id );
if ( $product && ! $product->is_virtual() && ! $product->is_downloadable() ) {
$has_physical_product = true;
break;
}
}
}
// 3. Billing information is required for a healthy order
$required_billing_fields = [
'first_name' => __( 'Billing first name', 'woonoow' ),
'last_name' => __( 'Billing last name', 'woonoow' ),
'email' => __( 'Billing email', 'woonoow' ),
];
// Address fields only required for physical products
if ( $has_physical_product ) {
$required_billing_fields['address_1'] = __( 'Billing address', 'woonoow' );
$required_billing_fields['city'] = __( 'Billing city', 'woonoow' );
$required_billing_fields['postcode'] = __( 'Billing postcode', 'woonoow' );
$required_billing_fields['country'] = __( 'Billing country', 'woonoow' );
}
foreach ( $required_billing_fields as $field => $label ) {
if ( empty( $billing[ $field ] ) ) {
/* translators: %s: field label */
$validation_errors[] = sprintf( __( '%s is required', 'woonoow' ), $label );
}
}
// 3. Validate email format if provided
if ( ! empty( $billing['email'] ) && ! is_email( $billing['email'] ) ) {
$validation_errors[] = __( 'Billing email is not valid', 'woonoow' );
}
// If there are validation errors, return them
if ( ! empty( $validation_errors ) ) {
return new \WP_REST_Response( [
'error' => 'validation_failed',
'message' => __( 'Please complete all required fields', 'woonoow' ),
'fields' => $validation_errors
], 400 );
}
try {
$order = wc_create_order();
// Items
foreach ( $items as $row ) {
$pid = absint( $row['product_id'] ?? 0 );
$qty = max( 1, absint( $row['qty'] ?? 1 ) );
if ( ! $pid ) { continue; }
$product = wc_get_product( $pid );
if ( ! $product ) { continue; }
$order->add_product( $product, $qty );
}
// Billing
if ( $billing ) {
$order->set_address( [
'first_name' => (string) ( $billing['first_name'] ?? '' ),
'last_name' => (string) ( $billing['last_name'] ?? '' ),
'email' => (string) ( $billing['email'] ?? '' ),
'phone' => (string) ( $billing['phone'] ?? '' ),
'address_1' => (string) ( $billing['address_1'] ?? '' ),
'address_2' => (string) ( $billing['address_2'] ?? '' ),
'city' => (string) ( $billing['city'] ?? '' ),
'state' => (string) ( $billing['state'] ?? '' ),
'postcode' => (string) ( $billing['postcode'] ?? '' ),
'country' => (string) ( $billing['country'] ?? '' ),
], 'billing' );
}
// Shipping: copy billing unless explicit ship_to_different
$ship_to_different = (bool) ( $shipping['ship_to_different'] ?? false );
$ship_addr = $ship_to_different ? $shipping : $billing;
if ( $ship_addr ) {
$order->set_address( [
'first_name' => (string) ( $ship_addr['first_name'] ?? ( $ship_addr['name'] ?? '' ) ),
'last_name' => (string) ( $ship_addr['last_name'] ?? '' ),
'phone' => (string) ( $ship_addr['phone'] ?? '' ),
'address_1' => (string) ( $ship_addr['address_1'] ?? '' ),
'address_2' => (string) ( $ship_addr['address_2'] ?? '' ),
'city' => (string) ( $ship_addr['city'] ?? '' ),
'state' => (string) ( $ship_addr['state'] ?? '' ),
'postcode' => (string) ( $ship_addr['postcode'] ?? '' ),
'country' => (string) ( $ship_addr['country'] ?? '' ),
], 'shipping' );
}
// Shipping method - calculate using WooCommerce cart for accurate rates
if ( $shipping_method ) {
// Initialize cart if needed
if ( ! WC()->cart ) {
wc_load_cart();
}
if ( ! WC()->session ) {
WC()->session = new \WC_Session_Handler();
WC()->session->init();
}
// Temporarily use cart to calculate shipping
WC()->cart->empty_cart();
// Add items to cart
foreach ( $items as $row ) {
$pid = absint( $row['product_id'] ?? 0 );
$qty = max( 1, absint( $row['qty'] ?? 1 ) );
if ( $pid ) {
WC()->cart->add_to_cart( $pid, $qty );
}
}
// Set shipping address for calculation
if ( $ship_addr ) {
WC()->customer->set_shipping_country( $ship_addr['country'] ?? '' );
WC()->customer->set_shipping_state( $ship_addr['state'] ?? '' );
WC()->customer->set_shipping_postcode( $ship_addr['postcode'] ?? '' );
WC()->customer->set_shipping_city( $ship_addr['city'] ?? '' );
WC()->customer->set_shipping_address( $ship_addr['address_1'] ?? '' );
}
// Set chosen shipping method
WC()->session->set( 'chosen_shipping_methods', [ $shipping_method ] );
// Calculate shipping
WC()->cart->calculate_shipping();
WC()->cart->calculate_totals();
// Get the calculated rate
$packages = WC()->shipping()->get_packages();
$shipping_cost = 0;
$shipping_title = $shipping_method;
$shipping_taxes = [];
foreach ( $packages as $package ) {
if ( isset( $package['rates'][ $shipping_method ] ) ) {
$rate = $package['rates'][ $shipping_method ];
$shipping_cost = $rate->get_cost();
$shipping_title = $rate->get_label();
$shipping_taxes = $rate->get_taxes();
break;
}
}
// Clean up cart
WC()->cart->empty_cart();
// Parse method ID and instance ID
$method_id = $shipping_method;
$instance_id = null;
if ( strpos( $shipping_method, ':' ) !== false ) {
list( $method_id, $instance_str ) = explode( ':', $shipping_method, 2 );
$instance_id = absint( $instance_str );
}
// Add shipping line item
$ship_item = new \WC_Order_Item_Shipping();
$ship_item->set_method_id( $method_id );
if ( null !== $instance_id && method_exists( $ship_item, 'set_instance_id' ) ) {
$ship_item->set_instance_id( $instance_id );
}
$ship_item->set_method_title( $shipping_title );
$ship_item->set_total( $shipping_cost );
// Set shipping taxes if available
if ( ! empty( $shipping_taxes ) ) {
$ship_item->set_taxes( [ 'total' => $shipping_taxes ] );
}
$order->add_item( $ship_item );
}
// Payment method
if ( $payment_method ) {
$order->set_payment_method( $payment_method );
}
// Coupons
foreach ( $coupons as $code ) {
try {
$coupon = new \WC_Coupon( $code );
if ( $coupon && $coupon->get_code() ) {
// Apply coupon to order (this will calculate discount)
$order->apply_coupon( $coupon );
}
} catch ( \Throwable $e ) {
error_log( '[WooNooW] Coupon error: ' . $e->getMessage() );
}
}
// Customer note (visible to customer)
if ( $note ) {
$order->set_customer_note( $note );
}
// Calculate totals (includes products, shipping, coupons, taxes)
$order->calculate_totals( true );
// Set status after totals
try { $order->set_status( $status, 'Set via API' ); } catch ( \Throwable $e ) { /* ignore invalid */ }
// Block WooCommerce analytics tracking during save (same as update method)
add_filter( 'pre_http_request', function( $preempt, $args, $url ) {
if ( strpos( $url, 'pixel.wp.com' ) !== false || strpos( $url, 'stats.wp.com' ) !== false ) {
return new \WP_Error( 'http_request_blocked', 'WooCommerce analytics blocked during order save' );
}
return $preempt;
}, PHP_INT_MAX, 3 );
$order->save();
// Clean up
remove_all_filters( 'pre_http_request' );
// Process payment gateway if payment method is set and order is pending
if ( $payment_method && $status === 'pending' ) {
self::process_payment_gateway( $order, $payment_method );
}
// Link order to customer and update/create user account
if ( ! empty( $billing['email'] ) ) {
$email = sanitize_email( $billing['email'] );
// Check if user already exists
if ( email_exists( $email ) || username_exists( $email ) ) {
// User exists - always link and update data
$user = get_user_by( 'email', $email );
if ( $user ) {
// Upgrade subscriber to customer role (WooCommerce best practice)
if ( in_array( 'subscriber', (array) $user->roles, true ) ) {
$user->set_role( 'customer' );
}
// Update customer billing & shipping data
$customer = new \WC_Customer( $user->ID );
// Update billing address
if ( ! empty( $billing['first_name'] ) ) $customer->set_billing_first_name( $billing['first_name'] );
if ( ! empty( $billing['last_name'] ) ) $customer->set_billing_last_name( $billing['last_name'] );
if ( ! empty( $billing['email'] ) ) $customer->set_billing_email( $billing['email'] );
if ( ! empty( $billing['phone'] ) ) $customer->set_billing_phone( $billing['phone'] );
if ( ! empty( $billing['address_1'] ) ) $customer->set_billing_address_1( $billing['address_1'] );
if ( ! empty( $billing['city'] ) ) $customer->set_billing_city( $billing['city'] );
if ( ! empty( $billing['state'] ) ) $customer->set_billing_state( $billing['state'] );
if ( ! empty( $billing['postcode'] ) ) $customer->set_billing_postcode( $billing['postcode'] );
if ( ! empty( $billing['country'] ) ) $customer->set_billing_country( $billing['country'] );
// Update shipping address (if provided)
if ( ! empty( $shipping ) && is_array( $shipping ) ) {
if ( ! empty( $shipping['first_name'] ) ) $customer->set_shipping_first_name( $shipping['first_name'] );
if ( ! empty( $shipping['last_name'] ) ) $customer->set_shipping_last_name( $shipping['last_name'] );
if ( ! empty( $shipping['address_1'] ) ) $customer->set_shipping_address_1( $shipping['address_1'] );
if ( ! empty( $shipping['city'] ) ) $customer->set_shipping_city( $shipping['city'] );
if ( ! empty( $shipping['state'] ) ) $customer->set_shipping_state( $shipping['state'] );
if ( ! empty( $shipping['postcode'] ) ) $customer->set_shipping_postcode( $shipping['postcode'] );
if ( ! empty( $shipping['country'] ) ) $customer->set_shipping_country( $shipping['country'] );
}
$customer->save();
$order->set_customer_id( $user->ID );
$order->save();
}
} elseif ( $register_member ) {
// User doesn't exist - create only if checkbox is checked
// Generate password
$password = wp_generate_password( 12, true, true );
// Prepare user data
$userdata = [
'user_login' => $email,
'user_email' => $email,
'user_pass' => $password,
'first_name' => $billing['first_name'] ?? '',
'last_name' => $billing['last_name'] ?? '',
'display_name' => trim( ( $billing['first_name'] ?? '' ) . ' ' . ( $billing['last_name'] ?? '' ) ) ?: $email,
'role' => 'customer', // WooCommerce customer role
];
$user_id = wp_insert_user( $userdata );
if ( ! is_wp_error( $user_id ) ) {
// Set WooCommerce customer billing data
$customer = new \WC_Customer( $user_id );
// Billing address
if ( ! empty( $billing['first_name'] ) ) $customer->set_billing_first_name( $billing['first_name'] );
if ( ! empty( $billing['last_name'] ) ) $customer->set_billing_last_name( $billing['last_name'] );
if ( ! empty( $billing['email'] ) ) $customer->set_billing_email( $billing['email'] );
if ( ! empty( $billing['phone'] ) ) $customer->set_billing_phone( $billing['phone'] );
if ( ! empty( $billing['address_1'] ) ) $customer->set_billing_address_1( $billing['address_1'] );
if ( ! empty( $billing['city'] ) ) $customer->set_billing_city( $billing['city'] );
if ( ! empty( $billing['state'] ) ) $customer->set_billing_state( $billing['state'] );
if ( ! empty( $billing['postcode'] ) ) $customer->set_billing_postcode( $billing['postcode'] );
if ( ! empty( $billing['country'] ) ) $customer->set_billing_country( $billing['country'] );
// Shipping address (if provided)
if ( ! empty( $shipping ) && is_array( $shipping ) ) {
if ( ! empty( $shipping['first_name'] ) ) $customer->set_shipping_first_name( $shipping['first_name'] );
if ( ! empty( $shipping['last_name'] ) ) $customer->set_shipping_last_name( $shipping['last_name'] );
if ( ! empty( $shipping['address_1'] ) ) $customer->set_shipping_address_1( $shipping['address_1'] );
if ( ! empty( $shipping['city'] ) ) $customer->set_shipping_city( $shipping['city'] );
if ( ! empty( $shipping['state'] ) ) $customer->set_shipping_state( $shipping['state'] );
if ( ! empty( $shipping['postcode'] ) ) $customer->set_shipping_postcode( $shipping['postcode'] );
if ( ! empty( $shipping['country'] ) ) $customer->set_shipping_country( $shipping['country'] );
}
$customer->save();
// Link order to customer
$order->set_customer_id( $user_id );
$order->save();
// Send new user notification (password reset link)
wp_new_user_notification( $user_id, null, 'user' );
}
}
}
// Schedule email notification with 15s delay (non-blocking) - on shutdown to avoid blocking
add_action( 'shutdown', function() use ( $order, $status ) {
self::schedule_order_email( $order->get_id(), $status );
}, 999 );
return new \WP_REST_Response( [
'ok' => true,
'id' => $order->get_id(),
'number' => $order->get_order_number(),
], 201 );
} catch ( \Throwable $e ) {
// Log the actual error for debugging
error_log('[WooNooW] Order creation failed: ' . $e->getMessage());
// Return user-friendly error message
return new \WP_REST_Response( [
'error' => 'create_failed',
'message' => __( 'Unable to create order. Please check all required fields and try again.', 'woonoow' )
], 500 );
}
}
/**
* GET /woonoow/v1/products
*/
public static function products( WP_REST_Request $req ) : WP_REST_Response {
$s = sanitize_text_field( $req->get_param('search') ?? '' );
$limit = max( 1, min( 20, absint( $req->get_param('limit') ?? 10 ) ) );
$args = [ 'limit' => $limit, 'status' => 'publish' ];
if ( $s ) { $args['s'] = $s; }
$prods = wc_get_products( $args );
$rows = array_map( function( $p ) {
return [
'id' => $p->get_id(),
'name' => $p->get_name(),
'price' => (float) $p->get_price(),
'regular_price' => (float) $p->get_regular_price(),
'sale_price' => $p->get_sale_price() ? (float) $p->get_sale_price() : null,
'sku' => $p->get_sku(),
'stock' => $p->get_stock_quantity(),
'virtual' => $p->is_virtual(),
'downloadable' => $p->is_downloadable(),
];
}, $prods );
return new WP_REST_Response( [ 'rows' => $rows ], 200 );
}
public static function payments( WP_REST_Request $req ) : WP_REST_Response {
$methods = [];
if ( function_exists('WC') ) {
$gateways = WC()->payment_gateways ? WC()->payment_gateways->get_available_payment_gateways() : [];
foreach ( $gateways as $id => $gw ) {
// Skip disabled gateways (double-check even though get_available_payment_gateways should filter)
if ( ! isset( $gw->enabled ) || $gw->enabled !== 'yes' ) {
continue;
}
$gateway_data = [
'id' => (string) $id,
'title' => (string) ( $gw->get_title() ?? $gw->title ?? $id ),
'enabled' => true,
'channels' => [],
];
/**
* Filter: woonoow/payment_gateway_channels
*
* Allows payment gateways to provide channel-level options (e.g., bank accounts).
* If channels are provided, they will be shown instead of the gateway itself.
*
* @param array $channels Array of channel configurations
* @param string $gateway_id Gateway ID
* @param object $gateway Gateway instance
*
* Example:
* add_filter('woonoow/payment_gateway_channels', function($channels, $gateway_id, $gateway) {
* if ($gateway_id === 'bacs') {
* $accounts = $gateway->get_option('account_details', []);
* foreach ($accounts as $account) {
* $channels[] = [
* 'id' => 'bacs_' . sanitize_title($account['account_name']),
* 'title' => $account['bank_name'] . ' - ' . $account['account_name'],
* 'meta' => $account,
* ];
* }
* }
* return $channels;
* }, 10, 3);
*/
$channels = apply_filters( 'woonoow/payment_gateway_channels', [], $id, $gw );
// If gateway has channels, add them
if ( ! empty( $channels ) && is_array( $channels ) ) {
$gateway_data['channels'] = $channels;
}
$methods[] = $gateway_data;
}
}
return new \WP_REST_Response( $methods, 200 );
}
public static function shippings( WP_REST_Request $req ) : WP_REST_Response {
$rows = [];
if ( ! class_exists( '\WC_Shipping_Zones' ) ) {
return new \WP_REST_Response( $rows, 200 );
}
$zones = \WC_Shipping_Zones::get_zones();
// Also include the “locations not covered by your other zones” pseudo-zone
$zones[] = \WC_Shipping_Zones::get_zone_by( 'zone_id', 0 )->get_data();
foreach ( $zones as $zone ) {
$zone_obj = is_object( $zone ) ? $zone : new \WC_Shipping_Zone( $zone['id'] ?? 0 );
foreach ( $zone_obj->get_shipping_methods() as $m ) {
/** @var \WC_Shipping_Method $m */
// Skip disabled shipping methods
if ( ! $m->is_enabled() ) {
continue;
}
$id = $m->id;
$instance = method_exists( $m, 'get_instance_id' ) ? $m->get_instance_id() : 0;
$title = !empty($m->title) ? $m->title :
(method_exists($m, 'get_method_title') ? $m->get_method_title() : $m->id);
// Get shipping cost
$cost = 0;
if ( method_exists( $m, 'get_option' ) ) {
$cost = $m->get_option( 'cost', 0 );
}
$rows[] = [
'id' => $instance ? "{$id}:{$instance}" : $id, // e.g. "flat_rate:3" or "free_shipping"
'method' => $id,
'title' => (string) ( $m->title ?? $m->get_method_title() ?? $id ), // Use user's label first
'cost' => (float) $cost, // Shipping cost
];
}
}
// Deduplicate by id
$rows = array_values( array_reduce( $rows, function($carry, $r){
$carry[$r['id']] = $r; return $carry;
}, [] ) );
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 {
// Initialize WooCommerce cart and session if not already
if ( ! WC()->cart ) {
wc_load_cart();
}
if ( ! WC()->session ) {
WC()->session = new \WC_Session_Handler();
WC()->session->init();
}
// 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 ) ) {
$country = $shipping['country'] ?? '';
$state = $shipping['state'] ?? '';
$postcode = $shipping['postcode'] ?? '';
$city = $shipping['city'] ?? '';
$address_1 = $shipping['address_1'] ?? '';
$address_2 = $shipping['address_2'] ?? '';
WC()->customer->set_shipping_country( $country );
WC()->customer->set_shipping_state( $state );
WC()->customer->set_shipping_postcode( $postcode );
WC()->customer->set_shipping_city( $city );
WC()->customer->set_shipping_address( $address_1 );
WC()->customer->set_shipping_address_2( $address_2 );
// Also set billing for tax calculation context
WC()->customer->set_billing_country( $country );
WC()->customer->set_billing_state( $state );
WC()->customer->set_billing_postcode( $postcode );
WC()->customer->set_billing_city( $city );
// Support for Rajaongkir plugin - set destination in session
// Rajaongkir uses session-based destination instead of standard address fields
if ( $country === 'ID' && ! empty( $shipping['destination_id'] ) ) {
WC()->session->set( 'selected_destination_id', $shipping['destination_id'] );
WC()->session->set( 'selected_destination_label', $shipping['destination_label'] ?? $city );
} else {
// Clear Rajaongkir session data for non-ID countries
WC()->session->__unset( 'selected_destination_id' );
WC()->session->__unset( 'selected_destination_label' );
}
}
// 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 {
// Initialize WooCommerce cart and session if not already
if ( ! WC()->cart ) {
wc_load_cart();
}
if ( ! WC()->session ) {
WC()->session = new \WC_Session_Handler();
WC()->session->init();
}
// 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 = [];
$default = '';
if ( function_exists('wc') && wc() && isset( wc()->countries ) ) {
$c = wc()->countries;
// All countries and which ones are allowed for checkout
$all = (array) $c->get_countries(); // [ 'ID' => 'Indonesia', ... ]
$allowed = (array) $c->get_allowed_countries(); // obeys General ▶ Selling location(s)
$states = (array) $c->get_states(); // [ 'US' => [ 'CA' => 'California', ... ], ... ]
// Use only the allowed countries list; if empty, fall back to all.
$use = ! empty( $allowed ) ? $allowed : $all; // shape: [ 'ID' => 'Indonesia', ... ]
// Default country is the store base if present in the allowed set; otherwise first allowed.
$base = wc_get_base_location(); // ['country' => 'ID', 'state' => 'JK']
$default = isset( $base['country'] ) && array_key_exists( $base['country'], $use )
? $base['country']
: (string) ( array_key_first( $use ) ?: '' );
// Filter states down to the allowed countries only
if ( ! empty( $states ) ) {
$states = array_intersect_key( $states, $use );
}
foreach ( $use as $code => $name ) {
$rows[] = [
'code' => (string) $code,
'name' => (string) $name,
];
}
}
// Shape: { countries: [{code,name}], states: { CODE: {STATE: Name} }, default_country: 'ID' }
return new \WP_REST_Response([
'countries' => $rows,
'states' => $states,
'default_country' => $default,
], 200 );
}
/**
* Send order email notification (runs async via Action Scheduler or wp-cron).
* This is triggered 15 seconds after order status change to avoid blocking API responses.
*/
public static function send_order_email( $args ) {
$order_id = absint( $args['order_id'] ?? 0 );
$status = sanitize_text_field( $args['status'] ?? '' );
if ( ! $order_id || ! $status ) {
return;
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
// Trigger WooCommerce email notification for the status
// This will call wp_mail() which is intercepted by WooEmailOverride
do_action( 'woocommerce_order_status_' . $status, $order_id, $order );
// Also trigger generic status change hook
do_action( 'woocommerce_order_status_changed', $order_id, $order->get_status(), $status, $order );
}
/**
* Delete an order (move to trash or permanently delete based on WooCommerce settings)
*/
public static function delete( WP_REST_Request $req ) : WP_REST_Response {
$id = (int) $req['id'];
if ( ! $id ) {
return new WP_REST_Response([
'error' => __( 'Invalid order ID', 'woonoow' ),
], 400 );
}
$order = wc_get_order( $id );
if ( ! $order ) {
return new WP_REST_Response([
'error' => __( 'Order not found', 'woonoow' ),
], 404 );
}
// Check if HPOS is enabled
$using_hpos = OrderUtil::custom_orders_table_usage_is_enabled();
try {
// For HPOS orders, use the delete method directly
if ( $using_hpos ) {
// Move to trash first (soft delete)
$order->delete( false );
// If you want to permanently delete instead, use:
// $order->delete( true );
} else {
// For legacy post-based orders, use wp_trash_post or wp_delete_post
wp_trash_post( $id );
// For permanent deletion:
// wp_delete_post( $id, true );
}
// Log the deletion
if ( function_exists( 'wc_get_logger' ) ) {
$logger = wc_get_logger();
$logger->info(
sprintf( 'Order #%d deleted via WooNooW API by user %d', $id, get_current_user_id() ),
[ 'source' => 'woonoow-orders' ]
);
}
return new WP_REST_Response([
'success' => true,
'id' => $id,
'message' => __( 'Order deleted successfully', 'woonoow' ),
], 200 );
} catch ( \Exception $e ) {
return new WP_REST_Response([
'error' => __( 'Failed to delete order', 'woonoow' ),
'message' => $e->getMessage(),
], 500 );
}
}
/**
* Get payment method title from order
* Handles both regular gateways and channel-based payments (e.g., bacs_dwindi-ramadhana_0)
*
* @param \WC_Order $order Order object
* @return string Payment method title
*/
private static function get_payment_method_title( $order ): string {
$payment_method_id = $order->get_payment_method();
if ( empty( $payment_method_id ) ) {
return __('No payment method', 'woonoow');
}
// Get ALL payment gateways (not just available ones)
if ( function_exists('WC') && WC()->payment_gateways ) {
$all_gateways = WC()->payment_gateways->payment_gateways();
// First, check if it's a direct gateway ID (e.g., tripay_bniva, bacs, cod)
if ( isset( $all_gateways[ $payment_method_id ] ) ) {
$gateway = $all_gateways[ $payment_method_id ];
// Check if this gateway has channels
$channels = apply_filters( 'woonoow/payment_gateway_channels', [], $payment_method_id, $gateway );
// If no channels, return gateway title
if ( empty( $channels ) ) {
return $gateway->get_title();
}
}
// If not a direct gateway, check if it's a channel ID (e.g., bacs_dwindi-ramadhana_0)
if ( strpos( $payment_method_id, '_' ) !== false ) {
// Extract gateway ID (e.g., "bacs" from "bacs_dwindi-ramadhana_0")
$parts = explode( '_', $payment_method_id, 2 );
$gateway_id = $parts[0];
if ( isset( $all_gateways[ $gateway_id ] ) ) {
$gateway = $all_gateways[ $gateway_id ];
// Get channels for this gateway
$channels = apply_filters( 'woonoow/payment_gateway_channels', [], $gateway_id, $gateway );
// Find matching channel
foreach ( $channels as $channel ) {
if ( $channel['id'] === $payment_method_id ) {
return $channel['title'];
}
}
}
}
}
// Fallback to WooCommerce's get_payment_method_title()
$title = $order->get_payment_method_title();
return $title ?: __('No payment method', 'woonoow');
}
/**
* Get shipping method title from order
* Uses WC_Shipping_Zones to get the actual user-configured title
*
* @param \WC_Order $order Order object
* @return string Shipping method title
*/
private static function get_shipping_method_title( $order ): string {
$shipping_methods = $order->get_shipping_methods();
if ( empty( $shipping_methods ) ) {
return __('No shipping method', 'woonoow');
}
// Get first shipping method item
$shipping_item = reset( $shipping_methods );
$method_id = $shipping_item->get_method_id();
$instance_id = $shipping_item->get_instance_id();
// Look up the actual shipping method from WC_Shipping_Zones
if ( class_exists( '\WC_Shipping_Zones' ) && $instance_id ) {
$zones = \WC_Shipping_Zones::get_zones();
$zones[] = \WC_Shipping_Zones::get_zone_by( 'zone_id', 0 )->get_data();
foreach ( $zones as $zone ) {
$zone_obj = is_object( $zone ) ? $zone : new \WC_Shipping_Zone( $zone['id'] ?? 0 );
foreach ( $zone_obj->get_shipping_methods() as $method ) {
// Match by method_id and instance_id
if ( $method->id === $method_id && $method->get_instance_id() == $instance_id ) {
// Found it! Return the user's custom title
return $method->title ?: $method->get_method_title() ?: $method_id;
}
}
}
}
// Fallback: try to get title from the order item itself
$title = $shipping_item->get_name();
if ( $title && $title !== $method_id ) {
return $title;
}
// Last resort: return method ID
return $method_id ?: __('No shipping method', 'woonoow');
}
/**
* Get shipping method ID from order
* WooCommerce format: "method_id:instance_id" (e.g., "free_shipping:1")
*
* @param \WC_Order $order Order object
* @return string Shipping method ID with instance
*/
private static function get_shipping_method_id( $order ): string {
$shipping_methods = $order->get_shipping_methods();
if ( empty( $shipping_methods ) ) {
return '';
}
// Get first shipping method
$shipping_method = reset( $shipping_methods );
$method_id = $shipping_method->get_method_id();
$instance_id = $shipping_method->get_instance_id();
// Return in WooCommerce format: method_id:instance_id
return $instance_id ? "{$method_id}:{$instance_id}" : $method_id;
}
/**
* GET /woonoow/v1/customers/search
* Search customers by email or general search for autofill functionality
*/
public static function search_customers( WP_REST_Request $req ) : WP_REST_Response {
$email = sanitize_email( $req->get_param( 'email' ) ?? '' );
$search = sanitize_text_field( $req->get_param( 'search' ) ?? '' );
// If email provided, search by email (exact match)
if ( ! empty( $email ) ) {
$user = get_user_by( 'email', $email );
if ( ! $user ) {
return new \WP_REST_Response( [ 'found' => false ], 200 );
}
return new \WP_REST_Response( self::format_customer_data( $user ), 200 );
}
// If search provided, search by name/email (multiple results)
if ( ! empty( $search ) ) {
$users = get_users( [
'search' => '*' . $search . '*',
'search_columns' => [ 'user_login', 'user_email', 'display_name' ],
'number' => 10,
'role__in' => [ 'customer', 'administrator', 'shop_manager', 'subscriber' ],
] );
$results = [];
foreach ( $users as $user ) {
$customer = new \WC_Customer( $user->ID );
$results[] = [
'id' => $user->ID,
'email' => $user->user_email,
'name' => trim( ( $customer->get_billing_first_name() ?: $user->first_name ) . ' ' . ( $customer->get_billing_last_name() ?: $user->last_name ) ),
'first_name' => $customer->get_billing_first_name() ?: $user->first_name,
'last_name' => $customer->get_billing_last_name() ?: $user->last_name,
];
}
return new \WP_REST_Response( $results, 200 );
}
return new \WP_REST_Response( [ 'error' => 'email_or_search_required' ], 400 );
}
/**
* Format customer data for autofill
*/
private static function format_customer_data( $user ): array {
$customer = new \WC_Customer( $user->ID );
return [
'found' => true,
'user_id' => $user->ID,
'email' => $user->user_email,
'first_name' => $customer->get_billing_first_name() ?: $user->first_name,
'last_name' => $customer->get_billing_last_name() ?: $user->last_name,
'phone' => $customer->get_billing_phone(),
'billing' => [
'first_name' => $customer->get_billing_first_name(),
'last_name' => $customer->get_billing_last_name(),
'email' => $customer->get_billing_email() ?: $user->user_email,
'phone' => $customer->get_billing_phone(),
'address_1' => $customer->get_billing_address_1(),
'address_2' => $customer->get_billing_address_2(),
'city' => $customer->get_billing_city(),
'state' => $customer->get_billing_state(),
'postcode' => $customer->get_billing_postcode(),
'country' => $customer->get_billing_country(),
],
'shipping' => [
'first_name' => $customer->get_shipping_first_name(),
'last_name' => $customer->get_shipping_last_name(),
'address_1' => $customer->get_shipping_address_1(),
'address_2' => $customer->get_shipping_address_2(),
'city' => $customer->get_shipping_city(),
'state' => $customer->get_shipping_state(),
'postcode' => $customer->get_shipping_postcode(),
'country' => $customer->get_shipping_country(),
],
];
}
/**
* Process payment gateway for an order
* Calls the gateway's process_payment() method to create external transactions
*
* @param \WC_Order $order Order object
* @param string $payment_method Payment method ID
* @return array|\WP_Error Payment result or error
*/
private static function process_payment_gateway( $order, $payment_method ) {
// Get all payment gateways
if ( ! function_exists('WC') || ! WC()->payment_gateways ) {
return new \WP_Error( 'no_wc', __( 'WooCommerce not initialized', 'woonoow' ) );
}
// Initialize WooCommerce cart and session if not exists
// Some gateways call WC()->cart->empty_cart() or WC()->session->set() after payment processing
if ( ! WC()->cart ) {
WC()->initialize_cart();
}
// Initialize session if not exists
if ( ! WC()->session || ! WC()->session instanceof \WC_Session ) {
WC()->initialize_session();
}
$gateways = WC()->payment_gateways->payment_gateways();
// Extract base gateway ID (handle channels like bacs_account_0)
$gateway_id = $payment_method;
if ( strpos( $payment_method, '_' ) !== false ) {
$parts = explode( '_', $payment_method, 2 );
// Check if base gateway exists
if ( isset( $gateways[ $parts[0] ] ) ) {
$gateway_id = $parts[0];
}
}
// Check if gateway exists
if ( ! isset( $gateways[ $gateway_id ] ) ) {
error_log( '[WooNooW] Payment gateway not found: ' . $gateway_id );
return new \WP_Error( 'gateway_not_found', sprintf( __( 'Payment gateway not found: %s', 'woonoow' ), $gateway_id ) );
}
$gateway = $gateways[ $gateway_id ];
// Check if gateway has process_payment method
if ( ! method_exists( $gateway, 'process_payment' ) ) {
error_log( '[WooNooW] Gateway does not have process_payment method: ' . $gateway_id );
return new \WP_Error( 'no_process_method', sprintf( __( 'Gateway does not support payment processing: %s', 'woonoow' ), $gateway_id ) );
}
try {
// Mark as admin-created for gateways that need to know
$order->update_meta_data( '_woonoow_admin_created', true );
$order->save();
// Set flag for gateways to detect admin context
add_filter( 'woonoow/is_admin_order', '__return_true' );
error_log( '[WooNooW] Processing payment for order #' . $order->get_id() . ' with gateway: ' . $gateway_id );
// Call gateway's process_payment method
$result = $gateway->process_payment( $order->get_id() );
// Clean up filter
remove_filter( 'woonoow/is_admin_order', '__return_true' );
// Store result metadata
if ( is_array( $result ) ) {
if ( isset( $result['redirect'] ) ) {
$order->update_meta_data( '_woonoow_payment_redirect', $result['redirect'] );
}
if ( isset( $result['result'] ) && $result['result'] === 'success' ) {
$order->add_order_note( __( 'Payment gateway processing completed via WooNooW', 'woonoow' ) );
error_log( '[WooNooW] Payment processing succeeded for order #' . $order->get_id() );
} elseif ( isset( $result['result'] ) && $result['result'] === 'failure' ) {
$message = isset( $result['message'] ) ? $result['message'] : __( 'Payment processing failed', 'woonoow' );
$order->add_order_note( sprintf( __( 'Payment gateway error: %s', 'woonoow' ), $message ) );
error_log( '[WooNooW] Payment processing failed for order #' . $order->get_id() . ': ' . $message );
}
$order->save();
}
return $result;
} catch ( \Throwable $e ) {
error_log( '[WooNooW] Payment processing exception for order #' . $order->get_id() . ': ' . $e->getMessage() );
$order->add_order_note( sprintf( __( 'Payment gateway exception: %s', 'woonoow' ), $e->getMessage() ) );
$order->save();
return new \WP_Error( 'gateway_exception', $e->getMessage() );
}
}
/**
* Get payment gateway metadata for display
* Extracts common payment metadata like VA number, QR code, expiry, etc.
*
* @param \WC_Order $order Order object
* @return array Payment metadata
*/
private static function get_payment_metadata( $order ): array {
$meta = [];
// Common metadata keys used by Indonesian payment gateways
$meta_keys = apply_filters( 'woonoow/payment_meta_keys', [
// Tripay
'_tripay_payment_pay_code' => __( 'Payment Code', 'woonoow' ),
'_tripay_payment_reference' => __( 'Reference', 'woonoow' ),
'_tripay_payment_expired_time' => __( 'Expires At', 'woonoow' ),
'_tripay_payment_amount' => __( 'Amount', 'woonoow' ),
'_tripay_payment_type' => __( 'Payment Type', 'woonoow' ),
// Duitku
'_duitku_reference' => __( 'Reference', 'woonoow' ),
'_duitku_va_number' => __( 'VA Number', 'woonoow' ),
'_duitku_payment_url' => __( 'Payment URL', 'woonoow' ),
// Xendit
'_xendit_invoice_id' => __( 'Invoice ID', 'woonoow' ),
'_xendit_invoice_url' => __( 'Invoice URL', 'woonoow' ),
'_xendit_expiry_date' => __( 'Expires At', 'woonoow' ),
// WooNooW
'_woonoow_payment_redirect' => __( 'Payment URL', 'woonoow' ),
'_woonoow_admin_created' => __( 'Created via Admin', 'woonoow' ),
], $order );
foreach ( $meta_keys as $key => $label ) {
$value = $order->get_meta( $key );
if ( ! empty( $value ) ) {
// Format timestamps
if ( strpos( $key, 'expired_time' ) !== false || strpos( $key, 'expiry' ) !== false ) {
if ( is_numeric( $value ) ) {
$value = date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $value );
}
}
// Format amounts (currency)
if ( strpos( $key, 'amount' ) !== false && is_numeric( $value ) ) {
$value = wc_price( $value, [ 'currency' => $order->get_currency() ] );
}
// Format booleans
if ( is_bool( $value ) ) {
$value = $value ? __( 'Yes', 'woonoow' ) : __( 'No', 'woonoow' );
}
$meta[] = [
'key' => $key,
'label' => $label,
'value' => $value,
];
}
}
return $meta;
}
/**
* POST /woonoow/v1/orders/{id}/retry-payment
* Retry payment gateway processing for an order
*/
public static function retry_payment( WP_REST_Request $req ): WP_REST_Response {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return new WP_REST_Response( [ 'error' => 'forbidden' ], 403 );
}
$id = absint( $req['id'] );
if ( ! $id ) {
return new WP_REST_Response( [ 'error' => 'invalid_id' ], 400 );
}
$order = wc_get_order( $id );
if ( ! $order ) {
return new WP_REST_Response( [ 'error' => 'not_found' ], 404 );
}
$payment_method = $order->get_payment_method();
if ( empty( $payment_method ) ) {
return new WP_REST_Response( [
'error' => 'no_payment_method',
'message' => __( 'Order has no payment method', 'woonoow' )
], 400 );
}
// Only allow retry for pending/on-hold orders
$status = $order->get_status();
if ( ! in_array( $status, [ 'pending', 'on-hold', 'failed' ] ) ) {
return new WP_REST_Response( [
'error' => 'invalid_status',
'message' => sprintf(
__( 'Cannot retry payment for order with status: %s', 'woonoow' ),
$status
)
], 400 );
}
// Add order note
$order->add_order_note( __( 'Payment retry requested via WooNooW Admin', 'woonoow' ) );
// Block WooCommerce analytics tracking during ALL save operations (prevents 30s delay)
// This needs to wrap the entire operation because process_payment_gateway() also calls save()
add_filter('pre_http_request', function($preempt, $args, $url) {
if (strpos($url, 'pixel.wp.com') !== false || strpos($url, 'stats.wp.com') !== false) {
return new \WP_Error('http_request_blocked', 'WooCommerce analytics blocked');
}
return $preempt;
}, PHP_INT_MAX, 3);
$order->save();
// Trigger payment processing and capture result (this also calls save() internally)
$result = self::process_payment_gateway( $order, $payment_method );
// Remove filter after ALL operations complete
remove_all_filters('pre_http_request');
// Check if payment processing failed
if ( is_wp_error( $result ) ) {
return new WP_REST_Response( [
'error' => 'payment_failed',
'message' => $result->get_error_message()
], 400 );
}
return new WP_REST_Response( [
'success' => true,
'message' => __( 'Payment processing retried', 'woonoow' )
], 200 );
}
/**
* POST /woonoow/v1/coupons/validate
* Validate a coupon code and calculate discount
*/
public static function validate_coupon( WP_REST_Request $req ): WP_REST_Response {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return new WP_REST_Response( [ 'error' => 'forbidden' ], 403 );
}
$code = sanitize_text_field( $req['code'] );
if ( empty( $code ) ) {
return new WP_REST_Response( [
'valid' => false,
'error' => __( 'Coupon code is required', 'woonoow' )
], 400 );
}
// Get coupon
$coupon = new \WC_Coupon( $code );
// Check if coupon exists
if ( ! $coupon->get_id() ) {
return new WP_REST_Response( [
'valid' => false,
'error' => __( 'Coupon does not exist', 'woonoow' )
], 200 );
}
// Check if coupon is valid (not expired, usage limits, etc.)
$validation = $coupon->is_valid();
if ( is_wp_error( $validation ) ) {
return new WP_REST_Response( [
'valid' => false,
'error' => $validation->get_error_message()
], 200 );
}
// Get coupon details
$discount_type = $coupon->get_discount_type();
$amount = $coupon->get_amount();
$subtotal = floatval( $req['subtotal'] ?? 0 );
// Calculate discount amount
$discount_amount = 0;
if ( $discount_type === 'percent' ) {
$discount_amount = ( $subtotal * $amount ) / 100;
} elseif ( $discount_type === 'fixed_cart' ) {
$discount_amount = min( $amount, $subtotal );
} elseif ( $discount_type === 'fixed_product' ) {
// For product-level discounts, we'd need item details
$discount_amount = $amount;
}
// Format response
return new WP_REST_Response( [
'valid' => true,
'code' => $coupon->get_code(),
'discount_type' => $discount_type,
'amount' => $amount,
'discount_amount' => $discount_amount,
'description' => $coupon->get_description(),
'individual_use' => $coupon->get_individual_use(),
'free_shipping' => $coupon->get_free_shipping(),
'minimum_amount' => $coupon->get_minimum_amount(),
'maximum_amount' => $coupon->get_maximum_amount(),
'usage_count' => $coupon->get_usage_count(),
'usage_limit' => $coupon->get_usage_limit(),
'expiry_date' => $coupon->get_date_expires() ? $coupon->get_date_expires()->date( 'Y-m-d' ) : null,
], 200 );
}
}