QB push and every "sync this one QB entity now" webhook job * goes through here instead of running synchronously inline in a hook or * REST callback — a slow/rate-limited/down QB API must never fail a * customer's checkout or make Intuit's webhook POST time out and retry * duplicate deliveries. Failures back off exponentially and stop retrying * (status='failed') after MAX_ATTEMPTS, surfaced in the admin "Queue" tab * for manual re-run. */ if (!defined('ABSPATH')) exit; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This plugin reads/writes only its own custom tables (live sync state), which must never be served from an object cache. class QBWS_Queue { const MAX_ATTEMPTS = 6; public static function enqueue(string $entityType, int $entityId, string $direction, array $payload = [], int $delaySeconds = 0): void { global $wpdb; // Avoid stacking duplicate pending jobs for the same entity+direction // (e.g. three quick order-status changes before the queue drains). $already = $wpdb->get_var($wpdb->prepare( "SELECT id FROM {$wpdb->prefix}qbws_sync_queue WHERE entity_type = %s AND entity_id = %d AND direction = %s AND status = 'pending'", $entityType, $entityId, $direction )); if ($already) { // Refresh payload/available_at instead of inserting a duplicate row. $wpdb->update($wpdb->prefix . 'qbws_sync_queue', [ 'payload' => wp_json_encode($payload), 'available_at' => gmdate('Y-m-d H:i:s', time() + $delaySeconds), ], ['id' => $already]); return; } $wpdb->insert($wpdb->prefix . 'qbws_sync_queue', [ 'entity_type' => $entityType, 'entity_id' => $entityId, 'direction' => $direction, 'payload' => wp_json_encode($payload), 'status' => 'pending', 'attempts' => 0, 'available_at' => gmdate('Y-m-d H:i:s', time() + $delaySeconds), 'created_at' => current_time('mysql', true), ]); } /** Called from WP-Cron every minute (see QBWS_Cron). */ public static function process_due_jobs(int $limit = 20): void { global $wpdb; $jobs = $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$wpdb->prefix}qbws_sync_queue WHERE status = 'pending' AND available_at <= NOW() ORDER BY available_at ASC LIMIT %d", $limit ), ARRAY_A); foreach ($jobs as $job) { self::run_job($job); } } private static function run_job(array $job): void { global $wpdb; $wpdb->update($wpdb->prefix . 'qbws_sync_queue', ['status' => 'processing'], ['id' => $job['id']]); $payload = json_decode($job['payload'] ?: '[]', true) ?: []; try { $outcome = self::dispatch($job['entity_type'], $job['direction'], (int) $job['entity_id'], $payload); if ($outcome === 'conflict') { $wpdb->update($wpdb->prefix . 'qbws_sync_queue', ['status' => 'conflict'], ['id' => $job['id']]); return; } $wpdb->update($wpdb->prefix . 'qbws_sync_queue', ['status' => 'done'], ['id' => $job['id']]); } catch (\Throwable $e) { $attempts = (int) $job['attempts'] + 1; if ($attempts >= self::MAX_ATTEMPTS) { $wpdb->update($wpdb->prefix . 'qbws_sync_queue', [ 'status' => 'failed', 'attempts' => $attempts, 'last_error' => $e->getMessage(), ], ['id' => $job['id']]); QBWS_Logger::error('QUEUE', "Job #{$job['id']} ({$job['entity_type']}/{$job['direction']} #{$job['entity_id']}) failed permanently after {$attempts} attempts: " . $e->getMessage()); return; } $backoffMinutes = 2 ** $attempts; // 2, 4, 8, 16, 32 min $wpdb->update($wpdb->prefix . 'qbws_sync_queue', [ 'status' => 'pending', 'attempts' => $attempts, 'last_error' => $e->getMessage(), 'available_at' => gmdate('Y-m-d H:i:s', time() + $backoffMinutes * 60), ], ['id' => $job['id']]); QBWS_Logger::warn('QUEUE', "Job #{$job['id']} failed (attempt {$attempts}/" . self::MAX_ATTEMPTS . "), retrying in {$backoffMinutes}m: " . $e->getMessage()); } } /** @return string 'ok'|'conflict' */ private static function dispatch(string $entityType, string $direction, int $entityId, array $payload): string { switch ("{$entityType}:{$direction}") { case 'order:to_qb': return (new QBWS_Sync_Orders_Outbound())->push_order($entityId); case 'order:to_woo': return (new QBWS_Sync_Orders_Inbound())->sync_single_from_qb($payload['qb_txn_id'], $payload['qb_txn_type'] ?? 'Invoice'); case 'customer:to_qb': (new QBWS_Sync_Customers())->push_to_qb($entityId); return 'ok'; case 'customer:to_woo': (new QBWS_Sync_Customers())->sync_single_from_qb($payload['qb_customer_id']); return 'ok'; case 'product:to_woo': (new QBWS_Sync_Products())->sync_single($payload['qb_item_id']); return 'ok'; case 'product:to_qb': (new QBWS_Sync_Products())->push_to_qb($entityId); return 'ok'; case 'payment:to_qb': (new QBWS_Sync_Payments())->push_to_qb($entityId); return 'ok'; case 'payment:to_woo': (new QBWS_Sync_Payments())->sync_single_from_qb($payload['qb_payment_id']); return 'ok'; default: QBWS_Logger::error('QUEUE', "Unknown job type {$entityType}:{$direction}"); return 'ok'; // don't retry forever on a bad job type } } }