2564 lines
96 KiB
PHP
2564 lines
96 KiB
PHP
<?php
|
||
|
||
namespace WooNooW\Api;
|
||
|
||
use WP_REST_Request;
|
||
use WP_REST_Response;
|
||
use Automattic\WooCommerce\Utilities\OrderUtil;
|
||
use WooNooW\Core\ModuleRegistry;
|
||
|
||
class OrdersController
|
||
{
|
||
/**
|
||
* Sanitize and validate a text field
|
||
* Returns empty string if value is empty, whitespace-only, or invalid
|
||
*/
|
||
private static function sanitize_field($value)
|
||
{
|
||
if (! isset($value) || $value === '') {
|
||
return '';
|
||
}
|
||
|
||
$sanitized = sanitize_text_field($value);
|
||
$trimmed = trim($sanitized);
|
||
|
||
// Return empty if only whitespace or sanitization removed everything
|
||
return $trimmed !== '' ? $trimmed : '';
|
||
}
|
||
|
||
/**
|
||
* Sanitize phone number
|
||
* Removes non-numeric characters except +, spaces, and hyphens
|
||
* Returns empty string if result is invalid
|
||
*/
|
||
private static function sanitize_phone($phone)
|
||
{
|
||
if (! isset($phone) || $phone === '') {
|
||
return '';
|
||
}
|
||
|
||
// Remove non-numeric characters except + and spaces/hyphens
|
||
$phone = preg_replace('/[^0-9+\s-]/', '', $phone);
|
||
$phone = trim($phone);
|
||
|
||
// If result is empty or just symbols, return empty
|
||
if ($phone === '' || preg_match('/^[+\s-]+$/', $phone)) {
|
||
return '';
|
||
}
|
||
|
||
return $phone;
|
||
}
|
||
|
||
/**
|
||
* Sanitize email
|
||
* Returns empty string if invalid email
|
||
*/
|
||
private static function sanitize_email_field($email)
|
||
{
|
||
if (! isset($email) || $email === '') {
|
||
return '';
|
||
}
|
||
|
||
$sanitized = sanitize_email($email);
|
||
return is_email($sanitized) ? $sanitized : '';
|
||
}
|
||
|
||
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
|
||
// Changed from /products to /products/search to avoid conflict with ProductsController
|
||
register_rest_route('woonoow/v1', '/products/search', [
|
||
'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])];
|
||
}
|
||
|
||
// Optional customer filter
|
||
$customer_id = $req->get_param('customer_id');
|
||
if ($customer_id) {
|
||
$args['customer_id'] = absint($customer_id);
|
||
}
|
||
|
||
$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(),
|
||
'meta' => self::get_order_meta_data($order),
|
||
];
|
||
|
||
// Get related subscription
|
||
if (ModuleRegistry::is_enabled('subscription')) {
|
||
if (class_exists('\WooNooW\Modules\Subscription\SubscriptionManager')) {
|
||
$subscription = \WooNooW\Modules\Subscription\SubscriptionManager::get_by_order_id($id);
|
||
if ($subscription) {
|
||
$period_labels = [
|
||
'day' => __('day', 'woonoow'),
|
||
'week' => __('week', 'woonoow'),
|
||
'month' => __('month', 'woonoow'),
|
||
'year' => __('year', 'woonoow'),
|
||
];
|
||
$interval = $subscription->billing_interval > 1 ? $subscription->billing_interval . ' ' : '';
|
||
$period = $period_labels[$subscription->billing_period] ?? $subscription->billing_period;
|
||
if ($subscription->billing_interval > 1) {
|
||
$period .= 's'; // Pluralize
|
||
}
|
||
|
||
$data['related_subscription'] = [
|
||
'id' => (int) $subscription->id,
|
||
'status' => $subscription->status,
|
||
'next_payment_date' => $subscription->next_payment_date,
|
||
'billing_period' => $subscription->billing_period,
|
||
'billing_interval' => (int) $subscription->billing_interval,
|
||
'recurring_amount' => (float) $subscription->recurring_amount,
|
||
'billing_schedule' => sprintf(__('Every %s%s', 'woonoow'), $interval, $period),
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get related licenses
|
||
if (ModuleRegistry::is_enabled('licensing')) {
|
||
if (class_exists('\WooNooW\Modules\Licensing\LicenseManager')) {
|
||
$licenses = \WooNooW\Modules\Licensing\LicenseManager::get_licenses_by_order($id);
|
||
if (! empty($licenses)) {
|
||
$data['related_licenses'] = array_map(function ($lic) {
|
||
$product = wc_get_product($lic['product_id']);
|
||
return [
|
||
'id' => (int) $lic['id'],
|
||
'license_key' => $lic['license_key'],
|
||
'status' => $lic['status'],
|
||
'product_id' => (int) $lic['product_id'],
|
||
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'woonoow'),
|
||
];
|
||
}, $licenses);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Allow plugins to modify response (Level 1 compatibility)
|
||
$data = apply_filters('woonoow/order_api_data', $data, $order, $req);
|
||
|
||
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' => self::sanitize_field($billing['first_name'] ?? ''),
|
||
'last_name' => self::sanitize_field($billing['last_name'] ?? ''),
|
||
'email' => self::sanitize_email_field($billing['email'] ?? ''),
|
||
'phone' => self::sanitize_phone($billing['phone'] ?? ''),
|
||
'address_1' => self::sanitize_field($billing['address_1'] ?? ''),
|
||
'address_2' => self::sanitize_field($billing['address_2'] ?? ''),
|
||
'city' => self::sanitize_field($billing['city'] ?? ''),
|
||
'state' => self::sanitize_field($billing['state'] ?? ''),
|
||
'postcode' => self::sanitize_field($billing['postcode'] ?? ''),
|
||
'country' => self::sanitize_field($billing['country'] ?? ''),
|
||
], 'billing');
|
||
}
|
||
|
||
// === Shipping ===
|
||
if (is_array($shipping)) {
|
||
$order->set_address([
|
||
'first_name' => self::sanitize_field($shipping['first_name'] ?? ($shipping['name'] ?? '')),
|
||
'last_name' => self::sanitize_field($shipping['last_name'] ?? ''),
|
||
'phone' => self::sanitize_phone($shipping['phone'] ?? ''),
|
||
'address_1' => self::sanitize_field($shipping['address_1'] ?? ''),
|
||
'address_2' => self::sanitize_field($shipping['address_2'] ?? ''),
|
||
'city' => self::sanitize_field($shipping['city'] ?? ''),
|
||
'state' => self::sanitize_field($shipping['state'] ?? ''),
|
||
'postcode' => self::sanitize_field($shipping['postcode'] ?? ''),
|
||
'country' => self::sanitize_field($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 */
|
||
}
|
||
}
|
||
|
||
// Update custom meta fields (Level 1 compatibility)
|
||
if (isset($p['meta']) && is_array($p['meta'])) {
|
||
self::update_order_meta_data($order, $p['meta']);
|
||
}
|
||
|
||
// 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();
|
||
|
||
// Allow plugins to perform additional updates (Level 1 compatibility)
|
||
do_action('woonoow/order_updated', $order, $p, $req);
|
||
|
||
// 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) {
|
||
self::schedule_order_email($order_id, $status);
|
||
}, 999);
|
||
}
|
||
|
||
return new \WP_REST_Response(['ok' => true, 'id' => $order->get_id()], 200);
|
||
} catch (\Throwable $e) {
|
||
// Log the actual error for debugging
|
||
|
||
// 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) {
|
||
return;
|
||
}
|
||
|
||
// Schedule email notification with 15s delay
|
||
self::schedule_order_email($order_id, $status_to);
|
||
}
|
||
|
||
/**
|
||
* 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']) : '';
|
||
|
||
// Get auto-register setting from customer settings (site-level)
|
||
$customer_settings = \WooNooW\Compat\CustomerSettingsProvider::get_settings();
|
||
$register_member = $customer_settings['auto_register_members'] ?? 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 required based on checkout fields configuration
|
||
// Get checkout field settings to respect hidden/required status from PHP snippets
|
||
$checkout_fields = apply_filters('woonoow/checkout/fields', [], $items);
|
||
|
||
// Helper to check if a billing field is required
|
||
$is_field_required = function ($field_key) use ($checkout_fields) {
|
||
foreach ($checkout_fields as $field) {
|
||
if (isset($field['key']) && $field['key'] === $field_key) {
|
||
// Field is not required if hidden or explicitly not required
|
||
if (! empty($field['hidden']) || $field['type'] === 'hidden') {
|
||
return false;
|
||
}
|
||
return ! empty($field['required']);
|
||
}
|
||
}
|
||
// Default: core fields are required if not found in API
|
||
return true;
|
||
};
|
||
|
||
// Core billing fields - check against API configuration
|
||
if ($is_field_required('billing_first_name') && empty($billing['first_name'])) {
|
||
$validation_errors[] = __('Billing first name is required', 'woonoow');
|
||
}
|
||
if ($is_field_required('billing_last_name') && empty($billing['last_name'])) {
|
||
$validation_errors[] = __('Billing last name is required', 'woonoow');
|
||
}
|
||
if ($is_field_required('billing_email') && empty($billing['email'])) {
|
||
$validation_errors[] = __('Billing email is required', 'woonoow');
|
||
}
|
||
|
||
// Address fields only required for physical products AND if not hidden
|
||
if ($has_physical_product) {
|
||
if ($is_field_required('billing_address_1') && empty($billing['address_1'])) {
|
||
$validation_errors[] = __('Billing address is required', 'woonoow');
|
||
}
|
||
if ($is_field_required('billing_city') && empty($billing['city'])) {
|
||
$validation_errors[] = __('Billing city is required', 'woonoow');
|
||
}
|
||
if ($is_field_required('billing_postcode') && empty($billing['postcode'])) {
|
||
$validation_errors[] = __('Billing postcode is required', 'woonoow');
|
||
}
|
||
if ($is_field_required('billing_country') && empty($billing['country'])) {
|
||
$validation_errors[] = __('Billing country is required', 'woonoow');
|
||
}
|
||
}
|
||
|
||
// 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' => self::sanitize_field($billing['first_name'] ?? ''),
|
||
'last_name' => self::sanitize_field($billing['last_name'] ?? ''),
|
||
'email' => self::sanitize_email_field($billing['email'] ?? ''),
|
||
'phone' => self::sanitize_phone($billing['phone'] ?? ''),
|
||
'address_1' => self::sanitize_field($billing['address_1'] ?? ''),
|
||
'address_2' => self::sanitize_field($billing['address_2'] ?? ''),
|
||
'city' => self::sanitize_field($billing['city'] ?? ''),
|
||
'state' => self::sanitize_field($billing['state'] ?? ''),
|
||
'postcode' => self::sanitize_field($billing['postcode'] ?? ''),
|
||
'country' => self::sanitize_field($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' => self::sanitize_field($ship_addr['first_name'] ?? ($ship_addr['name'] ?? '')),
|
||
'last_name' => self::sanitize_field($ship_addr['last_name'] ?? ''),
|
||
'phone' => self::sanitize_phone($ship_addr['phone'] ?? ''),
|
||
'address_1' => self::sanitize_field($ship_addr['address_1'] ?? ''),
|
||
'address_2' => self::sanitize_field($ship_addr['address_2'] ?? ''),
|
||
'city' => self::sanitize_field($ship_addr['city'] ?? ''),
|
||
'state' => self::sanitize_field($ship_addr['state'] ?? ''),
|
||
'postcode' => self::sanitize_field($ship_addr['postcode'] ?? ''),
|
||
'country' => self::sanitize_field($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) {
|
||
}
|
||
}
|
||
|
||
// 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 - sanitize and validate each field
|
||
$first_name = self::sanitize_field($billing['first_name'] ?? '');
|
||
$last_name = self::sanitize_field($billing['last_name'] ?? '');
|
||
$email = self::sanitize_email_field($billing['email'] ?? '');
|
||
$phone = self::sanitize_phone($billing['phone'] ?? '');
|
||
$address_1 = self::sanitize_field($billing['address_1'] ?? '');
|
||
$address_2 = self::sanitize_field($billing['address_2'] ?? '');
|
||
$city = self::sanitize_field($billing['city'] ?? '');
|
||
$state = self::sanitize_field($billing['state'] ?? '');
|
||
$postcode = self::sanitize_field($billing['postcode'] ?? '');
|
||
$country = self::sanitize_field($billing['country'] ?? '');
|
||
|
||
// Only set if not empty (allow clearing fields by passing empty string)
|
||
if ($first_name !== '') $customer->set_billing_first_name($first_name);
|
||
if ($last_name !== '') $customer->set_billing_last_name($last_name);
|
||
if ($email !== '') $customer->set_billing_email($email);
|
||
if ($phone !== '') $customer->set_billing_phone($phone);
|
||
if ($address_1 !== '') $customer->set_billing_address_1($address_1);
|
||
if ($address_2 !== '') $customer->set_billing_address_2($address_2);
|
||
if ($city !== '') $customer->set_billing_city($city);
|
||
if ($state !== '') $customer->set_billing_state($state);
|
||
if ($postcode !== '') $customer->set_billing_postcode($postcode);
|
||
if ($country !== '') $customer->set_billing_country($country);
|
||
|
||
// Update shipping address (if provided)
|
||
if (! empty($shipping) && is_array($shipping)) {
|
||
$s_first_name = self::sanitize_field($shipping['first_name'] ?? '');
|
||
$s_last_name = self::sanitize_field($shipping['last_name'] ?? '');
|
||
$s_address_1 = self::sanitize_field($shipping['address_1'] ?? '');
|
||
$s_address_2 = self::sanitize_field($shipping['address_2'] ?? '');
|
||
$s_city = self::sanitize_field($shipping['city'] ?? '');
|
||
$s_state = self::sanitize_field($shipping['state'] ?? '');
|
||
$s_postcode = self::sanitize_field($shipping['postcode'] ?? '');
|
||
$s_country = self::sanitize_field($shipping['country'] ?? '');
|
||
|
||
if ($s_first_name !== '') $customer->set_shipping_first_name($s_first_name);
|
||
if ($s_last_name !== '') $customer->set_shipping_last_name($s_last_name);
|
||
if ($s_address_1 !== '') $customer->set_shipping_address_1($s_address_1);
|
||
if ($s_address_2 !== '') $customer->set_shipping_address_2($s_address_2);
|
||
if ($s_city !== '') $customer->set_shipping_city($s_city);
|
||
if ($s_state !== '') $customer->set_shipping_state($s_state);
|
||
if ($s_postcode !== '') $customer->set_shipping_postcode($s_postcode);
|
||
if ($s_country !== '') $customer->set_shipping_country($s_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 - sanitize and validate each field
|
||
$first_name = self::sanitize_field($billing['first_name'] ?? '');
|
||
$last_name = self::sanitize_field($billing['last_name'] ?? '');
|
||
$email = self::sanitize_email_field($billing['email'] ?? '');
|
||
$phone = self::sanitize_phone($billing['phone'] ?? '');
|
||
$address_1 = self::sanitize_field($billing['address_1'] ?? '');
|
||
$address_2 = self::sanitize_field($billing['address_2'] ?? '');
|
||
$city = self::sanitize_field($billing['city'] ?? '');
|
||
$state = self::sanitize_field($billing['state'] ?? '');
|
||
$postcode = self::sanitize_field($billing['postcode'] ?? '');
|
||
$country = self::sanitize_field($billing['country'] ?? '');
|
||
|
||
// Only set if not empty
|
||
if ($first_name !== '') $customer->set_billing_first_name($first_name);
|
||
if ($last_name !== '') $customer->set_billing_last_name($last_name);
|
||
if ($email !== '') $customer->set_billing_email($email);
|
||
if ($phone !== '') $customer->set_billing_phone($phone);
|
||
if ($address_1 !== '') $customer->set_billing_address_1($address_1);
|
||
if ($address_2 !== '') $customer->set_billing_address_2($address_2);
|
||
if ($city !== '') $customer->set_billing_city($city);
|
||
if ($state !== '') $customer->set_billing_state($state);
|
||
if ($postcode !== '') $customer->set_billing_postcode($postcode);
|
||
if ($country !== '') $customer->set_billing_country($country);
|
||
|
||
// Shipping address (if provided)
|
||
if (! empty($shipping) && is_array($shipping)) {
|
||
$s_first_name = self::sanitize_field($shipping['first_name'] ?? '');
|
||
$s_last_name = self::sanitize_field($shipping['last_name'] ?? '');
|
||
$s_address_1 = self::sanitize_field($shipping['address_1'] ?? '');
|
||
$s_address_2 = self::sanitize_field($shipping['address_2'] ?? '');
|
||
$s_city = self::sanitize_field($shipping['city'] ?? '');
|
||
$s_state = self::sanitize_field($shipping['state'] ?? '');
|
||
$s_postcode = self::sanitize_field($shipping['postcode'] ?? '');
|
||
$s_country = self::sanitize_field($shipping['country'] ?? '');
|
||
|
||
if ($s_first_name !== '') $customer->set_shipping_first_name($s_first_name);
|
||
if ($s_last_name !== '') $customer->set_shipping_last_name($s_last_name);
|
||
if ($s_address_1 !== '') $customer->set_shipping_address_1($s_address_1);
|
||
if ($s_address_2 !== '') $customer->set_shipping_address_2($s_address_2);
|
||
if ($s_city !== '') $customer->set_shipping_city($s_city);
|
||
if ($s_state !== '') $customer->set_shipping_state($s_state);
|
||
if ($s_postcode !== '') $customer->set_shipping_postcode($s_postcode);
|
||
if ($s_country !== '') $customer->set_shipping_country($s_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
|
||
|
||
// 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)));
|
||
|
||
// Use WP_Query for proper search support (wc_get_products doesn't support 's' parameter)
|
||
$args = [
|
||
'post_type' => ['product'],
|
||
'post_status' => 'publish',
|
||
'posts_per_page' => $limit,
|
||
];
|
||
if ($s) {
|
||
$args['s'] = $s;
|
||
}
|
||
|
||
$query = new \WP_Query($args);
|
||
$prods = array_filter(array_map('wc_get_product', $query->posts));
|
||
$rows = array_map(function ($p) {
|
||
$data = [
|
||
'id' => $p->get_id(),
|
||
'name' => $p->get_name(),
|
||
'type' => $p->get_type(),
|
||
'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(),
|
||
'variations' => [],
|
||
];
|
||
|
||
// If variable product, include variations
|
||
if ($p->get_type() === 'variable') {
|
||
$variation_ids = $p->get_children();
|
||
$parent_attributes = $p->get_attributes();
|
||
|
||
foreach ($variation_ids as $variation_id) {
|
||
$variation = wc_get_product($variation_id);
|
||
if (! $variation) continue;
|
||
|
||
// Get variation attributes properly from parent attributes
|
||
$formatted_attributes = [];
|
||
|
||
foreach ($parent_attributes as $parent_attr) {
|
||
if (! $parent_attr->get_variation()) {
|
||
continue; // Skip non-variation attributes
|
||
}
|
||
|
||
$attr_name = $parent_attr->get_name();
|
||
$clean_name = $attr_name;
|
||
|
||
// Get the variation's value for this attribute
|
||
if (strpos($attr_name, 'pa_') === 0) {
|
||
// Global/taxonomy attribute
|
||
$clean_name = wc_attribute_label($attr_name);
|
||
$value = $variation->get_attribute($attr_name);
|
||
|
||
// Convert slug to term name
|
||
if (! empty($value)) {
|
||
$term = get_term_by('slug', $value, $attr_name);
|
||
$value = $term ? $term->name : $value;
|
||
}
|
||
} else {
|
||
// Custom attribute - WooCommerce stores as 'attribute_' + lowercase sanitized name
|
||
$meta_key = 'attribute_' . sanitize_title($attr_name);
|
||
$value = get_post_meta($variation_id, $meta_key, true);
|
||
|
||
// Capitalize the attribute name for display
|
||
$clean_name = ucfirst($attr_name);
|
||
}
|
||
|
||
$formatted_attributes[$clean_name] = $value;
|
||
}
|
||
|
||
$data['variations'][] = [
|
||
'id' => $variation->get_id(),
|
||
'attributes' => $formatted_attributes,
|
||
'price' => (float) $variation->get_price(),
|
||
'regular_price' => (float) $variation->get_regular_price(),
|
||
'sale_price' => $variation->get_sale_price() ? (float) $variation->get_sale_price() : null,
|
||
'sku' => $variation->get_sku(),
|
||
'stock' => $variation->get_stock_quantity(),
|
||
'in_stock' => $variation->is_in_stock(),
|
||
];
|
||
}
|
||
}
|
||
|
||
return $data;
|
||
}, $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);
|
||
|
||
/**
|
||
* Allow shipping addons to prepare session/data before shipping calculation.
|
||
*
|
||
* This hook allows third-party shipping plugins (like Rajaongkir, Biteship, etc.)
|
||
* to set any session variables or prepare data they need before WooCommerce
|
||
* calculates shipping rates.
|
||
*
|
||
* @since 1.0.0
|
||
* @param array $shipping The shipping address data from frontend (country, state, city, postcode, address_1, etc.)
|
||
* @param array $items The cart items being shipped
|
||
*/
|
||
do_action('woonoow/shipping/before_calculate', $shipping, $items ?? []);
|
||
}
|
||
|
||
// Calculate shipping
|
||
WC()->cart->calculate_shipping();
|
||
WC()->cart->calculate_totals();
|
||
|
||
// Get available shipping packages and rates
|
||
$packages = WC()->shipping()->get_packages();
|
||
$rates = [];
|
||
|
||
foreach ($packages as $package_key => $package) {
|
||
$package_rates = $package['rates'] ?? [];
|
||
|
||
foreach ($package_rates as $rate_id => $rate) {
|
||
/** @var \WC_Shipping_Rate $rate */
|
||
$rates[] = [
|
||
'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(),
|
||
];
|
||
}
|
||
}
|
||
|
||
// Fallback: If no rates from packages, manually calculate from matching zone
|
||
if (empty($rates) && ! empty($shipping['country'])) {
|
||
$package = [
|
||
'destination' => [
|
||
'country' => $shipping['country'] ?? '',
|
||
'state' => $shipping['state'] ?? '',
|
||
'postcode' => $shipping['postcode'] ?? '',
|
||
'city' => $shipping['city'] ?? '',
|
||
],
|
||
'contents' => WC()->cart->get_cart(),
|
||
'contents_cost' => WC()->cart->get_subtotal(),
|
||
'applied_coupons' => WC()->cart->get_applied_coupons(),
|
||
'user' => ['ID' => get_current_user_id()],
|
||
];
|
||
|
||
$zone = \WC_Shipping_Zones::get_zone_matching_package($package);
|
||
if ($zone) {
|
||
foreach ($zone->get_shipping_methods(true) as $method) {
|
||
if (method_exists($method, 'get_rates_for_package')) {
|
||
$method_rates = $method->get_rates_for_package($package);
|
||
foreach ($method_rates as $rate_id => $rate) {
|
||
$rates[] = [
|
||
'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(),
|
||
];
|
||
}
|
||
} else {
|
||
// Fallback for methods without get_rates_for_package (like Free Shipping)
|
||
$method_id = $method->id . ':' . $method->instance_id;
|
||
$rates[] = [
|
||
'id' => $method_id,
|
||
'method_id' => $method->id,
|
||
'instance_id' => $method->instance_id,
|
||
'label' => $method->get_title(),
|
||
'cost' => $method->id === 'free_shipping' ? 0 : (float) ($method->cost ?? 0),
|
||
'taxes' => [],
|
||
'meta_data' => [],
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clean up
|
||
WC()->cart->empty_cart();
|
||
|
||
return new \WP_REST_Response([
|
||
'methods' => $rates, // Keep as 'methods' for frontend compatibility
|
||
'has_methods' => ! empty($rates),
|
||
'debug' => [
|
||
'packages_count' => count($packages),
|
||
'cart_items_count' => count(WC()->cart->get_cart()),
|
||
'address' => $shipping,
|
||
],
|
||
], 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')) {
|
||
$gateways_handler = WC()->payment_gateways();
|
||
if ($gateways_handler) {
|
||
$all_gateways = $gateways_handler->payment_gateways();
|
||
|
||
// First, check if it's a direct gateway ID
|
||
if (isset($all_gateways[$payment_method_id])) {
|
||
$gateway = $all_gateways[$payment_method_id];
|
||
return $gateway->get_title() ?: $gateway->method_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) {
|
||
$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])) {
|
||
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')) {
|
||
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');
|
||
|
||
|
||
// 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'));
|
||
} 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));
|
||
}
|
||
|
||
$order->save();
|
||
}
|
||
|
||
return $result;
|
||
} catch (\Throwable $e) {
|
||
$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);
|
||
}
|
||
|
||
/**
|
||
* Get order meta data for API exposure (Level 1 compatibility)
|
||
* Filters out internal meta unless explicitly allowed
|
||
*
|
||
* @param \WC_Order $order
|
||
* @return array
|
||
*/
|
||
private static function get_order_meta_data($order)
|
||
{
|
||
$meta_data = [];
|
||
|
||
foreach ($order->get_meta_data() as $meta) {
|
||
$key = $meta->key;
|
||
$value = $meta->value;
|
||
|
||
// Skip internal WooCommerce meta (starts with _wc_)
|
||
if (strpos($key, '_wc_') === 0) {
|
||
continue;
|
||
}
|
||
|
||
// Skip WooNooW internal meta
|
||
if (strpos($key, '_woonoow_') === 0) {
|
||
continue;
|
||
}
|
||
|
||
// Public meta (no underscore) - always expose
|
||
if (strpos($key, '_') !== 0) {
|
||
$meta_data[$key] = $value;
|
||
continue;
|
||
}
|
||
|
||
// Private meta (starts with _) - check if allowed
|
||
// Core has ZERO defaults - plugins register via filter
|
||
$allowed_private = apply_filters('woonoow/order_allowed_private_meta', [], $order);
|
||
|
||
if (in_array($key, $allowed_private, true)) {
|
||
$meta_data[$key] = $value;
|
||
}
|
||
}
|
||
|
||
return $meta_data;
|
||
}
|
||
|
||
/**
|
||
* Update order meta data from API (Level 1 compatibility)
|
||
*
|
||
* @param \WC_Order $order
|
||
* @param array $meta_updates
|
||
*/
|
||
private static function update_order_meta_data($order, $meta_updates)
|
||
{
|
||
// Get allowed updatable meta keys
|
||
// Core has ZERO defaults - plugins register via filter
|
||
$allowed = apply_filters('woonoow/order_updatable_meta', [], $order);
|
||
|
||
foreach ($meta_updates as $key => $value) {
|
||
// Skip internal WooCommerce meta
|
||
if (strpos($key, '_wc_') === 0) {
|
||
continue;
|
||
}
|
||
|
||
// Skip WooNooW internal meta
|
||
if (strpos($key, '_woonoow_') === 0) {
|
||
continue;
|
||
}
|
||
|
||
// Public meta (no underscore) - always allow
|
||
if (strpos($key, '_') !== 0) {
|
||
$order->update_meta_data($key, $value);
|
||
continue;
|
||
}
|
||
|
||
// Private meta - check if allowed
|
||
if (in_array($key, $allowed, true)) {
|
||
$order->update_meta_data($key, $value);
|
||
}
|
||
}
|
||
}
|
||
}
|