File: //wordpress/plugins/wp-cloud-client/1.1.9/src/Integration/WooCommerceProductCaller.php
<?php
declare(strict_types=1);
namespace VPlugins\WPCloudClient\Integration;
use VPlugins\WPCloudClient\Settings\SettingsRepository;
use VPlugins\WPCloudClient\Support\Logger;
/**
* Sends WooCommerce product events to wp-cloud-manager via HTTP.
*
* Handles new products, updates, and deletions. Schedules a single WP-Cron
* retry on transport failure or 5xx response so that product saves are never
* blocked by a slow or unavailable manager.
*
* TODO: ~95% of send/dispatch/scheduleRetry/buildUrl logic is duplicated in
* WooCommerceOrderCaller. Extract a shared WPCloudHttpClient service to keep
* future fixes from needing to land in two places.
*/
final class WooCommerceProductCaller {
private const RETRY_HOOK = 'wpcc_wc_product_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 product hooks and the WP-Cron retry listener.
*
* @return void
*/
public function register(): void {
add_action( 'woocommerce_new_product', [ $this, 'onNewProduct' ], 10, 2 );
add_action( 'woocommerce_update_product', [ $this, 'onUpdateProduct' ], 10, 2 );
add_action( 'woocommerce_trash_product', [ $this, 'onTrashProduct' ], 10, 1 );
add_action( 'woocommerce_delete_product', [ $this, 'onDeleteProduct' ], 10, 1 );
add_action( self::RETRY_HOOK, [ $this, 'executeRetry' ], 10, 3 );
}
/**
* Send a new product to wp-cloud-manager.
*
* @internal WooCommerce hook: woocommerce_new_product
*
* @param int $productId Product ID.
* @param object $product WC_Product instance.
* @return void
*/
public function onNewProduct( int $productId, object $product ): void {
$payload = WooCommerceHelper::extractProduct( $product );
$modifiedTs = $this->productModifiedTs( $product );
$this->dispatch( 'register-wc-product', $payload, $productId, 'new', $modifiedTs );
}
/**
* Send a product update to wp-cloud-manager.
*
* @internal WooCommerce hook: woocommerce_update_product
*
* @param int $productId Product ID.
* @param object $product WC_Product instance.
* @return void
*/
public function onUpdateProduct( int $productId, object $product ): void {
$payload = WooCommerceHelper::extractProduct( $product );
$modifiedTs = $this->productModifiedTs( $product );
$this->dispatch( 'register-wc-product', $payload, $productId, 'updated', $modifiedTs );
}
/**
* Send a trashed product deletion to wp-cloud-manager.
*
* @internal WooCommerce hook: woocommerce_trash_product
*
* @param int $productId Product ID.
* @return void
*/
public function onTrashProduct( int $productId ): void {
$siteId = (string) $this->settings->getSiteId();
$payload = [
'site_id' => $siteId,
'id' => $productId,
];
$this->dispatch( 'delete-wc-product', $payload, $productId, 'trash' );
}
/**
* Send a permanently deleted product to wp-cloud-manager.
*
* @internal WooCommerce hook: woocommerce_delete_product
*
* @param int $productId Product ID.
* @return void
*/
public function onDeleteProduct( int $productId ): void {
$siteId = (string) $this->settings->getSiteId();
$payload = [
'site_id' => $siteId,
'id' => $productId,
];
$this->dispatch( 'delete-wc-product', $payload, $productId, 'delete' );
}
/**
* Execute a scheduled one-shot retry for a failed product POST.
*
* @internal WP-Cron hook: wpcc_wc_product_retry
*
* Executes a single retry for a previously failed product POST. Does not
* reschedule further on failure — one retry per original event.
*
* @param string $route Route segment, e.g. 'register-wc-product'.
* @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 product 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 product no longer exists to provide a timestamp).
*
* @param string $route Route segment.
* @param array<string, mixed> $payload Request body.
* @param int $id Product 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 product 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 product 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 product 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 product caller unauthorized — check site ID, API key, and route strategy', wp_remote_retrieve_body( $response ) );
} elseif ( 400 === $code ) {
$this->logger->error( 'WC product caller bad request — malformed payload', wp_remote_retrieve_body( $response ) );
} elseif ( 405 === $code ) {
$this->logger->error( 'WC product caller method not allowed — route misconfiguration', $url );
} elseif ( 202 !== $code ) {
$this->logger->error(
'WC product 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 product 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-product'.
* @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 . '/';
}
/**
* Get the modification timestamp from a WC_Product object for the idempotency key.
*
* @param object $product WC_Product instance.
* @return string RFC3339 timestamp string, or current Unix timestamp as fallback.
*/
private function productModifiedTs( object $product ): string {
if ( method_exists( $product, 'get_date_modified' ) ) {
$date = $product->get_date_modified();
if ( $date && method_exists( $date, 'format' ) ) {
return $date->format( 'c' );
}
}
return (string) time();
}
}