diff --git a/modules/erp/import-integration/shopify-webhook.php b/modules/erp/import-integration/shopify-webhook.php new file mode 100644 index 0000000..e66b7f4 --- /dev/null +++ b/modules/erp/import-integration/shopify-webhook.php @@ -0,0 +1,164 @@ + trim((string) ($server['HTTP_X_SHOPIFY_HMAC_SHA256'] ?? '')), + 'topic' => trim((string) ($server['HTTP_X_SHOPIFY_TOPIC'] ?? '')), + 'webhook_id' => trim((string) ($server['HTTP_X_SHOPIFY_WEBHOOK_ID'] ?? '')), + 'shop_domain' => trim((string) ($server['HTTP_X_SHOPIFY_SHOP_DOMAIN'] ?? '')), + ]; +} + +function shopify_webhook_hmac_is_valid(string $rawPayload, string $providedHmac, string $secret): bool +{ + if ($providedHmac === '' || $secret === '') { + return false; + } + + $expectedHmac = base64_encode(hash_hmac('sha256', $rawPayload, $secret, true)); + return hash_equals($expectedHmac, $providedHmac); +} + +function shopify_webhook_start_run(PDO $pdo, array $headers): int +{ + $stmt = $pdo->prepare( + 'INSERT INTO public.process_runs (process_name, status, scope_json, started_at) + VALUES (:process_name, \'running\', :scope_json::jsonb, NOW()) + RETURNING id' + ); + $stmt->execute([ + ':process_name' => 'erp.import-integration.shopify_order_sync', + ':scope_json' => json_encode([ + 'topic' => $headers['topic'], + 'webhook_id' => $headers['webhook_id'], + 'shop_domain' => $headers['shop_domain'], + ], JSON_THROW_ON_ERROR), + ]); + + return (int) $stmt->fetchColumn(); +} + +function shopify_webhook_finish_run(PDO $pdo, int $runId, string $status, array $result, ?string $errorCode = null, ?string $errorMessage = null): void +{ + $stmt = $pdo->prepare( + 'UPDATE public.process_runs + SET status = :status, result_json = :result_json::jsonb, finished_at = NOW(), + error_code = :error_code, error_message = :error_message + WHERE id = :id' + ); + $stmt->execute([ + ':status' => $status, + ':result_json' => json_encode($result, JSON_THROW_ON_ERROR), + ':error_code' => $errorCode, + ':error_message' => $errorMessage, + ':id' => $runId, + ]); +} + +function handle_shopify_webhook(PDO $pdo, array $env, string $rawPayload, array $server): array +{ + $headers = shopify_webhook_headers($server); + $secret = env_value('SHOPIFY_API_SECRET', $env); + + if (($server['REQUEST_METHOD'] ?? '') !== 'POST') { + return ['http_status' => 405, 'payload' => ['status' => 'rejected', 'error' => 'method_not_allowed']]; + } + + if ($headers['hmac'] === '' || $headers['topic'] === '' || $headers['webhook_id'] === '' || $headers['shop_domain'] === '') { + return ['http_status' => 400, 'payload' => ['status' => 'rejected', 'error' => 'required_shopify_header_missing']]; + } + + if (!shopify_webhook_hmac_is_valid($rawPayload, $headers['hmac'], $secret)) { + return ['http_status' => 401, 'payload' => ['status' => 'rejected', 'error' => 'invalid_shopify_hmac']]; + } + + try { + $data = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return ['http_status' => 400, 'payload' => ['status' => 'rejected', 'error' => 'invalid_json']]; + } + + if (!is_array($data) || array_is_list($data)) { + return ['http_status' => 400, 'payload' => ['status' => 'rejected', 'error' => 'json_object_expected']]; + } + + $runId = shopify_webhook_start_run($pdo, $headers); + $payloadHash = hash('sha256', $rawPayload); + $orderGid = shopify_webhook_order_gid($data, $headers['topic']); + + try { + $pdo->beginTransaction(); + $stmt = $pdo->prepare( + 'INSERT INTO public.shopify_webhook_event + (webhook_id, topic, shop_domain, payload_sha256, payload, shopify_order_gid, status) + VALUES (:webhook_id, :topic, :shop_domain, :payload_sha256, :payload::jsonb, :shopify_order_gid, \'processed\') + ON CONFLICT (webhook_id) DO NOTHING + RETURNING id' + ); + $stmt->execute([ + ':webhook_id' => $headers['webhook_id'], + ':topic' => $headers['topic'], + ':shop_domain' => $headers['shop_domain'], + ':payload_sha256' => $payloadHash, + ':payload' => $rawPayload, + ':shopify_order_gid' => $orderGid, + ]); + + $eventId = $stmt->fetchColumn(); + if ($eventId === false) { + $duplicateStmt = $pdo->prepare('SELECT id, shopify_order_gid FROM public.shopify_webhook_event WHERE webhook_id = :webhook_id'); + $duplicateStmt->execute([':webhook_id' => $headers['webhook_id']]); + $duplicate = $duplicateStmt->fetch(); + $pdo->commit(); + shopify_webhook_finish_run($pdo, $runId, 'done', ['status' => 'duplicate', 'technical_event_id' => (int) ($duplicate['id'] ?? 0)]); + + return ['http_status' => 200, 'payload' => [ + 'status' => 'duplicate', + 'webhook_id' => $headers['webhook_id'], + 'topic' => $headers['topic'], + 'shop_domain' => $headers['shop_domain'], + 'shopify_order_gid' => $duplicate['shopify_order_gid'] ?? $orderGid, + 'technical_run_id' => $runId, + ]]; + } + + $pdo->commit(); + shopify_webhook_finish_run($pdo, $runId, 'done', ['status' => 'accepted', 'technical_event_id' => (int) $eventId]); + + return ['http_status' => 200, 'payload' => [ + 'status' => 'accepted', + 'webhook_id' => $headers['webhook_id'], + 'topic' => $headers['topic'], + 'shop_domain' => $headers['shop_domain'], + 'shopify_order_gid' => $orderGid, + 'technical_run_id' => $runId, + ]]; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + shopify_webhook_finish_run($pdo, $runId, 'failed', ['status' => 'failed'], 'shopify_webhook_persistence_failed', $e->getMessage()); + throw $e; + } +} diff --git a/public/api/shopify/webhooks.php b/public/api/shopify/webhooks.php new file mode 100644 index 0000000..921efb4 --- /dev/null +++ b/public/api/shopify/webhooks.php @@ -0,0 +1,20 @@ + 'rejected', 'error' => 'empty_body']); + } + + $result = handle_shopify_webhook($pdo, $env, $rawPayload, $_SERVER); + json_response($result['http_status'], $result['payload']); +} catch (Throwable) { + json_response(500, ['status' => 'failed', 'error' => 'technical_error']); +}