File: //wordpress/plugins/wp-cloud-client/1.2.1/src/Integration/WooCommerceOrderCaller.php
<?php
declare(strict_types=1);
namespace VPlugins\WPCloudClient\Integration;
use VPlugins\WPCloudClient\Settings\SettingsRepository;
use VPlugins\WPCloudClient\Support\Logger;
/**
* Sends WooCommerce order events to wp-cloud-manager via HTTP.
*
* Handles new orders, status changes, updates, deletions, and item removals.
* Schedules a single WP-Cron retry on transport failure or 5xx response so
* that WooCommerce checkout is never blocked by a slow or unavailable manager.
*
* TODO: ~95% of send/dispatch/scheduleRetry/buildUrl logic is duplicated in
* WooCommerceProductCaller. Extract a shared WPCloudHttpClient service to keep
* future fixes from needing to land in two places.
*/
final class WooCommerceOrderCaller {
private const RETRY_HOOK = 'wpcc_wc_order_retry';
/**
* Class constructor.
*
* @param SettingsRepository $settings Plugin settings.
* @param Logger $logger Logger service.
*/
public function __construct(
private readonly SettingsRepository $settings,
private readonly Logger $logger
) {}
/**
* Register all WooCommerce order hooks and the WP-Cron retry listener.
*
* @return void
*/
public function register(): void {
// TODO: Replace string route literals with a Routes enum (src/Enum/) to get
// exhaustiveness checks and eliminate the duplication with ProductCaller.
add_action( 'woocommerce_checkout_order_created', [ $this, 'onNewOrder' ], 10, 1 );
add_action( 'woocommerce_order_status_changed', [ $this, 'onOrderStatusChanged' ], 10, 4 );
add_action( 'woocommerce_update_order', [ $this, 'onUpdateOrder' ], 10, 1 );
add_action( 'woocommerce_trash_order', [ $this, 'onTrashOrder' ], 10, 1 );
add_action( 'woocommerce_delete_order', [ $this, 'onDeleteOrder' ], 10, 1 );
add_action( 'woocommerce_remove_order_item', [ $this, 'onRemoveOrderItem' ], 10, 2 );
add_action( self::RETRY_HOOK, [ $this, 'executeRetry' ], 10, 3 );
}
/**
* Send a new checkout order to wp-cloud-manager.
*
* @internal WooCommerce hook: woocommerce_checkout_order_created
*
* @param object $order WC_Order instance.
* @return void
*/
public function onNewOrder( object $order ): void {
// TODO: Consider offloading to an immediate WP-Cron event to avoid blocking
// checkout for up to 5 s on a slow or unhealthy manager.
$payload = WooCommerceHelper::extractOrder( $order );
$this->dispatch( 'register-wc-order', $payload, (int) $order->get_id(), 'new', $this->orderModifiedTs( $payload ) );
}
/**
* Send an order status change to wp-cloud-manager.
*
* @internal WooCommerce hook: woocommerce_order_status_changed
*
* @param int $orderId Order ID.
* @param string $oldStatus Previous status slug.
* @param string $newStatus New status slug.
* @param object $order WC_Order instance.
* @return void
*/
public function onOrderStatusChanged( int $orderId, string $oldStatus, string $newStatus, object $order ): void {
$payload = WooCommerceHelper::extractOrder( $order );
$this->dispatch( 'update-wc-order', $payload, $orderId, 'status_changed', $this->orderModifiedTs( $payload ) );
}
/**
* Send an order update to wp-cloud-manager.
*
* @internal WooCommerce hook: woocommerce_update_order
*
* @param int $orderId Order ID.
* @return void
*/
public function onUpdateOrder( int $orderId ): void {
$order = wc_get_order( $orderId );
if ( ! $order ) {
return;
}
$payload = WooCommerceHelper::extractOrder( $order );
$this->dispatch( 'update-wc-order', $payload, $orderId, 'updated', $this->orderModifiedTs( $payload ) );
}
/**
* Send a trashed order deletion to wp-cloud-manager.
*
* @internal WooCommerce hook: woocommerce_trash_order
*
* @param int $orderId Order ID.
* @return void
*/
public function onTrashOrder( int $orderId ): void {
$siteId = (string) $this->settings->getSiteId();
$payload = [
'site_id' => $siteId,
'id' => $orderId,
];
$this->dispatch( 'delete-wc-order', $payload, $orderId, 'trash' );
}
/**
* Send a permanently deleted order to wp-cloud-manager.
*
* @internal WooCommerce hook: woocommerce_delete_order
*
* @param int $orderId Order ID.
* @return void
*/
public function onDeleteOrder( int $orderId ): void {
$siteId = (string) $this->settings->getSiteId();
$payload = [
'site_id' => $siteId,
'id' => $orderId,
];
$this->dispatch( 'delete-wc-order', $payload, $orderId, 'delete' );
}
/**
* Send a removed order item deletion to wp-cloud-manager.
*
* @internal WooCommerce hook: woocommerce_remove_order_item
*
* @param int $itemId Order item ID.
* @param object $item WC_Order_Item instance.
* @return void
*/
public function onRemoveOrderItem( int $itemId, object $item ): void {
$siteId = (string) $this->settings->getSiteId();
$orderId = (int) $item->get_order_id();
$productId = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0;
$payload = [
'site_id' => $siteId,
'order_id' => $orderId,
'product_id' => $productId,
];
$this->dispatch( 'delete-wc-order-item', $payload, $orderId, 'remove_item' );
}
/**
* Execute a scheduled one-shot retry for a failed order POST.
*
* @internal WP-Cron hook: wpcc_wc_order_retry
*
* Executes a single retry for a previously failed order POST. Does not
* reschedule further on failure — one retry per original event.
*
* @param string $route Route segment, e.g. 'update-wc-order'.
* @param string $payloadJson JSON-encoded payload from the original attempt.
* @param string $idempotencyKey Original idempotency key to reuse on retry.
* @return void
*/
public function executeRetry( string $route, string $payloadJson, string $idempotencyKey ): void {
$url = $this->buildUrl( $route );
$payload = json_decode( $payloadJson, true );
if ( ! is_array( $payload ) ) {
$this->logger->error( 'WC order caller retry skipped — corrupt payload JSON', $route );
return;
}
$this->send( $url, $payload, $idempotencyKey );
}
/**
* Build the full URL, compute the idempotency key, and call send().
*
* Delete events omit the modification timestamp from the key so it stays
* stable across retries (the order no longer exists to provide a timestamp).
*
* @param string $route Route segment.
* @param array<string, mixed> $payload Request body.
* @param int $id Order ID for the idempotency key.
* @param string $event Event type for the idempotency key.
* @param string $modifiedTs Modification timestamp; omit for delete events.
* @return void
*/
private function dispatch( string $route, array $payload, int $id, string $event, string $modifiedTs = '' ): void {
$url = $this->buildUrl( $route );
$siteId = (string) $this->settings->getSiteId();
$parts = [ $siteId, $id, $event ];
if ( '' !== $modifiedTs ) {
$parts[] = $modifiedTs;
}
$this->send( $url, $payload, implode( ':', $parts ), $route );
}
/**
* Execute the HTTP POST and handle the response.
*
* @param string $url Full endpoint URL.
* @param array<string, mixed> $payload JSON body.
* @param string $idempotencyKey X-WPCC-Idempotency-Key header value.
* @param string $retryRoute Route to schedule for retry on failure; empty disables retry.
* @return void
*/
private function send( string $url, array $payload, string $idempotencyKey, string $retryRoute = '' ): void {
if ( empty( $url ) ) {
return;
}
if ( ! $this->settings->isConfigured() ) {
$this->logger->warning( 'WC order caller skipped — site ID or API key not configured', $url );
return;
}
$headers = [
'Content-Type' => 'application/json',
'X-WPCC-Site-ID' => (string) $this->settings->getSiteId(),
'X-WPCC-API-Key' => (string) $this->settings->getApiKey(),
'X-WPCC-Idempotency-Key' => $idempotencyKey,
];
$response = wp_remote_post(
$url,
[
'body' => wp_json_encode( $payload ),
'data_format' => 'body',
'headers' => $headers,
'timeout' => 5,
]
);
if ( is_wp_error( $response ) ) {
$this->logger->error( 'WC order caller transport error', $response->get_error_message() );
if ( '' !== $retryRoute ) {
$this->scheduleRetry( $retryRoute, $payload, $idempotencyKey );
}
return;
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( $code >= 500 ) {
$this->logger->error(
'WC order caller server error',
[
'status' => $code,
'url' => $url,
]
);
if ( '' !== $retryRoute ) {
$this->scheduleRetry( $retryRoute, $payload, $idempotencyKey );
}
} elseif ( 401 === $code ) {
// Three possible reasons: missing auth headers, invalid credentials, or site not eligible.
// Log the body so on-call can distinguish credential bugs from routing-not-yet-flipped.
$this->logger->error( 'WC order caller unauthorized — check site ID, API key, and route strategy', wp_remote_retrieve_body( $response ) );
} elseif ( 400 === $code ) {
$this->logger->error( 'WC order caller bad request — malformed payload', wp_remote_retrieve_body( $response ) );
} elseif ( 405 === $code ) {
$this->logger->error( 'WC order caller method not allowed — route misconfiguration', $url );
} elseif ( 202 !== $code ) {
$this->logger->error(
'WC order caller unexpected response',
[
'status' => $code,
'url' => $url,
]
);
}
}
/**
* Schedule a single WP-Cron retry 60 seconds from now.
*
* Avoids scheduling a duplicate if one is already pending for the same
* route + payload + idempotency-key combination. Stores the idempotency key
* so executeRetry can reuse it and avoid sending a different key on retry.
*
* @param string $route Route segment for the retry.
* @param array<string, mixed> $payload Original payload.
* @param string $idempotencyKey Original idempotency key.
* @return void
*/
private function scheduleRetry( string $route, array $payload, string $idempotencyKey ): void {
$payloadJson = (string) wp_json_encode( $payload );
if ( ! wp_next_scheduled( self::RETRY_HOOK, [ $route, $payloadJson, $idempotencyKey ] ) ) {
$scheduled = wp_schedule_single_event( time() + 60, self::RETRY_HOOK, [ $route, $payloadJson, $idempotencyKey ] );
if ( false === $scheduled ) {
$this->logger->error( 'WC order caller failed to schedule retry', $route );
}
}
}
/**
* Build the full endpoint URL from the manager base URL and route segment.
*
* @param string $route Route segment, e.g. 'register-wc-order'.
* @return string Full URL, or empty string if manager URL is not configured.
*/
private function buildUrl( string $route ): string {
$managerUrl = $this->settings->getManagerUrl();
if ( empty( $managerUrl ) ) {
return '';
}
return rtrim( $managerUrl, '/' ) . '/wordpress/v1/' . $route . '/';
}
/**
* Extract the date_modified field from an already-built order payload array.
*
* @param array<string, mixed> $payload Payload produced by WooCommerceHelper::extractOrder().
* @return string RFC3339 timestamp string, or current Unix timestamp as fallback.
*/
private function orderModifiedTs( array $payload ): string {
return isset( $payload['date_modified'] ) && is_string( $payload['date_modified'] )
? $payload['date_modified']
: (string) time();
}
}