CrmWebhookController.php 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. <?php
  2. namespace Controllers;
  3. use Libs\Hmac;
  4. use Libs\Logger;
  5. use Libs\Payload;
  6. use Models\CrmModel;
  7. use Psr\Http\Message\ServerRequestInterface;
  8. /**
  9. * Endpoint de ingestão de dados de CRM.
  10. *
  11. * Outra API envia dados (produtos, clientes) para a nossa plataforma. O fluxo:
  12. *
  13. * 1. A empresa é identificada pelo {companyId} na URL.
  14. * 2. Carregamos a empresa e o seu segredo HMAC próprio (Forma A: um segredo
  15. * por empresa) — o company_id usado para gravar vem SEMPRE daqui, nunca
  16. * do corpo, o que isola os dados de uma empresa dos de outra.
  17. * 3. Validamos a assinatura HMAC do corpo cru com o segredo da empresa.
  18. * 4. Só então processamos o payload e gravamos no banco.
  19. *
  20. * Contrato do payload (definido por nós):
  21. * {
  22. * "type": "product" | "client",
  23. * "data": { ...campos conforme o tipo... }
  24. * }
  25. *
  26. * Header de assinatura: X-Signature: sha256=<hmac hex do corpo cru>
  27. */
  28. class CrmWebhookController
  29. {
  30. private const SIGNATURE_HEADER = 'X-Signature';
  31. private CrmModel $crmModel;
  32. public function __construct()
  33. {
  34. $this->crmModel = new CrmModel();
  35. }
  36. public function __invoke(ServerRequestInterface $request): \React\Http\Message\Response
  37. {
  38. $companyId = (int) ($request->getAttribute('companyId') ?? 0);
  39. if ($companyId <= 0) {
  40. return Payload::fail('Invalid company identifier', [], 'E_VALIDATE', 400);
  41. }
  42. $company = $this->crmModel->findCompanyById($companyId);
  43. if ($company === null) {
  44. return Payload::fail('Unknown company', [], 'E_NOT_FOUND', 404);
  45. }
  46. // Assina-se o corpo CRU: precisa ser o byte-exato que o emissor assinou,
  47. // por isso lemos antes de qualquer json_decode.
  48. $rawBody = (string) $request->getBody();
  49. $secret = (string) ($company['company_hmac_secret'] ?? '');
  50. $signature = $request->getHeaderLine(self::SIGNATURE_HEADER);
  51. if (!Hmac::verify($rawBody, $secret, $signature)) {
  52. Logger::warning('CRM webhook rejected: invalid signature', ['company_id' => $companyId]);
  53. return Payload::fail('Invalid signature', [], 'E_VALIDATE', 401);
  54. }
  55. $payload = json_decode($rawBody, true);
  56. if (!is_array($payload)) {
  57. return Payload::fail('Invalid JSON payload', [], 'E_VALIDATE', 400);
  58. }
  59. $type = mb_strtolower(trim((string) ($payload['type'] ?? '')));
  60. $data = $payload['data'] ?? null;
  61. if ($type === '' || !is_array($data)) {
  62. return Payload::fail('Payload must contain "type" and "data"', [], 'E_VALIDATE', 400);
  63. }
  64. try {
  65. return $this->dispatch($companyId, $type, $data);
  66. } catch (\InvalidArgumentException $e) {
  67. return Payload::fail($e->getMessage(), [], 'E_VALIDATE', 400);
  68. } catch (\Throwable $e) {
  69. Logger::error('Failed to process CRM webhook', [
  70. 'company_id' => $companyId,
  71. 'type' => $type,
  72. 'error' => $e->getMessage(),
  73. ]);
  74. return Payload::fail('Failed to process webhook', [], 'E_GENERIC', 500);
  75. }
  76. }
  77. /**
  78. * Encaminha o payload já validado para a gravação conforme o tipo.
  79. *
  80. * Apenas os campos ESSENCIAIS (identidade do registro) são exigidos aqui.
  81. * Os demais campos são opcionais: podem vir como "" ou ausentes, e o
  82. * CrmModel aplica um valor neutro (string vazia ou 0). Ver os docblocks de
  83. * upsertSku/upsertClient para a lista de campos opcionais por tipo.
  84. */
  85. private function dispatch(int $companyId, string $type, array $data): \React\Http\Message\Response
  86. {
  87. switch ($type) {
  88. case 'product':
  89. // Essencial: name (é a identidade do SKU, usada no upsert).
  90. $name = trim((string) ($data['name'] ?? ''));
  91. if ($name === '') {
  92. throw new \InvalidArgumentException('product requires a "name"');
  93. }
  94. $result = $this->crmModel->upsertSku($companyId, $data);
  95. $code = $result['created'] ? 'S_CREATED' : 'S_OK';
  96. return Payload::ok(['sku_id' => $result['sku_id']], $code, 'Product saved.');
  97. case 'client':
  98. // Essencial: phone (chave única company_id + client_phone).
  99. $phone = trim((string) ($data['phone'] ?? ''));
  100. if ($phone === '') {
  101. throw new \InvalidArgumentException('client requires a "phone"');
  102. }
  103. $result = $this->crmModel->upsertClient($companyId, $data);
  104. return Payload::ok(['client_id' => $result['client_id']], 'S_OK', 'Client saved.');
  105. case 'sale':
  106. // Essenciais: external_id (idempotência), amount (faturamento)
  107. // e occurred_at (data da venda — base do histórico por período).
  108. $externalId = trim((string) ($data['external_id'] ?? ''));
  109. if ($externalId === '') {
  110. throw new \InvalidArgumentException('sale requires an "external_id"');
  111. }
  112. if (!is_numeric($data['amount'] ?? null)) {
  113. throw new \InvalidArgumentException('sale requires a numeric "amount"');
  114. }
  115. if (trim((string) ($data['occurred_at'] ?? '')) === '') {
  116. throw new \InvalidArgumentException('sale requires an "occurred_at"');
  117. }
  118. $result = $this->crmModel->upsertSale($companyId, $data);
  119. $code = $result['created'] ? 'S_CREATED' : 'S_OK';
  120. $message = $result['created'] ? 'Sale recorded.' : 'Sale already recorded.';
  121. return Payload::ok(['sale_id' => $result['sale_id']], $code, $message);
  122. default:
  123. throw new \InvalidArgumentException('Unsupported type: ' . $type);
  124. }
  125. }
  126. }