Răsfoiți Sursa

add the crm webhooks

gdias 1 săptămână în urmă
părinte
comite
0cb480981a

+ 4 - 0
.env.example

@@ -6,6 +6,10 @@ DB_USER=postgres
 DB_PASS=
 JWT_SECRET=Aer8woa9zeec2gai4ahQuah3Ahbee5eiSefae8pheepahnootuShoo0oKahf
 
+# Segredo de provisionamento: exigido no header X-Provision-Secret para criar
+# empresas (POST /v1/companies). Use um valor longo e aleatório.
+PROVISION_SECRET=
+
 # Logging
 # LOG_ENABLED: liga/desliga o sistema de log (true/false)
 LOG_ENABLED=true

+ 25 - 0
DATABASE.md

@@ -35,6 +35,7 @@ Tabela principal de empresas atendidas pelo sistema.
 | `company_name` | `VARCHAR(100)` | Nome da empresa. |
 | `company_cnpj` | `VARCHAR(14)` | CNPJ da empresa. Único. |
 | `company_logo` | `TEXT` | Logo da empresa. |
+| `company_hmac_secret` | `VARCHAR(64)` | Segredo HMAC próprio da empresa, usado para validar os webhooks de CRM. Default vazio (tratado como "não configurado"). |
 | `company_created_at` | `TIMESTAMP` | Data de criação. |
 | `company_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
 
@@ -506,6 +507,29 @@ Snapshot consolidado de KPIs da empresa.
 **Restrição:**
 - `UNIQUE (company_id, kpi_snapshot_date)`
 
+### 29. `sale`
+
+Histórico de vendas recebido do CRM. Cada linha é uma venda com a data em que
+ocorreu, permitindo apurar faturamento por dia, semana ou mês.
+
+| Coluna | Tipo | Descrição |
+| --- | --- | --- |
+| `sale_id` | `SERIAL` | Identificador. |
+| `company_id` | `INT` | FK para `company`. |
+| `sku_id` | `INT` | Produto vendido. `0` quando não vinculado. |
+| `client_id` | `INT` | Cliente da venda. `0` quando não vinculado. |
+| `operator_id` | `INT` | Operador responsável. `0` quando não vinculado. |
+| `sale_external_id` | `TEXT` | ID da venda no CRM (idempotência). |
+| `sale_amount` | `DECIMAL(12,2)` | Valor faturado. |
+| `sale_quantity` | `INT` | Quantidade vendida. Default: `1`. |
+| `sale_occurred_at` | `TIMESTAMP` | Data/hora em que a venda ocorreu. |
+| `sale_created_at` | `TIMESTAMP` | Data de criação do registro. |
+| `sale_deleted_at` | `TIMESTAMP` | Exclusão lógica, padrão `'infinity'`. |
+
+**Restrições:**
+- `UNIQUE (company_id, sale_external_id)` (parcial, registros ativos) — evita duplicar faturamento em reenvios.
+- Índice `(company_id, sale_occurred_at)` para consultas de faturamento por período.
+
 ## Relacionamentos principais
 
 - **`user.company_id` → `company.company_id`**
@@ -546,6 +570,7 @@ Snapshot consolidado de KPIs da empresa.
 - **`operator_daily_stats.company_id` → `company.company_id`**
 - **`operator_daily_stats.operator_id` → `operator.operator_id`**
 - **`kpi_snapshot.company_id` → `company.company_id`**
+- **`sale.company_id` → `company.company_id`**
 
 ## Observações importantes
 

+ 142 - 0
controllers/CrmWebhookController.php

@@ -0,0 +1,142 @@
+<?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);
+        }
+    }
+}

+ 122 - 0
controllers/RegisterCompanyController.php

@@ -0,0 +1,122 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Logger;
+use Libs\Payload;
+use Libs\Validator;
+use Models\CompanyModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Provisiona uma nova empresa (tenant) junto com o seu primeiro usuário admin.
+ *
+ * Criar uma empresa nova é uma operação de PLATAFORMA: não pode herdar o
+ * company_id de um usuário autenticado (ele estaria criando OUTRA empresa).
+ * Por isso o acesso é protegido por um segredo de provisionamento
+ * (PROVISION_SECRET), enviado no header X-Provision-Secret.
+ *
+ * Corpo esperado:
+ *   {
+ *     "company": { "name": "...", "cnpj": "14 dígitos", "logo": "(opcional)" },
+ *     "admin":   { "name": "...", "email": "...", "phone": "...", "password": "min 8" }
+ *   }
+ *
+ * A resposta inclui o company_hmac_secret gerado — é o segredo a ser entregue
+ * ao CRM dessa empresa (ver crm.md).
+ */
+class RegisterCompanyController
+{
+    private CompanyModel $companyModel;
+
+    public function __construct()
+    {
+        $this->companyModel = new CompanyModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request): \React\Http\Message\Response
+    {
+        if (!$this->isAuthorized($request)) {
+            return Payload::fail('Unauthorized', [], 'E_VALIDATE', 401);
+        }
+
+        $body = json_decode((string) $request->getBody(), true);
+        if (!is_array($body)) {
+            return Payload::fail('Invalid JSON payload', [], 'E_VALIDATE', 400);
+        }
+
+        $company = is_array($body['company'] ?? null) ? $body['company'] : [];
+        $admin = is_array($body['admin'] ?? null) ? $body['admin'] : [];
+
+        $error = $this->validate($company, $admin);
+        if ($error !== null) {
+            return Payload::fail($error, [], 'E_VALIDATE', 400);
+        }
+
+        try {
+            $result = $this->companyModel->createCompanyWithAdmin($company, $admin);
+        } catch (\Throwable $e) {
+            Logger::error('Failed to register company', ['error' => $e->getMessage()]);
+            return Payload::fail('Failed to register company', [], 'E_GENERIC', 500);
+        }
+
+        switch ($result['status']) {
+            case 'cnpj_exists':
+                return Payload::fail('CNPJ already registered', [], 'E_VALIDATE', 400);
+            case 'email_exists':
+                return Payload::fail('Email already exists', [], 'E_VALIDATE', 400);
+            case 'created':
+                return Payload::ok(
+                    ['company' => $result['company'], 'user' => $result['user']],
+                    'S_CREATED',
+                    'Company created.'
+                );
+            default:
+                return Payload::fail('Failed to register company', [], 'E_GENERIC', 500);
+        }
+    }
+
+    /**
+     * Valida os campos da empresa e do admin. Retorna a mensagem de erro ou null.
+     */
+    private function validate(array $company, array $admin): ?string
+    {
+        $cnpj = preg_replace('/\D/', '', (string) ($company['cnpj'] ?? ''));
+
+        $validator = (new Validator([
+            'company_name' => $company['name'] ?? null,
+            'cnpj' => $cnpj,
+            'name' => $admin['name'] ?? null,
+            'email' => $admin['email'] ?? null,
+            'phone' => $admin['phone'] ?? null,
+            'password' => $admin['password'] ?? null,
+        ]))
+            ->required('company_name')->maxLength('company_name', 100)
+            ->required('cnpj')
+            ->required('name')->maxLength('name', 100)
+            ->required('email')->email('email')->maxLength('email', 100)
+            ->required('phone')->phone('phone')->maxLength('phone', 20)
+            ->required('password')->minLength('password', 8)->maxLength('password', 255);
+
+        if ($validator->fails()) {
+            return $validator->firstError();
+        }
+
+        if (strlen($cnpj) !== 14) {
+            return 'CNPJ must have 14 digits';
+        }
+
+        return null;
+    }
+
+    private function isAuthorized(ServerRequestInterface $request): bool
+    {
+        $expected = (string) ($_ENV['PROVISION_SECRET'] ?? '');
+        if ($expected === '') {
+            Logger::error('PROVISION_SECRET is not configured; rejecting company provisioning');
+            return false;
+        }
+
+        return hash_equals($expected, $request->getHeaderLine('X-Provision-Secret'));
+    }
+}

+ 295 - 0
crm.md

@@ -0,0 +1,295 @@
+# Integração de CRM — Guia de Envio de Dados
+
+Este documento descreve como uma plataforma de CRM deve enviar dados para a
+nossa plataforma. O envio é feito por um único endpoint de webhook, autenticado
+por **HMAC-SHA256**, e os dados são gravados nas tabelas-fonte da empresa
+correspondente.
+
+---
+
+## 1. Visão geral
+
+- Existe **um único endpoint** que recebe todos os tipos de dado.
+- Cada requisição informa um campo `type` que define **o que** está sendo
+  enviado (`product`, `client` ou `sale`) e um objeto `data` com os campos.
+- A autenticação é por **segredo HMAC exclusivo de cada empresa**. O segredo
+  é entregue de forma segura para a empresa e **nunca trafega na requisição**.
+- A empresa de destino é identificada na **URL** (`{companyId}`). Os dados de
+  uma empresa nunca se misturam com os de outra.
+
+---
+
+## 2. Endpoint
+
+```
+POST /v1/webhooks/crm/{companyId}
+```
+
+| Item | Valor |
+|---|---|
+| Método | `POST` |
+| Caminho | `/v1/webhooks/crm/{companyId}` |
+| `{companyId}` | Identificador numérico da empresa (fornecido por nós). |
+| `Content-Type` | `application/json` |
+| Header de assinatura | `X-Signature: sha256=<hmac>` |
+| Corpo | JSON no formato do envelope (ver seção 4). |
+
+> Cada empresa recebe o seu `companyId` e o seu segredo HMAC. Use sempre o par
+> correto: o segredo de uma empresa só é válido para o `companyId` dela.
+
+---
+
+## 3. Autenticação (HMAC-SHA256)
+
+Cada requisição precisa provar que é autêntica e não foi adulterada. Para isso:
+
+1. Monte o corpo JSON da requisição (a string exata que será enviada).
+2. Calcule `HMAC_SHA256(corpo, segredo)` em hexadecimal.
+3. Envie o resultado no header `X-Signature`, no formato `sha256=<hex>`.
+
+Nós recalculamos a assinatura do corpo recebido com o segredo da empresa e
+comparamos. Se não bater, a requisição é rejeitada com **401**.
+
+### Regras importantes
+
+- A assinatura é calculada sobre o **corpo exato em bytes** (o mesmo que vai no
+  `--data`). Qualquer diferença (espaço, quebra de linha, reordenação) muda a
+  assinatura. **Assine exatamente o que enviar.**
+- O prefixo `sha256=` é aceito (e recomendado); enviar só o hex também funciona.
+- O segredo é secreto: nunca o coloque na URL, em logs ou no corpo.
+
+### Exemplos de geração da assinatura
+
+**bash / openssl**
+```bash
+SECRET="<SEU_SEGREDO>"
+BODY='{"type":"product","data":{"name":"Plano Pro","value":199.90}}'
+SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
+
+curl -X POST "https://<host>/v1/webhooks/crm/3" \
+  -H "Content-Type: application/json" \
+  -H "X-Signature: sha256=$SIG" \
+  --data "$BODY"
+```
+
+**Node.js**
+```js
+const crypto = require('crypto');
+const body = JSON.stringify({ type: 'product', data: { name: 'Plano Pro', value: 199.9 } });
+const sig = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
+// header: 'X-Signature': `sha256=${sig}`   | enviar exatamente `body` no POST
+```
+
+**PHP**
+```php
+$body = json_encode(['type' => 'product', 'data' => ['name' => 'Plano Pro', 'value' => 199.90]]);
+$sig  = hash_hmac('sha256', $body, $secret);
+// header: "X-Signature: sha256=$sig"       | enviar exatamente $body no POST
+```
+
+**Python**
+```python
+import hmac, hashlib, json
+body = json.dumps({"type": "product", "data": {"name": "Plano Pro", "value": 199.90}})
+sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
+# header: {"X-Signature": f"sha256={sig}"}  | enviar exatamente `body` no POST
+```
+
+---
+
+## 4. Formato do corpo (envelope)
+
+Todo corpo segue o mesmo formato:
+
+```json
+{
+  "type": "product | client | sale",
+  "data": { ... campos conforme o type ... }
+}
+```
+
+- `type` (string, obrigatório): o tipo do dado.
+- `data` (objeto, obrigatório): os campos daquele tipo.
+
+---
+
+## 5. Regras gerais de campos
+
+- **Campos essenciais** são obrigatórios. Se faltarem (ou vierem vazios),
+  a requisição é rejeitada com **400**.
+- **Campos opcionais** podem vir como `""` ou ser omitidos — nesse caso
+  assumem um valor neutro (string vazia, `0`, ou um valor derivado).
+- Valores monetários aceitam número ou string numérica (ex.: `199.90` ou
+  `"199.90"`); valores não numéricos viram `0`.
+- Datas devem estar em formato reconhecível (ISO 8601 recomendado, ex.:
+  `2026-06-11T14:30:00`).
+
+---
+
+## 6. Tipos de dado
+
+### 6.1 `product` — Produto / SKU
+
+Cadastra ou atualiza um produto. A identidade é o **nome** dentro da empresa:
+se já existe um produto com o mesmo `name`, ele é **atualizado**; senão, é criado.
+
+| Campo | Obrigatório | Tipo | Descrição / comportamento |
+|---|---|---|---|
+| `name` | ✅ | string | Nome do produto. Identidade do SKU. |
+| `value` | ❌ | número | Preço. Vazio/ausente → `0`. |
+| `line` | ❌ | string | Linha/categoria. Vazio/ausente → `""`. |
+| `sold` | ❌ | inteiro | Quantidade vendida. Vazio/ausente → `0`. **Sobrescreve** o valor atual (ver aviso abaixo). |
+
+> ⚠️ **Sobre `sold`:** o `product` **substitui** o contador `sold` pelo valor
+> enviado. Se você omitir `sold`, ele vira `0`. Já o tipo `sale` **incrementa**
+> esse contador a cada venda. Para não zerar a contagem sem querer, escolha uma
+> estratégia: ou você gerencia `sold` só pelo `product` (mandando sempre o total
+> acumulado), ou deixa o `sold` por conta dos eventos `sale` e **não** envia
+> `sold` em atualizações de produto.
+
+**Exemplo**
+```json
+{
+  "type": "product",
+  "data": {
+    "name": "Plano Pro",
+    "value": 199.90,
+    "line": "Assinaturas",
+    "sold": 0
+  }
+}
+```
+
+Resposta: `S_CREATED` (criado) ou `S_OK` (atualizado), com `data.sku_id`.
+
+---
+
+### 6.2 `client` — Cliente
+
+Cadastra ou atualiza um cliente. A identidade é o **telefone** dentro da empresa
+(`company_id` + `phone`): mesmo telefone → atualiza; senão → cria.
+
+| Campo | Obrigatório | Tipo | Descrição / comportamento |
+|---|---|---|---|
+| `phone` | ✅ | string | Telefone. Chave única do cliente. |
+| `name` | ❌ | string | Nome. Vazio/ausente → `""`. |
+| `email` | ❌ | string | E-mail. Vazio/ausente → `""`. |
+| `segment` | ❌ | string | Segmento. Vazio/ausente → `""`. |
+| `provider_id` | ❌ | string | ID do cliente no CRM. Vazio/ausente → derivado como `crm:<phone>`. |
+
+**Exemplo**
+```json
+{
+  "type": "client",
+  "data": {
+    "phone": "+5511999990001",
+    "name": "Maria Silva",
+    "email": "maria@exemplo.com",
+    "segment": "Premium",
+    "provider_id": "CRM-CLI-4521"
+  }
+}
+```
+
+Resposta: `S_OK`, com `data.client_id`.
+
+---
+
+### 6.3 `sale` — Venda
+
+Registra uma venda no histórico. Cada venda é um **evento com data**, o que
+permite apurar faturamento por período. É **idempotente** pela chave
+`company_id` + `external_id`: reenviar a mesma venda **não duplica** o faturamento.
+
+| Campo | Obrigatório | Tipo | Descrição / comportamento |
+|---|---|---|---|
+| `external_id` | ✅ | string | ID da venda no CRM. Garante a idempotência. |
+| `amount` | ✅ | número | Valor faturado. Deve ser numérico. |
+| `occurred_at` | ✅ | data/hora | Quando a venda ocorreu (ISO 8601). Base do histórico. |
+| `product_name` | ❌ | string | Nome do produto vendido. Se casar com um SKU, vincula e incrementa `sold`. Sem match → venda sem produto vinculado. |
+| `quantity` | ❌ | inteiro | Quantidade. Vazio/ausente → `1`. |
+| `client_phone` | ❌ | string | Telefone do cliente. Se casar, vincula a venda ao cliente. |
+| `operator_email` | ❌ | string | E-mail do operador/vendedor. Se casar, vincula ao operador. |
+
+**Comportamento da idempotência**
+- Venda nova → resposta `S_CREATED` com `data.sale_id`.
+- Reenvio do mesmo `external_id` → resposta `S_OK` ("Sale already recorded.")
+  com `data.sale_id: 0`. O faturamento e o `sold` **não** são contados de novo.
+
+**Exemplo**
+```json
+{
+  "type": "sale",
+  "data": {
+    "external_id": "PEDIDO-2026-0001",
+    "amount": 199.90,
+    "occurred_at": "2026-06-11T14:30:00",
+    "product_name": "Plano Pro",
+    "quantity": 1,
+    "client_phone": "+5511999990001",
+    "operator_email": "vendedor@empresa.com"
+  }
+}
+```
+
+Resposta: `S_CREATED` ou `S_OK`, com `data.sale_id`.
+
+---
+
+## 7. Respostas e códigos de status
+
+Toda resposta segue o formato padrão:
+
+```json
+{
+  "status": "ok | failed",
+  "code": "S_OK | S_CREATED | E_VALIDATE | E_NOT_FOUND | E_GENERIC",
+  "message": "mensagem legível",
+  "data": { ... }   // presente quando há dados
+}
+```
+
+| HTTP | `code` | Quando acontece |
+|---|---|---|
+| 200 | `S_OK` | Processado com sucesso (atualização ou reenvio idempotente). |
+| 200 | `S_CREATED` | Registro novo criado. |
+| 400 | `E_VALIDATE` | JSON inválido, falta `type`/`data`, campo essencial ausente, `type` desconhecido, ou `companyId` inválido. |
+| 401 | `E_VALIDATE` | Assinatura HMAC inválida ou ausente. |
+| 404 | `E_NOT_FOUND` | `companyId` não corresponde a nenhuma empresa ativa. |
+| 500 | `E_GENERIC` | Falha interna ao processar. Pode reenviar. |
+
+Exemplos de erro:
+```json
+{ "status": "failed", "code": "E_VALIDATE", "message": "Invalid signature" }
+{ "status": "failed", "code": "E_VALIDATE", "message": "sale requires a numeric \"amount\"" }
+{ "status": "failed", "code": "E_NOT_FOUND", "message": "Unknown company" }
+```
+
+---
+
+## 8. Boas práticas e observações
+
+- **Ordem de envio:** envie `product` e `client` antes das `sale` que os
+  referenciam, para que a venda já consiga vincular o produto/cliente.
+- **Reenvio seguro:** vendas têm idempotência por `external_id`. Em caso de
+  timeout ou erro de rede, pode reenviar a mesma venda sem medo de duplicar.
+- **O que NÃO enviar:** não enviamos/recebemos métricas calculadas (churn, LTV,
+  ticket médio, faturamento consolidado). Essas são derivadas internamente a
+  partir dos dados crus (`product`, `client`, `sale`). Mande apenas os dados
+  brutos.
+- **Datas:** prefira ISO 8601 com hora (`2026-06-11T14:30:00`). Se o fuso
+  importar, inclua-o.
+- **Segredo HMAC:** se suspeitar de vazamento, solicite a rotação do segredo da
+  empresa.
+
+---
+
+## 9. Checklist de integração
+
+- [ ] Recebi o `companyId` e o segredo HMAC da empresa.
+- [ ] Monto o corpo JSON e calculo `HMAC_SHA256(corpo, segredo)`.
+- [ ] Envio `X-Signature: sha256=<hex>` e `Content-Type: application/json`.
+- [ ] Assino exatamente o corpo que envio (sem reserializar depois).
+- [ ] Uso `type` = `product` | `client` | `sale` com os campos essenciais.
+- [ ] Trato os códigos de status (200/400/401/404/500).
+- [ ] Reenvio vendas com o mesmo `external_id` em caso de falha.

+ 66 - 0
libs/Hmac.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace Libs;
+
+/**
+ * Assinatura e verificação HMAC-SHA256.
+ *
+ * Usado para autenticar webhooks máquina-a-máquina: quem envia calcula
+ * HMAC_SHA256(corpo, segredo) e manda no header; quem recebe recalcula com o
+ * mesmo segredo e compara. Como o segredo nunca trafega, só quem o possui
+ * consegue gerar uma assinatura válida — e qualquer alteração no corpo
+ * invalida a assinatura.
+ *
+ * O segredo é compartilhado entre os dois lados por um canal seguro e, neste
+ * projeto, é armazenado por empresa (company.company_hmac_secret).
+ */
+final class Hmac
+{
+    private const ALGO = 'sha256';
+
+    /**
+     * Prefixo opcional aceito no header de assinatura (ex.: "sha256=ab12...").
+     * Muitos emissores usam esse formato; aceitamos com ou sem prefixo.
+     */
+    private const PREFIX = 'sha256=';
+
+    /**
+     * Gera um novo segredo HMAC forte (32 bytes -> 64 caracteres hexadecimais).
+     * Deve ser usado ao provisionar a integração de CRM de uma empresa.
+     */
+    public static function generateSecret(): string
+    {
+        return bin2hex(random_bytes(32));
+    }
+
+    /**
+     * Calcula a assinatura hexadecimal do corpo com o segredo informado.
+     */
+    public static function sign(string $body, string $secret): string
+    {
+        return hash_hmac(self::ALGO, $body, $secret);
+    }
+
+    /**
+     * Verifica se a assinatura recebida corresponde ao corpo, usando o segredo.
+     *
+     * - Rejeita quando segredo ou assinatura estão vazios (config ausente).
+     * - Aceita a assinatura com ou sem o prefixo "sha256=".
+     * - Compara em tempo constante (hash_equals) para evitar timing attacks.
+     */
+    public static function verify(string $body, string $secret, string $providedSignature): bool
+    {
+        if ($secret === '' || $providedSignature === '') {
+            return false;
+        }
+
+        $provided = $providedSignature;
+        if (str_starts_with($provided, self::PREFIX)) {
+            $provided = substr($provided, strlen(self::PREFIX));
+        }
+
+        $expected = self::sign($body, $secret);
+
+        return hash_equals($expected, $provided);
+    }
+}

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
log.txt


+ 21 - 0
migrations/migrations_v3_crm.sql

@@ -0,0 +1,21 @@
+-- Webhooks de CRM: cada empresa possui um segredo HMAC próprio.
+--
+-- O segredo é usado para validar a autenticidade do corpo das requisições
+-- recebidas no endpoint de ingestão de dados de CRM. Como é por empresa,
+-- o segredo de uma empresa nunca consegue assinar dados de outra.
+--
+-- Formato: 32 bytes aleatórios em hexadecimal = 64 caracteres.
+-- Default vazio: a aplicação trata segredo vazio como "não configurado"
+-- e rejeita o webhook (401), evitando aceitar requisições sem proteção.
+
+ALTER TABLE company
+    ADD COLUMN IF NOT EXISTS company_hmac_secret VARCHAR(64) NOT NULL DEFAULT '';
+
+-- Backfill: gera um segredo para empresas já existentes para que possam
+-- receber webhooks imediatamente. Novos segredos de produção devem ser
+-- gerados pela aplicação via random_bytes (ver Libs\Hmac::generateSecret).
+UPDATE company
+SET company_hmac_secret =
+        md5(random()::text || clock_timestamp()::text || company_id::text)
+        || md5(random()::text || clock_timestamp()::text || company_id::text || random()::text)
+WHERE company_hmac_secret = '';

+ 41 - 0
migrations/migrations_v4_sale.sql

@@ -0,0 +1,41 @@
+-- Histórico de vendas vindas do CRM.
+--
+-- Cada venda é uma LINHA com a data em que ocorreu (sale_occurred_at). É essa
+-- dimensão de tempo que permite responder "faturado hoje / na semana / no mês",
+-- algo impossível com o contador agregado sku.sku_sold.
+--
+-- O sku.sku_sold continua existindo como total acumulado (cache de leitura
+-- rápida), incrementado pela aplicação a cada nova venda registrada aqui.
+--
+-- Isolamento: company_id é resolvido pela URL/HMAC do webhook, nunca pelo corpo.
+-- sku_id / client_id / operator_id são INT DEFAULT 0 (sem FK), seguindo o mesmo
+-- padrão adotado em integration.user_id/operator_id (migrations_v2): 0 = não
+-- vinculado, pois o CRM pode mandar uma venda sem produto/cliente/operador
+-- cadastrado na plataforma.
+
+CREATE TABLE IF NOT EXISTS sale (
+    sale_id            SERIAL PRIMARY KEY,
+    company_id         INT NOT NULL,
+    sku_id             INT NOT NULL DEFAULT 0,
+    client_id          INT NOT NULL DEFAULT 0,
+    operator_id        INT NOT NULL DEFAULT 0,
+    sale_external_id   TEXT NOT NULL,
+    sale_amount        DECIMAL(12,2) NOT NULL,
+    sale_quantity      INT NOT NULL DEFAULT 1,
+    sale_occurred_at   TIMESTAMP NOT NULL,
+    sale_created_at    TIMESTAMP NOT NULL DEFAULT NOW(),
+    sale_deleted_at    TIMESTAMP NOT NULL DEFAULT 'infinity',
+    CONSTRAINT fk_sale_company FOREIGN KEY (company_id) REFERENCES company(company_id)
+);
+
+-- Idempotência: o CRM pode reenviar a mesma venda. A chave (company_id,
+-- sale_external_id) impede duplicar o faturamento. Parcial em registros ativos
+-- para conviver com a exclusão lógica.
+CREATE UNIQUE INDEX IF NOT EXISTS uq_sale_company_external_active
+    ON sale (company_id, sale_external_id)
+    WHERE sale_deleted_at = 'infinity';
+
+-- Acelera as consultas de faturamento por período (dia/semana/mês).
+CREATE INDEX IF NOT EXISTS idx_sale_company_occurred
+    ON sale (company_id, sale_occurred_at)
+    WHERE sale_deleted_at = 'infinity';

+ 138 - 0
models/CompanyModel.php

@@ -0,0 +1,138 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+use Libs\Hmac;
+use Libs\Logger;
+
+/**
+ * Provisionamento de empresas (tenants).
+ *
+ * Cria a empresa e o seu primeiro usuário (admin) de forma ATÔMICA: ou os dois
+ * são criados, ou nenhum. Já gera o segredo HMAC da empresa (usado nos webhooks
+ * de CRM) no momento da criação.
+ */
+class CompanyModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    /**
+     * Cria a empresa + usuário admin numa única transação.
+     *
+     * @param array $company ['name', 'cnpj', 'logo']
+     * @param array $admin   ['name', 'email', 'phone', 'password']
+     * @return array{status:string, company?:array, user?:array}
+     *         status: 'created' | 'cnpj_exists' | 'email_exists' | 'error'
+     */
+    public function createCompanyWithAdmin(array $company, array $admin): array
+    {
+        $companyName = trim((string) ($company['name'] ?? ''));
+        $cnpj = preg_replace('/\D/', '', (string) ($company['cnpj'] ?? ''));
+        $logo = trim((string) ($company['logo'] ?? ''));
+
+        $userName = trim((string) ($admin['name'] ?? ''));
+        $email = mb_strtolower(trim((string) ($admin['email'] ?? '')));
+        $phone = trim((string) ($admin['phone'] ?? ''));
+        $password = (string) ($admin['password'] ?? '');
+
+        if ($this->cnpjExists($cnpj)) {
+            return ['status' => 'cnpj_exists'];
+        }
+        if ($this->emailExists($email)) {
+            return ['status' => 'email_exists'];
+        }
+
+        $secret = Hmac::generateSecret();
+        $passwordHash = password_hash($password, PASSWORD_DEFAULT);
+
+        $this->pdo->beginTransaction();
+        try {
+            $companyStmt = $this->pdo->prepare(
+                "INSERT INTO company (company_name, company_cnpj, company_logo, company_hmac_secret)
+                VALUES (:name, :cnpj, :logo, :secret)
+                RETURNING company_id, company_name, company_cnpj, company_logo,
+                          company_hmac_secret, company_created_at"
+            );
+            $companyStmt->execute([
+                'name' => $companyName,
+                'cnpj' => $cnpj,
+                'logo' => $logo,
+                'secret' => $secret,
+            ]);
+            $createdCompany = $companyStmt->fetch(\PDO::FETCH_ASSOC);
+            $companyId = (int) $createdCompany['company_id'];
+
+            $userStmt = $this->pdo->prepare(
+                "INSERT INTO \"user\" (company_id, user_name, user_phone, user_email, user_role, user_password)
+                VALUES (:company_id, :user_name, :user_phone, :user_email, 'admin', :user_password)
+                RETURNING user_id, company_id, user_name, user_phone, user_email, user_role, user_created_at"
+            );
+            $userStmt->execute([
+                'company_id' => $companyId,
+                'user_name' => $userName,
+                'user_phone' => $phone,
+                'user_email' => $email,
+                'user_password' => $passwordHash,
+            ]);
+            $createdUser = $userStmt->fetch(\PDO::FETCH_ASSOC);
+
+            $this->pdo->commit();
+        } catch (\Throwable $e) {
+            $this->pdo->rollBack();
+            Logger::error('Failed to create company with admin', [
+                'cnpj' => $cnpj,
+                'email' => $email,
+                'error' => $e->getMessage(),
+            ]);
+            return ['status' => 'error'];
+        }
+
+        return [
+            'status' => 'created',
+            'company' => [
+                'company_id' => $companyId,
+                'company_name' => $createdCompany['company_name'],
+                'company_cnpj' => $createdCompany['company_cnpj'],
+                'company_logo' => $createdCompany['company_logo'],
+                'company_hmac_secret' => $createdCompany['company_hmac_secret'],
+                'company_created_at' => $createdCompany['company_created_at'],
+            ],
+            'user' => [
+                'user_id' => (int) $createdUser['user_id'],
+                'company_id' => (int) $createdUser['company_id'],
+                'user_name' => $createdUser['user_name'],
+                'user_phone' => $createdUser['user_phone'],
+                'user_email' => $createdUser['user_email'],
+                'user_role' => $createdUser['user_role'],
+                'user_created_at' => $createdUser['user_created_at'],
+            ],
+        ];
+    }
+
+    private function cnpjExists(string $cnpj): bool
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT 1 FROM company
+            WHERE company_cnpj = :cnpj AND company_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute(['cnpj' => $cnpj]);
+
+        return $stmt->fetchColumn() !== false;
+    }
+
+    private function emailExists(string $email): bool
+    {
+        // user_email é UNIQUE global (não filtra por empresa).
+        $stmt = $this->pdo->prepare('SELECT 1 FROM "user" WHERE user_email = :email LIMIT 1');
+        $stmt->execute(['email' => $email]);
+
+        return $stmt->fetchColumn() !== false;
+    }
+}

+ 347 - 0
models/CrmModel.php

@@ -0,0 +1,347 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+/**
+ * Persistência dos dados recebidos via webhook de CRM.
+ *
+ * O company_id NUNCA vem do corpo do payload: é sempre resolvido a partir da
+ * empresa identificada na URL e validada por HMAC no controller. Todos os
+ * métodos de gravação aqui recebem o company_id já confiável como primeiro
+ * parâmetro e o aplicam em cada query, garantindo o isolamento entre empresas.
+ */
+class CrmModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    /**
+     * Carrega a empresa ativa e seu segredo HMAC. Retorna null se não existir
+     * ou estiver excluída logicamente.
+     */
+    public function findCompanyById(int $companyId): ?array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT company_id, company_hmac_secret
+            FROM company
+            WHERE company_id = :company_id
+              AND company_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        return $row === false ? null : $row;
+    }
+
+    /**
+     * Insere ou atualiza um produto (SKU) da empresa.
+     *
+     * A tabela sku não possui chave única natural além do id, então a
+     * identidade do produto é (company_id, sku_name): se já existir um SKU
+     * ativo com o mesmo nome, ele é atualizado; caso contrário, é criado.
+     *
+     * Essencial (validado no controller): name.
+     * Opcionais (aceitam "" ou ausência, com valor neutro):
+     *   - value -> 0.0 (qualquer valor não numérico vira 0)
+     *   - line  -> ""
+     *   - sold  -> 0
+     *
+     * @return array{sku_id:int, created:bool}
+     */
+    public function upsertSku(int $companyId, array $data): array
+    {
+        $name = trim((string) ($data['name'] ?? ''));
+        $value = $this->toDecimal($data['value'] ?? null);
+        $line = trim((string) ($data['line'] ?? ''));
+        $sold = (int) ($data['sold'] ?? 0);
+
+        $stmt = $this->pdo->prepare(
+            "SELECT sku_id
+            FROM sku
+            WHERE company_id = :company_id
+              AND sku_name = :name
+              AND sku_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute(['company_id' => $companyId, 'name' => $name]);
+        $existingId = $stmt->fetchColumn();
+
+        if ($existingId !== false) {
+            $update = $this->pdo->prepare(
+                "UPDATE sku
+                SET sku_value = :value,
+                    sku_line = :line,
+                    sku_sold = :sold
+                WHERE sku_id = :sku_id
+                  AND company_id = :company_id"
+            );
+            $update->execute([
+                'value' => $value,
+                'line' => $line,
+                'sold' => $sold,
+                'sku_id' => (int) $existingId,
+                'company_id' => $companyId,
+            ]);
+
+            return ['sku_id' => (int) $existingId, 'created' => false];
+        }
+
+        $insert = $this->pdo->prepare(
+            "INSERT INTO sku (company_id, sku_name, sku_value, sku_sold, sku_line)
+            VALUES (:company_id, :name, :value, :sold, :line)
+            RETURNING sku_id"
+        );
+        $insert->execute([
+            'company_id' => $companyId,
+            'name' => $name,
+            'value' => $value,
+            'sold' => $sold,
+            'line' => $line,
+        ]);
+
+        return ['sku_id' => (int) $insert->fetchColumn(), 'created' => true];
+    }
+
+    /**
+     * Insere ou atualiza um cliente da empresa.
+     *
+     * A identidade é (company_id, client_phone) — chave única da tabela. O
+     * client_provider_id é preenchido com o id externo do CRM quando informado
+     * (ou derivado do telefone) para satisfazer o índice único parcial e
+     * permitir rastrear a origem.
+     *
+     * Essencial (validado no controller): phone.
+     * Opcionais (aceitam "" ou ausência, com valor neutro):
+     *   - name        -> ""
+     *   - email       -> ""
+     *   - segment     -> ""
+     *   - provider_id -> "crm:<phone>" (derivado quando vazio)
+     *
+     * @return array{client_id:int}
+     */
+    public function upsertClient(int $companyId, array $data): array
+    {
+        $phone = trim((string) ($data['phone'] ?? ''));
+        $name = trim((string) ($data['name'] ?? ''));
+        $email = trim((string) ($data['email'] ?? ''));
+        $segment = trim((string) ($data['segment'] ?? ''));
+        $providerId = trim((string) ($data['provider_id'] ?? ''));
+        if ($providerId === '') {
+            $providerId = 'crm:' . $phone;
+        }
+
+        $stmt = $this->pdo->prepare(
+            "INSERT INTO client
+                (company_id, client_provider_id, client_phone, client_name,
+                 client_email, client_segment, client_is_registered)
+            VALUES
+                (:company_id, :provider_id, :phone, :name, :email, :segment, TRUE)
+            ON CONFLICT (company_id, client_phone) DO UPDATE
+            SET client_provider_id = EXCLUDED.client_provider_id,
+                client_name = EXCLUDED.client_name,
+                client_email = EXCLUDED.client_email,
+                client_segment = EXCLUDED.client_segment,
+                client_is_registered = TRUE
+            RETURNING client_id"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'provider_id' => $providerId,
+            'phone' => $phone,
+            'name' => $name,
+            'email' => $email,
+            'segment' => $segment,
+        ]);
+
+        return ['client_id' => (int) $stmt->fetchColumn()];
+    }
+
+    /**
+     * Registra uma venda no histórico (tabela sale) e atualiza o total
+     * acumulado do produto (sku.sku_sold).
+     *
+     * É idempotente: se o CRM reenviar a mesma venda (mesmo external_id), a
+     * linha não é duplicada e o sku_sold NÃO é incrementado de novo. Por isso
+     * o incremento do contador só acontece quando a venda é realmente nova.
+     *
+     * Essencial (validado no controller): external_id, amount, occurred_at.
+     * Opcionais (aceitam "" ou ausência):
+     *   - product_name   -> resolve sku_id; vazio/sem match => 0 (venda sem produto vinculado)
+     *   - quantity       -> 1
+     *   - client_phone   -> resolve client_id; vazio/sem match => 0
+     *   - operator_email -> resolve operator_id; vazio/sem match => 0
+     *
+     * @return array{sale_id:int, created:bool, sku_id:int}
+     */
+    public function upsertSale(int $companyId, array $data): array
+    {
+        $externalId = trim((string) ($data['external_id'] ?? ''));
+        $amount = $this->toDecimal($data['amount'] ?? null);
+        $quantity = (int) ($data['quantity'] ?? 1);
+        if ($quantity < 1) {
+            $quantity = 1;
+        }
+
+        $occurredAt = $this->toTimestamp($data['occurred_at'] ?? null);
+        if ($occurredAt === null) {
+            throw new \InvalidArgumentException('sale "occurred_at" must be a valid date/time');
+        }
+
+        $productName = trim((string) ($data['product_name'] ?? ''));
+        $clientPhone = trim((string) ($data['client_phone'] ?? ''));
+        $operatorEmail = trim((string) ($data['operator_email'] ?? ''));
+
+        $skuId = $productName !== '' ? $this->findSkuIdByName($companyId, $productName) : 0;
+        $clientId = $clientPhone !== '' ? $this->findClientIdByPhone($companyId, $clientPhone) : 0;
+        $operatorId = $operatorEmail !== '' ? $this->findOperatorIdByEmail($companyId, $operatorEmail) : 0;
+
+        $this->pdo->beginTransaction();
+        try {
+            $insert = $this->pdo->prepare(
+                "INSERT INTO sale
+                    (company_id, sku_id, client_id, operator_id, sale_external_id,
+                     sale_amount, sale_quantity, sale_occurred_at)
+                VALUES
+                    (:company_id, :sku_id, :client_id, :operator_id, :external_id,
+                     :amount, :quantity, :occurred_at)
+                ON CONFLICT (company_id, sale_external_id) WHERE sale_deleted_at = 'infinity'
+                DO NOTHING
+                RETURNING sale_id"
+            );
+            $insert->execute([
+                'company_id' => $companyId,
+                'sku_id' => $skuId,
+                'client_id' => $clientId,
+                'operator_id' => $operatorId,
+                'external_id' => $externalId,
+                'amount' => $amount,
+                'quantity' => $quantity,
+                'occurred_at' => $occurredAt,
+            ]);
+
+            $saleId = $insert->fetchColumn();
+            $created = $saleId !== false;
+
+            // Só incrementa o contador quando a venda é nova e há produto vinculado.
+            if ($created && $skuId > 0) {
+                $update = $this->pdo->prepare(
+                    "UPDATE sku
+                    SET sku_sold = sku_sold + :quantity
+                    WHERE sku_id = :sku_id
+                      AND company_id = :company_id"
+                );
+                $update->execute([
+                    'quantity' => $quantity,
+                    'sku_id' => $skuId,
+                    'company_id' => $companyId,
+                ]);
+            }
+
+            $this->pdo->commit();
+        } catch (\Throwable $e) {
+            $this->pdo->rollBack();
+            throw $e;
+        }
+
+        return [
+            'sale_id' => $created ? (int) $saleId : 0,
+            'created' => $created,
+            'sku_id' => $skuId,
+        ];
+    }
+
+    private function findSkuIdByName(int $companyId, string $name): int
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT sku_id
+            FROM sku
+            WHERE company_id = :company_id
+              AND sku_name = :name
+              AND sku_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute(['company_id' => $companyId, 'name' => $name]);
+        $id = $stmt->fetchColumn();
+
+        return $id === false ? 0 : (int) $id;
+    }
+
+    private function findClientIdByPhone(int $companyId, string $phone): int
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT client_id
+            FROM client
+            WHERE company_id = :company_id
+              AND client_phone = :phone
+              AND client_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute(['company_id' => $companyId, 'phone' => $phone]);
+        $id = $stmt->fetchColumn();
+
+        return $id === false ? 0 : (int) $id;
+    }
+
+    private function findOperatorIdByEmail(int $companyId, string $email): int
+    {
+        $normalized = mb_strtolower(trim($email));
+        if ($normalized === '') {
+            return 0;
+        }
+
+        $stmt = $this->pdo->prepare(
+            "SELECT operator_id
+            FROM operator
+            WHERE company_id = :company_id
+              AND lower(operator_email) = :email
+              AND operator_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute(['company_id' => $companyId, 'email' => $normalized]);
+        $id = $stmt->fetchColumn();
+
+        return $id === false ? 0 : (int) $id;
+    }
+
+    /**
+     * Normaliza uma data/hora vinda do payload para 'Y-m-d H:i:s'.
+     * Aceita qualquer formato que o DateTimeImmutable entenda (ex.: ISO 8601).
+     * Retorna null quando ausente ou inválida.
+     */
+    private function toTimestamp($value): ?string
+    {
+        if (!is_string($value) || trim($value) === '') {
+            return null;
+        }
+
+        try {
+            return (new \DateTimeImmutable(trim($value)))->format('Y-m-d H:i:s');
+        } catch (\Throwable $e) {
+            return null;
+        }
+    }
+
+    /**
+     * Normaliza um valor monetário vindo do payload para DECIMAL.
+     * Aceita número ou string; valores inválidos viram 0.
+     */
+    private function toDecimal($value): float
+    {
+        if (is_int($value) || is_float($value)) {
+            return (float) $value;
+        }
+
+        if (is_string($value) && is_numeric(trim($value))) {
+            return (float) trim($value);
+        }
+
+        return 0.0;
+    }
+}

+ 8 - 0
routes/Dispatcher.php

@@ -29,6 +29,14 @@ final class Dispatcher
         $app->post('/v1/login', \Controllers\LoginController::class);
         $app->post('/v1/webhooks/unipile', \Controllers\UnipileWebhookController::class);
         $app->post('/v1/webhooks/unipile/hosted-auth', \Controllers\UnipileHostedAuthWebhookController::class);
+        // Ingestão de CRM: autenticada por HMAC próprio da empresa (sem JWT).
+        // O {companyId} resolve a empresa; o segredo valida o corpo.
+        $app->post('/v1/webhooks/crm/{companyId}', \Controllers\CrmWebhookController::class);
+
+        // Provisionamento de empresa + admin: operação de plataforma, protegida
+        // pelo header X-Provision-Secret (PROVISION_SECRET). Sem JWT porque cria
+        // o primeiro tenant/usuário, que ainda não tem token.
+        $app->post('/v1/companies', \Controllers\RegisterCompanyController::class);
         $app->get('/v1/integrations/unipile/whatsapp/success', new \Controllers\UnipileRedirectController('success'));
         $app->get('/v1/integrations/unipile/whatsapp/failure', new \Controllers\UnipileRedirectController('failure'));
 

+ 106 - 0
test_crm_endpoints.sh

@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+#
+# Teste de ponta a ponta dos webhooks de CRM.
+#
+# Para cada caso, calcula o HMAC-SHA256 do corpo CRU com o segredo da empresa,
+# envia no header X-Signature e confere o HTTP status retornado.
+#
+# Uso:
+#   ./test_crm_endpoints.sh
+#   BASE_URL=http://127.0.0.1:8080 COMPANY_ID=3 SECRET=xxxx ./test_crm_endpoints.sh
+#
+set -uo pipefail
+
+BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
+COMPANY_ID="${COMPANY_ID:-3}"
+SECRET="${SECRET:-50d5e7f5faadbdb7681edc0a63dcb7d6fce93e613d290794885d292cb10fcc5b}"
+URL="$BASE_URL/v1/webhooks/crm/$COMPANY_ID"
+
+# external_id único por execução para o teste de venda/idempotência ser determinístico.
+RUN_ID="TEST-$(date +%s)"
+
+PASS=0
+FAIL=0
+
+hmac() {
+    # Assina o corpo CRU (sem newline) com o segredo, devolve o hex.
+    printf '%s' "$1" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}'
+}
+
+# run <descrição> <http_esperado> <corpo> [assinatura_forçada]
+run() {
+    local desc="$1" expected="$2" body="$3" forced_sig="${4:-}"
+    local sig
+    if [[ -n "$forced_sig" ]]; then
+        sig="$forced_sig"
+    else
+        sig="sha256=$(hmac "$body")"
+    fi
+
+    local resp http payload
+    resp=$(curl -s -w $'\n%{http_code}' -X POST "$URL" \
+        -H "Content-Type: application/json" \
+        -H "X-Signature: $sig" \
+        --data "$body")
+    http=$(printf '%s' "$resp" | tail -n1)
+    payload=$(printf '%s' "$resp" | sed '$d')
+
+    if [[ "$http" == "$expected" ]]; then
+        printf '  ✅ PASS  [%s] %s\n' "$http" "$desc"
+        PASS=$((PASS + 1))
+    else
+        printf '  ❌ FAIL  [esperado %s, veio %s] %s\n' "$expected" "$http" "$desc"
+        FAIL=$((FAIL + 1))
+    fi
+    printf '         → %s\n' "$payload"
+}
+
+echo "Alvo: $URL  (company_id=$COMPANY_ID)"
+echo "------------------------------------------------------------"
+
+echo "[1] PRODUCT — cria/atualiza SKU"
+run "product novo" 200 \
+    '{"type":"product","data":{"name":"Plano Pro CRM Test","value":199.90,"line":"Assinaturas","sold":0}}'
+
+echo "[2] PRODUCT — mesmo nome (atualiza, opcionais vazios permitidos)"
+run "product update com campos vazios" 200 \
+    '{"type":"product","data":{"name":"Plano Pro CRM Test","value":249.90,"line":""}}'
+
+echo "[3] CLIENT — cria/atualiza cliente"
+run "client" 200 \
+    '{"type":"client","data":{"phone":"+5511999990001","name":"Cliente Teste","email":"teste@crm.com","segment":"Premium"}}'
+
+echo "[4] SALE — registra venda (vincula ao produto, incrementa sku_sold)"
+run "sale nova" 200 \
+    "{\"type\":\"sale\",\"data\":{\"external_id\":\"$RUN_ID\",\"amount\":199.90,\"occurred_at\":\"2026-06-11T14:30:00\",\"product_name\":\"Plano Pro CRM Test\",\"quantity\":2,\"client_phone\":\"+5511999990001\"}}"
+
+echo "[5] SALE — reenvio do mesmo external_id (idempotência: não duplica)"
+run "sale duplicada" 200 \
+    "{\"type\":\"sale\",\"data\":{\"external_id\":\"$RUN_ID\",\"amount\":199.90,\"occurred_at\":\"2026-06-11T14:30:00\",\"product_name\":\"Plano Pro CRM Test\",\"quantity\":2}}"
+
+echo "------------------------------------------------------------"
+echo "Casos de erro (devem ser rejeitados):"
+
+echo "[6] Assinatura inválida → 401"
+run "assinatura errada" 401 \
+    '{"type":"product","data":{"name":"X","value":1}}' \
+    "sha256=deadbeef"
+
+echo "[7] Campo essencial faltando (sale sem amount) → 400"
+run "sale sem amount" 400 \
+    "{\"type\":\"sale\",\"data\":{\"external_id\":\"$RUN_ID-x\",\"occurred_at\":\"2026-06-11T14:30:00\"}}"
+
+echo "[8] type desconhecido → 400"
+run "type invalido" 400 \
+    '{"type":"foobar","data":{}}'
+
+echo "[9] Empresa inexistente → 404"
+COMPANY_ID_BKP="$COMPANY_ID"
+URL="$BASE_URL/v1/webhooks/crm/999999"
+run "company inexistente" 404 \
+    '{"type":"product","data":{"name":"X","value":1}}'
+URL="$BASE_URL/v1/webhooks/crm/$COMPANY_ID_BKP"
+
+echo "------------------------------------------------------------"
+printf 'RESULTADO: %d passou, %d falhou\n' "$PASS" "$FAIL"
+[[ "$FAIL" -eq 0 ]]

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff