diff --git a/includes/class-tack-api-client.php b/includes/class-tack-api-client.php
index 6769c0d..ef32aca 100755
--- a/includes/class-tack-api-client.php
+++ b/includes/class-tack-api-client.php
@@ -92,7 +92,33 @@ public function request( $method, $path, $body = null, $timeout = null, $headers
$message = is_array( $data ) && isset( $data['message'] )
? ( is_array( $data['message'] ) ? implode( ', ', $data['message'] ) : $data['message'] )
: sprintf( /* translators: %d: HTTP status code */ __( 'TackQuote API returned HTTP %d.', 'tackquote' ), $code );
- return new WP_Error( 'tack_http_' . $code, $message );
+
+ /*
+ * What the API SAID, kept beside the sentence, because the caller has to decide
+ * whether trying again can ever help (see Tack_Sync_Gate). A JSON 403 naming a
+ * missing scope will be refused forever; a 403 HTML page from a firewall in front
+ * of the API will not. Only the status and the API's own machine fields are
+ * kept, never the request or the key.
+ */
+ $retry_after = wp_remote_retrieve_header( $response, 'retry-after' );
+ return new WP_Error(
+ 'tack_http_' . $code,
+ $message,
+ array(
+ 'status' => $code,
+ 'json' => is_array( $data ),
+ // TackQuote's own errors echo the status in the body; a proxy's JSON does not.
+ 'statusCode' => is_array( $data ) && isset( $data['statusCode'] ) && is_numeric( $data['statusCode'] ) ? (int) $data['statusCode'] : 0,
+ 'code' => is_array( $data ) && isset( $data['code'] ) && is_string( $data['code'] ) ? $data['code'] : '',
+ 'requiredScopes' => is_array( $data ) && isset( $data['requiredScopes'] ) && is_array( $data['requiredScopes'] )
+ ? array_values( array_filter( $data['requiredScopes'], 'is_string' ) )
+ : array(),
+ 'retryAfterSeconds' => is_array( $data ) && isset( $data['retryAfterSeconds'] ) && is_numeric( $data['retryAfterSeconds'] )
+ ? (int) $data['retryAfterSeconds']
+ : 0,
+ 'retryAfterHeader' => is_array( $retry_after ) ? (string) reset( $retry_after ) : (string) $retry_after,
+ )
+ );
}
return is_array( $data ) ? $data : array();
diff --git a/includes/class-tack-order-sync.php b/includes/class-tack-order-sync.php
index 04a6a0a..26f1c94 100755
--- a/includes/class-tack-order-sync.php
+++ b/includes/class-tack-order-sync.php
@@ -37,6 +37,23 @@ class Tack_Order_Sync {
*/
const SYNC_KEY_META = '_tack_quotes_sync_key';
+ /**
+ * Action that re-queues the orders skipped while TackQuote refused order sync.
+ */
+ const REQUEUE_HOOK = 'tack_quotes_requeue_unsynced';
+
+ /**
+ * Most orders one re-queue will look at. Bounded so a long outage on a busy store
+ * cannot enqueue thousands of jobs at once; what is left over is logged, and each of
+ * those orders is still pushed the next time it changes.
+ */
+ const REQUEUE_LIMIT = 200;
+
+ /**
+ * How far back a re-queue reaches at most, whatever the block's start time says.
+ */
+ const REQUEUE_MAX_AGE = 2592000; // 30 days.
+
/**
* Whether outbound order sync is switched on.
*
@@ -96,6 +113,71 @@ public function init() {
*/
public function register_worker() {
add_action( self::SYNC_HOOK, array( $this, 'run_sync' ), 10, 1 );
+ // Registered with the worker, not init(): a merchant who switches sync off while it
+ // is refused still deserves to know why orders stopped arriving.
+ add_action( 'admin_notices', array( 'Tack_Sync_Gate', 'render_admin_notice' ) );
+
+ // A refusal lifted (a push succeeded, or a different key was saved): re-send what
+ // was skipped meanwhile, off the request that lifted it.
+ add_action( Tack_Sync_Gate::UNBLOCKED_ACTION, array( $this, 'schedule_requeue' ), 10, 1 );
+ add_action( self::REQUEUE_HOOK, array( $this, 'requeue_unsynced' ), 10, 1 );
+ add_action( 'update_option_tack_quotes_api_key', array( 'Tack_Sync_Gate', 'on_api_key_changed' ), 10, 2 );
+ }
+
+ /**
+ * Queue one re-queue job for the orders skipped since `$since`.
+ *
+ * @param int $since Unix time pushes started being held.
+ */
+ public function schedule_requeue( $since ) {
+ $args = array( (int) $since );
+ if ( function_exists( 'as_enqueue_async_action' ) ) {
+ as_enqueue_async_action( self::REQUEUE_HOOK, $args, self::SYNC_GROUP, true );
+ return;
+ }
+ if ( ! wp_next_scheduled( self::REQUEUE_HOOK, $args ) ) {
+ wp_schedule_single_event( time() + 1, self::REQUEUE_HOOK, $args );
+ }
+ }
+
+ /**
+ * Re-queue the orders modified since `$since`. Each goes through the normal worker,
+ * which skips an order whose current state was already accepted (its sync key), so
+ * re-queuing an order that did get through costs one meta read and no request.
+ *
+ * `date_modified => '>' . timestamp` is the documented wc_get_orders() form
+ * (woocommerce/docs/features/orders/wc-get-orders.md).
+ *
+ * @param int $since Unix time pushes started being held.
+ */
+ public function requeue_unsynced( $since ) {
+ if ( ! self::is_enabled() || ! function_exists( 'wc_get_orders' ) ) {
+ return;
+ }
+ $floor = max( (int) $since - 60, time() - self::REQUEUE_MAX_AGE );
+ $ids = wc_get_orders(
+ array(
+ 'type' => 'shop_order',
+ 'date_modified' => '>' . $floor,
+ 'orderby' => 'modified',
+ 'order' => 'ASC',
+ 'limit' => self::REQUEUE_LIMIT + 1,
+ 'return' => 'ids',
+ )
+ );
+ $ids = is_array( $ids ) ? $ids : array();
+ if ( count( $ids ) > self::REQUEUE_LIMIT && function_exists( 'wc_get_logger' ) && wc_get_logger() ) {
+ wc_get_logger()->warning(
+ sprintf(
+ 'TackQuote order sync resumed: re-sending the %d oldest orders changed since it was refused; later ones are sent when they next change.',
+ self::REQUEUE_LIMIT
+ ),
+ array( 'source' => 'tackquote' )
+ );
+ }
+ foreach ( array_slice( $ids, 0, self::REQUEUE_LIMIT ) as $order_id ) {
+ $this->enqueue( (int) $order_id );
+ }
}
/**
@@ -187,10 +269,24 @@ public function run_sync( $order_id ) {
return;
}
+ /*
+ * TackQuote already refused this key in a way no retry can change (a missing
+ * `orders:write` scope, a revoked key, a lapsed subscription), or asked us to slow
+ * down. Do not send: the order is left unmarked, so its next trigger pushes it once
+ * the merchant has acted. See Tack_Sync_Gate for the classification and why a
+ * refusal used to turn into a request every few seconds.
+ */
+ $api_key = (string) get_option( 'tack_quotes_api_key', '' );
+ $now = time();
+ if ( null !== Tack_Sync_Gate::active_block( $api_key, $now ) ) {
+ return;
+ }
+
$payload['idempotencyKey'] = $key;
$result = ( new Tack_Api_Client() )->sync_order( $payload, $key );
if ( is_wp_error( $result ) ) {
+ Tack_Sync_Gate::record_failure( $result, $api_key, $now );
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->error(
sprintf(
@@ -210,6 +306,7 @@ public function run_sync( $order_id ) {
// update_post_meta(), because with HPOS enabled orders do not live in wp_postmeta.
$order->update_meta_data( self::SYNC_KEY_META, $key );
$order->save_meta_data();
+ Tack_Sync_Gate::record_success();
}
/**
diff --git a/includes/class-tack-quotes.php b/includes/class-tack-quotes.php
index 6fdb10f..74b177e 100755
--- a/includes/class-tack-quotes.php
+++ b/includes/class-tack-quotes.php
@@ -12,6 +12,7 @@
require_once TACK_QUOTES_DIR . 'includes/class-tack-settings.php';
require_once TACK_QUOTES_DIR . 'includes/class-tack-api-client.php';
require_once TACK_QUOTES_DIR . 'includes/class-tack-widget.php';
+require_once TACK_QUOTES_DIR . 'includes/class-tack-sync-gate.php';
require_once TACK_QUOTES_DIR . 'includes/class-tack-order-sync.php';
require_once TACK_QUOTES_DIR . 'includes/class-tack-catalog-mode.php';
require_once TACK_QUOTES_DIR . 'includes/class-tack-wholesale-pricing.php';
diff --git a/includes/class-tack-sync-gate.php b/includes/class-tack-sync-gate.php
new file mode 100644
index 0000000..6f1ec45
--- /dev/null
+++ b/includes/class-tack-sync-gate.php
@@ -0,0 +1,334 @@
+get_error_data();
+ $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0;
+ $block = array(
+ 'status' => $status,
+ 'code' => is_array( $data ) && isset( $data['code'] ) ? (string) $data['code'] : '',
+ 'scopes' => is_array( $data ) && isset( $data['requiredScopes'] ) && is_array( $data['requiredScopes'] ) ? array_values( $data['requiredScopes'] ) : array(),
+ 'message' => (string) $error->get_error_message(),
+ 'at' => (int) $now,
+ );
+
+ /*
+ * Only TackQuote's OWN error body may make a refusal terminal: JSON whose
+ * `statusCode` equals the HTTP status and which carries a non-empty `code` (the
+ * shape every TackQuote API error has). A proxy, WAF or load balancer that happens
+ * to answer 403 with some JSON of its own is not TackQuote saying "never", and
+ * goes away on its own.
+ */
+ $from_tackquote = is_array( $data )
+ && ! empty( $data['json'] )
+ && isset( $data['statusCode'] ) && (int) $data['statusCode'] === $status
+ && '' !== $block['code'];
+
+ if ( 429 === $status ) {
+ $wait = self::wait_seconds( $data, $now );
+ /*
+ * TackQuote answers a key that keeps hitting a missing scope with 429 instead of
+ * 403 after a few refusals. It is still the same terminal refusal, so it stays
+ * terminal — otherwise the wp-admin notice would flicker off for each throttle
+ * window — and it is held at least as long as any terminal block.
+ */
+ if ( $from_tackquote && ( 'insufficient_scope' === $block['code'] || array() !== $block['scopes'] ) ) {
+ $block['kind'] = 'terminal';
+ $block['until'] = max( (int) $now + $wait, (int) $now + self::TERMINAL_REPROBE );
+ return $block;
+ }
+ $block['kind'] = 'throttled';
+ $block['until'] = (int) $now + $wait;
+ return $block;
+ }
+
+ if ( ( 401 === $status || 403 === $status ) && $from_tackquote ) {
+ $block['kind'] = 'terminal';
+ $block['until'] = (int) $now + self::TERMINAL_REPROBE;
+ return $block;
+ }
+
+ return null;
+ }
+
+ /**
+ * Remember a failed push, if it is one that must hold back the next.
+ *
+ * @param mixed $error The failure.
+ * @param string $api_key The key the push was made with. Only a hash is stored.
+ * @param int $now Current Unix time.
+ * @return array|null The stored block.
+ */
+ public static function record_failure( $error, $api_key, $now ) {
+ $block = self::classify( $error, $now );
+ if ( null === $block ) {
+ return null;
+ }
+ $block['key'] = self::key_fingerprint( $api_key );
+ // When pushes STARTED being held, kept across re-records, so the re-queue on
+ // unblock reaches back to the first skipped order rather than the last refusal.
+ $previous = get_option( self::OPTION, null );
+ $block['since'] = is_array( $previous ) && ( $previous['key'] ?? '' ) === $block['key']
+ ? (int) ( $previous['since'] ?? $previous['at'] ?? $now )
+ : (int) $now;
+ update_option( self::OPTION, $block, false );
+ return $block;
+ }
+
+ /**
+ * A push succeeded: whatever was blocking has been fixed. Pushes skipped while it
+ * stood are re-queued.
+ */
+ public static function record_success() {
+ $block = get_option( self::OPTION, null );
+ if ( is_array( $block ) ) {
+ self::lift( $block );
+ }
+ }
+
+ /**
+ * `update_option_tack_quotes_api_key` handler: a different key was saved, so a block
+ * recorded against the old one no longer applies. Lifted now, not on the next push,
+ * so the skipped orders are re-queued immediately.
+ *
+ * @param mixed $old_value Previous key.
+ * @param mixed $new_value New key.
+ */
+ public static function on_api_key_changed( $old_value, $new_value ) {
+ if ( (string) $old_value === (string) $new_value ) {
+ return;
+ }
+ $block = get_option( self::OPTION, null );
+ if ( is_array( $block ) ) {
+ self::lift( $block );
+ }
+ }
+
+ /**
+ * Clear a block and announce it, so the skipped orders can be re-queued.
+ *
+ * @param array $block The block being lifted.
+ */
+ private static function lift( array $block ) {
+ self::clear();
+ do_action( self::UNBLOCKED_ACTION, (int) ( $block['since'] ?? $block['at'] ?? 0 ) );
+ }
+
+ /**
+ * Forget any block.
+ */
+ public static function clear() {
+ delete_option( self::OPTION );
+ }
+
+ /**
+ * The block that must hold back a push right now, if any.
+ *
+ * @param string $api_key The key the next push would use.
+ * @param int $now Current Unix time.
+ * @return array|null
+ */
+ public static function active_block( $api_key, $now ) {
+ $block = self::stored_for_key( $api_key );
+ if ( null === $block ) {
+ return null;
+ }
+ return (int) $now < (int) ( $block['until'] ?? 0 ) ? $block : null;
+ }
+
+ /**
+ * Print the wp-admin notice while order sync is refused.
+ *
+ * Shown to anyone who can manage WooCommerce, because they are the people whose
+ * orders are not arriving. It stays until a push succeeds or a different key is saved;
+ * a throttle on its own is not shown, since it clears itself.
+ */
+ public static function render_admin_notice() {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ return;
+ }
+ $block = self::stored_for_key( (string) get_option( 'tack_quotes_api_key', '' ) );
+ if ( null === $block || 'terminal' !== ( $block['kind'] ?? '' ) ) {
+ return;
+ }
+
+ // Tack_Settings is always loaded by the plugin bootstrap (class-tack-quotes.php).
+ $settings = admin_url( 'admin.php?page=' . Tack_Settings::PAGE_SLUG );
+ echo '
';
+ }
+
+ /**
+ * The merchant-facing explanation for a terminal block.
+ *
+ * @param array $block Stored block.
+ * @return string
+ */
+ public static function notice_text( array $block ) {
+ $scopes = isset( $block['scopes'] ) && is_array( $block['scopes'] ) ? array_filter( $block['scopes'], 'is_string' ) : array();
+ if ( 'insufficient_scope' === ( $block['code'] ?? '' ) || array() !== $scopes ) {
+ $list = array() !== $scopes ? implode( ', ', $scopes ) : 'orders:write';
+ return sprintf(
+ /* translators: %1$s: comma-separated API key scopes, e.g. orders:write */
+ __( 'TackQuote refused order sync because the API key saved here is missing the %1$s scope. In TackQuote, open Settings > API keys, create a key with the %1$s scope, paste it into TackQuote settings on this site, then revoke the old key. Orders placed until then are sent when they next change.', 'tackquote' ),
+ $list
+ );
+ }
+ if ( 'SUBSCRIPTION_INACTIVE' === ( $block['code'] ?? '' ) ) {
+ return __( 'Your TackQuote subscription is not active, so TackQuote refuses new orders. Choose a plan in TackQuote under Profile > Billing & Plan; sync resumes within the hour.', 'tackquote' );
+ }
+ if ( 401 === (int) ( $block['status'] ?? 0 ) ) {
+ return __( 'TackQuote does not accept the API key saved here (it may have been revoked). Create a new key in TackQuote under Settings > API keys with the orders:write scope and save it in TackQuote settings on this site.', 'tackquote' );
+ }
+ return sprintf(
+ /* translators: %s: the refusal message TackQuote returned */
+ __( 'TackQuote refused order sync: %s', 'tackquote' ),
+ (string) ( $block['message'] ?? '' )
+ );
+ }
+
+ /**
+ * The stored block, if it was recorded for this key.
+ *
+ * A block recorded for a different key is stale — the merchant has saved a new one —
+ * and is dropped rather than trusted.
+ *
+ * @param string $api_key Current key.
+ * @return array|null
+ */
+ private static function stored_for_key( $api_key ) {
+ $block = get_option( self::OPTION, null );
+ if ( ! is_array( $block ) ) {
+ return null;
+ }
+ if ( ( $block['key'] ?? '' ) !== self::key_fingerprint( $api_key ) ) {
+ self::lift( $block );
+ return null;
+ }
+ return $block;
+ }
+
+ /**
+ * A short one-way fingerprint, so the option can tell keys apart without holding one.
+ *
+ * @param string $api_key Key.
+ * @return string
+ */
+ private static function key_fingerprint( $api_key ) {
+ return substr( hash( 'sha256', (string) $api_key ), 0, 16 );
+ }
+
+ /**
+ * Seconds to wait after a 429: Retry-After (delta-seconds or an HTTP-date, RFC 9110
+ * section 10.2.3), else the body's retryAfterSeconds, else DEFAULT_WAIT; at most MAX_WAIT.
+ *
+ * @param mixed $data Error data.
+ * @param int $now Current Unix time.
+ * @return int
+ */
+ private static function wait_seconds( $data, $now ) {
+ $wait = 0;
+ $header = is_array( $data ) && isset( $data['retryAfterHeader'] ) ? trim( (string) $data['retryAfterHeader'] ) : '';
+ if ( '' !== $header && preg_match( '/^\d+$/', $header ) ) {
+ $wait = (int) $header;
+ } elseif ( '' !== $header ) {
+ $when = strtotime( $header );
+ $wait = false === $when ? 0 : $when - (int) $now;
+ }
+ if ( $wait <= 0 && is_array( $data ) && ! empty( $data['retryAfterSeconds'] ) ) {
+ $wait = (int) $data['retryAfterSeconds'];
+ }
+ if ( $wait <= 0 ) {
+ $wait = self::DEFAULT_WAIT;
+ }
+ return min( $wait, self::MAX_WAIT );
+ }
+}
diff --git a/readme.txt b/readme.txt
index c6b80b4..5fa3566 100755
--- a/readme.txt
+++ b/readme.txt
@@ -238,6 +238,11 @@ So shoppers can add multiple products before requesting one combined quote. Use
== Changelog ==
+= Unreleased =
+* **Order sync stops when TackQuote refuses the API key, and tells you why.** If the key saved in TackQuote settings lacks the `orders:write` scope, has been revoked, or your TackQuote subscription is inactive, TackQuote refuses every order. The plugin used to try again on every order change, which on a busy store meant a refused request every few seconds, and the reason only appeared in WooCommerce > Status > Logs. It now stops sending, shows an error notice in wp-admin that names the missing scope or the billing step, and checks again at most once an hour. Saving a different key resumes at once.
+* When order sync resumes, orders changed while it was refused are sent automatically (up to 200 of the oldest at a time; any beyond that are sent when they next change).
+* When TackQuote asks the plugin to slow down (HTTP 429), the plugin waits for the time TackQuote names, up to one hour. A firewall or challenge page in front of TackQuote is treated as temporary and does not stop sync.
+
= 1.8.1 =
* **The "awaiting approval" message no longer says more than TackQuote knows.** TackQuote now answers "awaiting approval" for every quote request made on behalf of a company, so that a shopper typing a company name can no longer learn whether that company is already a customer of the store. The message used to read "Your company registration is awaiting approval by the seller", which is now false for a company that needs no approval. It reads "Request received. If your company account needs approval, we'll email you when it is ready."
* After a company request the buyer portal is offered as a link instead of not at all. The automatic redirect still happens only for an individual request; a company shopper may not be able to sign in yet, so they are never dropped onto a login they cannot pass.
diff --git a/tests/run.php b/tests/run.php
index a21b9ed..36f52a9 100644
--- a/tests/run.php
+++ b/tests/run.php
@@ -95,5 +95,9 @@ function check( $label, $condition, $detail = '' ) {
echo "\n-- settings page structure + rule editing --\n";
require __DIR__ . '/settings-page-test.php';
+// Order sync stops on a TERMINAL refusal (401/403 from TackQuote) and backs off on 429.
+echo "\n-- order-sync gate: terminal 401/403, 429 back-off, admin notice --\n";
+require __DIR__ . '/sync-gate-test.php';
+
echo $failures ? "\n$failures failure(s)\n" : "\nAll checks passed\n";
exit( $failures ? 1 : 0 );
diff --git a/tests/sync-gate-test.php b/tests/sync-gate-test.php
new file mode 100644
index 0000000..161e7f6
--- /dev/null
+++ b/tests/sync-gate-test.php
@@ -0,0 +1,321 @@
+sync_order( array( 'externalOrderId' => '1' ), 'k1' );
+}
+
+$scope_body = wp_json_encode(
+ array(
+ 'statusCode' => 403,
+ 'code' => 'insufficient_scope',
+ 'message' => 'This API key is missing the required scope(s): orders:write. Create a new key with those scopes in Settings -> API keys.',
+ 'requiredScopes' => array( 'orders:write' ),
+ )
+);
+
+// ── The client keeps what the API said, not just a sentence ─────────────────
+$err = tack_gate_push( 403, $scope_body );
+$data = is_wp_error( $err ) ? $err->get_error_data() : null;
+check( 'a 403 comes back as a WP_Error', is_wp_error( $err ) );
+check(
+ 'the WP_Error carries the HTTP status, the API code and the scopes it named',
+ is_array( $data ) && 403 === ( $data['status'] ?? null )
+ && 'insufficient_scope' === ( $data['code'] ?? null )
+ && array( 'orders:write' ) === ( $data['requiredScopes'] ?? null )
+ && true === ( $data['json'] ?? null ),
+ 'data: ' . var_export( $data, true )
+);
+
+// ── Classification ──────────────────────────────────────────────────────────
+$block = Tack_Sync_Gate::classify( $err, $now );
+check( 'a JSON 403 for a missing scope is TERMINAL', is_array( $block ) && 'terminal' === $block['kind'] );
+check(
+ 'the terminal block names the missing scope',
+ is_array( $block ) && array( 'orders:write' ) === $block['scopes']
+);
+
+$sub = tack_gate_push( 403, wp_json_encode( array( 'statusCode' => 403, 'code' => 'SUBSCRIPTION_INACTIVE', 'error' => 'SUBSCRIPTION_INACTIVE', 'message' => 'Your subscription is inactive.' ) ) );
+$b = Tack_Sync_Gate::classify( $sub, $now );
+check( 'a JSON 403 for a lapsed subscription is TERMINAL', is_array( $b ) && 'terminal' === $b['kind'] && 'SUBSCRIPTION_INACTIVE' === $b['code'] );
+
+$bad = tack_gate_push( 401, wp_json_encode( array( 'statusCode' => 401, 'code' => 'UNAUTHORIZED', 'message' => 'Invalid API key' ) ) );
+$b = Tack_Sync_Gate::classify( $bad, $now );
+check( 'a JSON 401 (invalid or revoked key) is TERMINAL', is_array( $b ) && 'terminal' === $b['kind'] );
+
+$waf = tack_gate_push( 403, 'Just a moment...' );
+check(
+ 'a 403 whose body is NOT JSON (a WAF / challenge page) is TEMPORARY, not terminal',
+ null === Tack_Sync_Gate::classify( $waf, $now )
+);
+
+$down = tack_gate_push( 502, '' );
+check( 'a 5xx is TEMPORARY', null === Tack_Sync_Gate::classify( $down, $now ) );
+
+check(
+ 'a transport error (no HTTP status) is TEMPORARY',
+ null === Tack_Sync_Gate::classify( new WP_Error( 'http_request_failed', 'cURL error 28' ), $now )
+);
+
+$slow = tack_gate_push( 429, wp_json_encode( array( 'statusCode' => 429, 'code' => 'TOO_MANY_REQUESTS', 'message' => 'slow down', 'retryAfterSeconds' => 120 ) ), array( 'retry-after' => '120' ) );
+$b = Tack_Sync_Gate::classify( $slow, $now );
+check(
+ 'a 429 is THROTTLED until Retry-After',
+ is_array( $b ) && 'throttled' === $b['kind'] && $now + 120 === $b['until'],
+ var_export( $b, true )
+);
+
+$huge = tack_gate_push( 429, '{}', array( 'retry-after' => '999999' ) );
+$b = Tack_Sync_Gate::classify( $huge, $now );
+check(
+ 'a 429 Retry-After is capped at one hour',
+ is_array( $b ) && $now + Tack_Sync_Gate::MAX_WAIT === $b['until']
+);
+
+// A 429 that is TackQuote's throttle of a scope refusal is still terminal: same
+// refusal, louder. Held at least an hour so the notice does not flicker.
+$scope429 = tack_gate_push(
+ 429,
+ wp_json_encode( array( 'statusCode' => 429, 'code' => 'insufficient_scope', 'message' => 'stop retrying', 'requiredScopes' => array( 'orders:write' ), 'retryAfterSeconds' => 300 ) ),
+ array( 'retry-after' => '300' )
+);
+$b = Tack_Sync_Gate::classify( $scope429, $now );
+check(
+ 'a 429 carrying insufficient_scope stays TERMINAL',
+ is_array( $b ) && 'terminal' === $b['kind'] && array( 'orders:write' ) === $b['scopes'],
+ var_export( $b, true )
+);
+check(
+ '... held for max(Retry-After, TERMINAL_REPROBE)',
+ is_array( $b ) && $now + Tack_Sync_Gate::TERMINAL_REPROBE === $b['until']
+);
+
+// Only TackQuote's OWN error body is terminal: JSON whose statusCode matches the
+// HTTP status and which carries a non-empty code.
+$proxy = tack_gate_push( 403, wp_json_encode( array( 'error' => 'forbidden', 'message' => 'Blocked by policy' ) ) );
+check( 'a 403 with a proxy\'s JSON (no statusCode, no code) is TEMPORARY', null === Tack_Sync_Gate::classify( $proxy, $now ) );
+$mismatch = tack_gate_push( 403, wp_json_encode( array( 'statusCode' => 200, 'code' => 'X', 'message' => 'x' ) ) );
+check( 'a 403 whose body statusCode disagrees is TEMPORARY', null === Tack_Sync_Gate::classify( $mismatch, $now ) );
+$nocode = tack_gate_push( 403, wp_json_encode( array( 'statusCode' => 403, 'code' => '', 'message' => 'x' ) ) );
+check( 'a 403 with an empty code is TEMPORARY', null === Tack_Sync_Gate::classify( $nocode, $now ) );
+
+$date429 = tack_gate_push( 429, '{}', array( 'retry-after' => gmdate( 'D, d M Y H:i:s', $now + 90 ) . ' GMT' ) );
+$b = Tack_Sync_Gate::classify( $date429, $now );
+check( 'Retry-After as an HTTP-date is honoured', is_array( $b ) && $now + 90 === $b['until'], var_export( $b, true ) );
+
+// ── The gate: what stops the storm ──────────────────────────────────────────
+Tack_Sync_Gate::clear();
+check( 'no block before any refusal', null === Tack_Sync_Gate::active_block( $key, $now ) );
+
+Tack_Sync_Gate::record_failure( $err, $key, $now );
+check(
+ 'after a terminal refusal the very next push is held',
+ is_array( Tack_Sync_Gate::active_block( $key, $now + 3 ) )
+);
+check(
+ 'it is still held 59 minutes later (no 3-second retry storm)',
+ is_array( Tack_Sync_Gate::active_block( $key, $now + 59 * 60 ) )
+);
+check(
+ 'one re-probe is allowed after an hour (a renewed subscription heals without a key change)',
+ null === Tack_Sync_Gate::active_block( $key, $now + Tack_Sync_Gate::TERMINAL_REPROBE + 1 )
+);
+$stored = get_option( Tack_Sync_Gate::OPTION );
+check(
+ 'the stored block never contains the API key itself',
+ is_array( $stored ) && false === strpos( wp_json_encode( $stored ), $key )
+);
+
+check(
+ 'a NEW API key lifts the block immediately',
+ null === Tack_Sync_Gate::active_block( 'tk_live_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', $now + 3 )
+);
+
+Tack_Sync_Gate::clear();
+Tack_Sync_Gate::record_failure( $down, $key, $now );
+check( 'a temporary failure does not block the next push', null === Tack_Sync_Gate::active_block( $key, $now + 1 ) );
+
+Tack_Sync_Gate::record_failure( $err, $key, $now );
+Tack_Sync_Gate::record_failure( $err, $key, $now + 4000 );
+check(
+ 'a re-recorded block keeps the time pushes STARTED being held',
+ $now === ( get_option( Tack_Sync_Gate::OPTION )['since'] ?? null )
+);
+$GLOBALS['TACK_DONE_ACTIONS'] = array();
+Tack_Sync_Gate::record_success();
+check( 'a successful push clears the block', null === get_option( Tack_Sync_Gate::OPTION, null ) );
+check(
+ '... and announces the unblock with that start time (drives the re-queue)',
+ array( array( Tack_Sync_Gate::UNBLOCKED_ACTION, array( $now ) ) ) === $GLOBALS['TACK_DONE_ACTIONS'],
+ var_export( $GLOBALS['TACK_DONE_ACTIONS'], true )
+);
+
+$GLOBALS['TACK_DONE_ACTIONS'] = array();
+Tack_Sync_Gate::record_success();
+check( 'a success with no block announces nothing', array() === $GLOBALS['TACK_DONE_ACTIONS'] );
+
+Tack_Sync_Gate::record_failure( $err, $key, $now );
+Tack_Sync_Gate::on_api_key_changed( $key, 'tk_live_cccccccccccccccccccccccccccccccc' );
+check(
+ 'saving a different key lifts the block at once and announces it',
+ null === get_option( Tack_Sync_Gate::OPTION, null )
+ && array( array( Tack_Sync_Gate::UNBLOCKED_ACTION, array( $now ) ) ) === $GLOBALS['TACK_DONE_ACTIONS']
+);
+$GLOBALS['TACK_DONE_ACTIONS'] = array();
+Tack_Sync_Gate::record_failure( $err, $key, $now );
+Tack_Sync_Gate::on_api_key_changed( $key, $key );
+check( 're-saving the SAME key does not lift it', is_array( get_option( Tack_Sync_Gate::OPTION, null ) ) );
+Tack_Sync_Gate::clear();
+
+// ── The re-queue that follows an unblock ────────────────────────────────────
+require_once TACK_QUOTES_DIR . 'includes/class-tack-order-sync.php';
+require_once __DIR__ . '/wc-stubs.php';
+tack_test_set_option( 'tack_quotes_enable_order_sync', 'yes' );
+$sync = new Tack_Order_Sync();
+$GLOBALS['TACK_HOOKS'] = array();
+$sync->register_worker();
+$hooked = array_column( $GLOBALS['TACK_HOOKS'], 'hook' );
+check(
+ 'register_worker wires the unblock, the re-queue job and the key-change hook',
+ in_array( Tack_Sync_Gate::UNBLOCKED_ACTION, $hooked, true )
+ && in_array( Tack_Order_Sync::REQUEUE_HOOK, $hooked, true )
+ && in_array( 'update_option_tack_quotes_api_key', $hooked, true ),
+ implode( ', ', $hooked )
+);
+$GLOBALS['TACK_AS_ENQUEUED'] = array();
+$GLOBALS['TACK_WC_ORDERS_Q'] = array();
+$GLOBALS['TACK_WC_ORDER_IDS'] = array( 11, 12 );
+$since = time() - 600;
+$sync->schedule_requeue( $since );
+check(
+ 'an unblock schedules ONE re-queue job off the request',
+ array( array( Tack_Order_Sync::REQUEUE_HOOK, array( $since ), Tack_Order_Sync::SYNC_GROUP, true ) ) === $GLOBALS['TACK_AS_ENQUEUED']
+);
+$GLOBALS['TACK_AS_ENQUEUED'] = array();
+$sync->requeue_unsynced( $since );
+$q = $GLOBALS['TACK_WC_ORDERS_Q'][0] ?? array();
+check(
+ 'the re-queue asks WooCommerce for orders modified since the block began',
+ '>' . ( $since - 60 ) === ( $q['date_modified'] ?? null ) && 'ids' === ( $q['return'] ?? null ),
+ var_export( $q, true )
+);
+check(
+ '... and queues each through the normal worker',
+ array( array( 11 ), array( 12 ) ) === array_map(
+ function ( $e ) {
+ return $e[1];
+ },
+ array_values(
+ array_filter(
+ $GLOBALS['TACK_AS_ENQUEUED'],
+ function ( $e ) {
+ return Tack_Order_Sync::SYNC_HOOK === $e[0];
+ }
+ )
+ )
+ )
+);
+$GLOBALS['TACK_WC_ORDERS_Q'] = array();
+$sync->requeue_unsynced( 1 );
+check(
+ 'it never reaches back more than 30 days',
+ isset( $GLOBALS['TACK_WC_ORDERS_Q'][0]['date_modified'] )
+ && (int) substr( $GLOBALS['TACK_WC_ORDERS_Q'][0]['date_modified'], 1 ) >= time() - Tack_Order_Sync::REQUEUE_MAX_AGE - 5
+);
+tack_test_set_option( 'tack_quotes_enable_order_sync', 'no' );
+$GLOBALS['TACK_AS_ENQUEUED'] = array();
+$sync->requeue_unsynced( $since );
+check( 'with order sync switched off, nothing is re-queued', array() === $GLOBALS['TACK_AS_ENQUEUED'] );
+
+// ── What the merchant is told ───────────────────────────────────────────────
+Tack_Sync_Gate::record_failure( $err, $key, $now );
+$GLOBALS['TACK_CAPS'] = array( 'manage_woocommerce' );
+ob_start();
+Tack_Sync_Gate::render_admin_notice();
+$notice = ob_get_clean();
+check( 'wp-admin shows an error notice while sync is blocked', false !== strpos( $notice, 'notice notice-error' ) );
+check( 'the notice names the missing scope', false !== strpos( $notice, 'orders:write' ) );
+check( 'the notice says orders are not being sent', false !== stripos( $notice, 'not being sent' ) );
+check(
+ 'the notice links the settings page by its registered slug',
+ false !== strpos( $notice, 'page=' . Tack_Settings::PAGE_SLUG )
+);
+check(
+ 'the lapsed-subscription copy names where to choose a plan (Profile > Billing & Plan)',
+ false !== strpos( Tack_Sync_Gate::notice_text( array( 'code' => 'SUBSCRIPTION_INACTIVE', 'status' => 403 ) ), 'Profile > Billing & Plan' )
+);
+
+$GLOBALS['TACK_CAPS'] = array();
+ob_start();
+Tack_Sync_Gate::render_admin_notice();
+check( 'users who cannot manage WooCommerce do not see it', '' === ob_get_clean() );
+
+tack_test_set_option( 'tack_quotes_api_key', 'tk_live_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' );
+$GLOBALS['TACK_CAPS'] = array( 'manage_woocommerce' );
+ob_start();
+Tack_Sync_Gate::render_admin_notice();
+check( 'the notice disappears once a different key is saved', '' === ob_get_clean() );
+
+// ── The worker consults the gate BEFORE it makes the request ────────────────
+$src = (string) file_get_contents( TACK_QUOTES_DIR . 'includes/class-tack-order-sync.php' );
+$start = strpos( $src, 'public function run_sync(' );
+$end = false === $start ? false : strpos( $src, "\n\t}\n", $start );
+$body = ( false === $start || false === $end ) ? '' : substr( $src, $start, $end - $start );
+$gate = strpos( $body, 'Tack_Sync_Gate::active_block(' );
+$send = strpos( $body, '->sync_order(' );
+check(
+ 'run_sync() checks Tack_Sync_Gate::active_block() before ->sync_order()',
+ false !== $gate && false !== $send && $gate < $send
+);
+check( 'run_sync() records a failed push with the gate', false !== strpos( $body, 'Tack_Sync_Gate::record_failure(' ) );
+check( 'run_sync() clears the gate on success', false !== strpos( $body, 'Tack_Sync_Gate::record_success(' ) );
+
+$uninstall = (string) file_get_contents( TACK_QUOTES_DIR . 'uninstall.php' );
+check( 'uninstall.php removes the block option', false !== strpos( $uninstall, "'" . Tack_Sync_Gate::OPTION . "'" ) );
+
+Tack_Sync_Gate::clear();
+tack_test_set_option( 'tack_quotes_api_key', '' );
+$GLOBALS['TACK_CAPS'] = array();
diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php
index e83f28d..e758d2e 100644
--- a/tests/wp-stubs.php
+++ b/tests/wp-stubs.php
@@ -153,16 +153,29 @@ class WP_Error {
private $code;
/** @var string */
private $message;
+ /** @var mixed */
+ private $data;
/**
- * Constructor.
+ * Constructor. Same three parameters as core's `WP_Error::__construct( $code, $message, $data )`.
*
* @param string $code Error code.
* @param string $message Error message.
+ * @param mixed $data Error data.
*/
- public function __construct( $code = '', $message = '' ) {
+ public function __construct( $code = '', $message = '', $data = '' ) {
$this->code = $code;
$this->message = $message;
+ $this->data = $data;
+ }
+
+ /**
+ * Core returns the data for the first code, or null when none was added.
+ *
+ * @return mixed
+ */
+ public function get_error_data() {
+ return '' === $this->data ? null : $this->data;
}
/** @return string */
@@ -589,3 +602,116 @@ function get_userdata( $user_id ) {
function tack_test_reset_user_meta() {
$GLOBALS['TACK_USER_META'] = array();
}
+
+
+// ── Stubs added for the order-sync gate (terminal 401/403, 429 back-off) ─────
+//
+// The HTTP functions below replay ONE scripted response and count calls, so a test can
+// assert that a blocked push made no request at all. Shapes follow core: a response is an
+// array with `response.code`, `body` and `headers`; `wp_remote_retrieve_header()` returns
+// '' for an absent header (developer.wordpress.org/reference/functions/wp_remote_retrieve_header/).
+
+$GLOBALS['TACK_HTTP_CALLS'] = 0;
+$GLOBALS['TACK_HTTP_RESPONSE'] = null;
+
+if ( ! function_exists( 'delete_option' ) ) {
+ /**
+ * @param string $key Option name.
+ * @return bool
+ */
+ function delete_option( $key ) {
+ unset( $GLOBALS['TACK_OPTIONS'][ $key ] );
+ return true;
+ }
+}
+
+/**
+ * Script the next HTTP response.
+ *
+ * @param int $code Status code.
+ * @param string $body Raw body.
+ * @param array $headers Lower-case header name => value.
+ */
+function tack_test_set_http_response( $code, $body, $headers = array() ) {
+ $GLOBALS['TACK_HTTP_RESPONSE'] = array(
+ 'response' => array( 'code' => $code ),
+ 'body' => $body,
+ 'headers' => $headers,
+ );
+}
+
+if ( ! function_exists( 'wp_remote_request' ) ) {
+ /**
+ * @param string $url URL.
+ * @param array $args Args.
+ * @return array|WP_Error
+ */
+ function wp_remote_request( $url, $args = array() ) {
+ $GLOBALS['TACK_HTTP_CALLS']++;
+ return $GLOBALS['TACK_HTTP_RESPONSE'];
+ }
+}
+if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) {
+ /** @param array $r Response. @return int|string */
+ function wp_remote_retrieve_response_code( $r ) {
+ return is_array( $r ) && isset( $r['response']['code'] ) ? $r['response']['code'] : '';
+ }
+}
+if ( ! function_exists( 'wp_remote_retrieve_body' ) ) {
+ /** @param array $r Response. @return string */
+ function wp_remote_retrieve_body( $r ) {
+ return is_array( $r ) && isset( $r['body'] ) ? $r['body'] : '';
+ }
+}
+if ( ! function_exists( 'wp_remote_retrieve_header' ) ) {
+ /** @param array $r Response. @param string $h Header. @return string */
+ function wp_remote_retrieve_header( $r, $h ) {
+ return is_array( $r ) && isset( $r['headers'][ strtolower( $h ) ] ) ? $r['headers'][ strtolower( $h ) ] : '';
+ }
+}
+
+// Recorded so the gate tests can see the unblock event and the re-queue it drives.
+$GLOBALS['TACK_DONE_ACTIONS'] = array();
+$GLOBALS['TACK_AS_ENQUEUED'] = array();
+$GLOBALS['TACK_WC_ORDERS_Q'] = array();
+$GLOBALS['TACK_WC_ORDER_IDS'] = array();
+
+if ( ! function_exists( 'do_action' ) ) {
+ /**
+ * @param string $hook Hook.
+ * @param mixed ...$args Args.
+ */
+ function do_action( $hook, ...$args ) {
+ $GLOBALS['TACK_DONE_ACTIONS'][] = array( $hook, $args );
+ }
+}
+if ( ! function_exists( 'as_enqueue_async_action' ) ) {
+ /**
+ * Action Scheduler's signature: ( $hook, $args, $group, $unique, $priority ).
+ *
+ * @return int
+ */
+ function as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) {
+ $GLOBALS['TACK_AS_ENQUEUED'][] = array( $hook, $args, $group, $unique );
+ return count( $GLOBALS['TACK_AS_ENQUEUED'] );
+ }
+}
+if ( ! function_exists( 'wc_get_orders' ) ) {
+ /**
+ * @param array $args Query.
+ * @return array Scripted ids.
+ */
+ function wc_get_orders( $args ) {
+ $GLOBALS['TACK_WC_ORDERS_Q'][] = $args;
+ return $GLOBALS['TACK_WC_ORDER_IDS'];
+ }
+}
+if ( ! function_exists( 'wc_get_order' ) ) {
+ /**
+ * @param int $id Order id.
+ * @return WC_Order|false
+ */
+ function wc_get_order( $id ) {
+ return class_exists( 'WC_Order' ) ? new WC_Order( array( 'id' => (int) $id ) ) : false;
+ }
+}
diff --git a/uninstall.php b/uninstall.php
index c6111d4..9ec1ccf 100755
--- a/uninstall.php
+++ b/uninstall.php
@@ -63,6 +63,9 @@
'tack_quotes_payment_group_map',
'tack_quotes_shipping_group_map',
'tack_quotes_buyer_group_codes',
+
+ // Order-sync circuit breaker (Tack_Sync_Gate::OPTION) — added after 1.8.1.
+ 'tack_quotes_order_sync_block',
);
/**