*/ 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); } } }