# Gerador de Recibos — Fases 0 a 4

Camada de banco, núcleo de domínio e serviços das fases 1 a 4. Tudo foi
executado contra um PostgreSQL 16 real — os critérios de aceite estão
provados, não afirmados.

**O que está coberto por teste:** migrations, funções do banco, núcleo
(extenso, canonicalização, hash) e os serviços de domínio.
**O que não está:** a camada NestJS em `src/nestjs/` é fiação — módulo,
interceptor e controller de referência; e `PuppeteerPdf`, que exige Chromium.
O HTML que ele recebe está coberto por 21 testes.

```
db/migrations/
  001_schema.sql            estrutura completa (12 tabelas, schema `recibos`)
  002_rls_e_grants.sql      isolamento por tenant e permissões da aplicação
  003_imutabilidade.sql     triggers de bloqueio
  004_selo_e_cadeia.sql     selo, numeração, cancelamento, verificação
db/tests/
  run.sh                    roda tudo num banco limpo
  00_seed.sql               dois tenants, para provar o isolamento
  01_criterios_de_aceite.sql
  02_defesa_em_profundidade.sql
  03_concorrencia.sh
src/core/
  extenso.ts                valor por extenso e conversão monetária
  canonical.ts              canonicalização, hash encadeado, código público
  identity.ts               papéis, matriz de permissão, permissão por série
  errors.ts                 erros de domínio com mensagem para o usuário final
  __tests__/nucleo.test.ts
src/db/
  client.ts                 contexto de tenant e transação com app.tenant_id
src/domain/
  cadastros.service.ts      fase 1 — empresas, séries, contrapartes
  usuarios.service.ts       fase 2 — convites, papéis, permissão por série
  licenca.service.ts        licença Klavo e regra de degradação
  recibos.service.ts        fase 3 — rascunho, selo, cancelamento
src/render/
  template.ts               folha A4 em duas vias — função pura
  pdf.ts                    adaptador Puppeteer (sem teste automatizado)
  __tests__/template.test.ts
src/domain/impressao.service.ts   monta o documento a partir do snapshot
src/nestjs/                 fiação (sem teste automatizado)
amostras/                   folhas A4 geradas de recibos reais, para conferir no papel
src/__tests__/integracao.test.ts
```

## Como rodar

```bash
export PGHOST=/tmp PGPORT=5432 PGUSER=postgres
./db/tests/run.sh
```

## Resultado da última execução

```
Critérios de aceite ............ 17 de 17
Defesa em profundidade .......... 6 de 6
Concorrência .................... 41 emissões simultâneas,
                                  41 números distintos, 1 a 41, sem buraco
Núcleo (TypeScript) ............. 15 de 15
Layout A4 (template) ............ 21 de 21
Integração fases 1 a 4 .......... 34 de 34
```

## As camadas de imutabilidade, e o que cada uma cobre

| Camada | Cobre | Não cobre |
|---|---|---|
| Sem `INSERT` em `receipts` para o app | criar recibo fora da numeração | acesso com papel dono |
| `selar_recibo()` SECURITY DEFINER | número duplicado, buraco, corrida | — |
| Triggers `BEFORE UPDATE/DELETE` | alteração por DBA distraído, script, restore | quem desliga o trigger de propósito |
| FK de `receipt_events` e `receipt_cancellations` | exclusão desatenta | atacante que apaga a trilha junto |
| Encadeamento de hash (por série) | exclusão e reordenação no meio da cadeia | remoção do último documento |
| Contador da série em `verificar_cadeia()` | remoção do último documento | — |
| `verificarCadeia()` em TypeScript | adulteração de campo | — |

A verificação no banco confere só o encadeamento. Adulteração de conteúdo
exige recalcular o payload canônico, o que só a aplicação sabe fazer — é o
que a função do `canonical.ts` faz, e o que o futuro comando
`recibos verify-chain` vai usar.

## Decisões tomadas na implementação

**Numeração sem `SEQUENCE`.** Sequence não faz rollback: uma transação
abortada consumiria o número e abriria buraco no talão. O contador vive em
`series.proximo_numero`, travado com `SELECT … FOR UPDATE`. O teste T04 prova
que um rollback devolve o número.

**A aplicação não tem `INSERT` em `receipts`.** A única porta de entrada de um
documento selado é `recibos.selar_recibo()`, que roda como `SECURITY DEFINER`.
Não existe caminho que crie recibo pulando a numeração ou o hash.

**Hash calculado no banco, payload canonicalizado na aplicação.** A
canonicalização precisa ser testável e determinística, e isso se faz melhor em
TypeScript. Mas o SHA-256 é calculado dentro da função, com o lock já na mão —
assim ninguém vence a corrida pelo `hash_anterior`.

**Cancelamento é contra-registro.** `cancelar_recibo()` valida o papel do
usuário e insere em `receipt_cancellations`. O recibo permanece, o número fica
consumido para sempre (T12 e T13).

**Direção da série é imutável depois do primeiro documento.** Antes disso é
erro de cadastro e pode ser corrigido; depois, a série precisa ser desativada e
substituída (T14).

## Bug encontrado pelos testes de integração

A função `selar_recibo()` calculava o hash com `(texto)::bytea`. O cast **não
converte texto em bytes** — ele interpreta a string como sintaxe de escape do
bytea. Com acento, cedilha, travessão ou contrabarra, quebra ou produz bytes
errados. Os testes SQL não pegaram porque usavam texto sem acento; o teste de
integração, que sela um recibo com "manutenção de exaustão — OS \"4412\"",
pegou na hora.

Corrigido para `convert_to(texto, 'UTF8')`, que faz a codificação de verdade e
casa com `createHash('sha256').update(texto, 'utf8')` do Node. O teste T17 e o
teste de integração do hash existem para o bug não voltar.

Isso teria passado para produção e quebrado no primeiro recibo brasileiro de
verdade — ou pior, teria gerado hash que não fecha na conferência.

## Pontos de atenção operacional

**O papel de conexão no Neon não pode ser superusuário.** `FORCE ROW LEVEL
SECURITY` vale para o dono das tabelas, mas superusuário ignora RLS por
definição do PostgreSQL. Se a aplicação conectar como superusuário, o
isolamento entre tenants deixa de existir. Usar `recibos_app`, e só ele.

**`app.tenant_id` precisa ser definido em toda transação.** Sem ele,
`recibos.tenant_atual()` devolve NULL, as políticas não casam com nada e as
consultas voltam vazias — falha fechada, que é o comportamento correto, mas
gera bug silencioso se o interceptor do NestJS esquecer de setar.

**A cadeia é por série.** Cada talão tem sua própria linha do tempo, começando
no próprio bloco gênese — que é como a contabilidade já confere talão. Como a
numeração tem o mesmo escopo, um único `FOR UPDATE` na série governa as duas
coisas, e séries diferentes emitem em paralelo: o recebimento no caixa não
espera o pagamento de diária no RH. Há teste provando isso com uma transação
segurando o lock de uma série enquanto outra sela normalmente.

Consequência para a auditoria: `verify-chain` percorre N cadeias em vez de uma,
e `verificarCadeia()` no TypeScript só pode receber documentos de uma série por
vez — misturar séries na mesma lista acusa falso positivo.

## Decisões das fases 2 e 3

**Cadeia de hash no escopo da série.** Decidido antes de existir documento em
produção, que era a janela barata para escolher. Depois seria migration em
tabela imutável.

**Segregação de funções.** Emissor não cancela. Cancelar é a única escrita que
existe sobre documento selado, e concentrar emissão e cancelamento na mesma
pessoa é o primeiro ponto que um contador levanta.

**Permissão de emissão é por série.** Quem lança entrada não precisa ser quem
lança saída. Proprietário emite em qualquer série — é quem as cria.

**Convite pendente ocupa lugar no plano.** Se não contasse, daria para estourar
o limite deixando convites abertos.

**O último proprietário ativo não pode ser desativado.** Sem ele ninguém mais
administra usuários, séries ou licença, e o cliente fica trancado fora da
própria conta.

**O limite de usuários vem da licença, não do código.** Mudar de plano no Klavo
não pode exigir deploy.

**Licença suspensa não bloqueia cancelamento.** Cancelar é correção de erro
passado. Impedir isso prenderia o cliente a um documento errado por questão
comercial.

**O número entra no payload antes do selo.** A função do banco atribui o número
definitivo; se ele divergir do que foi assinado no payload, a emissão aborta.
Melhor não emitir do que emitir com hash que não representa o documento.

## Fase 4 — o documento impresso

**A folha fecha na conta.** Cada via ocupa 133mm, a faixa de corte 11mm:
133 + 11 + 133 = 277mm, que é o A4 de 297mm menos as margens de 10mm. O corte
cai exatamente na metade da página. Há teste conferindo a aritmética e o CSS.

**Os rótulos das vias não têm condicional.** "Via do pagador" e "Via do
recebedor" estão certos nas duas direções — o que muda é quem é cada um, e o
nome sai impresso logo abaixo do rótulo. A primeira via fica sempre com quem
pagou, porque é quem precisa da prova.

**O documento sai do snapshot, nunca do cadastro atual.** Há teste que sela um
recibo, troca o nome da contraparte no banco depois, e exige que a impressão
continue mostrando o nome do dia da emissão.

**Tudo que vem do usuário é escapado.** A descrição é texto livre que vira HTML
e depois PDF. Sem escapar, um `</p><script>` na descrição quebraria o documento
ou pior. Dois testes cobrem injeção pela descrição e aspas no nome da empresa.

**Impressão e leitura nunca dependem da licença.** Com a licença suspensa há 90
dias, o documento continua saindo — tem teste.

**Descrição longa encolhe o corpo em vez de estourar a meia folha.** Acima de
260 caracteres a via entra em modo compacto. Não resolve o caso extremo: uma
descrição de 2.000 caracteres ainda vai cortar. A saída de verdade é limitar o
campo na emissão, decisão que ficou pendente.

**`printBackground: true` no Puppeteer não é detalhe.** Sem ele a tarja de
CANCELADO some do PDF, e um recibo cancelado sairia com cara de válido.

### Conferir no papel

Abra os arquivos de `amostras/` no navegador e imprima em A4 sem ajuste de
escala ("tamanho real", não "ajustar à página"). Confira se a linha tracejada
cai na metade da folha e se sobra margem suficiente para a assinatura à mão.
Este é o critério de aceite da fase e não dá para automatizar.
