| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142 |
- <?php
- namespace Controllers;
- use Libs\Hmac;
- use Libs\Logger;
- use Libs\Payload;
- use Models\CrmModel;
- use Psr\Http\Message\ServerRequestInterface;
- /**
- * Endpoint de ingestão de dados de CRM.
- *
- * Outra API envia dados (produtos, clientes) para a nossa plataforma. O fluxo:
- *
- * 1. A empresa é identificada pelo {companyId} na URL.
- * 2. Carregamos a empresa e o seu segredo HMAC próprio (Forma A: um segredo
- * por empresa) — o company_id usado para gravar vem SEMPRE daqui, nunca
- * do corpo, o que isola os dados de uma empresa dos de outra.
- * 3. Validamos a assinatura HMAC do corpo cru com o segredo da empresa.
- * 4. Só então processamos o payload e gravamos no banco.
- *
- * Contrato do payload (definido por nós):
- * {
- * "type": "product" | "client",
- * "data": { ...campos conforme o tipo... }
- * }
- *
- * Header de assinatura: X-Signature: sha256=<hmac hex do corpo cru>
- */
- class CrmWebhookController
- {
- private const SIGNATURE_HEADER = 'X-Signature';
- private CrmModel $crmModel;
- public function __construct()
- {
- $this->crmModel = new CrmModel();
- }
- public function __invoke(ServerRequestInterface $request): \React\Http\Message\Response
- {
- $companyId = (int) ($request->getAttribute('companyId') ?? 0);
- if ($companyId <= 0) {
- return Payload::fail('Invalid company identifier', [], 'E_VALIDATE', 400);
- }
- $company = $this->crmModel->findCompanyById($companyId);
- if ($company === null) {
- return Payload::fail('Unknown company', [], 'E_NOT_FOUND', 404);
- }
- // Assina-se o corpo CRU: precisa ser o byte-exato que o emissor assinou,
- // por isso lemos antes de qualquer json_decode.
- $rawBody = (string) $request->getBody();
- $secret = (string) ($company['company_hmac_secret'] ?? '');
- $signature = $request->getHeaderLine(self::SIGNATURE_HEADER);
- if (!Hmac::verify($rawBody, $secret, $signature)) {
- Logger::warning('CRM webhook rejected: invalid signature', ['company_id' => $companyId]);
- return Payload::fail('Invalid signature', [], 'E_VALIDATE', 401);
- }
- $payload = json_decode($rawBody, true);
- if (!is_array($payload)) {
- return Payload::fail('Invalid JSON payload', [], 'E_VALIDATE', 400);
- }
- $type = mb_strtolower(trim((string) ($payload['type'] ?? '')));
- $data = $payload['data'] ?? null;
- if ($type === '' || !is_array($data)) {
- return Payload::fail('Payload must contain "type" and "data"', [], 'E_VALIDATE', 400);
- }
- try {
- return $this->dispatch($companyId, $type, $data);
- } catch (\InvalidArgumentException $e) {
- return Payload::fail($e->getMessage(), [], 'E_VALIDATE', 400);
- } catch (\Throwable $e) {
- Logger::error('Failed to process CRM webhook', [
- 'company_id' => $companyId,
- 'type' => $type,
- 'error' => $e->getMessage(),
- ]);
- return Payload::fail('Failed to process webhook', [], 'E_GENERIC', 500);
- }
- }
- /**
- * Encaminha o payload já validado para a gravação conforme o tipo.
- *
- * Apenas os campos ESSENCIAIS (identidade do registro) são exigidos aqui.
- * Os demais campos são opcionais: podem vir como "" ou ausentes, e o
- * CrmModel aplica um valor neutro (string vazia ou 0). Ver os docblocks de
- * upsertSku/upsertClient para a lista de campos opcionais por tipo.
- */
- private function dispatch(int $companyId, string $type, array $data): \React\Http\Message\Response
- {
- switch ($type) {
- case 'product':
- // Essencial: name (é a identidade do SKU, usada no upsert).
- $name = trim((string) ($data['name'] ?? ''));
- if ($name === '') {
- throw new \InvalidArgumentException('product requires a "name"');
- }
- $result = $this->crmModel->upsertSku($companyId, $data);
- $code = $result['created'] ? 'S_CREATED' : 'S_OK';
- return Payload::ok(['sku_id' => $result['sku_id']], $code, 'Product saved.');
- case 'client':
- // Essencial: phone (chave única company_id + client_phone).
- $phone = trim((string) ($data['phone'] ?? ''));
- if ($phone === '') {
- throw new \InvalidArgumentException('client requires a "phone"');
- }
- $result = $this->crmModel->upsertClient($companyId, $data);
- return Payload::ok(['client_id' => $result['client_id']], 'S_OK', 'Client saved.');
- case 'sale':
- // Essenciais: external_id (idempotência), amount (faturamento)
- // e occurred_at (data da venda — base do histórico por período).
- $externalId = trim((string) ($data['external_id'] ?? ''));
- if ($externalId === '') {
- throw new \InvalidArgumentException('sale requires an "external_id"');
- }
- if (!is_numeric($data['amount'] ?? null)) {
- throw new \InvalidArgumentException('sale requires a numeric "amount"');
- }
- if (trim((string) ($data['occurred_at'] ?? '')) === '') {
- throw new \InvalidArgumentException('sale requires an "occurred_at"');
- }
- $result = $this->crmModel->upsertSale($companyId, $data);
- $code = $result['created'] ? 'S_CREATED' : 'S_OK';
- $message = $result['created'] ? 'Sale recorded.' : 'Sale already recorded.';
- return Payload::ok(['sale_id' => $result['sale_id']], $code, $message);
- default:
- throw new \InvalidArgumentException('Unsupported type: ' . $type);
- }
- }
- }
|