HEX
Server: nginx
System: Linux pool195-106-36.bur.atomicsites.net 6.12.57+deb12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1~bpo12+1 (2025-11-17) x86_64
User: (0)
PHP: 8.3.32
Disabled: pcntl_fork
Upload Files
File: /wordpress/plugins/wp-cloud-client/1.1.6/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();
	}
}