# NFe Flow — Infotech de Implementação

**G4D Soluções e Desenvolvimento**  
**Versão:** 1.0 — 2026  
**Stack:** PHP 8.1+ · MySQL 8+ · PDO · PSR-4 · PHPUnit 10.5+

---

## Índice

1. [Visão Geral da Arquitetura](#1-visão-geral-da-arquitetura)
2. [Fluxo de uma Requisição](#2-fluxo-de-uma-requisição)
3. [Camada Support](#3-camada-support)
4. [Camada HTTP](#4-camada-http)
5. [Camada Exceptions](#5-camada-exceptions)
6. [Camada Contracts](#6-camada-contracts)
7. [Camada Validators](#7-camada-validators)
8. [Camada Repositories](#8-camada-repositories)
9. [Camada Services](#9-camada-services)
10. [Camada Controllers](#10-camada-controllers)
11. [Middleware de Autenticação](#11-middleware-de-autenticação)
12. [Sistema de API Keys](#12-sistema-de-api-keys)
13. [Bootstrap e Roteamento](#13-bootstrap-e-roteamento)
14. [Banco de Dados e Queries](#14-banco-de-dados-e-queries)
15. [Divergências Documentais](#15-divergências-documentais)
16. [Como Adicionar um Novo Domínio](#16-como-adicionar-um-novo-domínio)
17. [Testes](#17-testes)
18. [Variáveis de Ambiente](#18-variáveis-de-ambiente)
19. [Envelope de Resposta](#19-envelope-de-resposta)
20. [Tabela de Referência Rápida](#20-tabela-de-referência-rápida)

---

## 1. Visão Geral da Arquitetura

O NFe Flow é uma API REST de consulta de tabelas auxiliares da NF-e. Sem framework. Sem ORM. Sem dependências de runtime além das extensões nativas do PHP.

```
Request HTTP
    │
    ▼
public/index.php          ← front controller único
    │
    ▼
bootstrap/app.php         ← Env::load() · timezone · RequestId::generate()
    │
    ▼
Router::dispatch()        ← pipeline de middlewares por rota
    │
    ├─ ApiKeyAuthMiddleware   ← valida X-API-Key em todas as rotas /nfe/
    │
    ▼
Controller                ← extrai parâmetro bruto · try/catch ApiException
    │
    ▼
Service  (implements ServiceInterface)
    │   validator->validate($raw)
    │   $clean = validator->normalize($raw)
    ▼
Repository  (extends BaseRepository)
    │   loadSql(domínio, arquivo) → PDO prepare/execute
    ▼
View MySQL                ← toda lógica de composição de resposta
    │
    ▼
JsonResponder::success($data, 'CODE', 'Mensagem.')
```

**Princípios inegociáveis:**

- Controller nunca contém SQL, validação nem regra de negócio
- Service nunca responde JSON diretamente
- Repository nunca conhece HTTP
- Validator sempre tem `validate()` e `normalize()` — nunca retorna bool
- `normalize()` é chamado no service **após** `validate()` — o repository só recebe valor limpo
- `JsonResponder` é o único ponto que escreve para o output

---

## 2. Fluxo de uma Requisição

### Exemplo concreto: `GET /api/v1/nfe/estado/sp`

```
1. public/index.php
   $router->dispatch($request)

2. Router encontra a rota, executa ApiKeyAuthMiddleware
   → lê X-API-Key do header
   → valida contra api_keys no banco (bcrypt verify)
   → se inválida: JsonResponder::error('AUTHENTICATION_ERROR', ..., 401) + exit(0)
   → se válida: $next($request)

3. Closure da rota instancia e chama o controller:
   (new EstadoController(
       new EstadoService(new EstadoRepository(), new UfValidator())
   ))->findByUf($request)

4. EstadoController::findByUf()
   $uf = (string) $request->routeParam('uf');   // → "sp"
   try {
       $data = $this->service->findByUf($uf);
       JsonResponder::success($data, 'ESTADO_FOUND', 'Estado encontrado.');
   } catch (ApiException $e) {
       Logger::info('Estado não encontrado', ['uf' => $uf, 'code' => $e->getApiCode()]);
       JsonResponder::error($e->getApiCode(), $e->getMessage(), $e->getHttpStatus(), $e->getErrors());
   }

5. EstadoService::findByUf("sp")
   $this->validator->validate("sp");        // UfValidator: normaliza → "SP", valida
   $sigla = $this->validator->normalize("sp"); // → "SP"
   $result = $this->repository->findByUf("SP");
   if (empty($result)) throw new NotFoundException(...);
   return $result;

6. EstadoRepository::findByUf("SP")
   $sql = $this->loadSql('estados', 'find_by_uf');
   return $this->fetchAll($sql, [':uf' => 'SP']);
   // SQL: SELECT ... FROM vw_nfe_flow_estados WHERE uf = :uf

7. JsonResponder::success($data, 'ESTADO_FOUND', 'Estado encontrado com sucesso.')
   HTTP 200, Content-Type: application/json, Cache-Control: no-store, X-Request-ID: ...
   exit(0)
```

---

## 3. Camada Support

### `Env` — Carregamento de variáveis de ambiente

```php
// Sem dependências externas. Idempotente.
Env::load(dirname(__DIR__) . '/.env');   // chamado uma vez no bootstrap
Env::get('APP_DEBUG', false);            // retorna default se ausente
Env::require('DB_DATABASE');             // lança RuntimeException se ausente
```

Comportamentos especiais de `Env::get()`:
- `'true'` / `'(true)'`   → `true` (bool)
- `'false'` / `'(false)'` → `false` (bool)
- `'null'` / `'(null)'`   → `null`
- Qualquer outro valor     → string original

### `Database` — Singleton PDO

```php
// Obtém (ou cria) a conexão
$pdo = Database::connection();

// Em testes: injeta PDO mock
Database::setInstance($pdoMock);
Database::reset(); // limpa para o próximo teste
```

A conexão força:
- `utf8mb4_unicode_ci`
- `time_zone = '+00:00'` (UTC)
- `sql_mode = STRICT_ALL_TABLES,...`
- `group_concat_max_len = 1048576`

### `Router` — Roteador com pipeline de middlewares

```php
// Suporta GET, POST e PATCH
$router->get('/api/v1/nfe/estado/{uf}', $handler, [$auth]);
$router->post('/api/v1/admin/api-keys', $handler);
$router->patch('/api/v1/admin/api-keys/{id}/disable', $handler);

// Parâmetros de rota no formato {nome}
// Capturados como array e injetados no Request via setRouteParams()
// Middleware executado em ordem de registro antes do handler
```

O router chama `JsonResponder::routeNotFound()` (404) ou `JsonResponder::methodNotAllowed()` (405) automaticamente quando não há correspondência.

### `Logger` — Logger estático em arquivo

```php
Logger::debug('mensagem', ['contexto' => 'valor']);
Logger::info('mensagem', ['contexto' => 'valor']);
Logger::warning('mensagem', ['contexto' => 'valor']);
Logger::error('mensagem', ['contexto' => 'valor']);
```

Respeita `LOG_LEVEL` (debug/info/warning/error/none). Grava em `LOG_FILE`. Cada linha inclui timestamp, nível, `req:{requestId}` e contexto JSON.

### `RequestId` — Identificador único por requisição

```php
RequestId::generate();     // chamado no bootstrap — gera e armazena
RequestId::get();          // retorna o ID atual (gera se ainda vazio)
RequestId::set('test-id'); // útil em testes para valor determinístico
```

---

## 4. Camada HTTP

### `Request` — Encapsulamento da requisição

```php
$request->method();              // 'GET', 'POST', 'PATCH'
$request->path();                // '/api/v1/nfe/estado/SP'
$request->header('X-API-Key');   // string|null
$request->routeParam('uf');      // string|null — capturado pelo Router
$request->routeParams();         // array<string, string>
$request->body();                // array — JSON decodificado do input
$request->bodyParam('client_name', ''); // valor com default
$request->withBody(['key' => 'val']); // clone com body injetado (testes)
```

### `JsonResponder` — Único ponto de saída

```php
// Sucesso — retorna never (exit(0) interno)
JsonResponder::success($data, 'PAIS_FOUND', 'País encontrado.', 200);
JsonResponder::success($data, 'API_KEY_CREATED', 'Key criada.', 201);

// Erro — retorna never (exit(0) interno)
JsonResponder::error('VALIDATION_ERROR', 'Dados inválidos.', 422, $errors);
JsonResponder::error('NOT_FOUND', 'Não encontrado.', 404, $errors);

// Atalhos do Router
JsonResponder::routeNotFound();    // 404 ROUTE_NOT_FOUND
JsonResponder::methodNotAllowed(); // 405 METHOD_NOT_ALLOWED
```

Headers emitidos em toda resposta:
- `Content-Type: application/json; charset=UTF-8`
- `X-Request-ID: {requestId}`
- `Cache-Control: no-store`

---

## 5. Camada Exceptions

Hierarquia completa:

```
\RuntimeException
    └── ApiException(httpStatus, apiCode, message, errors[])
            ├── ValidationException     → 422  VALIDATION_ERROR
            ├── NotFoundException       → 404  NOT_FOUND
            ├── AuthenticationException → 401  AUTHENTICATION_ERROR
            └── AuthorizationException  → 403  AUTHORIZATION_ERROR
```

```php
// Lançar nos services/validators:
throw new ValidationException('Código inválido.', [
    ['field' => 'codigo_pais', 'message' => 'Deve ter no máximo 4 dígitos.'],
]);

throw new NotFoundException("País '9999' não encontrado.", [
    ['field' => 'codigo_pais', 'message' => "O código '9999' não existe."],
]);

// Capturar nos controllers:
catch (ApiException $e) {
    Logger::info('Contexto', ['code' => $e->getApiCode()]);
    JsonResponder::error(
        $e->getApiCode(),
        $e->getMessage(),
        $e->getHttpStatus(),
        $e->getErrors()
    );
}
```

---

## 6. Camada Contracts

Três interfaces — todas em `src/Contracts/`:

```php
// Marcadores semânticos (vazios) — usados para type hints e mocks
interface RepositoryInterface {}
interface ServiceInterface {}

// Contrato funcional — validators DEVEM implementar ambos
interface ValidatorInterface
{
    public function validate(string $value): void;    // lança ValidationException
    public function normalize(string $value): string; // retorna valor limpo
}
```

**Regra:** todo `Service` declara `implements ServiceInterface`. Todo `Validator` declara `implements ValidatorInterface`.

---

## 7. Camada Validators

### Estrutura obrigatória

```php
final class XxxValidator implements ValidatorInterface
{
    public function validate(string $value): void
    {
        $v = $this->normalize($value); // sempre normaliza antes de validar

        if ($v === '') {
            throw new ValidationException('...', [
                ['field' => 'nome_campo', 'message' => '...'],
            ]);
        }
        // demais regras usando $v (valor normalizado)
    }

    public function normalize(string $value): string
    {
        return trim($value); // ou strtoupper(trim()), ou trim(urldecode()), etc.
    }
}
```

### Validators disponíveis

| Validator | `normalize()` | Regra principal |
|---|---|---|
| `CodigoPaisValidator` | `trim()` | 1–4 dígitos numéricos |
| `UfValidator` | `strtoupper(trim())` | Sigla de 2 letras — lista de 27 UFs válidas |
| `MunicipioValidator` | `trim(urldecode())` | Mínimo 2 caracteres |
| `NcmValidator` | `trim()` | Exatamente 8 dígitos |
| `CfopValidator` | `trim()` | Exatamente 4 dígitos |
| `ServicoValidator` | `trim(urldecode())` | Formato N, NN, N.NN ou NN.NN |
| `TipoVeiculoValidator` | `trim()` | Exatamente 2 dígitos |
| `EspecieVeiculoValidator` | `trim()` | Exatamente 1 dígito |
| `MeioPagamentoValidator` | `trim()` | Exatamente 2 dígitos |
| `ProdutoAnpValidator` | `trim()` | Exatamente 9 dígitos |
| `IbsCbsValidator` | `trim()` | Exatamente 3 dígitos |

> **Atenção veículos:** `VeiculoValidator` foi removido. O domínio usa **dois validators separados** — `TipoVeiculoValidator` e `EspecieVeiculoValidator` — injetados individualmente no `VeiculoService`.

---

## 8. Camada Repositories

### `BaseRepository` — Herança para todos os domínios

```php
abstract class BaseRepository
{
    protected PDO $pdo;

    public function __construct(?PDO $pdo = null)
    {
        $this->pdo = $pdo ?? Database::connection();
    }

    // Carrega SQL de database/queries/{domain}/{file}.sql
    protected function loadSql(string $domain, string $file): string;

    // Executa e retorna todos os resultados como array associativo
    protected function fetchAll(string $sql, array $bindings = []): array;

    // Executa e retorna o primeiro resultado ou null
    protected function fetchOne(string $sql, array $bindings = []): ?array;

    // Monta parâmetro LIKE: '%valor%'
    protected function likeParam(string $value): string;

    // Converte dd/mm/yyyy → yyyy-mm-dd (null-safe)
    protected function convertDateBrToIso(?string $value): ?string;

    // Converte TINYINT 0/1 para bool PHP nativo
    protected function toBool(mixed $value): bool;
}
```

### Padrão de um repository de domínio

```php
final class PaisRepository extends BaseRepository
{
    public function findByCodigo(string $codigoPais): array
    {
        $sql = $this->loadSql('paises', 'find_by_codigo');
        return $this->fetchAll($sql, [':codigo_pais' => $codigoPais]);
    }

    public function findByDescricao(string $descricao): array
    {
        $sql  = $this->loadSql('paises', 'find_by_descricao');
        $like = $this->likeParam($descricao);
        return $this->fetchAll($sql, [
            ':descricao'             => $like,
            ':descricao_normalizada' => $like,
        ]);
    }
}
```

### Repositories com mapeamento especial

**`CfopRepository`** — DIV-03: aliases acentuados na view

```php
// A view vw_nfe_flow_cfop retorna as chaves `descrição` e `aplicação` com acento.
// PDO entrega exatamente essas chaves. O mapRow() preserva e converte indicadores.
protected function mapRow(array $row): array
{
    return [
        'cfop'                  => $row['cfop'],
        'descrição'             => $row['descrição'],   // com acento — correto
        'aplicação'             => $row['aplicação'],   // com acento — correto
        'indnfe'                => isset($row['indnfe']) ? (int) $row['indnfe'] : null,
        // ... demais campos
    ];
}
```

**`IbsCbsRepository`** — DIV-05: datas em CHAR dd/mm/yyyy

```php
protected function mapRow(array $row): array
{
    return [
        // Datas armazenadas como CHAR(10) 'dd/mm/yyyy' → convertidas para ISO
        'inicio_vigencia'  => $this->convertDateBrToIso($row['inicio_vigencia']),
        'fim_vigencia'     => $this->convertDateBrToIso($row['fim_vigencia']),
        'data_atualizacao' => $this->convertDateBrToIso($row['data_atualizacao']),
        // Indicadores TINYINT → bool PHP nativo
        'ind_nfe'          => $this->toBool($row['ind_nfe']),
        'exige_grupo_trib_regular' => $this->toBool($row['exige_grupo_trib_regular']),
        // ... 44 campos no total
    ];
}
```

**`ServicoRepository`** — DIV-04: campo `detalhes` como string JSON

```php
public function findDetalhesJsonByRaiz(string $codigoRaiz): array
{
    $sql  = $this->loadSql('servicos', 'find_detalhes_json_by_raiz');
    $rows = $this->fetchAll($sql, [':codigo_servico_raiz' => $codigoRaiz]);

    return array_map(function (array $row): array {
        return [
            'codigo'   => $row['codigo'],
            'detalhes' => json_decode((string) ($row['detalhes'] ?? '[]'), true) ?? [],
        ];
    }, $rows);
}
```

**`NcmRepository`** — DIV-02: JOIN direto (não usa a view)

```sql
-- vw_nfe_flow_ncm NÃO tem descricao, descricao_normalizada nem ipi
-- A query usa JOIN com nfe_flow_ncm_detalhes WHERE eh_item_final = 1
SELECT n.codigo_ncm AS ncm, d.descricao, d.descricao_normalizada, d.ipi, ...
FROM nfe_flow_ncm n
JOIN nfe_flow_unidades_tributaveis_exportacao u ON u.id = n.unidade_tributavel_exportacao_id
LEFT JOIN nfe_flow_ncm_detalhes d ON d.codigo_ncm = n.codigo_ncm AND d.eh_item_final = 1
WHERE n.codigo_ncm = :codigo_ncm
LIMIT 1
```

---

## 9. Camada Services

### Estrutura obrigatória

```php
final class XxxService implements ServiceInterface
{
    public function __construct(
        private readonly XxxRepository $repository,
        private readonly XxxValidator  $validator
    ) {}

    public function findByCodigo(string $rawValue): array
    {
        // 1. Valida o valor bruto (lança ValidationException se inválido)
        $this->validator->validate($rawValue);

        // 2. Normaliza — obtém o valor limpo para o banco
        $clean = $this->validator->normalize($rawValue);

        // 3. Consulta o repository com o valor limpo
        $result = $this->repository->findByCodigo($clean);

        // 4. Lança NotFoundException se vazio
        if (empty($result)) {
            throw new NotFoundException("Recurso '{$clean}' não encontrado.", [
                ['field' => 'campo', 'message' => "O valor '{$clean}' não existe."],
            ]);
        }

        return $result;
    }
}
```

### Serviços com múltiplos validators (Veículo)

```php
final class VeiculoService implements ServiceInterface
{
    public function __construct(
        private readonly VeiculoRepository      $repository,
        private readonly TipoVeiculoValidator   $tipoValidator,
        private readonly EspecieVeiculoValidator $especieValidator
    ) {}

    public function findByTipo(string $tipo): array
    {
        $this->tipoValidator->validate($tipo);
        $t = $this->tipoValidator->normalize($tipo);
        // ...
    }

    public function findByTipoAndEspecie(string $tipo, string $especie): array
    {
        $this->tipoValidator->validate($tipo);
        $t = $this->tipoValidator->normalize($tipo);

        $this->especieValidator->validate($especie);
        $e = $this->especieValidator->normalize($especie);

        $result = $this->repository->findByTipoAndEspecie($t, $e);
        // ...
    }
}
```

### Busca por texto livre (sem validator)

Para parâmetros do tipo `descricao` que chegam via URL, o service aplica `trim(urldecode())` diretamente:

```php
public function findByDescricao(string $descricao): array
{
    $desc = trim(urldecode($descricao));

    if ($desc === '') {
        throw new ValidationException('A descrição não pode ser vazia.', [
            ['field' => 'descricao', 'message' => 'Informe ao menos um caractere.'],
        ]);
    }

    $result = $this->repository->findByDescricao($desc);

    if (empty($result)) {
        throw new NotFoundException("Nenhum resultado para '{$desc}'.", [
            ['field' => 'descricao', 'message' => "Nenhum registro corresponde a '{$desc}'."],
        ]);
    }

    return $result;
}
```

---

## 10. Camada Controllers

### Estrutura obrigatória

```php
final class XxxController
{
    public function __construct(private readonly XxxService $service) {}

    public function findByCodigo(Request $request): void
    {
        // 1. Extrai o parâmetro bruto da rota — sem trim, sem urldecode
        $codigo = (string) $request->routeParam('codigo_xxx');

        // 2. Delega ao service dentro de try/catch
        try {
            $data = $this->service->findByCodigo($codigo);

            // 3. Emite sucesso com code nomeado
            JsonResponder::success($data, 'XXX_FOUND', 'Recurso encontrado com sucesso.');

        } catch (ApiException $e) {
            // 4. Loga e emite erro
            Logger::info('Recurso não encontrado', [
                'codigo' => $codigo,
                'code'   => $e->getApiCode(),
            ]);
            JsonResponder::error(
                $e->getApiCode(),
                $e->getMessage(),
                $e->getHttpStatus(),
                $e->getErrors()
            );
        }
    }
}
```

### Regras rígidas dos controllers

- **Nunca** conter SQL
- **Nunca** conter regras de negócio
- **Nunca** chamar `trim()`, `urldecode()` ou `strtoupper()` nos parâmetros
- **Sempre** ter `try { } catch (ApiException $e)` em cada método público
- **Sempre** ter `Logger::info()` no catch com contexto relevante
- **Sempre** usar `JsonResponder::success($data, 'CODE_NOMEADO', 'Mensagem.')`

### Codes nomeados por domínio

| Domínio | Code de sucesso |
|---|---|
| País | `PAIS_FOUND` |
| Estado | `ESTADO_FOUND` |
| Município | `MUNICIPIO_FOUND` |
| NCM | `NCM_FOUND` |
| CFOP | `CFOP_FOUND` |
| Serviço | `SERVICO_FOUND` |
| Serviço (JSON detalhes) | `SERVICO_DETALHES_FOUND` |
| Veículo | `VEICULO_FOUND` |
| Bandeira | `BANDEIRA_FOUND` |
| FCP | `FCP_FOUND` |
| Meio de Pagamento | `MEIO_PAGAMENTO_FOUND` |
| Produto ANP | `ANP_FOUND` |
| IBS/CBS | `IBS_CBS_FOUND` |
| API Key (criação) | `API_KEY_CREATED` (HTTP 201) |
| API Key (listagem) | `API_KEYS_LISTED` |
| API Key (desativar) | `API_KEY_DISABLED` |
| API Key (ativar) | `API_KEY_ENABLED` |
| API Key (expirar) | `API_KEY_EXPIRED` |

---

## 11. Middleware de Autenticação

### `ApiKeyAuthMiddleware`

```php
// Aplicado como segundo argumento em todas as rotas /nfe/
$router->get('/api/v1/nfe/...', $handler, [$auth]);

// Implementa o padrão callable do pipeline:
public function __invoke(Request $request, callable $next): void
{
    $rawKey = $request->header('X-API-Key');

    if ($rawKey === null || trim($rawKey) === '') {
        Logger::warning('Sem X-API-Key', ['path' => $request->path()]);
        JsonResponder::error('AUTHENTICATION_ERROR', 'Autenticação necessária.', 401, [...]);
        // JsonResponder::error() faz exit(0) — $next nunca é chamado
    }

    try {
        $this->apiKeyService->validateRawKey(trim($rawKey));
    } catch (AuthenticationException $e) {
        Logger::warning('Autenticação falhou', [...]);
        JsonResponder::error(...);
    }

    $next($request); // só chega aqui se a key for válida
}
```

### Processo de validação da chave

```
1. Busca todas as keys com is_active = 1 no banco
2. Para cada key: password_verify($rawKey, $apiKey->api_key_hash)
3. Se encontrar: verifica isExpired() (expires_at < now())
4. Se expirada: AuthenticationException('API key expirada.')
5. Se válida: touchLastUsed($id) + retorna o ApiKey
6. Se nenhuma bater: AuthenticationException('API key inválida.')
```

---

## 12. Sistema de API Keys

### Tabela `api_keys`

```sql
CREATE TABLE api_keys (
    id                  BIGINT       PK AUTO_INCREMENT,
    client_name         VARCHAR(100) NOT NULL,
    client_description  VARCHAR(255) NULL,
    api_key_hash        VARCHAR(255) NOT NULL,  -- bcrypt hash — a chave bruta nunca é salva
    is_active           TINYINT(1)   NOT NULL DEFAULT 1,
    expires_at          DATETIME     NULL,       -- NULL = sem expiração
    last_used_at        DATETIME     NULL,
    created_at          DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at          DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uq_api_key_hash (api_key_hash(72))
);
```

### Rotas admin (protegidas por `ADMIN_KEY`)

```
POST   /api/v1/admin/api-keys
GET    /api/v1/admin/api-keys
PATCH  /api/v1/admin/api-keys/{id}/disable
PATCH  /api/v1/admin/api-keys/{id}/enable
PATCH  /api/v1/admin/api-keys/{id}/expire
```

As rotas admin **não passam** pelo `ApiKeyAuthMiddleware`. A verificação é feita diretamente no `ApiKeyController` via `hash_equals($expected, $provided)`.

### Criação de uma nova key

```bash
curl -X POST http://localhost:8080/api/v1/admin/api-keys \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ${ADMIN_KEY}" \
  -d '{
    "client_name": "Meu Sistema",
    "client_description": "Descrição opcional",
    "expires_at": "2027-12-31"
  }'
```

> A `api_key` retornada é exibida **apenas uma vez**. Salve imediatamente — é o hash que fica no banco, nunca a chave bruta.

### disable vs expire

| Ação | Efeito | Reversível |
|---|---|---|
| `disable` | `is_active = 0` | Sim — use `enable` |
| `expire` | `expires_at = CURRENT_TIMESTAMP` | Não — requer edição direta no banco |

---

## 13. Bootstrap e Roteamento

### `bootstrap/app.php`

```php
// 1. Autoloader PSR-4 via Composer
require_once $autoload;

// 2. .env sem dependência externa
Env::load(dirname(__DIR__) . '/.env');

// 3. Timezone e modo de erro
date_default_timezone_set((string) Env::get('APP_TIMEZONE', 'America/Sao_Paulo'));
ini_set('display_errors', $debug ? '1' : '0');

// 4. ID único desta requisição
RequestId::generate();
```

### `public/index.php`

```php
require_once dirname(__DIR__) . '/bootstrap/app.php';

$router  = new Router();
$request = new Request();

require_once dirname(__DIR__) . '/bootstrap/routes.php';

try {
    $router->dispatch($request);
} catch (\Throwable $e) {
    Logger::error('Erro não tratado', [...]);
    JsonResponder::error('INTERNAL_ERROR', 'Erro interno.', 500, [...]);
}
```

### `bootstrap/routes.php` — Padrão de registro

```php
$auth = new ApiKeyAuthMiddleware();

// Rotas de consulta — todas passam pelo middleware de auth
$router->get(
    '/api/v1/nfe/classificacao/pais/{codigo_pais}',
    static fn($req) => (new PaisController(
        new PaisService(new PaisRepository(), new CodigoPaisValidator())
    ))->findByCodigo($req),
    [$auth]   // ← middleware obrigatório em todas as rotas /nfe/
);

// Rotas admin — sem middleware (verificação interna por ADMIN_KEY)
$router->post(
    '/api/v1/admin/api-keys',
    static fn($req) => (new ApiKeyController())->create($req)
    // sem [$auth]
);
```

**Atenção à ordem das rotas de Serviços:** rotas com segmentos literais devem vir antes das com parâmetros curingas:

```php
// CORRETO — ordem obrigatória para serviços
$router->get('/api/v1/nfe/classificacao/servico/raiz/{id}/json', ...);
$router->get('/api/v1/nfe/classificacao/servico/raiz/{id}/simplificado', ...);
$router->get('/api/v1/nfe/classificacao/servico/raiz/descricao/{desc}', ...);
$router->get('/api/v1/nfe/classificacao/servico/raiz/{id}', ...);
$router->get('/api/v1/nfe/classificacao/servico/descricao/{desc}', ...);
$router->get('/api/v1/nfe/classificacao/servico/{codigo}/simplificado', ...);
$router->get('/api/v1/nfe/classificacao/servico/{codigo}', ...); // ← por último
```

---

## 14. Banco de Dados e Queries

### Organização dos arquivos SQL

```
database/
├── migrations/           ← DDL + DML executados em ordem numérica
│   ├── 001_tabelas_base.sql
│   ├── ...
│   ├── 019_create_api_keys.sql
│   └── 020_optional_seed_api_keys.sql
└── queries/              ← SQL por domínio, carregados por loadSql()
    ├── paises/
    │   ├── find_by_codigo.sql
    │   └── find_by_descricao.sql
    ├── estados/
    ├── municipios/
    ├── ncm/
    ├── cfop/
    ├── servicos/
    ├── veiculos/
    ├── bandeiras_cartao/
    ├── fcp/
    ├── meios_pagamento/
    ├── anp/
    └── ibs_cbs/
```

### Padrão de arquivo SQL

```sql
-- database/queries/paises/find_by_codigo.sql
SELECT
    pais_ibge,
    pais,
    pais_normalizado,
    situacao,
    inicio_vigencia,
    fim_vigencia
FROM vw_nfe_flow_paises
WHERE pais_ibge = :codigo_pais
```

Parâmetros nomeados com `:` — sempre. Nunca interpolação de string.

### Views MySQL consumidas

| View | Domínio |
|---|---|
| `vw_nfe_flow_paises` | Países |
| `vw_nfe_flow_estados` | Estados |
| `vw_nfe_flow_municipios` | Municípios |
| `vw_nfe_flow_cfop` | CFOP |
| `vw_nfe_flow_servico` | Serviços (completo) |
| `vw_nfe_flow_servico_detalhes` | Serviços (JSON por raiz) |
| `vw_nfe_flow_captu_servico` | Serviços raiz simplificado |
| `vw_nfe_flow_detalhe_servico` | Serviços detalhe simplificado |
| `vw_nfe_flow_veiculo` | Veículos |
| `vw_nfe_flow_bandeiras_cartao` | Bandeiras |
| `vw_nfe_flow_alicota_fcp` | FCP por UF |
| `vw_nfe_flow_meios_pagamento` | Meios de pagamento |
| `vw_nfe_flow_codigo_produto_anp` | Produtos ANP |
| `vw_nfe_flow_ibs_cbs` | IBS/CBS |

> NCM **não usa view** — faz JOIN direto com as tabelas base (ver DIV-02).

---

## 15. Divergências Documentais

Identificadas e resolvidas durante análise das migrations SQL reais versus `tools/docs/endpoints.md`.

### DIV-02 — NCM: campos ausentes na view

**Problema:** `vw_nfe_flow_ncm` não contém `descricao`, `descricao_normalizada` nem `ipi`. Esses campos existem em `nfe_flow_ncm_detalhes`.

**Solução:** `NcmRepository` usa JOIN direto:
```sql
LEFT JOIN nfe_flow_ncm_detalhes d
    ON d.codigo_ncm = n.codigo_ncm
   AND d.eh_item_final = 1
```

### DIV-03 — CFOP: aliases acentuados

**Problema:** `vw_nfe_flow_cfop` retorna aliases `descrição` e `aplicação` com acento. PDO entrega exatamente essas chaves.

**Solução:** `CfopRepository::mapRow()` preserva os nomes acentuados — são os nomes corretos conforme documentação.

### DIV-04 — Serviços `/json`: `detalhes` como string

**Problema:** `vw_nfe_flow_servico_detalhes` retorna o campo `detalhes` como string JSON (construída via `CONCAT + GROUP_CONCAT + JSON_OBJECT`), não como array.

**Solução:** `ServicoRepository::findDetalhesJsonByRaiz()` aplica `json_decode()` antes de retornar.

### DIV-05 — IBS/CBS: datas em CHAR `dd/mm/yyyy`

**Problema:** Colunas `dinivig`, `dfimvig`, `dataatualizacao` são `CHAR(10)` no formato `dd/mm/yyyy`. A API documenta ISO-8601.

**Solução:** `IbsCbsRepository::mapRow()` converte via `BaseRepository::convertDateBrToIso()`:
```php
protected function convertDateBrToIso(?string $value): ?string
{
    if ($value === null || trim($value) === '') return null;
    $dt = \DateTime::createFromFormat('d/m/Y', trim($value));
    return $dt !== false ? $dt->format('Y-m-d') : null;
}
```

---

## 16. Como Adicionar um Novo Domínio

Exemplo: domínio `Cest` com busca por código.

### 1. Criar a query SQL

```sql
-- database/queries/cest/find_by_codigo.sql
SELECT
    codigo_cest,
    descricao,
    descricao_normalizada,
    segmento
FROM vw_nfe_flow_cest
WHERE codigo_cest = :codigo_cest
```

### 2. Criar o Validator

```php
// src/Validators/CestValidator.php
final class CestValidator implements ValidatorInterface
{
    public function validate(string $value): void
    {
        $v = $this->normalize($value);
        if ($v === '') {
            throw new ValidationException('O parâmetro codigo_cest é obrigatório.', [...]);
        }
        if (!preg_match('/^\d{7}$/', $v)) {
            throw new ValidationException('Código CEST inválido.', [...]);
        }
    }

    public function normalize(string $value): string
    {
        return trim($value);
    }
}
```

### 3. Criar o Repository

```php
// src/Repositories/CestRepository.php
final class CestRepository extends BaseRepository
{
    public function findByCodigo(string $codigo): array
    {
        $sql = $this->loadSql('cest', 'find_by_codigo');
        return $this->fetchAll($sql, [':codigo_cest' => $codigo]);
    }
}
```

### 4. Criar o Service

```php
// src/Services/CestService.php
final class CestService implements ServiceInterface
{
    public function __construct(
        private readonly CestRepository $repository,
        private readonly CestValidator  $validator
    ) {}

    public function findByCodigo(string $rawCodigo): array
    {
        $this->validator->validate($rawCodigo);
        $codigo = $this->validator->normalize($rawCodigo);

        $result = $this->repository->findByCodigo($codigo);

        if (empty($result)) {
            throw new NotFoundException("CEST '{$codigo}' não encontrado.", [
                ['field' => 'codigo_cest', 'message' => "O código '{$codigo}' não existe."],
            ]);
        }

        return $result;
    }
}
```

### 5. Criar o Controller

```php
// src/Controllers/CestController.php
final class CestController
{
    public function __construct(private readonly CestService $service) {}

    public function findByCodigo(Request $request): void
    {
        $codigo = (string) $request->routeParam('codigo_cest');

        try {
            $data = $this->service->findByCodigo($codigo);
            JsonResponder::success($data, 'CEST_FOUND', 'CEST encontrado com sucesso.');
        } catch (ApiException $e) {
            Logger::info('CEST não encontrado', ['codigo' => $codigo, 'code' => $e->getApiCode()]);
            JsonResponder::error($e->getApiCode(), $e->getMessage(), $e->getHttpStatus(), $e->getErrors());
        }
    }
}
```

### 6. Registrar a rota em `bootstrap/routes.php`

```php
use NfeFlow\Controllers\CestController;
use NfeFlow\Repositories\CestRepository;
use NfeFlow\Services\CestService;
use NfeFlow\Validators\CestValidator;

$router->get(
    '/api/v1/nfe/classificacao/cest/{codigo_cest}',
    static fn($req) => (new CestController(
        new CestService(new CestRepository(), new CestValidator())
    ))->findByCodigo($req),
    [$auth]
);
```

### Checklist de conformidade ao adicionar domínio

- [ ] Validator implementa `validate()` e `normalize()`
- [ ] `validate()` chama `$this->normalize()` internamente
- [ ] Service implementa `ServiceInterface`
- [ ] Service chama `validate($raw)` → `normalize($raw)` → `repository->method($clean)`
- [ ] Service lança `NotFoundException` com `errors[]` preenchido
- [ ] Controller extrai parâmetro sem `trim()`/`urldecode()`
- [ ] Controller tem `try { } catch (ApiException $e)`
- [ ] Controller usa `Logger::info()` no catch
- [ ] Controller usa code nomeado `XXX_FOUND` no success
- [ ] SQL usa parâmetros nomeados `:param`
- [ ] Rota registrada com `[$auth]`

---

## 17. Testes

### Configuração (`phpunit.xml`)

```xml
<phpunit bootstrap="bootstrap/app.php" colors="true">
    <testsuites>
        <testsuite name="Unit">        <directory>tests/Unit</directory>        </testsuite>
        <testsuite name="Integration"> <directory>tests/Integration</directory> </testsuite>
    </testsuites>
    <php>
        <env name="LOG_LEVEL"   value="none"/>
        <env name="BCRYPT_COST" value="4"/>
        <env name="ADMIN_KEY"   value="test-admin-key"/>
        <env name="DB_DATABASE" value="nfe_flow_test"/>
    </php>
</phpunit>
```

### Injeção de PDO mock

```php
// Em testes unitários — sem banco real
class StubPdo extends PDO { public function __construct() {} }
Database::setInstance(new StubPdo());

// Ou via PdoMockFactory (tests/Fixtures/PdoMockFactory.php)
$pdo = PdoMockFactory::withRows($this, [$rowArray]);
$pdo = PdoMockFactory::withNoRows($this);
```

### Padrão de teste unitário de service

```php
public function test_retorna_dados_quando_encontrado(): void
{
    $repo = $this->createMock(PaisRepository::class);
    $repo->method('findByCodigo')->willReturn([['pais_ibge' => '1058', ...]]);

    $service = new PaisService($repo, new CodigoPaisValidator());
    $result  = $service->findByCodigo('1058');

    $this->assertSame('1058', $result[0]['pais_ibge']);
}

public function test_lanca_not_found_quando_vazio(): void
{
    $repo = $this->createMock(PaisRepository::class);
    $repo->method('findByCodigo')->willReturn([]);

    $service = new PaisService($repo, new CodigoPaisValidator());

    $this->expectException(NotFoundException::class);
    $service->findByCodigo('9999');
}

public function test_lanca_validation_para_codigo_invalido(): void
{
    $repo    = $this->createMock(PaisRepository::class);
    $service = new PaisService($repo, new CodigoPaisValidator());

    $this->expectException(ValidationException::class);
    $service->findByCodigo('ABCD');
}
```

### Comandos

```bash
composer test             # todos os testes
composer test-unit        # só unitários (sem banco)
composer test-integration # só integração
```

---

## 18. Variáveis de Ambiente

| Variável | Padrão | Descrição |
|---|---|---|
| `APP_ENV` | `production` | `production` / `testing` / `development` |
| `APP_DEBUG` | `false` | `true` expõe stack trace nas respostas |
| `APP_TIMEZONE` | `America/Sao_Paulo` | Timezone do PHP |
| `DB_HOST` | `127.0.0.1` | Host do MySQL |
| `DB_PORT` | `3306` | Porta do MySQL |
| `DB_DATABASE` | — | **Obrigatório** — nome do banco |
| `DB_USERNAME` | — | **Obrigatório** — usuário do banco |
| `DB_PASSWORD` | `''` | Senha do banco |
| `DB_CHARSET` | `utf8mb4` | Charset da conexão |
| `ADMIN_KEY` | — | Chave para rotas `/admin/` — gere aleatório e longo |
| `BCRYPT_COST` | `12` | Custo do bcrypt (12–14 em produção, 4 em testes) |
| `LOG_LEVEL` | `warning` | `debug` / `info` / `warning` / `error` / `none` |
| `LOG_FILE` | `storage/logs/app.log` | Caminho do arquivo de log |

---

## 19. Envelope de Resposta

### Sucesso

```json
{
  "success": true,
  "code": "PAIS_FOUND",
  "message": "País encontrado com sucesso.",
  "data": [
    {
      "pais_ibge": "1058",
      "pais": "BRASIL",
      "pais_normalizado": "BRASIL",
      "situacao": "ATIVO",
      "inicio_vigencia": "2006-01-01",
      "fim_vigencia": null
    }
  ],
  "meta": {
    "timestamp": "2026-03-28T21:00:00Z",
    "requestId": "20260328210000abc123ef"
  },
  "errors": []
}
```

### Erro

```json
{
  "success": false,
  "code": "VALIDATION_ERROR",
  "message": "Código NCM inválido.",
  "data": null,
  "meta": {
    "timestamp": "2026-03-28T21:00:00Z",
    "requestId": "20260328210000abc123ef"
  },
  "errors": [
    {
      "field": "codigo_ncm",
      "message": "O código NCM deve ter exatamente 8 dígitos numéricos."
    }
  ]
}
```

### Tabela de códigos HTTP e codes internos

| HTTP | Code | Quando |
|---|---|---|
| 200 | `XXX_FOUND` | Consulta com resultado |
| 201 | `API_KEY_CREATED` | Key criada |
| 422 | `VALIDATION_ERROR` | Parâmetro inválido ou ausente |
| 401 | `AUTHENTICATION_ERROR` | Key ausente, inválida, inativa ou expirada |
| 403 | `AUTHORIZATION_ERROR` | Key sem permissão para rota admin |
| 404 | `NOT_FOUND` | Recurso não encontrado no banco |
| 404 | `ROUTE_NOT_FOUND` | Rota não registrada |
| 405 | `METHOD_NOT_ALLOWED` | Método HTTP não permitido |
| 500 | `INTERNAL_ERROR` | Throwable não tratado |

---

## 20. Tabela de Referência Rápida

### Endpoints de consulta (todos exigem `X-API-Key`)

| Método | Endpoint | Domínio |
|---|---|---|
| GET | `/api/v1/nfe/classificacao/pais/{codigo_pais}` | País por código BACEN |
| GET | `/api/v1/nfe/classificacao/pais/descricao/{descricao}` | País por nome |
| GET | `/api/v1/nfe/estado/{uf}` | Estado por sigla |
| GET | `/api/v1/nfe/localidade/estado/{uf}/cidade/{nome_cidade}` | Município por UF e nome |
| GET | `/api/v1/nfe/classificacao/ncm/{codigo_ncm}` | NCM (8 dígitos) |
| GET | `/api/v1/nfe/classificacao/cfop/{codigo_cfop}` | CFOP (4 dígitos) |
| GET | `/api/v1/nfe/classificacao/cfop/descricao/{descricao}` | CFOP por descrição |
| GET | `/api/v1/nfe/classificacao/servico/{codigo_servico}` | Serviço por código detalhado |
| GET | `/api/v1/nfe/classificacao/servico/raiz/{codigo_raiz}` | Serviços por raiz |
| GET | `/api/v1/nfe/classificacao/servico/raiz/{codigo_raiz}/json` | Serviços raiz em JSON estruturado |
| GET | `/api/v1/nfe/classificacao/servico/raiz/{codigo_raiz}/simplificado` | Raiz simplificado |
| GET | `/api/v1/nfe/classificacao/servico/raiz/descricao/{descricao}` | Raiz por descrição |
| GET | `/api/v1/nfe/classificacao/servico/{codigo_servico}/simplificado` | Detalhe simplificado |
| GET | `/api/v1/nfe/classificacao/servico/descricao/{descricao}` | Serviço por descrição |
| GET | `/api/v1/nfe/classificacao/veiculo/tipo/{tipo}` | Veículo por tipo |
| GET | `/api/v1/nfe/classificacao/veiculo/tipo/descricao/{descricao}` | Veículo tipo por descrição |
| GET | `/api/v1/nfe/classificacao/veiculo/especie/{especie}` | Veículo por espécie |
| GET | `/api/v1/nfe/classificacao/veiculo/especie/descricao/{descricao}` | Veículo espécie por descrição |
| GET | `/api/v1/nfe/classificacao/veiculo/tipo/{tipo}/especie/{especie}` | Veículo tipo + espécie |
| GET | `/api/v1/nfe/classificacao/bandeira/{codigo_bandeira}` | Bandeira por código |
| GET | `/api/v1/nfe/classificacao/bandeira/operadora/{operadora}` | Bandeira por operadora |
| GET | `/api/v1/nfe/classificacao/alicota/fcp/{uf}` | FCP por UF |
| GET | `/api/v1/nfe/classificacao/meio_pagamento/{codigo}` | Meio de pagamento por código |
| GET | `/api/v1/nfe/classificacao/meio_pagamento/descricao/{descricao}` | Meio por descrição |
| GET | `/api/v1/nfe/classificacao/anp/{codigo_produto_anp}` | Produto ANP por código |
| GET | `/api/v1/nfe/classificacao/anp/nome/{nome_produto}` | ANP por nome |
| GET | `/api/v1/nfe/classificacao/anp/descricao/{descricao_produto}` | ANP por descrição |
| GET | `/api/v1/nfe/classificacao/ibs_cbs/cst/{cst_ibs_cbs}` | IBS/CBS por CST |
| GET | `/api/v1/nfe/classificacao/ibs_cbs/cst/descricao/{descricao_cst_ibs_cbs}` | IBS/CBS por descrição |

### Endpoints admin (exigem `ADMIN_KEY`)

| Método | Endpoint | Ação |
|---|---|---|
| POST | `/api/v1/admin/api-keys` | Criar nova key |
| GET | `/api/v1/admin/api-keys` | Listar todas as keys |
| PATCH | `/api/v1/admin/api-keys/{id}/disable` | Desativar key |
| PATCH | `/api/v1/admin/api-keys/{id}/enable` | Reativar key |
| PATCH | `/api/v1/admin/api-keys/{id}/expire` | Expirar key imediatamente |

---

*G4D Soluções e Desenvolvimento — 2026*