['GET', 'POST'], 'callback' => [$this, 'handle'], 'permission_callback' => '__return_true', // Intuit signs the body instead of using WP auth ]); } public function handle(\WP_REST_Request $request) { if ($request->get_method() === 'GET') { // Intuit's endpoint verification ping. return new \WP_REST_Response('OK', 200); } $raw = $request->get_body(); if (empty($raw)) { return new \WP_REST_Response('OK', 200); // nothing to do, but ack so Intuit stops retrying } if (!$this->verify_signature($raw, $request)) { QBWS_Logger::warn('WEBHOOK', 'Signature verification failed'); return new \WP_REST_Response('Unauthorized', 401); } $payload = json_decode($raw, true); if (json_last_error() !== JSON_ERROR_NONE) { QBWS_Logger::warn('WEBHOOK', 'Could not parse JSON payload'); return new \WP_REST_Response('Bad Request', 400); } $entities = $this->extract_entities($payload); if (empty($entities)) { QBWS_Logger::info('WEBHOOK', 'No processable entities in payload'); return new \WP_REST_Response('OK', 200); } foreach ($entities as $entity) { $this->route_entity($entity); } return new \WP_REST_Response('OK', 200); } private function route_entity(array $entity): void { $type = $entity['type']; $qbId = $entity['id']; $operation = $entity['operation']; QBWS_Logger::info('WEBHOOK', "entity type={$type} id={$qbId} op={$operation}"); if (in_array($operation, ['Void', 'Delete'], true)) { QBWS_Logger::info('WEBHOOK', "Skipping {$operation} for {$type} #{$qbId} — this plugin never auto-deletes, resolve manually if needed"); return; } switch ($type) { case 'Invoice': case 'SalesReceipt': QBWS_Queue::enqueue('order', 0, 'to_woo', ['qb_txn_id' => $qbId, 'qb_txn_type' => $type]); break; case 'Item': QBWS_Queue::enqueue('product', 0, 'to_woo', ['qb_item_id' => $qbId]); break; case 'Customer': QBWS_Queue::enqueue('customer', 0, 'to_woo', ['qb_customer_id' => $qbId]); break; case 'Payment': QBWS_Queue::enqueue('payment', 0, 'to_woo', ['qb_payment_id' => $qbId]); break; case 'Category': // Category sync isn't part of this plugin — Item events are // still routed to product sync above, which is sufficient. break; default: QBWS_Logger::info('WEBHOOK', "Unhandled entity type: {$type}"); } } /** * Normalize both Intuit payload formats into a unified entity list. * Ported verbatim (logic-wise) from extractEntities()/parseCloudEventType() * in qb_webhook_order.php. */ private function extract_entities($payload): array { $entities = []; // ── NEW CloudEvents format (array at root) ──────────────────────── if (isset($payload[0]['specversion']) || isset($payload[0]['type'])) { foreach ($payload as $event) { $type = $event['type'] ?? ''; $entityId = $event['intuitentityid'] ?? ''; $parsed = $this->parse_cloud_event_type($type); if (!$parsed) continue; $entities[] = ['type' => $parsed['entityType'], 'id' => $entityId, 'operation' => $parsed['operation']]; } return $entities; } // ── OLD format: {"eventNotifications":[...]} ───────────────────── if (!empty($payload['eventNotifications'])) { foreach ($payload['eventNotifications'] as $notification) { if (empty($notification['dataChangeEvent']['entities'])) continue; foreach ($notification['dataChangeEvent']['entities'] as $entity) { $entities[] = [ 'type' => $entity['name'] ?? '', 'id' => $entity['id'] ?? '', 'operation' => $entity['operation'] ?? '', ]; } } return $entities; } return []; } private function parse_cloud_event_type(string $type): ?array { $entityMap = [ 'invoice' => 'Invoice', 'salesreceipt' => 'SalesReceipt', 'item' => 'Item', 'customer' => 'Customer', 'payment' => 'Payment', 'category' => 'Category', ]; $operationMap = [ 'created' => 'Create', 'updated' => 'Update', 'deleted' => 'Delete', 'voided' => 'Void', ]; $parts = explode('.', strtolower($type)); if (count($parts) < 3) return null; $entityKey = $parts[1] ?? ''; $operationKey = $parts[2] ?? ''; if (!isset($entityMap[$entityKey])) return null; return ['entityType' => $entityMap[$entityKey], 'operation' => $operationMap[$operationKey] ?? 'Update']; } private function verify_signature(string $rawBody, \WP_REST_Request $request): bool { $signature = $request->get_header('intuit-signature'); if (empty($signature)) return false; $verifierToken = QBWS_Config::get_secret('webhook_verifier'); if (empty($verifierToken)) { QBWS_Logger::warn('WEBHOOK', 'No webhook verifier token configured — cannot verify signature'); return false; } $expected = base64_encode(hash_hmac('sha256', $rawBody, $verifierToken, true)); return hash_equals($expected, $signature); } }