Fase 0 — descoberta e arquitetura:
- Inventário do projeto, glossário de domínio, arquitetura com bounded
contexts e topologia de containers, threat model inicial.
- 12 ADRs cobrindo modular monolith, topologia de containers (Postgres
isolado + eden-core/parceiros/assinante em containers e portas
distintos), auth/sessões, modelo de permissões, criptografia/segredos,
contrato first-class, stock ledger, separação billing/finance/fiscal,
outbox transacional, adapters SaperX e Focus NFe, e identidade
compartilhada entre as 3 apps.
- 14 subagentes e 7 skills especializados por domínio em .claude/.
- Hooks de segurança (PreToolUse/PostToolUse/Stop) testados via pipe.
Fase 1 — plataforma (em andamento):
- Monorepo pnpm workspaces + Turborepo: apps/{api,worker,core-web,
reseller-web,subscriber-web} + 9 packages compartilhados.
- apps/api: NestJS mínimo com /health/live e /health/ready (checando
Postgres real via @eden/database).
- 3 frontends Vite + React + TypeScript + Tailwind, com o favicon
oficial do EDEN.
- packages/database: migration baseline (node-pg-migrate) criando
roles/role_permissions/applications/users/user_applications/sessions/
audit_log — audit log append-only com hash-chain, testado ao vivo
(UPDATE/DELETE bloqueados pelo trigger).
- compose.yaml implementando a topologia da ADR-0002, validada de ponta
a ponta: os 6 containers sobem e ficam saudáveis com um único
`docker compose up`.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4598 lines
318 KiB
Markdown
4598 lines
318 KiB
Markdown
# EDEN — Especificação Funcional Completa para Reconstrução do ERP
|
||
|
||
> Este documento foi extraído por engenharia reversa do sistema legado **"OrçaFácil"** (ERP de
|
||
> vendas/gestão para revendas de telecom/ISP, operado pela Handix), lendo o código-fonte
|
||
> diretamente (rotas, migrations, componentes de frontend). O objetivo é servir de
|
||
> **especificação funcional completa** para outra sessão do Claude construir, do zero, um
|
||
> sistema equivalente chamado **"Eden"**, usando um frontend diferente.
|
||
>
|
||
> **Este arquivo NÃO contém código do Eden** — é documentação de regras de negócio, modelo de
|
||
> dados, fluxos, cálculos e segurança do sistema atual, escrita para ser lida e implementada por
|
||
> um agente de IA sem acesso ao repositório original.
|
||
|
||
---
|
||
|
||
## Stack do sistema de referência (OrçaFácil) — contexto, não obrigação
|
||
|
||
O Eden pode (e provavelmente vai) usar outro frontend, conforme pedido. Para contexto, o
|
||
sistema atual é:
|
||
|
||
- **Frontend**: React 18 + Vite, React Router, `@tanstack/react-query` para cache/estado de
|
||
chamadas, Tailwind CSS, Radix UI (componentes), React Hook Form + Zod, Tiptap (editor rico),
|
||
`html2canvas` + `jsPDF` (geração de PDF client-side, usada em templates legados/certificado de
|
||
assinatura) e Playwright/Chromium server-side (geração de PDF nova, mais robusta).
|
||
- **Backend**: Node.js + Express, `express-async-errors`, `jsonwebtoken` (JWT HS256),
|
||
`bcryptjs` (hash de senha), `express-rate-limit`, `pg` (Postgres) + `node-pg-migrate`
|
||
(migrations versionadas em arquivo), `multer` (upload), `@aws-sdk/client-s3` (storage de
|
||
objetos), `nodemailer` (e-mail via SMTP), `playwright` (PDF), `sanitize-html`.
|
||
- **Banco**: PostgreSQL. Sem ORM — SQL parametrizado direto via `pg`.
|
||
- **Comunicação front↔back**: REST simples via `fetch`, sem GraphQL/tRPC. JWT Bearer no header
|
||
`Authorization`.
|
||
- **Sem multi-tenant de infraestrutura**: é uma aplicação única servindo várias revendas
|
||
(isolamento lógico via `reseller_id` + sistema de papéis/features, não por schema/banco
|
||
separado).
|
||
|
||
---
|
||
|
||
## Recomendação de como usar este documento (agentes/etapas sugeridas)
|
||
|
||
Este documento tem ~4500 linhas cobrindo 7 módulos. Para uma sessão do Claude construir o Eden
|
||
a partir dele com qualidade, a recomendação é **não tentar implementar tudo numa passada só**.
|
||
Sugestão de abordagem, da mesma forma que este documento foi produzido (paralelizar leitura,
|
||
mas serializar decisão de arquitetura):
|
||
|
||
1. **Fase de arquitetura (1 sessão, modo de planejamento, não implementação)** — ler este
|
||
documento inteiro (ou pelo menos as seções 1–4, que são o núcleo comercial) e definir: stack
|
||
escolhida, schema de banco definitivo (pode divergir do legado onde este documento sinalizar
|
||
"avaliar/decidir no Eden"), estrutura de pastas, estratégia de auth. Produzir um plano curto
|
||
antes de escrever qualquer código. **Não pule esta fase** — o sistema tem dependências
|
||
cruzadas fortes (ex.: fidelidade contratual afeta cálculo financeiro, texto de contrato E
|
||
vencimento ao mesmo tempo).
|
||
2. **Ordem de implementação sugerida** (cada módulo depende dos anteriores):
|
||
1. **Autenticação, Papéis e Permissões** (`## 1 — Auth, Roles & Segurança`) — é a base de
|
||
tudo, nenhum outro módulo funciona sem isso.
|
||
2. **Produtos, Ofertas e Contratos** (`## 2`) — o núcleo comercial do sistema.
|
||
3. **Cadastro de Cliente e Revenda** (`## 3`) — depende de Ofertas (uma oferta fechada gera
|
||
um cadastro).
|
||
4. **Templates de Documento e Assinatura Eletrônica** (`## 4`) — depende de Cadastro (gera
|
||
documentos a partir dos dados capturados) e de Empresa/Companies (dados do representante
|
||
legal).
|
||
5. **Fiscal** (`## 5`) — módulo mais isolado, pode entrar em paralelo com o item 4.
|
||
6. **Ponto Eletrônico / Timeclock** (`## 6`) — é essencialmente um sistema à parte dentro do
|
||
ERP (RH), sem dependência forte dos módulos comerciais — pode ser adiado ou paralelizado
|
||
por um agente/sessão dedicado.
|
||
7. **Backoffice diverso** (Backup, Agenda, Welcome Page, Empresa) (`## 7`) — menor risco,
|
||
deixar por último.
|
||
3. **Para cada módulo, um agente/sessão dedicado** (o mesmo padrão usado para produzir este
|
||
documento): ao chegar num módulo, é razoável abrir uma sessão do Claude focada só naquele
|
||
módulo, com a seção correspondente deste arquivo como contexto principal — evita diluir o
|
||
contexto de uma única sessão gigante entre 7 domínios diferentes. Um agente/sessão à parte
|
||
revisando consistência **entre** módulos (nomes de campo, formatos de data/moeda, papéis)
|
||
no final é recomendável, já que cada seção foi originalmente pesquisada por um agente
|
||
diferente e pode ter pequenas diferenças de nomenclatura.
|
||
4. **Segurança é transversal, não um módulo**: os detalhes de criptografia (bcrypt custo 10,
|
||
JWT HS256, HMAC-SHA256 para OTP, SHA-256 hash-chain de auditoria, tokens de 96–256 bits) estão
|
||
espalhados nas seções 1 e 4 — o agente responsável pela Fase de Arquitetura deve extrair isso
|
||
num documento/checklist próprio de segurança antes de começar a implementar, e cada módulo
|
||
subsequente deve ser verificado contra esse checklist.
|
||
5. **Não copiar lacunas de segurança conhecidas do legado sem decidir conscientemente**: este
|
||
documento aponta explicitamente pontos como ausência de `helmet`/CORS configurado e ausência
|
||
de rate limit em algumas rotas administrativas — o Eden deve decidir deliberadamente se
|
||
corrige isso (recomendado) ou não, não herdar por omissão.
|
||
|
||
---
|
||
|
||
## Índice de módulos
|
||
|
||
1. Autenticação, Usuários, Papéis, Permissões e Segurança/Criptografia
|
||
2. Produtos, Ofertas (Quotes), Faixas de Preço/Fidelidade e Contratos
|
||
3. Cadastro de Cliente e Cadastro/Gestão de Revendas
|
||
4. Templates de Documentos, Geração de PDF e Assinatura Eletrônica
|
||
5. Módulo Fiscal (NCM, CFOP, Municípios)
|
||
6. Módulo de Ponto Eletrônico (Timeclock)
|
||
7. Backoffice diverso (Backup, Agenda, Welcome Page, Empresa, Dashboard)
|
||
|
||
---
|
||
|
||
# 1. Autenticação, Usuários, Papéis, Permissões e Segurança
|
||
|
||
|
||
> Documentação extraída do sistema legado "OrçaFácil" (server/src Express + Postgres, frontend React+Vite) para servir de referência à reconstrução ("Eden"). Escopo: auth, users, roles, role_permissions, companies, criptografia/segurança.
|
||
|
||
---
|
||
|
||
## 1. Modelo de dados
|
||
|
||
### 1.1 Tabela `users`
|
||
|
||
Origem: `server/migrations/1785400000000_baseline-schema.js` (colunas base) + `1786250000000_add-custom-roles.js` (troca de `role` de enum para FK texto).
|
||
|
||
```sql
|
||
CREATE TABLE users (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
created_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_date TIMESTAMPTZ NOT NULL DEFAULT now(), -- atualizado por trigger set_updated_date()
|
||
created_by_id UUID REFERENCES users(id),
|
||
full_name TEXT,
|
||
email TEXT NOT NULL UNIQUE,
|
||
password_hash TEXT, -- bcrypt hash; NULL é possível (usuário sem senha definida ainda)
|
||
role TEXT NOT NULL DEFAULT 'user' REFERENCES roles(key), -- era ENUM user_role, migrado p/ TEXT+FK
|
||
reseller_id UUID REFERENCES resellers(id),
|
||
can_approve_discount BOOLEAN NOT NULL DEFAULT false,
|
||
reset_token TEXT,
|
||
reset_token_expires TIMESTAMPTZ,
|
||
token_version INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE INDEX idx_users_reseller ON users(reseller_id);
|
||
```
|
||
|
||
Notas de campo:
|
||
- `role`: originalmente `ENUM user_role ('admin','user')`, depois `ALTER TYPE ... ADD VALUE` para `'backoffice'` (migration `1785509417538_add-backoffice-role.js`) e `'super_admin'` (migration `1785606000000_add-super-admin-role.js`, com `pgm.noTransaction()` — ver seção "Gotchas"). Migration `1786250000000_add-custom-roles.js` converte a coluna de `user_role` (enum) para `TEXT REFERENCES roles(key)`, permitindo papéis customizados criados dinamicamente.
|
||
- `password_hash`: bcrypt via `bcryptjs`, custo (`saltRounds`) **10**, gerado com `bcrypt.hash(senha, 10)`. Comparação com `bcrypt.compare`.
|
||
- `token_version`: inteiro incrementado sempre que uma sessão precisa ser invalidada globalmente (logout, troca de senha, reset de senha, reset de senha via admin). O JWT carrega a versão vigente no momento da emissão (`ver`); no middleware de auth, se `users.token_version !== payload.ver`, o token é recusado mesmo sendo criptograficamente válido — é o mecanismo de "logout forçado / invalidação de sessão" já que JWT não tem estado no servidor por si só.
|
||
- `reset_token` / `reset_token_expires`: token de texto (hex, 24 bytes → 48 chars) para fluxo de "esqueci minha senha", expira em 1h.
|
||
- `can_approve_discount`: flag específica de negócio (fora do sistema de features) — permite ao usuário aprovar/reprovar condições especiais em ofertas (desconto especial, fidelidade reduzida). Setável manualmente por quem edita usuários.
|
||
- `reseller_id`: amarra o usuário a uma revenda (tabela `resellers`, fora deste escopo mas relevante — é o "tenant operacional" do sistema, não `companies`).
|
||
|
||
### 1.2 Tabela `roles`
|
||
|
||
Origem: `1786250000000_add-custom-roles.js` + `1786330000000_add-role-weight.js`.
|
||
|
||
```sql
|
||
CREATE TABLE roles (
|
||
key TEXT PRIMARY KEY, -- ex.: 'user', 'admin', 'suporte'
|
||
label TEXT NOT NULL, -- nome de exibição
|
||
is_system BOOLEAN NOT NULL DEFAULT false,
|
||
weight INTEGER NOT NULL DEFAULT 10, -- peso hierárquico
|
||
created_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
created_by_id UUID REFERENCES users(id)
|
||
);
|
||
```
|
||
|
||
Seed inicial (`is_system = true` para os 4 papéis nativos — não podem ser excluídos; `label` não editável se `is_system`):
|
||
| key | label | is_system | weight |
|
||
|---|---|---|---|
|
||
| `user` | Usuário | true | 10 |
|
||
| `backoffice` | Backoffice | true | 20 |
|
||
| `admin` | Administrador | true | 80 |
|
||
| `super_admin` | Super Administrador | true | 100 |
|
||
|
||
Papel customizado de exemplo já existente em produção: `suporte` (weight 20, não é `is_system`).
|
||
|
||
Regras de validação da chave (`key`), aplicadas tanto no backend (`roles.routes.js`) quanto espelhadas no frontend: regex `^[a-z][a-z0-9_]{1,29}$` (minúscula, começa com letra, 2–30 caracteres, apenas `a-z0-9_`).
|
||
|
||
`weight`: inteiro 0–99 (`MAX_ASSIGNABLE_WEIGHT = 99`) para qualquer papel criado/editado via API — o peso de `super_admin` (100) é o teto fixo do sistema e nunca é atribuível/editável via UI (o próprio papel `super_admin` não pode ser editado nem excluído: `PATCH /api/roles/super_admin` retorna 400).
|
||
|
||
### 1.3 Tabela `role_permissions`
|
||
|
||
Origem: `1786030000000_add-role-permissions.js` (criada com `role user_role PRIMARY KEY`) + `1786250000000_add-custom-roles.js` (coluna migrada para `TEXT REFERENCES roles(key) ON DELETE CASCADE`).
|
||
|
||
```sql
|
||
CREATE TABLE role_permissions (
|
||
role TEXT PRIMARY KEY REFERENCES roles(key) ON DELETE CASCADE,
|
||
permissions JSONB NOT NULL DEFAULT '{}'::jsonb, -- { feature_key: 'view' | 'edit' }
|
||
updated_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_by_id UUID REFERENCES users(id)
|
||
);
|
||
```
|
||
|
||
- Uma linha por papel **exceto `super_admin`** (que nunca entra nesta tabela — acesso total hardcoded).
|
||
- Ao criar um papel novo (`POST /api/roles`), uma linha `role_permissions` vazia (`{}`) é inserida na mesma transação — tudo começa como "sem acesso".
|
||
- Ao excluir um papel, `ON DELETE CASCADE` remove a linha de `role_permissions` junto.
|
||
- `permissions` é um objeto JSON plano `{ "<feature_key>": "view" | "edit" }`. Ausência de uma chave = sem acesso àquela feature.
|
||
|
||
### 1.4 Tabela `companies`
|
||
|
||
Origem: `1785606000001_add-companies.js`. Representa a(s) empresa(s) **emissora(s)** de contrato (ex.: a própria Handix) — não é multi-tenant de clientes, é cadastro jurídico usado para montar documentos/contratos.
|
||
|
||
```sql
|
||
CREATE TABLE companies (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
created_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
created_by_id UUID REFERENCES users(id),
|
||
company_name TEXT NOT NULL,
|
||
trade_name TEXT,
|
||
cnpj TEXT,
|
||
state_registration TEXT,
|
||
municipal_registration TEXT,
|
||
address_zip TEXT,
|
||
address_street TEXT,
|
||
address_number TEXT,
|
||
address_complement TEXT,
|
||
address_neighborhood TEXT,
|
||
address_city TEXT,
|
||
address_state TEXT,
|
||
address_country TEXT NOT NULL DEFAULT 'Brasil',
|
||
phone TEXT,
|
||
email TEXT,
|
||
website TEXT, -- (adicionada depois da migration original; ver companies.routes.js)
|
||
legal_rep_name TEXT,
|
||
legal_rep_cpf TEXT,
|
||
legal_rep_role TEXT,
|
||
legal_rep_email TEXT, -- (adicionada depois)
|
||
is_active BOOLEAN NOT NULL DEFAULT true
|
||
);
|
||
```
|
||
|
||
Validação: `cnpj` e `legal_rep_cpf` são normalizados com `onlyDigits()` e validados com `isValidCNPJ()` (dígito verificador) antes de gravar — CNPJ inválido é rejeitado com 400.
|
||
|
||
### 1.5 Relacionamentos-chave
|
||
|
||
```
|
||
roles.key ←── users.role (FK)
|
||
roles.key ←── role_permissions.role (FK, ON DELETE CASCADE)
|
||
users.id ←── users.created_by_id (auto-referência: quem convidou)
|
||
users.id ←── companies.created_by_id
|
||
users.id ←── role_permissions.updated_by_id
|
||
resellers.id ←── users.reseller_id
|
||
```
|
||
|
||
### 1.6 Conceito de "role weight" (peso hierárquico)
|
||
|
||
Introduzido em `1786330000000_add-role-weight.js`. Resolve o problema: um usuário com a feature `gestao_usuarios:edit` liberada (mas papel "fraco") não pode promover/editar/excluir alguém com papel "mais forte" que o seu, mesmo tendo a tela liberada.
|
||
|
||
Regra aplicada em `server/src/routes/users.routes.js`:
|
||
|
||
- **`assertCanAssignRole(req, res, targetRoleKey)`** — chamada em `POST /users/invite` e `PATCH /users/:id` antes de gravar `role`. Se `req.user.role === 'super_admin'`, sempre permite. Senão, busca `weight` do papel do ator e do papel alvo em `roles`; se `targetRole.weight > actorWeight`, bloqueia com 403 `"Você não pode atribuir um papel com peso maior que o seu."`.
|
||
- **`assertCanActOnUser(req, res, targetUserId)`** — chamada em `PATCH /users/:id`, `DELETE /users/:id` e `POST /users/:id/reset-password`. Mesma lógica, mas compara o peso do papel **atual** do usuário-alvo (não o papel que se quer atribuir) contra o peso do ator. Bloqueia com 403 `"Você não pode alterar um usuário com papel de peso maior que o seu."`. `super_admin` sempre passa.
|
||
|
||
Isso é **ortogonal** ao sistema de features: mesmo com `gestao_usuarios:edit`, um papel de peso baixo não consegue tocar em usuários/atribuir papéis de peso maior. `super_admin` ignora ambas as checagens (peso 100 é hardcoded como teto, nunca editável via UI).
|
||
|
||
No frontend (`UsersManager.jsx`), a mesma regra é espelhada apenas para UX (esconder opções que o backend recusaria): `assignableRoles()` filtra o dropdown de papel a `role.weight <= myWeight` (ou tudo, se `currentUserRole === "super_admin"`), e `canActOnUser()` decide se as ações de editar/resetar senha/excluir aparecem para aquele usuário-linha.
|
||
|
||
---
|
||
|
||
## 2. Papéis do sistema
|
||
|
||
| role key | label | is_system | weight padrão | Descrição |
|
||
|---|---|---|---|---|
|
||
| `user` | Usuário | true | 10 | Papel padrão de quem cria/edita as próprias ofertas. |
|
||
| `backoffice` | Backoffice | true | 20 | Time de apoio operacional — por padrão acesso a ofertas (próprias + de outros, view), clientes, contratos (view), assinaturas. |
|
||
| `admin` | Administrador | true | 80 | Acesso amplo — por padrão edita ofertas de todos, clientes, produtos, fiscal, gestão de usuários, vê painel do gestor. |
|
||
| `super_admin` | Super Administrador | true | 100 | Bypass total e hardcoded — nunca passa por `role_permissions`, nunca editável via UI (nem seu label, nem seu weight, nem exclusão). |
|
||
| `suporte` (exemplo) | Suporte | false | 20 | Papel customizado já criado em produção via a própria tela de Papéis. |
|
||
|
||
### 2.1 super_admin — como difere estruturalmente
|
||
|
||
`super_admin` **não é apenas "mais um papel com todas as permissões marcadas"** — é um caso hardcoded no código, em múltiplos pontos:
|
||
|
||
1. `hasFeatureAccess(user, key, minLevel)` (`server/src/lib/roles.js`): primeira linha é `if (user.role === 'super_admin') return true;` — nunca consulta `role_permissions`.
|
||
2. `requireFeatureOrSuperAdmin(key, minLevel)` (middleware): `if (req.user.role === 'super_admin' || hasFeatureAccess(...)) return next();` — redundante com o item 1 mas explícito.
|
||
3. `requireSuperAdmin` (middleware dedicado): usado para as telas exclusivas — cadastro da empresa emissora (escrita), CRUD de papéis (exceto listagem), CRUD de Permissões por Papel (toda a rota `role_permissions.routes.js`).
|
||
4. `role_permissions` nunca tem linha para `super_admin` — a tabela é `PRIMARY KEY (role) REFERENCES roles(key)`, mas a rota de permissões filtra explicitamente `WHERE key != 'super_admin'` ao listar papéis "editáveis".
|
||
5. `assertCanAssignRole` / `assertCanActOnUser`: `super_admin` sempre retorna `true` de cara, sem consultar pesos.
|
||
6. Regra de negócio "oferta travada": depois que o cadastro de cliente é iniciado numa oferta (`quotes.client_registration_id` setado), **ninguém** pode mais editar a oferta — **exceto `super_admin`** (`server/src/routes/quotes.routes.js`, rota `PATCH /api/quotes/:id`). Comentário no código: permite reverter um "fechado" que caiu ou corrigir item lançado errado antes do cadastro do cliente terminar.
|
||
7. Papel `super_admin` é promovido apenas por migration/seed direto no banco (`1785606100000_promote-matheus-super-admin.js` — promove `matheus@handix.com.br` via `UPDATE users SET role = 'super_admin' WHERE LOWER(email) = ...`), nunca pela API de usuários — não existe UI para criar um segundo super_admin, teria que ser via banco/migration.
|
||
|
||
### 2.2 admin vs super_admin
|
||
|
||
`isAdminRole(role)` = `role IN ('admin', 'super_admin')` — usado para checagens legadas mais grosseiras (antes do sistema de features por papel existir). `admin` **não** tem bypass automático; seu acesso é 100% dirigido pela linha correspondente em `role_permissions` (que, por padrão de seed, replica o que ele tinha "hardcoded" antes da feature existir — ver seção 3.4). Ou seja: hoje em dia `super_admin` só concede algumas capacidades genuinamente exclusivas (cadastro de empresa emissora, editar oferta travada, CRUD de papéis/permissões); todo o resto que `admin` "sempre teve" é, na prática, `role_permissions` do papel `admin` pré-configurado com tudo em `edit`.
|
||
|
||
---
|
||
|
||
## 3. Sistema de permissões por feature (`features.js`)
|
||
|
||
Arquivo: `server/src/lib/features.js`. É o registro único de "telas gateáveis" — cada entrada vira uma linha na matriz de permissão (Sem acesso / Visualizar / Editar) que o `super_admin` configura por papel em Gestão > Usuários > Permissões por Papel.
|
||
|
||
### 3.1 Formato
|
||
|
||
```js
|
||
export const FEATURES = [
|
||
{ key: 'ofertas', label: 'Ofertas (próprias)', group: 'Ofertas' },
|
||
{ key: 'ofertas_others', label: 'Ofertas de Outros Usuários', group: 'Ofertas' },
|
||
{ key: 'clientes_revenda', label: 'Cliente da Revenda', group: 'Clientes' },
|
||
{ key: 'clientes_todos', label: 'Todos os Clientes', group: 'Clientes' },
|
||
{ key: 'contratos', label: 'Todos os Contratos', group: 'Contratos' },
|
||
{ key: 'contratos_relatorios', label: 'Relatórios', group: 'Contratos' },
|
||
{ key: 'assinaturas', label: 'Assinaturas', group: 'Contratos' },
|
||
{ key: 'contratos_cessao', label: 'Cessão', group: 'Contratos' },
|
||
{ key: 'produtos', label: 'Produtos', group: 'Gestão' },
|
||
{ key: 'fiscal', label: 'Fiscal', group: 'Gestão' },
|
||
{ key: 'gestao_usuarios', label: 'Gestão de Usuários', group: 'Gestão' },
|
||
{ key: 'painel_gestor', label: 'Painel do Gestor', group: 'Gestão' },
|
||
{ key: 'empresa', label: 'Empresa', group: 'Gestão' },
|
||
{ key: 'backup', label: 'Backup', group: 'Gestão' },
|
||
{ key: 'documentos_modelos', label: 'Modelos de Documentos', group: 'Gestão' },
|
||
{ key: 'pagina_inicial', label: 'Página Inicial (Boas-vindas)', group: 'Gestão' },
|
||
{ key: 'rh_equipamentos', label: 'Equipamentos (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'rh_funcionarios', label: 'Colaboradores (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'rh_afd_importacoes', label: 'Importações AFD (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'rh_afd_auditoria', label: 'Auditoria NSR/AFD (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'rh_jornadas', label: 'Jornadas e Feriados (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'rh_ajustes', label: 'Ajustes de Ponto (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'rh_ajustes_aprovacao', label: 'Aprovação de Ajustes de Ponto (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'rh_apuracao', label: 'Apuração e Banco de Horas (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'rh_dashboard', label: 'Dashboard (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'rh_relatorios', label: 'Relatórios (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'rh_fechamento', label: 'Fechamento de Período (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'rh_fechamento_reabertura', label: 'Reabertura de Período Fechado (Controle de Ponto)', group: 'RH' },
|
||
{ key: 'revendas_cadastro', label: 'Cadastro de Revendas', group: 'Revendas' },
|
||
{ key: 'agenda', label: 'Agenda (Salas de Reunião)', group: 'Agenda' },
|
||
{ key: 'agenda_salas', label: 'Agenda — Cadastro de Salas', group: 'Agenda' },
|
||
{ key: 'agenda_carros', label: 'Agenda (Carros da Empresa)', group: 'Agenda' },
|
||
{ key: 'agenda_carros_cadastro', label: 'Agenda — Cadastro de Carros', group: 'Agenda' },
|
||
];
|
||
export const FEATURE_KEYS = FEATURES.map((f) => f.key);
|
||
```
|
||
|
||
Cada feature tem só dois níveis (`view` e `edit`) além da ausência (= sem acesso). Não há um terceiro nível "delete" separado — deletar é coberto por `edit`.
|
||
|
||
### 3.2 Regra especial de "ofertas" — duas entradas para próprio vs. de outros
|
||
|
||
`ofertas` controla acesso às **próprias** ofertas do usuário (todo papel edita as próprias por padrão — não depende de `role_permissions`, é incondicional no código de `quotes.routes.js` via `own && hasFeatureAccess(user,'ofertas','edit')`). `ofertas_others` controla acesso às ofertas **de outros usuários**: `view` = vê a lista completa, `edit` = também edita as de outros. É o mecanismo genérico de "próprio vs. todos", reaproveitado sem caso especial hardcoded em código (ver `canViewAllQuotes`/`canEditAllQuotes` em `roles.js`).
|
||
|
||
### 3.3 Como uma rota decide acesso
|
||
|
||
Em `server/src/middleware/auth.js`:
|
||
|
||
```js
|
||
export function requireFeatureOrSuperAdmin(key, minLevel = 'view') {
|
||
return (req, res, next) => {
|
||
if (req.user.role === 'super_admin' || hasFeatureAccess(req.user, key, minLevel)) return next();
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
};
|
||
}
|
||
```
|
||
|
||
`hasFeatureAccess` (em `server/src/lib/roles.js`):
|
||
|
||
```js
|
||
const FEATURE_LEVEL_RANK = { view: 1, edit: 2 };
|
||
export function hasFeatureAccess(user, key, minLevel = 'view') {
|
||
if (!user) return false;
|
||
if (user.role === 'super_admin') return true;
|
||
const level = user.role_permissions?.[key];
|
||
if (!level) return false;
|
||
return (FEATURE_LEVEL_RANK[level] || 0) >= (FEATURE_LEVEL_RANK[minLevel] || 0);
|
||
}
|
||
```
|
||
|
||
`user.role_permissions` chega já resolvido: o middleware `auth` faz `LEFT JOIN role_permissions rp ON rp.role = u.role` na query de carregamento do usuário autenticado, evitando query extra em cada rota:
|
||
|
||
```sql
|
||
SELECT u.*, rp.permissions AS role_permissions
|
||
FROM users u
|
||
LEFT JOIN role_permissions rp ON rp.role = u.role
|
||
WHERE u.id = $1
|
||
```
|
||
|
||
Padrão de uso em rotas (exemplo `users.routes.js`):
|
||
```js
|
||
const view = requireFeatureOrSuperAdmin('gestao_usuarios', 'view');
|
||
const edit = requireFeatureOrSuperAdmin('gestao_usuarios', 'edit');
|
||
router.get('/', auth, view, ...);
|
||
router.patch('/:id', auth, edit, ...);
|
||
```
|
||
|
||
Frontend espelha exatamente a mesma função em `src/lib/roles.js` (`hasFeatureAccess`, `isAdminRole`, `isAdminOrBackofficeRole`, `canViewAllQuotes`, `canEditAllQuotes`) — mesmas assinaturas, para gate de página/UI (esconder botões de edição, redirecionar se `view` não concedido). Guard típico de página (`Management.jsx`):
|
||
```js
|
||
if (role !== "super_admin" && !hasFeatureAccess(user, "gestao_usuarios", "view")) { /* bloqueia página */ }
|
||
const canEdit = role === "super_admin" || hasFeatureAccess(user, "gestao_usuarios", "edit");
|
||
```
|
||
Importante: o gate de frontend é só UX — a fonte da verdade é sempre o backend (toda rota de escrita/leitura sensível tem seu próprio `requireFeatureOrSuperAdmin`).
|
||
|
||
### 3.4 Como papéis customizados herdam/definem permissões
|
||
|
||
Não há herança implícita nenhuma. Um papel novo (`POST /api/roles`) nasce com `role_permissions` vazio (`{}` = nenhuma feature liberada) — o `super_admin` precisa entrar em Permissões por Papel e conceder manualmente `view`/`edit` por feature. Não existe conceito de "papel pai" ou herança em cadeia — cada papel (exceto `super_admin`) é uma linha 100% independente em `role_permissions`.
|
||
|
||
A migration `1786040000000_seed-role-permissions-defaults.js` documenta o valor inicial que replicava exatamente o comportamento anterior (quando o acesso era hardcoded por `role` no código) — isso foi feito uma única vez, no deploy que introduziu o sistema de features, para não quebrar o acesso de ninguém:
|
||
|
||
```json
|
||
// user
|
||
{ "ofertas": "edit", "clientes_revenda": "view" }
|
||
|
||
// backoffice
|
||
{
|
||
"ofertas": "edit", "ofertas_others": "view",
|
||
"clientes_revenda": "edit", "clientes_todos": "edit",
|
||
"contratos": "view", "contratos_relatorios": "view",
|
||
"assinaturas": "edit"
|
||
}
|
||
|
||
// admin
|
||
{
|
||
"ofertas": "edit", "ofertas_others": "edit",
|
||
"clientes_revenda": "edit", "clientes_todos": "edit",
|
||
"contratos": "view", "contratos_relatorios": "view",
|
||
"assinaturas": "edit", "produtos": "edit", "fiscal": "edit",
|
||
"gestao_usuarios": "edit", "painel_gestor": "view"
|
||
}
|
||
```
|
||
|
||
Como gatear uma tela nova por feature (processo documentado em comentário no próprio `features.js`, útil para replicar em Eden):
|
||
1. Adicionar `{ key, label, group }` em `FEATURES`.
|
||
2. Rota de leitura: middleware `requireFeatureOrSuperAdmin(key, 'view')`.
|
||
3. Rota de escrita: `requireFeatureOrSuperAdmin(key, 'edit')`.
|
||
4. Guard de página no front: `role !== "super_admin" && !hasFeatureAccess(user, key, "view")`; `canEdit = role === "super_admin" || hasFeatureAccess(user, key, "edit")`.
|
||
5. Migration de seed com o valor default para o papel que já tinha a tela — senão quem já usava perde acesso no deploy.
|
||
|
||
---
|
||
|
||
## 4. Autenticação
|
||
|
||
### 4.1 Login
|
||
|
||
`POST /api/auth/login` (rota pública, com rate limit — ver seção 5.4)
|
||
|
||
Request:
|
||
```json
|
||
{ "email": "user@empresa.com", "password": "senha" }
|
||
```
|
||
|
||
Fluxo (`server/src/routes/auth.routes.js`):
|
||
1. Valida presença de `email`/`password` (400 se faltando).
|
||
2. Busca usuário por `email.toLowerCase()` — e-mails são case-insensitive na prática (armazenados como veio, comparados em lowercase).
|
||
3. Se não existir usuário ou `password_hash` for nulo → 401 `"Credenciais inválidas"` (mensagem genérica, não revela qual dos dois falhou — nem se a conta existe).
|
||
4. `bcrypt.compare(password, user.password_hash)` — se falso, mesmo 401 genérico.
|
||
5. Sucesso: `signToken(user.id, user.token_version)` + `serializeUser(user)`.
|
||
|
||
Response:
|
||
```json
|
||
{ "access_token": "<jwt>", "user": { "id": "...", "email": "...", "role": "...", "permissions": {...}, ... } }
|
||
```
|
||
|
||
### 4.2 JWT — geração e validação
|
||
|
||
Arquivo `server/src/lib/jwt.js`:
|
||
|
||
```js
|
||
import jwt from 'jsonwebtoken';
|
||
const SECRET = process.env.JWT_SECRET;
|
||
if (!SECRET) throw new Error('JWT_SECRET env var is required'); // falha no boot se não setado
|
||
|
||
export const signToken = (userId, tokenVersion) =>
|
||
jwt.sign({ sub: userId, ver: tokenVersion }, SECRET, { expiresIn: '30d' });
|
||
|
||
export const verifyToken = (token) => jwt.verify(token, SECRET);
|
||
```
|
||
|
||
- **Algoritmo**: padrão da lib `jsonwebtoken` quando só uma string é passada como secret → **HS256** (HMAC-SHA256, simétrico). Não há RS256/par de chaves.
|
||
- **Payload**: apenas `{ sub: userId, ver: tokenVersion }` + claims padrão (`iat`, `exp`) — deliberadamente mínimo (não carrega role/permissions no token, essas são resolvidas a cada request via JOIN no banco, então mudanças de papel/permissão têm efeito imediato sem precisar reemitir token).
|
||
- **Expiração**: 30 dias (`expiresIn: '30d'`), fixo, sem refresh token — o token só é renovado fazendo login de novo. Não há endpoint de refresh.
|
||
- **Segredo**: variável de ambiente `JWT_SECRET`, obrigatória (processo derruba na subida se ausente). Sem valor default/hardcoded no código. Documentado em `.env.example`: gerar uma vez por ambiente com `openssl rand -hex 48`, nunca trocar depois (trocar invalida todas as sessões ativas).
|
||
- **Verificação** (middleware `auth`, `server/src/middleware/auth.js`):
|
||
1. Extrai `Authorization: Bearer <token>` do header.
|
||
2. `verifyToken(token)` — lança se assinatura/expiração inválidas → 401.
|
||
3. Busca usuário por `payload.sub`, com o LEFT JOIN de `role_permissions` (ver seção 3.3).
|
||
4. Se usuário não existe → 401.
|
||
5. **Checagem de invalidação de sessão**: `if (rows[0].token_version !== payload.ver) return 401 "Sessão encerrada, faça login novamente"`.
|
||
6. Popula `req.user` (linha completa de `users` + `role_permissions` resolvido) e `req.tokenVersion`.
|
||
|
||
Não há refresh token, nem rotação de token, nem blacklist explícita — a invalidação é 100% via `token_version` incremental.
|
||
|
||
### 4.3 `GET /api/auth/me`
|
||
|
||
Requer `auth`. Retorna `serializeUser(req.user)` — usado pelo frontend (`AuthContext`) para revalidar sessão a cada carregamento de app (se não há token no localStorage, nem tenta; se há, chama `/auth/me` e, em caso de erro, limpa o token local).
|
||
|
||
### 4.4 Logout
|
||
|
||
`POST /api/auth/logout` (requer `auth`). Incrementa `token_version` do usuário — invalida **o token atual e qualquer outro já emitido** (não existe "logout de uma sessão específica", é sempre logout de todas as sessões simultaneamente). Comentário no código: "não basta só apagar o token no cliente". Frontend também limpa o token do `localStorage` independente do resultado da chamada (best-effort).
|
||
|
||
### 4.5 Esqueci minha senha / reset
|
||
|
||
**Passo 1** — `POST /api/auth/forgot-password` (rate limited, pública):
|
||
```json
|
||
{ "email": "user@empresa.com" }
|
||
```
|
||
- Sempre responde `{ "ok": true }`, **mesmo se o e-mail não existir** (evita enumeração de contas — confirmado também no frontend: `ForgotPassword.jsx` sempre mostra a mesma mensagem de sucesso, inclusive em erro de rede/validação).
|
||
- Se o e-mail existir: gera `token = crypto.randomBytes(24).toString('hex')` (48 caracteres hex, 192 bits de entropia), `expires = now + 1h`, grava em `users.reset_token`/`reset_token_expires`, envia e-mail com link `${PUBLIC_BASE_URL}/reset-password?token=<token>`.
|
||
|
||
**Passo 2** — `POST /api/auth/reset-password` (pública, sem rate limit dedicado nesta rota especificamente — a proteção de força bruta acontece na etapa de solicitação, não na de confirmação; o token de 192 bits já é impraticável de adivinhar):
|
||
```json
|
||
{ "resetToken": "<token>", "newPassword": "novaSenha123" }
|
||
```
|
||
- Valida `newPassword.length >= 6` (única regra de complexidade de senha em todo o sistema — não exige maiúscula/número/símbolo).
|
||
- Busca usuário por `reset_token = $1 AND reset_token_expires > now()` — token errado ou expirado → 400 `"Link de redefinição inválido ou expirado"`.
|
||
- Grava novo `password_hash` (bcrypt custo 10), limpa `reset_token`/`reset_token_expires`, **incrementa `token_version`** (derruba qualquer sessão ativa daquele usuário).
|
||
|
||
### 4.6 Troca de senha autenticado
|
||
|
||
`POST /api/users/me/change-password` (requer `auth`, qualquer usuário sobre si mesmo):
|
||
```json
|
||
{ "current_password": "atual", "new_password": "nova123" }
|
||
```
|
||
Exige senha atual mesmo já autenticado (evita que uma sessão esquecida aberta troque a senha sem confirmação); mesma regra de tamanho mínimo (6); incrementa `token_version` ao final (exige novo login).
|
||
|
||
### 4.7 Reset de senha administrativo (por outro usuário)
|
||
|
||
`POST /api/users/:id/reset-password` (requer `auth` + feature `gestao_usuarios:edit`, sujeito a `assertCanActOnUser`). Gera senha temporária aleatória (`crypto.randomBytes(9).toString('base64url')`, ~12 caracteres), grava hash bcrypt, **incrementa `token_version`** do alvo, e devolve a senha em texto plano na resposta (`temp_password`) — não envia e-mail (diferente do convite), o admin repassa manualmente (WhatsApp, telefone). Frontend mostra num modal com botão de copiar.
|
||
|
||
### 4.8 Convite / cadastro de usuário
|
||
|
||
`POST /api/users/invite` (requer `auth` + `gestao_usuarios:edit`, sujeito a `assertCanAssignRole`):
|
||
```json
|
||
{ "email": "novo@empresa.com", "role": "user" }
|
||
```
|
||
- Rejeita se e-mail já cadastrado (409).
|
||
- Gera senha temporária aleatória (`crypto.randomBytes(9).toString('base64url')`), hash bcrypt custo 10.
|
||
- Cria o usuário com `full_name` derivado da parte antes do `@` do e-mail (placeholder), `role` (default `'user'` se omitido via `COALESCE`), `created_by_id` = ator.
|
||
- Envia e-mail com a senha temporária em texto plano (link de login + credenciais no corpo do e-mail).
|
||
- Retorna também `temp_password` na resposta da API (fallback caso o e-mail não chegue).
|
||
|
||
### 4.9 Rate limiting em rotas sensíveis
|
||
|
||
Arquivo `server/src/middleware/rateLimit.js`, usando `express-rate-limit`. Handler padrão de estouro: `429 { "error": "Muitas tentativas. Aguarde alguns minutos e tente novamente." }`.
|
||
|
||
| Limiter | Janela | Máximo | Chave |
|
||
|---|---|---|---|
|
||
| `loginIpLimiter` | 15 min | 20 | IP (padrão da lib) |
|
||
| `loginUserLimiter` | 15 min | 5 | e-mail normalizado do body (`byEmailKey`) |
|
||
| `forgotPasswordIpLimiter` | 15 min | 10 | IP |
|
||
| `forgotPasswordUserLimiter` | 15 min | 3 | e-mail normalizado do body |
|
||
| `publicClientRegistrationLimiter` | 15 min | 30 | IP |
|
||
| `publicResellerRegistrationLimiter` | 15 min | 30 | IP |
|
||
| `publicSignatureLimiter` | 15 min | 60 | IP |
|
||
| `otpRequestLimiter` | 15 min | 5 | token do signatário (`req.params.token`) |
|
||
| `otpVerifyLimiter` | 15 min | 15 | token do signatário |
|
||
|
||
`POST /api/auth/login` usa **dois** limiters empilhados (`loginIpLimiter, loginUserLimiter`) — protege tanto contra um IP tentando várias contas (password spraying) quanto contra força bruta numa conta específica vinda de IPs diferentes. Mesmo padrão duplo em `forgot-password`.
|
||
|
||
`app.set('trust proxy', 1)` em `server.js` — o servidor roda atrás de um único proxy reverso (nginx externo) e confia no `X-Forwarded-For` dele para que `req.ip` reflita o IP real do cliente (necessário para os limiters por IP funcionarem corretamente).
|
||
|
||
**Observação de segurança**: `POST /api/auth/reset-password` (confirmação do reset) e `POST /api/users/:id/reset-password` (reset administrativo) e `POST /api/users/:id/reset-password` não têm rate limiter dedicado — mitigado pela alta entropia do token (192 bits) na primeira, e pela exigência de estar autenticado + ter a feature de gestão de usuários na segunda.
|
||
|
||
---
|
||
|
||
## 5. Criptografia e segurança
|
||
|
||
### 5.1 Hash de senha
|
||
|
||
- Biblioteca: `bcryptjs` (`^2.4.3` — implementação JS pura do bcrypt, não a binding nativa `bcrypt`).
|
||
- Custo (**salt rounds**): **10**, hardcoded em todos os pontos de hash (`bcrypt.hash(senha, 10)`) — login/invite/reset/change-password/seed do admin inicial, sem variação.
|
||
- Comparação sempre via `bcrypt.compare`, nunca comparação manual de hash.
|
||
- Nenhum "pepper" adicional (segredo extra além do salt do bcrypt) é usado.
|
||
|
||
### 5.2 JWT
|
||
|
||
- Algoritmo: **HS256** (assinatura simétrica HMAC), implícito pela lib `jsonwebtoken` quando o secret é uma string simples.
|
||
- Segredo: env var `JWT_SECRET`, sem valor default nem fallback — a aplicação recusa subir sem ela (`throw new Error` no import do módulo). Gerada manualmente por ambiente (`openssl rand -hex 48` sugerido no `.env.example`), nunca versionada em git (`.env` está no `.gitignore`).
|
||
- Payload mínimo (`sub`, `ver`), expiração 30 dias, sem refresh token.
|
||
- Invalidação server-side via `users.token_version` (não há JWT blacklist nem revogação por jti).
|
||
|
||
### 5.3 Outras criptografias em repouso no sistema (fora do escopo direto de auth, mas relevante como padrão a replicar)
|
||
|
||
- `CONTROLID_ENCRYPTION_KEY` (env var, gerar com `openssl rand -hex 32`): usada para criptografar **de forma reversível** a senha admin de cada equipamento de ponto (Control iD) cadastrado — porque a aplicação precisa recuperar a senha em texto puro para autenticar no relógio de ponto (não pode ser hash unidirecional como senha de usuário). Trocar essa chave torna ilegíveis as senhas de equipamento já cadastradas. Este é o único caso de criptografia reversível de segredo identificado no sistema (fora do escopo de usuários/papéis, mas é o padrão de "quando não pode ser hash" usado no projeto).
|
||
- Senhas de usuário (`users.password_hash`) e nada mais relacionado a auth usa criptografia reversível — é sempre hash unidirecional (bcrypt).
|
||
|
||
### 5.4 Sanitização de inputs
|
||
|
||
- Não há biblioteca de sanitização genérica (tipo `express-validator`/`joi`/`zod`) usada nas rotas de auth/usuários/papéis — validação é manual, por rota, checando presença/formato/tamanho no próprio handler (ex.: regex de `key` de papel, `newPassword.length >= 6`, `isValidCNPJ`).
|
||
- Todas as queries usam **parâmetros posicionais do `pg`** (`$1, $2, ...`) — não há concatenação de string SQL com input do usuário nas rotas revisadas (proteção padrão contra SQL injection via driver).
|
||
- E-mails são normalizados com `.toLowerCase()` e `.trim()` antes de comparar/gravar.
|
||
- CNPJ/CPF normalizados via `onlyDigits()` e validados por dígito verificador (`isValidCNPJ`) antes de persistir.
|
||
|
||
### 5.5 CORS e headers de segurança
|
||
|
||
- **Não há middleware de CORS configurado** (`server.js` não importa nem usa o pacote `cors`; confirmado ausente em `server/package.json`) — a API assume que frontend e backend são servidos pela mesma origem (o Express serve os arquivos estáticos do build do Vite diretamente: `app.use(express.static(staticDir))` + fallback SPA `app.get('*', ...)`). Não há, portanto, política de CORS explícita para chamadas cross-origin — se algum client externo tentar chamar a API de outra origem, o comportamento é o padrão do browser (bloqueado, já que não há headers `Access-Control-Allow-Origin`).
|
||
- **Não há `helmet` nem outro middleware de security headers** (ausente do `package.json`) — sem CSP, sem `X-Frame-Options`, `X-Content-Type-Options`, `Strict-Transport-Security`, etc. configurados explicitamente no Express. Isso é uma lacuna a considerar/decidir conscientemente ao reconstruir em Eden (adicionar `helmet` seria uma melhoria direta).
|
||
- `express.json({ limit: '5mb' })` é o único middleware global de parsing/limite de payload.
|
||
- Erros não tratados caem num handler global (`app.use((err, req, res, next) => ...)`) que loga no console e responde `500 { error: 'Erro interno do servidor' }` — evita vazar stack trace para o cliente (exceto casos especiais de `MulterError` para upload).
|
||
|
||
### 5.6 O que está em `.env` vs. hardcoded
|
||
|
||
Variáveis de ambiente relevantes a este escopo (`.env.example`):
|
||
- `DATABASE_URL` — connection string do Postgres.
|
||
- `JWT_SECRET` — obrigatória, sem default.
|
||
- `PORT` — porta do servidor (8006 dev / 8007 prod, por convenção, não travado em código).
|
||
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` — usados só no primeiro boot com banco vazio (`seedAdmin()` em `server/src/lib/migrate.js`) para criar o primeiro `admin`. Se `SEED_ADMIN_PASSWORD` não definida, gera senha aleatória (`crypto.randomBytes(9).toString('base64url')`) e imprime nos **logs do container**.
|
||
- `SMTP_HOST/PORT/USER/PASS/FROM` — envio de e-mail (reset de senha, convite, cadastro de cliente). Se `SMTP_HOST` ausente, e-mails não são enviados de verdade — apenas logados no console (`[mailer] SMTP não configurado — e-mail para X não enviado`), útil para dev sem quebrar o fluxo.
|
||
- `PUBLIC_BASE_URL` — usada para montar links absolutos em e-mails (reset de senha, etc.); se ausente, o sistema tenta inferir a partir do host da requisição (`baseUrlFromReq`).
|
||
- `CONTROLID_ENCRYPTION_KEY` — fora do escopo direto, mas é o outro segredo simétrico do sistema (ver 5.3).
|
||
|
||
Hardcoded no código (não configurável por env):
|
||
- Custo do bcrypt = 10.
|
||
- Expiração do JWT = 30 dias.
|
||
- Regras de rate limit (janelas/máximos da tabela da seção 4.9).
|
||
- Regex de chave de papel, teto de peso (99), tamanho mínimo de senha (6).
|
||
- E-mail do super_admin permanente inicial (`matheus@handix.com.br`), fixado na migration de promoção — não é uma env var, é uma migration versionada.
|
||
|
||
### 5.7 Seed do usuário administrador inicial
|
||
|
||
`server/src/lib/migrate.js`, função `seedAdmin()`, chamada no boot do servidor (`start()` em `server.js`) **antes** de abrir a porta. Só executa se `users` estiver vazia (`SELECT count(*) FROM users`). Cria uma revenda "Handix" padrão e um usuário `role = 'admin'` (não `super_admin`) com `can_approve_discount = true`. O primeiro `super_admin` real do sistema é sempre promovido via migration de dados fixa (email hardcoded), não pelo seed automático.
|
||
|
||
---
|
||
|
||
## 6. Multi-empresa / `companies`
|
||
|
||
Importante: **não é multi-tenant de clientes/dados isolados**. `companies` representa **empresa(s) emissora(s)** de documentos/contratos (ex.: a própria Handix e eventuais outras razões sociais sob as quais contratos são emitidos) — usada para montar campos de contratos/termos legais (CNPJ, endereço, representante legal), não para segmentar/particionar os dados de usuários, ofertas, clientes, etc.
|
||
|
||
- **Leitura** (`GET /api/companies`, `GET /api/companies/:id`): aberta a qualquer usuário autenticado (`router.use(auth)` sem feature-gate na leitura) — dados considerados não sensíveis, aparecem em qualquer nota fiscal/contrato e são necessários para montar documentos (ex.: Termo de Portabilidade) a partir da tela de oferta, acessível a qualquer dono/backoffice.
|
||
- **Escrita** (`POST`/`PATCH`/`DELETE /api/companies`): gateada pela feature `empresa` (`requireFeatureOrSuperAdmin('empresa', 'edit')`) — não é `requireSuperAdmin` puro, é feature configurável (mas por padrão de seed só `admin`/`super_admin` tinham acesso à tela "Empresa").
|
||
- Não há coluna `company_id` em `users`, `quotes`, `products`, etc. — ou seja, o isolamento real de "quem vê o quê" no sistema é feito por **`reseller_id`** (revenda) e pelo sistema de features/roles, não por `companies`. A tabela `resellers` (fora do escopo pedido, mas citada aqui por clareza) é o conceito mais próximo de "tenant operacional": cada oferta (`quotes.reseller_id`) e usuário (`users.reseller_id`) pertence a uma revenda, e regras de feature como `clientes_revenda` (cliente da própria revenda) vs. `clientes_todos` decidem o alcance de visualização.
|
||
|
||
---
|
||
|
||
## 7. Fluxos e regras de negócio não óbvias (síntese para Eden)
|
||
|
||
1. **Peso de papel > feature de gestão de usuários**: ter `gestao_usuarios:edit` não é suficiente para mexer em qualquer usuário — só em usuários/papéis de peso `<=` o peso do próprio papel do ator. `super_admin` (peso 100, hardcoded) sempre ignora essa checagem.
|
||
2. **`super_admin` nunca aparece em `role_permissions`** e nunca é listado como editável nas telas de Papéis/Permissões (`WHERE key != 'super_admin'` em toda consulta relevante). Não pode ser editado, seu `label`/`weight` são imutáveis via API, e não pode ser excluído.
|
||
3. **Oferta travada só é editável por `super_admin`**: assim que `quotes.client_registration_id` é setado (cadastro de cliente iniciado), a rota `PATCH /api/quotes/:id` bloqueia qualquer edição para todo mundo, exceto `role === 'super_admin'` — regra de negócio adicionada especificamente para permitir corrigir erros/reverter fechamentos indevidos sem reabrir o processo todo.
|
||
4. **`token_version` é o mecanismo universal de "encerrar sessão"** — usado em: logout explícito, troca de senha pelo próprio usuário, reset de senha via link de e-mail, e reset de senha administrativo. Sempre que a senha muda ou logout é pedido, todas as sessões (todos os tokens já emitidos daquele usuário) são invalidadas de uma vez — não há como derrubar "só uma sessão".
|
||
5. **Criar um papel novo não copia nada de outro papel** — nasce com `role_permissions = {}` (zero acesso); é responsabilidade do `super_admin` configurar cada feature manualmente depois.
|
||
6. **Excluir um papel exige zero usuários com aquele papel** — `DELETE /api/roles/:key` verifica `COUNT(*) FROM users WHERE role = key` e recusa com 409 se houver algum, listando a contagem na mensagem de erro. Papéis `is_system = true` nunca podem ser excluídos, independentemente de terem usuários.
|
||
7. **`ofertas` vs. `ofertas_others` é o padrão geral para "próprio vs. todos"** — reaproveitado sem caso especial no código; ao adicionar uma feature nova em Eden que precise dessa distinção, seguir o mesmo padrão de duas entradas em vez de lógica condicional hardcoded.
|
||
8. **Convite de usuário gera senha temporária e a envia por e-mail E devolve na resposta da API** — dupla via de entrega (o e-mail pode não chegar; a resposta da API é o fallback mostrado num modal com botão de copiar no frontend).
|
||
9. **`forgot-password` sempre responde sucesso** independentemente de o e-mail existir — evita enumeração de contas por e-mail. `reset-password` (confirmação) é a única rota que de fato revela se o token é válido, mas o token em si tem entropia alta o bastante para não ser adivinhável.
|
||
10. **E-mail é a chave natural de usuário** (`UNIQUE`, comparado sempre em lowercase) — não existe "username" separado.
|
||
11. **Migration gotcha documentado no próprio código** (relevante para quem for portar o schema): adicionar um valor a um enum Postgres (`ALTER TYPE ... ADD VALUE`) e usá-lo na mesma transação/lote de migrations falha em produção ("unsafe use of new value") — a migration que introduziu `super_admin` precisou de `pgm.noTransaction()` para isolar o commit do novo valor antes de outra migration do mesmo lote tentar usá-lo. Em Eden, se o modelo de papéis for uma tabela (`roles`) desde o início (como o sistema evoluiu para em `1786250000000_add-custom-roles.js`), esse problema simplesmente não existe — recomenda-se começar direto com `roles` como tabela, não enum.
|
||
|
||
---
|
||
|
||
## 8. Referência rápida de endpoints (escopo deste documento)
|
||
|
||
| Método | Rota | Auth | Gate adicional |
|
||
|---|---|---|---|
|
||
| POST | `/api/auth/login` | não | rate limit (IP + e-mail) |
|
||
| GET | `/api/auth/me` | sim | — |
|
||
| POST | `/api/auth/logout` | sim | — |
|
||
| POST | `/api/auth/forgot-password` | não | rate limit (IP + e-mail) |
|
||
| POST | `/api/auth/reset-password` | não | token de 192 bits |
|
||
| GET | `/api/users` | sim | `gestao_usuarios:view` |
|
||
| POST | `/api/users/invite` | sim | `gestao_usuarios:edit` + peso |
|
||
| PATCH | `/api/users/me` | sim | — (autoatualização) |
|
||
| POST | `/api/users/me/change-password` | sim | exige senha atual |
|
||
| POST | `/api/users/:id/reset-password` | sim | `gestao_usuarios:edit` + peso |
|
||
| PATCH | `/api/users/:id` | sim | `gestao_usuarios:edit` + peso (atribuição e ação) |
|
||
| DELETE | `/api/users/:id` | sim | `gestao_usuarios:edit` + peso |
|
||
| GET | `/api/roles` | sim | `gestao_usuarios:view` |
|
||
| POST/PATCH/DELETE | `/api/roles[/:key]` | sim | `requireSuperAdmin` |
|
||
| GET/PATCH | `/api/role-permissions[...]` | sim | `requireSuperAdmin` (toda a rota) |
|
||
| GET | `/api/companies[/:id]` | sim | — (aberto a qualquer autenticado) |
|
||
| POST/PATCH/DELETE | `/api/companies[/:id]` | sim | `empresa:edit` |
|
||
|
||
---
|
||
|
||
## 9. Arquivos-fonte lidos (para rastreabilidade)
|
||
|
||
- `server/src/middleware/auth.js`
|
||
- `server/src/lib/jwt.js`
|
||
- `server/src/lib/roles.js`
|
||
- `server/src/lib/features.js`
|
||
- `server/src/lib/serialize.js`
|
||
- `server/src/lib/migrate.js`
|
||
- `server/src/lib/mailer.js`
|
||
- `server/src/routes/auth.routes.js`
|
||
- `server/src/routes/users.routes.js`
|
||
- `server/src/routes/roles.routes.js`
|
||
- `server/src/routes/rolePermissions.routes.js`
|
||
- `server/src/routes/companies.routes.js`
|
||
- `server/src/routes/quotes.routes.js` (trecho da trava de oferta)
|
||
- `server/src/middleware/rateLimit.js`
|
||
- `server/src/server.js`
|
||
- `server/package.json`
|
||
- `.env.example`
|
||
- `server/migrations/1785400000000_baseline-schema.js`
|
||
- `server/migrations/1785509417538_add-backoffice-role.js`
|
||
- `server/migrations/1785606000000_add-super-admin-role.js`
|
||
- `server/migrations/1785606100000_promote-matheus-super-admin.js`
|
||
- `server/migrations/1785606000001_add-companies.js`
|
||
- `server/migrations/1786030000000_add-role-permissions.js`
|
||
- `server/migrations/1786040000000_seed-role-permissions-defaults.js`
|
||
- `server/migrations/1786250000000_add-custom-roles.js`
|
||
- `server/migrations/1786330000000_add-role-weight.js`
|
||
- `src/pages/Management.jsx`
|
||
- `src/pages/Login.jsx`
|
||
- `src/pages/ForgotPassword.jsx`
|
||
- `src/pages/ResetPassword.jsx`
|
||
- `src/components/management/UsersManager.jsx`
|
||
- `src/components/management/RolePermissionsManager.jsx`
|
||
- `src/api/base44Client.js`
|
||
- `src/lib/roles.js`
|
||
- `src/lib/AuthContext.jsx`
|
||
- `src/components/ProtectedRoute.jsx`
|
||
- `src/lib/authReturnTo.js`
|
||
|
||
|
||
---
|
||
|
||
|
||
# 2. Produtos, Ofertas (Quotes), Faixas de Preço/Fidelidade e Contratos
|
||
|
||
|
||
> Documentação de referência para reconstrução fiel no sistema "Eden".
|
||
> Escopo: `products`, `quotes` e o conceito de "contrato" (que **não é uma
|
||
> tabela própria** — é derivado de `client_registrations` + `quotes`).
|
||
> Fiscal (`product_fiscal_profiles`, `fiscal_rules`, etc.) é coberto por
|
||
> outro agente; aqui só é citado onde interage com o core comercial.
|
||
|
||
---
|
||
|
||
## 1. Modelo de dados
|
||
|
||
### 1.1 Tabela `products`
|
||
|
||
Fonte: `server/migrations/1785400000000_baseline-schema.js` +
|
||
`1785700000001_add-product-fiscal-config.js` (não relevante aqui) +
|
||
`1785980000000_add-product-ixc-code.js` + `1785990000000_add-product-code.js`
|
||
+ `1786020000000_add-product-discontinued.js`.
|
||
|
||
| Coluna | Tipo | Default/Regras | Observações |
|
||
|---|---|---|---|
|
||
| `id` | UUID PK | `gen_random_uuid()` | |
|
||
| `created_date` / `updated_date` | TIMESTAMPTZ | `now()` / trigger `set_updated_date()` | |
|
||
| `created_by_id` | UUID FK `users(id)` | | |
|
||
| `name` | TEXT NOT NULL | | |
|
||
| `description` | TEXT | | |
|
||
| `category` | TEXT | | livre, texto |
|
||
| `price_0` | NUMERIC(12,2) | | preço "sem fidelidade" (faixa 0 meses) |
|
||
| `price_12` | NUMERIC(12,2) | | preço na faixa de 12 meses |
|
||
| `price_24` | NUMERIC(12,2) | | preço na faixa de 24 meses |
|
||
| `price_36` | NUMERIC(12,2) | | preço na faixa de 36 meses |
|
||
| `price_48` | NUMERIC(12,2) | | preço na faixa de 48 meses |
|
||
| `impl_unit_price` | NUMERIC(12,2) NOT NULL | default `0` | valor de implantação **por unidade**, multiplicado pela quantidade do item na oferta |
|
||
| `unit` | TEXT NOT NULL | default `'unidade'` | |
|
||
| `active` | BOOLEAN NOT NULL | default `true` | inativo não aparece no catálogo geral (`GET /products` filtra opcionalmente por `active`) |
|
||
| `service_type` | TEXT NOT NULL | default `'STFC'`; whitelist `SERVICE_TYPES` (server: `server/src/lib/constants.js`) | usado para agrupar itens na oferta/PDF e para sugerir documento fiscal |
|
||
| `metered` | BOOLEAN NOT NULL | default `false` | produto "tarifado" (chamadas) — habilita a tabela de tarifas |
|
||
| `minutes_allowance` | NUMERIC(12,2) | | franquia de minutos (só relevante se `metered`) |
|
||
| `tariff_rates` | JSONB NOT NULL | default `'{}'` | formato `{ [tipo]: { normal: number, reduced: number } }`, tipos: `LC, LDN, VC1, VC2, VC3, LDI` |
|
||
| `has_ldi` | BOOLEAN NOT NULL | default `false` | inclui tarifa de Longa Distância Internacional |
|
||
| `ixc_product_code` | TEXT | (migration `add-product-ixc-code`) | código do MESMO produto no sistema externo IXC — campo aberto, não sequencial |
|
||
| `product_code` | INTEGER NOT NULL UNIQUE | `nextval('products_product_code_seq')`, sequência própria começando em 1 | código interno do OrçaFácil, gerado automaticamente, distinto do UUID e do `ixc_product_code` |
|
||
| `discontinued` | BOOLEAN NOT NULL | default `false` | produto descontinuado some do seletor de produtos da oferta (`Product.filter({ active:true, discontinued:false })`), mas continua existindo (uso futuro em "Aditivo", ainda não implementado) |
|
||
|
||
Não existe soft-delete: `DELETE /products/:id` remove a linha de fato.
|
||
|
||
**Faixas de preço** = 5 colunas fixas (`price_0/12/24/36/48`) — **não é uma
|
||
tabela relacionada**, é fixo por produto. Ver seção 2.
|
||
|
||
Serialização (`serializeProduct`, `server/src/lib/serialize.js`) expõe todos
|
||
os campos acima, convertendo os `price_*`/`impl_unit_price`/
|
||
`minutes_allowance` para `Number` (ou `null`).
|
||
|
||
### 1.2 Tabela `quotes`
|
||
|
||
Fonte: `1785400000000_baseline-schema.js` + `1785598045226_add-partner-signature-fields-and-quote-sync.js`
|
||
(adiciona `client_document`, `client_code`) + `1785513008756_add-client-registrations.js`
|
||
(adiciona `client_registration_id`) + `1786340000000_add-quote-fidelity-period.js`
|
||
(adiciona `fidelity_period`).
|
||
|
||
| Coluna | Tipo | Default/Regras | Observações |
|
||
|---|---|---|---|
|
||
| `id` | UUID PK | | |
|
||
| `created_date` / `updated_date` | TIMESTAMPTZ | trigger auto-update | |
|
||
| `created_by_id` | UUID FK `users(id)` | | dono/vendedor responsável — só admin pode reatribuir (seção 9) |
|
||
| `quote_number` | TEXT | gerado no frontend: `String(Date.now()).slice(-6)` ao criar uma oferta nova | não é sequencial garantido nem único no banco |
|
||
| `reseller_id` | UUID NOT NULL FK `resellers(id)` | | revenda dona da oferta |
|
||
| `client_name` | TEXT NOT NULL | | |
|
||
| `client_company` | TEXT | | razão social/nome fantasia, se PJ |
|
||
| `client_email` | TEXT | | |
|
||
| `client_phone` | TEXT NOT NULL | | validado contra duplicidade entre revendas (`validateQuotePhone`, fora do escopo deste doc) |
|
||
| `client_document` | TEXT | migration `add-partner-signature-fields...` | CPF/CNPJ — preenchido quando o cadastro do cliente é validado (`registration_status = 'ativo'`), copiado do cadastro |
|
||
| `client_code` | INTEGER | idem | código sequencial interno do cliente, copiado do cadastro ativo |
|
||
| `ixc_client_code` | TEXT | | código do cliente no sistema IXC |
|
||
| `sales_rep_name` | TEXT | | nome de exibição de quem vendeu (texto livre, snapshot) |
|
||
| `contract_period` | INTEGER NOT NULL | default `0`; CHECK `IN (0,12,24,36,48)` | **faixa de preço contratada** — determina qual `price_N` foi usado. NÃO necessariamente igual ao prazo de fidelidade real (ver `fidelity_period`) |
|
||
| `fidelity_period` | INTEGER | nullable; CHECK `fidelity_period IS NULL OR (fidelity_period >= 0 AND fidelity_period <= contract_period)` | prazo de permanência **efetivamente assinado**, quando diferente (menor) da faixa de preço. `NULL` = sem exceção (fidelidade = `contract_period`, caso padrão) |
|
||
| `items` | JSONB NOT NULL | default `'[]'` | array de itens da oferta — estrutura na seção 1.3 |
|
||
| `implementation_fee` | NUMERIC(12,2) NOT NULL | default `0` | soma da implantação **calculada a partir dos produtos** (`impl_unit_price × quantity` de cada item) |
|
||
| `impl_geral` | NUMERIC(12,2) NOT NULL | default `0` | valor de implantação adicional "geral do projeto", digitado à mão, não ligado a nenhum produto |
|
||
| `monthly_total` | NUMERIC(12,2) | | total mensal "de tabela" (soma dos itens, sem condição especial) |
|
||
| `contract_total` | NUMERIC(12,2) | | ver fórmula seção 8 |
|
||
| `proposed_monthly_total` | NUMERIC(12,2) | | "Condição Especial" — valor mensal final negociado, quando diferente do total de tabela. `null`/vazio = sem condição especial |
|
||
| `approval_status` | ENUM `approval_status` (`none, pending, approved, rejected`) | default `'none'` | cobre **duas exceções ao mesmo tempo**: desconto especial (`proposed_monthly_total < monthly_total`) e/ou fidelidade reduzida (`fidelity_period < contract_period`) — uma única aprovação vale para ambas quando as duas existirem juntas na mesma oferta |
|
||
| `approval_notes` | TEXT | | observação livre do aprovador |
|
||
| `approved_by_id` | UUID FK `users(id)` | nullable | quem aprovou; setado pelo backend, nunca aceito cru do client |
|
||
| `notes` | TEXT | | observações gerais da oferta |
|
||
| `status` | ENUM `quote_status` (`rascunho, enviado, aprovado, recusado`) | default `'rascunho'` | status do **documento/proposta** em si (independente do negócio) — na prática o frontend sempre grava `'rascunho'` (ver `buildQuoteData`); não há fluxo de UI que altere para os outros valores neste módulo |
|
||
| `deal_status` | ENUM `deal_status` (`orcamento, fechado, perdido`) | default `'orcamento'` | status do **negócio** — máquina de estados, seção 5 |
|
||
| `impl_payment_condition` | TEXT NOT NULL | default `'À vista'` | ver seção 7 |
|
||
| `reseller_id` | (já listado acima) | | |
|
||
| `client_registration_id` | UUID FK `client_registrations(id)` | nullable | quando setado, a oferta está **travada** (seção 5) |
|
||
|
||
Índices: `idx_quotes_reseller`, `idx_quotes_created`, `idx_quotes_phone`,
|
||
`idx_quotes_deal`.
|
||
|
||
Serialização (`serializeQuote`) inclui também, via JOIN:
|
||
`created_by` (email do criador), `approved_by_name` (nome de quem aprovou).
|
||
|
||
### 1.3 Estrutura de `quotes.items` (JSONB array)
|
||
|
||
Cada item é um snapshot completo do produto no momento em que foi
|
||
adicionado/atualizado na oferta (preços e metadados **não** são recalculados
|
||
a partir de `products` depois de salvos — só na tela de edição, ao
|
||
recarregar o catálogo, os campos de preço são reconciliados com o produto
|
||
atual, ver `NewQuote.jsx` linhas ~201-217):
|
||
|
||
```js
|
||
{
|
||
product_id: "uuid",
|
||
product_name: "string", // snapshot do nome no momento da adição
|
||
quantity: number,
|
||
unit_price: number, // preço vigente para o período selecionado
|
||
total: number, // quantity * unit_price
|
||
impl_unit_price: number, // snapshot de products.impl_unit_price
|
||
impl_total: number, // quantity * impl_unit_price
|
||
price_0: number, // snapshot de products.price_0 (usado no cálculo de desconto por fidelidade)
|
||
price_12: number,
|
||
price_24: number,
|
||
price_36: number,
|
||
price_48: number,
|
||
service_type: string, // snapshot de products.service_type
|
||
metered: boolean,
|
||
minutes_allowance: number|null,
|
||
tariff_rates: object, // snapshot de products.tariff_rates
|
||
has_ldi: boolean,
|
||
ixc_product_code: string,
|
||
}
|
||
```
|
||
|
||
### 1.4 Relação com "contrato"
|
||
|
||
**Não existe tabela `contracts`.** Um "contrato" é a junção, em tempo de
|
||
consulta, de `client_registrations` (status `'ativo'`) com a `quotes` que o
|
||
originou (`client_registrations.quote_id`). Ver seção 10.
|
||
|
||
---
|
||
|
||
## 2. Faixas de preço por período de contrato
|
||
|
||
- Cada produto tem **5 colunas fixas** de preço mensal: `price_0` (sem
|
||
fidelidade), `price_12`, `price_24`, `price_36`, `price_48` — não é uma
|
||
tabela relacionada de "price tiers", é hardcoded no schema.
|
||
- `quotes.contract_period` grava qual faixa foi usada (`CHECK IN (0,12,24,36,48)`)
|
||
— os 5 valores são as únicas faixas suportadas hoje.
|
||
- Ao trocar de período na tela de oferta (`changePeriod` em `NewQuote.jsx`):
|
||
1. Para cada item já na oferta, `unit_price` é recalculado lendo
|
||
`item[\`price_${novoPeriodo}\`]` (o snapshot do item já carrega os 5
|
||
preços); se ausente, mantém o `unit_price` atual.
|
||
2. `total` do item é recalculado (`quantity * novoPreço`).
|
||
3. **`fidelityPeriod` é resetado para `null`** — uma exceção de fidelidade
|
||
é sempre relativa à faixa vigente; trocar de faixa invalida qualquer
|
||
valor pendente.
|
||
- Ao adicionar um produto (`addProduct`), o preço unitário inicial é
|
||
`getPrice(product, period) = product[\`price_${period}\`] || 0`.
|
||
- Produto descontinuado (`discontinued = true`) não aparece na lista de
|
||
produtos disponíveis para adicionar a uma nova oferta (`Product.filter({
|
||
active: true, discontinued: false })`), mas continua em ofertas já
|
||
existentes.
|
||
|
||
---
|
||
|
||
## 3. Fidelidade contratual reduzida (`fidelity_period`)
|
||
|
||
### 3.1 Conceito
|
||
|
||
- `contract_period` = faixa de preço (define qual `price_N` foi usado).
|
||
- `fidelity_period` = prazo de permanência **efetivamente assinado** pelo
|
||
cliente, quando **menor** que `contract_period` (ex.: cliente fecha no
|
||
preço da faixa de 48 meses, mas negocia permanecer só 3, 4, 5 ou 6 meses).
|
||
- `fidelity_period = NULL` (padrão, sempre) significa "sem exceção" — a
|
||
fidelidade real é igual a `contract_period`.
|
||
- Regra de negócio: só é válido preencher um prazo **MENOR** que a faixa de
|
||
preço contratada — não faz sentido negociar fidelidade maior que o
|
||
período que definiu o preço. CHECK no banco: `ck_fidelity_period`:
|
||
`fidelity_period IS NULL OR (fidelity_period >= 0 AND fidelity_period <= contract_period)`.
|
||
- Validação espelhada no backend (`validateFidelityPeriod` em
|
||
`quotes.routes.js`): inteiro entre `0` e `contract_period`.
|
||
|
||
### 3.2 Cálculo de "tem fidelidade reduzida"
|
||
|
||
```js
|
||
hasReducedFidelity = fidelityPeriod != null && fidelityPeriod < period
|
||
```
|
||
(no frontend, `period` = `contract_period` corrente da tela)
|
||
|
||
### 3.3 Fluxo de aprovação
|
||
|
||
- `hasReducedFidelity` entra no **mesmo** `needsApproval` do desconto
|
||
especial de preço (seção 4): `needsApproval = isDiscount || hasReducedFidelity`.
|
||
- Uma única `approval_status`/`approval_notes`/`approved_by_id` cobre as
|
||
duas exceções quando ambas existem na mesma oferta simultaneamente — não
|
||
há aprovação separada por tipo de exceção.
|
||
- Quem pode aprovar/recusar (`canApproveDiscount`, backend e mirror no
|
||
frontend):
|
||
```js
|
||
function canApproveDiscount(user) {
|
||
if (user.role === 'super_admin') return true; // sempre, sem flag
|
||
return user.role === 'admin' && user.can_approve_discount === true;
|
||
}
|
||
```
|
||
`admin` comum só pode aprovar se `users.can_approve_discount = true`.
|
||
- Ao alterar `contract_period` (faixa) ou `fidelity_period` na tela,
|
||
`approvalStatus` é resetado para `"none"` (nova negociação = nova
|
||
aprovação necessária).
|
||
- No `POST`/`PATCH` de `quotes`, se `b.approval_status` for `'approved'` ou
|
||
`'rejected'`, o backend exige `canApproveDiscount(req.user)` (403 caso
|
||
contrário) e define `approved_by_id` = usuário atual (se aprovado) ou
|
||
`null` (se recusado) — nunca aceita `approved_by_id` vindo do client.
|
||
|
||
### 3.4 Como "vaza" para vencimento, multa e documentos — só depois de aprovado
|
||
|
||
Regra idêntica em `variableRegistry.js` (`resolveOfertaRaw`,
|
||
`resolveContratoRaw`), `BackofficeAnatelPDFTemplate.jsx`,
|
||
`QuotePDFTemplate.jsx` e `contracts.routes.js` (`enrichContract`):
|
||
|
||
```js
|
||
fidelityPeriod =
|
||
(quote.fidelity_period != null && quote.approval_status === 'approved')
|
||
? quote.fidelity_period
|
||
: (quote.contract_period || 0);
|
||
```
|
||
|
||
Ou seja: **enquanto `approval_status` não é `'approved'`, todo documento,
|
||
cálculo de vigência, multa e vencimento de contrato usa `contract_period`**
|
||
— nunca expõe (nem calcula) sobre uma exceção pendente. Só depois de
|
||
aprovada é que o `fidelity_period` passa a valer para:
|
||
- Texto de "Nome Comercial da Oferta" (`Oferta com fidelidade de N meses` /
|
||
`Oferta sem fidelidade`).
|
||
- "Prazo de Vigência" e "Prazo de Permanência" no PDF Backoffice ANATEL
|
||
(conformidade RGC, arts. 3º/XIII, 26/27, 36).
|
||
- Teto de multa por rescisão antecipada (art. 37) — seção 3.5.
|
||
- Data de vencimento do contrato (seção 10).
|
||
- Variável `contrato.vigencia_meses` / `oferta.fidelidade` no gerador de
|
||
documentos (`variableRegistry.js`).
|
||
|
||
### 3.5 Multa (RGC art. 36/37)
|
||
|
||
No `BackofficeAnatelPDFTemplate.jsx`:
|
||
|
||
```js
|
||
// Desconto total mensal atribuível à fidelidade (NUNCA inclui o desconto
|
||
// especial — é o valor legal do "benefício concedido").
|
||
totalDescontoMensal = Σ items[ (item.price_0 - item.unit_price) * item.quantity ]
|
||
|
||
// Benefício total concedido pela fidelidade — base legal do teto de multa.
|
||
beneficioTotalFidelidade = totalDescontoMensal * fidelityPeriod // fidelityPeriod já resolvido pela regra 3.4
|
||
|
||
// Texto exibido:
|
||
"Multa por Rescisão Antecipada (art. 37)":
|
||
fidelityPeriod > 0
|
||
? `proporcional ao tempo restante, limitada a R$ ${beneficioTotalFidelidade.toFixed(2)} (benefício concedido)`
|
||
: "Não aplicável"
|
||
```
|
||
|
||
Importante: o cálculo do teto de multa usa **só** o desconto de
|
||
fidelidade — nunca soma o desconto/acréscimo especial (ver comentário no
|
||
código: "não pode misturar com condição especial").
|
||
|
||
---
|
||
|
||
## 4. Desconto / Condição Especial de preço (`proposed_monthly_total`)
|
||
|
||
### 4.1 Conceito
|
||
|
||
- `monthlyTotal` = soma de tabela (`Σ item.total`, com os preços da faixa
|
||
vigente).
|
||
- `proposed_monthly_total` = valor mensal final negociado, quando diferente
|
||
do total de tabela.
|
||
- Detecção (idêntica em frontend `NewQuote.jsx` e nos templates de PDF):
|
||
```js
|
||
hasSpecialPrice = proposedMonthly !== "" && Number(proposedMonthly) > 0
|
||
&& Number(proposedMonthly) !== monthlyTotal;
|
||
isDiscount = hasSpecialPrice && Number(proposedMonthly) < monthlyTotal;
|
||
isMarkup = hasSpecialPrice && Number(proposedMonthly) > monthlyTotal;
|
||
diffMonthly = hasSpecialPrice ? monthlyTotal - Number(proposedMonthly) : 0; // positivo=desconto, negativo=acréscimo
|
||
effectiveMonthly = hasSpecialPrice ? Number(proposedMonthly) : monthlyTotal;
|
||
```
|
||
|
||
### 4.2 Regra de aprovação — só desconto exige aprovação
|
||
|
||
- **Desconto** (`isDiscount`, valor final menor que a tabela): entra em
|
||
`needsApproval` — fica pendente até um admin autorizado aprovar (mesma
|
||
função `canApproveDiscount` da seção 3.3, mesmos campos `approval_status`
|
||
/ `approval_notes` / `approved_by_id` compartilhados com a fidelidade
|
||
reduzida).
|
||
- **Acréscimo** (`isMarkup`, valor final maior que a tabela): aplicado
|
||
**direto, sem necessidade de aprovação** — nunca passa por
|
||
`approval_status`.
|
||
- No servidor (`buildQuoteData`/rota), quando `needsApproval` é falso
|
||
(isMarkup puro, sem fidelidade reduzida), `approval_status` é forçado
|
||
para `'none'`.
|
||
|
||
### 4.3 Rateio proporcional por item
|
||
|
||
Quando há condição especial ativa (aprovada, se desconto; sempre, se
|
||
acréscimo), o valor final é rateado **proporcionalmente ao peso de cada
|
||
item no total de tabela** — nunca abate um item só:
|
||
|
||
```js
|
||
// QuotePDFTemplate.jsx — para exibição ao cliente
|
||
share_i = (item.total / monthlyTotal) * (monthlyTotal - effectiveMonthly)
|
||
item.total_final = item.total - share_i
|
||
item.unit_price_final = item.total_final / item.quantity
|
||
|
||
// BackofficeAnatelPDFTemplate.jsx — para o backoffice, com granularidade extra
|
||
specialDiscount = monthlyTotal - proposedMonthlyTotal // positivo=desconto, negativo(markup)
|
||
item.itemSpecialDiscTotal = (item.totalContr / monthlyTotal) * specialDiscount
|
||
item.itemSpecialDiscUnit = item.itemSpecialDiscTotal / item.quantity
|
||
item.totalDescGeral = item.descFidTotal + item.itemSpecialDiscTotal // desconto fidelidade + especial, por item
|
||
item.priceFinal = item.priceContr - item.itemSpecialDiscUnit
|
||
item.totalFinal = item.priceFinal * item.quantity
|
||
```
|
||
|
||
### 4.4 Fórmula CORRETA de "economia mensal" e "economia total do contrato" (bug corrigido)
|
||
|
||
Commit `154f588` corrigiu um bug em que esses dois campos do PDF Backoffice
|
||
ANATEL somavam **só** o desconto de fidelidade, ignorando o
|
||
desconto/acréscimo especial. Fórmula correta (vigente):
|
||
|
||
```js
|
||
// Desconto de fidelidade (nunca inclui condição especial — é valor "legal" pro art.36/37)
|
||
totalDescontoMensal = Σ items[ (item.price_0 - item.unit_price) * item.quantity ]
|
||
|
||
// specialDiscount: positivo = desconto especial, negativo = acréscimo especial
|
||
specialDiscount = hasSpecialPrice ? (monthlyTotal - proposedMonthlyTotal) : 0
|
||
|
||
// ECONOMIA MENSAL (soma dos dois — um acréscimo especial REDUZ a economia,
|
||
// pois specialDiscount é negativo nesse caso):
|
||
totalEconomiaMensal = totalDescontoMensal + specialDiscount
|
||
|
||
// ECONOMIA TOTAL DO CONTRATO (exibida só se totalEconomiaMensal > 0 e contract_period > 0):
|
||
economiaTotalContrato = totalEconomiaMensal * contract_period
|
||
```
|
||
|
||
Atenção: `beneficioTotalFidelidade` (usado no cálculo do teto de multa,
|
||
seção 3.5) **continua sendo só** `totalDescontoMensal * fidelityPeriod`
|
||
(fidelidade pura) — não deve ser confundido com `totalEconomiaMensal`, que
|
||
é exibido no quadro "Economia Mensal"/"Economia total no contrato" e soma
|
||
os dois tipos de desconto. São dois números distintos, calculados
|
||
separadamente, para propósitos diferentes (informativo comercial vs. teto
|
||
legal de multa).
|
||
|
||
---
|
||
|
||
## 5. Trava de edição de oferta e máquina de estados
|
||
|
||
### 5.1 Trava (`client_registration_id`)
|
||
|
||
- Assim que `quotes.client_registration_id` é setado (cadastro do cliente
|
||
iniciado — ver seção 6), a oferta **trava**: não pode mais ser editada
|
||
por ninguém, **exceto `super_admin`**.
|
||
- Checagem no backend (`PATCH /quotes/:id`, `quotes.routes.js`):
|
||
```js
|
||
if (quote.client_registration_id && req.user.role !== 'super_admin') {
|
||
return res.status(409).json({ error: 'Esta oferta está travada — o cadastro do cliente já foi iniciado.' });
|
||
}
|
||
```
|
||
- **`super_admin` pode editar tudo** na oferta travada (itens, cliente,
|
||
valores, status), **exceto** `client_registration_id` em si — esse campo
|
||
só muda pelas rotas de fechamento de negócio (`POST
|
||
/quotes/:id/client-registration`), nunca pelo `PATCH` genérico. O update
|
||
normal do `PATCH` simplesmente não inclui esse campo no `SET`.
|
||
- No frontend, a trava aparece como `locked = !!clientRegistrationId`; o
|
||
formulário inteiro fica num `<fieldset disabled={locked && !isSuperAdmin}>`.
|
||
Para super_admin com oferta travada, aparece um aviso e um atalho rápido
|
||
de "só trocar o status do negócio" via `PATCH { deal_status }` sem passar
|
||
pelo formulário completo (`superAdminChangeDealStatus`).
|
||
- A checagem existe **sempre no servidor**, nunca confia só na UI
|
||
desabilitada (comentário explícito no código-fonte).
|
||
|
||
### 5.2 Máquina de estados de `deal_status`
|
||
|
||
Enum `deal_status`: `'orcamento' | 'fechado' | 'perdido'`. Default:
|
||
`'orcamento'`.
|
||
|
||
- `orcamento` → estado inicial, oferta em negociação, totalmente editável.
|
||
- `fechado` → setado **apenas** através do fluxo de fechamento de negócio
|
||
(`POST /quotes/:id/client-registration`), que simultaneamente cria/copia
|
||
um `client_registrations` e seta `quotes.client_registration_id` — os
|
||
dois campos (`deal_status='fechado'` e `client_registration_id`) mudam
|
||
juntos, na mesma transação SQL. Ao setar `client_registration_id`, a
|
||
oferta trava (seção 5.1).
|
||
- Exceção: `super_admin` pode reverter `deal_status` direto (voltar para
|
||
`orcamento` ou marcar `perdido`) mesmo com a oferta travada, via
|
||
`PATCH { deal_status }` — não mexe em `client_registration_id`, só
|
||
corrige o status pontualmente (ex.: "reverter um fechado que caiu").
|
||
- `perdido` → setado manualmente pelo vendedor/admin enquanto a oferta não
|
||
está travada (clique direto no botão de status), ou pelo super_admin via
|
||
atalho mesmo travada.
|
||
- No frontend, clicar em "OFERTA Fechada" (`handleDealStatusClick`) só
|
||
dispara o assistente de fechamento (`showCloseDealDialog`) se: a oferta
|
||
já tem `editId` (foi salva antes), `dealStatus !== 'fechado'` ainda, e
|
||
`!locked`. Se já travada, só o super_admin chega ali e é tratado pelo
|
||
atalho de troca de status pontual.
|
||
|
||
### 5.3 Máquina de estados de `status`
|
||
|
||
Enum `quote_status`: `'rascunho' | 'enviado' | 'aprovado' | 'recusado'`.
|
||
Default `'rascunho'`. Na prática, o frontend sempre grava `'rascunho'`
|
||
(`buildQuoteData` hardcoda `status: "rascunho"`) — não há UI neste módulo
|
||
que altere para os outros valores; é um campo do modelo original que ficou
|
||
com uso residual (a tela de listagem `Quotes.jsx` exibe um Badge colorido
|
||
por esse status, mas nada no fluxo atual o muda).
|
||
|
||
---
|
||
|
||
## 6. Fluxo de fechamento de negócio ("fechado")
|
||
|
||
Rota: `POST /quotes/:id/client-registration` (`quotes.routes.js`).
|
||
Permissão: mesma de edição da oferta (`loadForWrite` — dono com feature
|
||
`ofertas:edit`, ou `canEditAllQuotes`). Falha com 409 se a oferta já tem
|
||
`client_registration_id`.
|
||
|
||
Dois caminhos, escolhidos pelo vendedor na UI (`showCloseDealDialog`,
|
||
`closeDealStep`):
|
||
|
||
### 6.1 Caminho normal (cliente novo) — `is_portability` + `person_type`
|
||
|
||
Body: `{ is_portability: boolean, person_type: 'pf'|'pj' }` (ambos
|
||
obrigatórios, validados no backend).
|
||
|
||
1. Gera `token = crypto.randomBytes(24).toString('hex')`.
|
||
2. `BEGIN` transação:
|
||
- `INSERT INTO client_registrations (quote_id, created_by_id, token,
|
||
is_portability, person_type, reseller_id)` — nasce com
|
||
`registration_status = 'rascunho'` (default da tabela).
|
||
- `UPDATE quotes SET deal_status='fechado', client_registration_id=<novo id>`.
|
||
- `COMMIT`.
|
||
3. Retorna `{ registration, public_link }`, onde
|
||
`public_link = \`${baseUrl}/cadastro-cliente?token=${token}\`` — link
|
||
público (sem autenticação) para o **próprio cliente** preencher seu
|
||
cadastro completo (fora do escopo deste doc — módulo de
|
||
`client_registrations`).
|
||
4. Frontend abre automaticamente um segundo diálogo (`showSendLinkDialog`)
|
||
para copiar/enviar esse link por e-mail ou WhatsApp.
|
||
|
||
### 6.2 Atalho "cliente já possui cadastro ativo" (`reuse_from_registration_id`)
|
||
|
||
Usado quando o mesmo cliente já comprou antes e tem um
|
||
`client_registrations` com `registration_status = 'ativo'`. Body:
|
||
`{ reuse_from_registration_id: uuid, person_type }`.
|
||
|
||
1. Busca o registro de origem; exige `registration_status === 'ativo'`
|
||
(400 caso contrário).
|
||
2. Escopo: quem não é admin/backoffice só pode reaproveitar cadastro da
|
||
**própria revenda** (`sourceReg.reseller_id === quote.reseller_id`, 403
|
||
caso contrário).
|
||
3. `BEGIN` transação:
|
||
- `INSERT INTO client_registrations (... campos de identidade/endereço/
|
||
contato copiados de REUSE_COPY_FIELDS ...) SELECT ... FROM
|
||
client_registrations WHERE id = origem` — nasce **já com**
|
||
`registration_status = 'ativo'`, `submitted_at = now()`,
|
||
`activated_at = now()` — sem token público, sem e-mail, sem validação
|
||
manual.
|
||
- `REUSE_COPY_FIELDS` (constante no topo do arquivo): endereço completo,
|
||
todos os contatos (principal/financeiro/técnico), todos os campos
|
||
PF/PJ, todos os campos de verificação de CPF/CNPJ, endereço do CNPJ.
|
||
**Explicitamente fora da lista** (não herdado): `ixc_client_id`,
|
||
`ixc_contract_number`, `client_code` (cada registro tem o seu, novo),
|
||
`portability_numbers/ranges`, `internal_notes`.
|
||
- Se a origem é PJ, copia também `client_registration_partners`
|
||
(sócios/representantes) do registro de origem para o novo.
|
||
- `UPDATE quotes SET deal_status='fechado', client_registration_id=<novo id>`.
|
||
- `COMMIT`.
|
||
4. Chama `syncActiveRegistrationToQuote(reg)` — sincroniza
|
||
`client_document`/`client_code`/etc. de volta para a `quotes` (fora do
|
||
escopo detalhado deste doc, mas é o mecanismo que preenche
|
||
`quotes.client_document`/`quotes.client_code`).
|
||
5. Frontend recarrega a página inteira (`window.location.reload()`) — não
|
||
mostra diálogo de link público (não é necessário, já está ativo).
|
||
|
||
Em ambos os caminhos, o resultado final é: `quotes.deal_status = 'fechado'`
|
||
e `quotes.client_registration_id` setado — o que trava a oferta (seção 5.1).
|
||
|
||
---
|
||
|
||
## 7. Condição de pagamento de implantação (`impl_payment_condition`)
|
||
|
||
Fonte: `server/src/lib/paymentConditions.js`.
|
||
|
||
```js
|
||
export const PAYMENT_CONDITIONS = ['À vista', '1+1', '1+2'];
|
||
|
||
export function getAvailablePaymentConditions(implValue) {
|
||
const value = implValue || 0;
|
||
if (value <= 1500) return ['À vista'];
|
||
if (value <= 3000) return ['À vista', '1+1'];
|
||
return ['À vista', '1+1', '1+2'];
|
||
}
|
||
```
|
||
|
||
- Progressivo por faixa de valor: quanto maior o total de implantação, mais
|
||
opções de parcelamento ficam disponíveis. "À vista" está sempre
|
||
disponível.
|
||
- `implTotal` usado na checagem = `implementation_fee + impl_geral` (soma
|
||
dos dois componentes da implantação, seção 8).
|
||
- Validação no backend (`validatePaymentCondition`, `quotes.routes.js`),
|
||
chamada tanto no `POST` quanto no `PATCH`:
|
||
```js
|
||
function validatePaymentCondition(condition, implTotal) {
|
||
if (condition === undefined) return null; // não veio no body, ignora
|
||
const allowed = getAvailablePaymentConditions(implTotal);
|
||
if (!allowed.includes(condition)) {
|
||
return `Condição de pagamento da implantação inválida para o valor de implantação (R$ ${implTotal.toFixed(2)}). Opções disponíveis: ${allowed.join(', ')}`;
|
||
}
|
||
return null;
|
||
}
|
||
```
|
||
Retorna 400 se inválida.
|
||
- No frontend, um `useEffect` observa `totalImpl` e, se a condição
|
||
selecionada deixar de estar disponível (ex.: implantação diminuiu), troca
|
||
automaticamente para a **última** opção ainda válida
|
||
(`availablePaymentConditions[availablePaymentConditions.length - 1]`).
|
||
- Default no schema: `'À vista'`.
|
||
|
||
---
|
||
|
||
## 8. Cálculos financeiros exatos
|
||
|
||
Variáveis-base (calculadas no frontend em `NewQuote.jsx`, e recalculadas de
|
||
forma idêntica nos templates de PDF a partir dos dados salvos):
|
||
|
||
```js
|
||
// --- Itens ---
|
||
monthlyTotal = Σ items[ item.total ] // total "de tabela", soma dos itens no preço da faixa vigente
|
||
implFromProducts = Σ items[ item.impl_total ] // Σ (item.impl_unit_price * item.quantity)
|
||
totalImpl = implFromProducts + Number(impl_geral || 0) // implantação total (produtos + geral do projeto)
|
||
|
||
// --- Condição especial (seção 4) ---
|
||
hasSpecialPrice = proposedMonthly !== "" && Number(proposedMonthly) > 0 && Number(proposedMonthly) !== monthlyTotal
|
||
isDiscount = hasSpecialPrice && Number(proposedMonthly) < monthlyTotal
|
||
isMarkup = hasSpecialPrice && Number(proposedMonthly) > monthlyTotal
|
||
diffMonthly = hasSpecialPrice ? monthlyTotal - Number(proposedMonthly) : 0 // > 0 desconto, < 0 acréscimo
|
||
effectiveMonthly = hasSpecialPrice ? Number(proposedMonthly) : monthlyTotal
|
||
|
||
// --- Fidelidade reduzida (seção 3) ---
|
||
hasReducedFidelity = fidelityPeriod != null && fidelityPeriod < contractPeriod
|
||
|
||
// --- Necessidade de aprovação ---
|
||
needsApproval = isDiscount || hasReducedFidelity
|
||
|
||
// --- Total do contrato ---
|
||
contractTotal = effectiveMonthly * (contractPeriod || 1) + totalImpl
|
||
```
|
||
|
||
Observações importantes sobre `contractTotal`:
|
||
- Usa `(contractPeriod || 1)` — se `contractPeriod = 0` (sem fidelidade),
|
||
o multiplicador vira `1` (evita zerar o total; na prática representa "1
|
||
mês" como base do total exibido, mesmo sem fidelidade formal).
|
||
- Usa **`contractPeriod`** (a faixa de preço), não `fidelityPeriod` — o
|
||
"Total do Contrato" reflete o valor comercial pela faixa de preço
|
||
contratada, não pelo prazo de permanência reduzido negociado à parte.
|
||
- Usa `effectiveMonthly` (já considerando condição especial, se ativa) —
|
||
isso é consistente tanto no momento de montar a oferta (`NewQuote.jsx`,
|
||
onde qualquer `proposedMonthly` preenchido conta, mesmo pendente) quanto
|
||
no PDF ao cliente (`QuotePDFTemplate.jsx`, onde só conta se
|
||
`specialActive` = `isMarkup || (isDiscount && approval_status ===
|
||
'approved')`).
|
||
|
||
**Persistência** (payload salvo em `quotes` via `POST`/`PATCH`):
|
||
```js
|
||
implementation_fee = implFromProducts // NÃO inclui impl_geral
|
||
impl_geral = Number(impl_geral || 0)
|
||
monthly_total = monthlyTotal
|
||
contract_total = contractTotal
|
||
proposed_monthly_total = hasSpecialPrice ? Number(proposedMonthly) : null
|
||
fidelity_period = hasReducedFidelity ? fidelityPeriod : null // só persiste se for de fato menor que a faixa
|
||
approval_status = needsApproval ? (approvalStatus === 'none' ? 'pending' : approvalStatus) : 'none'
|
||
```
|
||
|
||
**Economia mensal / total** (exibidos no PDF Backoffice ANATEL, seção 4.4):
|
||
```js
|
||
totalDescontoMensal = Σ items[ (item.price_0 - item.unit_price) * item.quantity ] // desconto de fidelidade puro
|
||
specialDiscount = hasSpecialPrice ? (monthlyTotal - proposedMonthlyTotal) : 0 // > 0 desconto especial, < 0 acréscimo especial
|
||
totalEconomiaMensal = totalDescontoMensal + specialDiscount
|
||
economiaTotalContrato = totalEconomiaMensal * contractPeriod // só exibida se totalEconomiaMensal > 0 e contractPeriod > 0
|
||
beneficioTotalFidelidade = totalDescontoMensal * fidelityPeriodResolvido // usado só no teto de multa (art. 37), NUNCA soma o especial
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Reatribuição de oferta (`created_by_id`)
|
||
|
||
- Só **admin** (`isAdminRole` = `admin` ou `super_admin`) pode mudar
|
||
`quotes.created_by_id` de uma oferta existente.
|
||
- No `PATCH /quotes/:id`:
|
||
```js
|
||
if (b.created_by_id !== undefined && b.created_by_id !== quote.created_by_id) {
|
||
if (!isAdminRole(req.user.role)) return 403;
|
||
const target = await pool.query('SELECT id FROM users WHERE id = $1', [b.created_by_id]);
|
||
if (!target.rows[0]) return 400; // usuário de destino não encontrado
|
||
createdById = b.created_by_id;
|
||
}
|
||
```
|
||
- No frontend, só aparece o seletor de "Responsável Comercial" (dropdown
|
||
com os usuários da mesma revenda) quando `isAdmin && editId &&
|
||
resellerUsers.length > 0` — para os demais, o campo é somente leitura
|
||
(mostra `salesRepName`, texto snapshot).
|
||
- Ao reatribuir, `sales_rep_name` também é atualizado no frontend para
|
||
refletir o novo responsável (mas é só um snapshot de texto, não uma FK).
|
||
|
||
---
|
||
|
||
## 10. Contratos
|
||
|
||
### 10.1 Não existe tabela `contracts`
|
||
|
||
"Contrato" é a junção, calculada em tempo de consulta, de
|
||
`client_registrations` (ativos) com a `quotes` de origem:
|
||
|
||
```sql
|
||
-- server/src/routes/contracts.routes.js, LIST_SELECT
|
||
SELECT cr.id, cr.client_code, cr.person_type, cr.pf_full_name, cr.pj_company_name, cr.pj_trade_name,
|
||
cr.pf_cpf, cr.pj_cnpj, cr.activated_at, cr.reseller_id, r.name AS reseller_name,
|
||
q.id AS quote_id, q.quote_number, q.contract_period, q.fidelity_period, q.approval_status,
|
||
q.monthly_total, q.contract_total, q.proposed_monthly_total, q.ixc_client_code
|
||
FROM client_registrations cr
|
||
JOIN quotes q ON q.id = cr.quote_id
|
||
LEFT JOIN resellers r ON r.id = cr.reseller_id
|
||
WHERE cr.registration_status = 'ativo'
|
||
```
|
||
|
||
### 10.2 Enriquecimento (`enrichContract`)
|
||
|
||
```js
|
||
clientName = person_type === 'pj' ? (pj_company_name || pj_trade_name) : pf_full_name
|
||
clientDocument = person_type === 'pj' ? pj_cnpj : pf_cpf
|
||
effectiveMonthly = (proposed_monthly_total != null && proposed_monthly_total > 0)
|
||
? proposed_monthly_total : (monthly_total || 0)
|
||
|
||
// Fidelidade REAL (mesma regra da seção 3.4): usa fidelity_period só se
|
||
// aprovado; senão cai para contract_period.
|
||
fidelityPeriod = (fidelity_period != null && approval_status === 'approved')
|
||
? fidelity_period : contract_period
|
||
|
||
dueDate = computeDueDate(activated_at, fidelityPeriod)
|
||
// null se activated_at ausente OU fidelityPeriod é 0/falsy (sem fidelidade = indeterminado)
|
||
// senão: new Date(activated_at) com +fidelityPeriod meses (setMonth)
|
||
|
||
daysUntilDue = dueDate ? Math.ceil((dueDate - now) / 86400000) : null
|
||
due_calculable = !!activated_at // cadastros ativados ANTES do rastreamento desta feature não têm activated_at e não têm vencimento inventado
|
||
indeterminate = !fidelityPeriod // sem fidelidade = vencimento indeterminado, mesmo com activated_at
|
||
```
|
||
|
||
Campos retornados por contrato: `id, client_code, client_name,
|
||
client_document, reseller_name, quote_id, quote_number, ixc_client_code,
|
||
activated_at, contract_period, fidelity_period (já resolvido), monthly_value,
|
||
contract_total, due_date, days_until_due, due_calculable, indeterminate`.
|
||
|
||
### 10.3 Rotas
|
||
|
||
- `GET /contracts` (feature `contratos:view`) — lista todos, ordenados por
|
||
`activated_at ASC NULLS LAST`. Usado por `src/pages/Contracts.jsx`
|
||
(tabela com busca por cliente/documento/revenda/oferta, badge de
|
||
vencimento colorido: vermelho se vencido/≤30 dias, âmbar se ≤60 dias,
|
||
verde caso contrário; "Não calculável" se `!due_calculable`,
|
||
"Indeterminado" se `indeterminate`).
|
||
- `GET /contracts/report` (feature `contratos_relatorios:view`) — agregados
|
||
para dashboard:
|
||
```js
|
||
total_active_contracts = contracts.length
|
||
total_active_mrr = Σ contracts[monthly_value]
|
||
due_30/60/90 = { count, value_at_risk } para contratos com
|
||
0 <= days_until_due <= N (value_at_risk = Σ monthly_value dos que vencem na janela)
|
||
by_reseller = agrupado por reseller_name: { count, mrr }, ordenado por mrr desc
|
||
upcoming_renewals = contratos com days_until_due <= 90, ordenados asc, top 20
|
||
not_calculable_count = Σ !due_calculable
|
||
indeterminate_count = Σ indeterminate
|
||
```
|
||
Usado por `src/pages/ContractReports.jsx` (cards de resumo + lista de
|
||
próximos vencimentos + MRR por revenda).
|
||
|
||
### 10.4 Vencimento sempre pela fidelidade REAL, nunca pela faixa de preço
|
||
|
||
Reforçando o ponto mais importante para reconstrução: em TODOS os lugares
|
||
que calculam vigência/vencimento/multa (contratos, PDFs, gerador de
|
||
documentos), a regra é idêntica e centralizada no padrão:
|
||
|
||
```js
|
||
fidelidadeEfetiva = (quote.fidelity_period != null && quote.approval_status === 'approved')
|
||
? quote.fidelity_period
|
||
: (quote.contract_period || 0)
|
||
```
|
||
|
||
`contract_period` continua sendo usado **apenas** para: (a) determinar qual
|
||
`price_N` foi aplicado, e (b) calcular `contract_total` (que é sempre pelo
|
||
período de preço contratado, seção 8) — nunca para vencimento/multa quando
|
||
há uma exceção de fidelidade já aprovada.
|
||
|
||
---
|
||
|
||
## 11. Permissões e escopo (resumo transversal, relevante a Produtos/Ofertas/Contratos)
|
||
|
||
Mirror exato entre `server/src/lib/roles.js` e `src/lib/roles.js`:
|
||
|
||
```js
|
||
ADMIN_ROLES = ['admin', 'super_admin']
|
||
isAdminRole(role) = ADMIN_ROLES.includes(role)
|
||
isAdminOrBackofficeRole(role) = isAdminRole(role) || role === 'backoffice'
|
||
|
||
hasFeatureAccess(user, key, minLevel='view'):
|
||
super_admin -> sempre true
|
||
senão -> user.role_permissions[key] (mapa 'view'|'edit' por feature, configurado em Permissões por Papel) precisa rank >= minLevel
|
||
(rank: view=1, edit=2)
|
||
|
||
canViewAllQuotes(user) = super_admin || hasFeatureAccess(user, 'ofertas_others', 'view')
|
||
canEditAllQuotes(user) = super_admin || hasFeatureAccess(user, 'ofertas_others', 'edit')
|
||
```
|
||
|
||
- Feature `produtos` (view/edit) controla CRUD de `products`.
|
||
- Feature `ofertas` (view/edit) controla CRUD de `quotes` — mas por padrão
|
||
cada usuário só vê/edita as **próprias** ofertas
|
||
(`created_by_id = user.id`), a menos que tenha `ofertas_others`.
|
||
- Feature `contratos`/`contratos_relatorios` controlam as rotas de
|
||
contratos.
|
||
- `GET /quotes` aplica escopo via `scopeClause`: sem `ofertas_others`, o
|
||
`WHERE` inclui `q.created_by_id = $1` (usuário atual); com
|
||
`ofertas_others`, vê tudo (sem filtro adicional, exceto os opcionais
|
||
`id`/`reseller_id` da query string).
|
||
- `POST /quotes`: usuário não-admin só pode criar oferta para a própria
|
||
`reseller_id` (403 caso tente para outra revenda).
|
||
- `DELETE /quotes/:id` e `DELETE /products/:id`: exigem `isAdminRole`
|
||
(produtos) / idem para quotes.
|
||
|
||
---
|
||
|
||
## 12. Referência rápida de arquivos-fonte
|
||
|
||
| Assunto | Arquivo |
|
||
|---|---|
|
||
| CRUD produtos | `server/src/routes/products.routes.js` |
|
||
| CRUD ofertas, fechamento de negócio, envio de PDF por e-mail | `server/src/routes/quotes.routes.js` |
|
||
| "Contratos" (derivado), relatórios | `server/src/routes/contracts.routes.js` |
|
||
| Condição de pagamento de implantação | `server/src/lib/paymentConditions.js` |
|
||
| Serialização de `products`/`quotes` | `server/src/lib/serialize.js` |
|
||
| Variáveis calculadas para geração de documentos (fidelidade, multa, vigência, desconto) | `server/src/lib/documentTemplates/variableRegistry.js` |
|
||
| Tela de criação/edição de oferta (toda a lógica de negócio do frontend) | `src/pages/NewQuote.jsx` |
|
||
| Tela de produtos | `src/pages/Products.jsx` |
|
||
| Listagem de ofertas | `src/pages/Quotes.jsx` |
|
||
| Listagem de contratos | `src/pages/Contracts.jsx` |
|
||
| Relatórios de contratos | `src/pages/ContractReports.jsx` |
|
||
| PDF da oferta (visão cliente) | `src/components/quote/QuotePDFTemplate.jsx` |
|
||
| PDF Backoffice/ANATEL (conformidade RGC, desconto, multa) | `src/components/quote/BackofficeAnatelPDFTemplate.jsx` |
|
||
| Papéis/permissões (mirror front/back) | `server/src/lib/roles.js`, `src/lib/roles.js` |
|
||
| Schema base | `server/migrations/1785400000000_baseline-schema.js` |
|
||
| `client_document`/`client_code` em quotes | `server/migrations/1785598045226_add-partner-signature-fields-and-quote-sync.js` |
|
||
| `client_registration_id` em quotes + tabela `client_registrations` | `server/migrations/1785513008756_add-client-registrations.js` |
|
||
| `products.ixc_product_code` | `server/migrations/1785980000000_add-product-ixc-code.js` |
|
||
| `products.product_code` (sequencial) | `server/migrations/1785990000000_add-product-code.js` |
|
||
| `products.discontinued` | `server/migrations/1786020000000_add-product-discontinued.js` |
|
||
| `quotes.fidelity_period` + CHECK | `server/migrations/1786340000000_add-quote-fidelity-period.js` |
|
||
| Bugfix "economia mensal/total soma desconto especial" | commit `154f588` |
|
||
| Trava de edição + exceção super_admin | commit `b20bf2f` |
|
||
| Fidelidade reduzida (feature completa) | commit `d3697bb` |
|
||
|
||
|
||
---
|
||
|
||
|
||
# 3. Cadastro de Cliente e Cadastro/Gestão de Revendas
|
||
|
||
|
||
Documentação de referência do sistema atual (OrçaFácil — Handix), extraída do código-fonte
|
||
(`server/src/routes/*.js`, `server/src/lib/*.js`, `server/migrations/*.js`, `src/pages/*.jsx`,
|
||
`src/lib/*.js`), para reconstrução fiel em outro stack.
|
||
|
||
Convenção de leitura: nomes de coluna/campo são citados literalmente (snake_case, como no
|
||
Postgres/JSON da API) porque o Eden deve preservar o mesmo vocabulário de domínio, mesmo
|
||
usando outro schema físico.
|
||
|
||
---
|
||
|
||
## 1. Modelo de dados
|
||
|
||
### 1.1 Tabela `client_registrations`
|
||
|
||
Cadastro de um cliente final (pessoa física ou jurídica) que fechou uma oferta (quote) — ou,
|
||
mais raramente, criado "avulso" sem oferta (ver seção 2.6). É o registro central de todo o
|
||
fluxo; tudo gira em torno dele (anexos, sócios, sincronização com a oferta, sincronização com
|
||
IXC).
|
||
|
||
Chave primária `id UUID DEFAULT gen_random_uuid()`. Timestamps `created_date`/`updated_date`
|
||
(trigger `set_updated_date()` atualiza `updated_date` a cada UPDATE).
|
||
|
||
**Vínculos e controle**
|
||
| Campo | Tipo | Observação |
|
||
|---|---|---|
|
||
| `quote_id` | UUID, FK `quotes(id)`, UNIQUE, **nullable** | 1:1 com a oferta que originou o cadastro. Nullable desde a migration `1786310000000` (permite cadastro "direto", sem oferta — ver 2.6). UNIQUE permite múltiplos NULL (Postgres). |
|
||
| `created_by_id` | UUID, FK `users(id)` | Vendedor/usuário que gerou o link. |
|
||
| `reseller_id` | UUID, FK `resellers(id)`, nullable | Snapshot da revenda dona da oferta no momento da criação — gravado à parte (não só via join com `quotes`) para manter histórico correto mesmo se a oferta for reatribuída depois. Populado por `UPDATE ... SET reseller_id = q.reseller_id FROM quotes` na migration que introduziu a coluna. |
|
||
| `token` | TEXT, UNIQUE, NOT NULL | `crypto.randomBytes(24).toString('hex')` — 48 caracteres hex. É a credencial do link público (ver seção 9). |
|
||
| `token_created_at` | TIMESTAMPTZ | Não há expiração automática — token vale até o cadastro deixar de estar `rascunho` (ver seção 9). |
|
||
| `client_code` | INTEGER, UNIQUE, NOT NULL, DEFAULT `nextval('client_registrations_client_code_seq')` | Código sequencial interno do cliente (visível ao usuário, ex: "Cliente #42"), começa em 1, sequência própria (não reaproveita id). |
|
||
|
||
**Estado do cadastro**
|
||
| Campo | Tipo | Observação |
|
||
|---|---|---|
|
||
| `is_portability` | BOOLEAN NOT NULL | Definido na criação (pelo vendedor/backoffice), não pelo cliente — controla se a seção de portabilidade numérica aparece no formulário público e se a fatura anexa é obrigatória. |
|
||
| `person_type` | ENUM `client_person_type` (`pf`, `pj`) NOT NULL | Definido na criação; o cliente pode escolher/confirmar no formulário público (pré-selecionado, mas ainda editável — ver `ClientRegistration.jsx`). |
|
||
| `registration_status` | ENUM `client_registration_status` (`rascunho`, `pendente_validacao`, `ativo`, `bloqueado`, `inativo`) NOT NULL DEFAULT `rascunho` | Máquina de estados — ver 1.1.1. |
|
||
| `submitted_at` | TIMESTAMPTZ | Setado quando o cliente envia o formulário público (rascunho → pendente_validacao). |
|
||
| `activated_at` | TIMESTAMPTZ | Setado **só na primeira vez** que o status vira `ativo` (`COALESCE(activated_at, now())` — nunca sobrescrito numa reativação). Usado pelo módulo Contratos para calcular vencimento (data de início de vigência). |
|
||
| `internal_notes` | TEXT | Observações internas do backoffice, nunca visível ao cliente/vendedor no formulário público. |
|
||
|
||
**Endereço final (compartilhado PF/PJ)** — preenchido pelo cliente no formulário público, editável depois pelo backoffice:
|
||
`address_zip`, `address_street`, `address_number`, `address_complement`, `address_neighborhood`, `address_city`, `address_state`, `address_country` (TEXT NOT NULL DEFAULT `'Brasil'`).
|
||
|
||
**Endereço "oficial" da Receita Federal (só PJ)** — trazido pela consulta pública de CNPJ, guardado **à parte** do endereço final, para o backoffice comparar os dois lado a lado (migration `1785592240556`):
|
||
`cnpj_address_confirmed` (BOOLEAN — true se o cliente confirmou que o endereço da Receita está correto), `cnpj_address_zip`, `cnpj_address_street`, `cnpj_address_number`, `cnpj_address_complement`, `cnpj_address_neighborhood`, `cnpj_address_city`, `cnpj_address_state`.
|
||
Quando `cnpj_address_confirmed = true`, o endereço final é copiado do endereço da Receita; quando `false`, o cliente preenche um endereço final diferente do zero.
|
||
|
||
**Contatos (compartilhado; PF só usa o financeiro, PJ usa os três)**:
|
||
`contact_principal_name/email/phone`, `contact_financial_name/email/phone`, `contact_technical_name/email/phone`.
|
||
|
||
**Pessoa Física**:
|
||
`pf_full_name`, `pf_cpf`, `pf_birth_date` (DATE), `pf_email`, `pf_mobile_phone`, `pf_alt_phone`, `pf_social_name` (nome social), `pf_id_document` (nº do documento de identidade — RG etc.), `pf_id_issuer` (órgão emissor).
|
||
|
||
**Pessoa Jurídica**:
|
||
`pj_cnpj`, `pj_company_name` (razão social), `pj_trade_name` (nome fantasia), `pj_state_registration` (Inscrição Estadual — ou "Isento"), `pj_municipal_registration` (Inscrição Municipal — ou "Isento"), `pj_opening_date` (DATE — data de abertura), `pj_legal_nature` (natureza jurídica), `pj_cnpj_situation` (situação cadastral, ex: "ATIVA"), `pj_main_activity` (atividade principal/CNAE descrição), `pj_rep_full_name`, `pj_rep_cpf`, `pj_rep_role` (cargo), `pj_rep_email`, `pj_rep_mobile_phone`, `pj_rep_authorization_ack` (BOOLEAN NOT NULL DEFAULT false — declaração "possuo poderes para representar a empresa").
|
||
|
||
**Verificação de documento** (formato apenas — nenhuma verificação externa real implementada; campos `*_externally_verified` deixados prontos para integração futura, ex: Receita Federal):
|
||
Por CPF: `cpf_format_valid` (BOOLEAN), `cpf_externally_verified` (BOOLEAN NOT NULL DEFAULT false), `cpf_verified_at`, `cpf_verification_provider`, `cpf_verification_reference`, `cpf_verification_status`, `cpf_verification_details` (JSONB).
|
||
Por CNPJ: campos espelhados com prefixo `cnpj_*`.
|
||
Hoje só `cpf_format_valid`/`cnpj_format_valid` são gravados (sempre `true` no submit — se o dígito verificador falhasse, o request já teria sido rejeitado com 400 antes).
|
||
|
||
**Portabilidade numérica** (migration `1785610000000`):
|
||
`portability_numbers JSONB NOT NULL DEFAULT '[]'` — array de strings (números avulsos, ex: `["(48) 3433-0582"]`).
|
||
`portability_ranges JSONB NOT NULL DEFAULT '[]'` — array de objetos `{first, last}` (faixas de números).
|
||
Só relevante quando `is_portability = true`; a validação no submit exige ao menos um item entre os dois arrays.
|
||
|
||
**Integração IXC**:
|
||
`ixc_client_id` TEXT — ID do cliente no IXC, preenchido pelo backoffice **no momento da validação** do cadastro (quando o cliente é efetivamente criado no ERP externo). Diferente de `ixc_contract_number` TEXT — nº do contrato no IXC, também preenchido manualmente pelo backoffice depois de formalizado. E diferente de `quotes.ixc_client_code` — referência opcional preenchida pelo vendedor ainda na oferta, antes de tudo isso.
|
||
|
||
### 1.1.1 Máquina de estados de `registration_status`
|
||
|
||
```
|
||
rascunho ──(cliente envia o formulário público)──▶ pendente_validacao
|
||
pendente_validacao ──(backoffice aprova)──▶ ativo
|
||
pendente_validacao ──(backoffice recusa, via PATCH registration_status)──▶ bloqueado
|
||
ativo ──▶ inativo (toggle "Inativar Cliente" no backoffice)
|
||
inativo ──▶ ativo (toggle "Reativar Cliente")
|
||
bloqueado/inativo ──▶ ativo (reativação manual)
|
||
```
|
||
Não há endpoint dedicado de transição — todas as mudanças passam por `PATCH /client-registrations/:id` com `registration_status` no corpo (rota genérica, ver 1.1.2). O front (`Clientes.jsx`) expõe um `<select>` de status livre para quem pode editar, mais um botão dedicado "Inativar/Reativar Cliente" que chama o mesmo PATCH.
|
||
|
||
Ao setar `registration_status = 'ativo'`: `activated_at` é gravado (só a primeira vez) e o
|
||
backend chama `syncActiveRegistrationToQuote(reg)` (ver seção 5) para replicar identificação na oferta.
|
||
|
||
### 1.1.2 Campos administrativamente editáveis vs. não editáveis
|
||
|
||
`PATCH /client-registrations/:id` (exclusivo de quem tem feature `clientes_todos` nível
|
||
`edit`, ou super_admin) aceita uma lista fechada de campos: `registration_status`,
|
||
`internal_notes`, `ixc_contract_number`, `ixc_client_id`, todo o bloco de endereço final, os
|
||
três blocos de contato, todos os campos `pf_*` **exceto `pf_cpf`**, todos os campos `pj_*`
|
||
**exceto `pj_cnpj`** e **exceto `pj_rep_cpf`** (implícito — não está na lista), mais
|
||
`portability_numbers`/`portability_ranges` (tratados à parte, serializados como JSON string).
|
||
|
||
**CPF e CNPJ (`pf_cpf`, `pj_cnpj`, `pj_rep_cpf`) são propositalmente insensíveis a essa rota** —
|
||
trocar o documento em si nunca é uma edição administrativa direta, exigiria um novo fluxo de
|
||
verificação. No Eden, replicar essa trava explicitamente (não expor esses 3 campos em nenhuma
|
||
tela/endpoint de edição administrativa do cadastro).
|
||
|
||
### 1.2 Tabela `client_registration_partners` (sócios/QSA)
|
||
|
||
Sócios/administradores de uma PJ, obtidos via consulta pública de CNPJ (cnpj.ws/BrasilAPI) no
|
||
momento do preenchimento do formulário — guardados à parte pensando na futura assinatura
|
||
eletrônica (é preciso saber quem são os possíveis signatários).
|
||
|
||
| Campo | Tipo | Observação |
|
||
|---|---|---|
|
||
| `id` | UUID PK | |
|
||
| `client_registration_id` | UUID, FK `client_registrations(id) ON DELETE CASCADE` | |
|
||
| `name` | TEXT NOT NULL | Nome do sócio, como veio da Receita. |
|
||
| `document` | TEXT | CPF/CNPJ do sócio **como retornado pela consulta pública** — já vem parcialmente mascarado pela própria fonte (Receita), não é confiável para uso jurídico. |
|
||
| `qualification` | TEXT | Papel do sócio (ex: "Sócio-Administrador"). |
|
||
| `source` | TEXT NOT NULL DEFAULT `'brasilapi'` | De onde veio o dado (auditoria). |
|
||
| `cpf` | TEXT | CPF **completo**, preenchido manualmente pelo próprio sócio/cliente no formulário — só existe se `will_sign = true` (campo adicionado na migration `1785598045226`). |
|
||
| `will_sign` | BOOLEAN NOT NULL DEFAULT false | Marca se este sócio vai assinar o contrato (em vez do representante legal genérico). |
|
||
| `signature_email` | TEXT | E-mail para onde a futura assinatura eletrônica será enviada. |
|
||
|
||
Regra de negócio importante: **se ao menos um sócio tem `will_sign = true`, ele substitui
|
||
completamente o bloco "Representante da Empresa"** do formulário — o primeiro sócio marcado
|
||
(`signerPartners[0]`) tem seus dados copiados para `client_registrations.pj_rep_full_name`
|
||
(← `name`), `pj_rep_cpf` (← `cpf`), `pj_rep_role` (← `qualification`), `pj_rep_email` (←
|
||
`signature_email`); `pj_rep_mobile_phone` fica `null` nesse caso. Se nenhum sócio assina, os
|
||
campos `pj_rep_*` vêm do bloco "Representante" preenchido manualmente.
|
||
|
||
### 1.3 Tabela `client_registration_attachments`
|
||
|
||
| Campo | Tipo | Observação |
|
||
|---|---|---|
|
||
| `id` | UUID PK | |
|
||
| `client_registration_id` | UUID FK CASCADE | |
|
||
| `category` | TEXT NOT NULL | No formulário público, só 3 valores fixos: `fatura`, `contrato_social`, `outros` (era um CHECK constraint, removido na migration `1785593404285` para permitir uploads internos com rótulo livre, ex: "RG do responsável"). |
|
||
| `original_filename`, `mime_type`, `size_bytes` | | Metadados do arquivo. |
|
||
| `s3_key` | TEXT NOT NULL | Chave no bucket de objetos (ver seção 8). |
|
||
| `uploaded_at` | TIMESTAMPTZ DEFAULT now() | |
|
||
| `uploaded_by_id` | UUID FK `users(id)`, nullable | **NULL = enviado pelo próprio cliente** via formulário público; preenchido = enviado internamente por admin/backoffice. |
|
||
| `purpose` | TEXT NOT NULL DEFAULT `'internal'`, CHECK IN (`'signature'`, `'internal'`) | `'signature'` = documento candidato a entrar num envelope de assinatura eletrônica; `'internal'` = só uso interno da equipe, nunca visível ao vendedor dono da oferta. Anexos enviados pelo cliente (`uploaded_by_id IS NULL`) sempre aparecem visíveis a quem já acessa o cadastro, independentemente de `purpose`. |
|
||
|
||
Limite de tamanho: 10MB por arquivo no submit público (multer, `files: 12` no máximo); 25MB no
|
||
upload interno pelo backoffice (comentário no código: PDFs pós-aprovação gerados via
|
||
html2canvas+jsPDF podem passar de 10MB).
|
||
|
||
### 1.4 Tabela `reseller_registrations`
|
||
|
||
Espelha a estrutura de `client_registrations` (link público por token, revisão interna,
|
||
status) mas é uma tabela própria — revenda não é cliente. Módulo "Programa de Canais".
|
||
|
||
| Campo | Tipo | Observação |
|
||
|---|---|---|
|
||
| `id` | UUID PK | |
|
||
| `token` | TEXT UNIQUE NOT NULL | Mesma lógica de token do cadastro de cliente. |
|
||
| `created_by_id` | UUID FK `users(id)` | Quem gerou o convite. |
|
||
| `reseller_type` | ENUM `reseller_registration_type` (`finder`, `recorrente`) NOT NULL | Definido na criação do convite; determina que contrato(s) serão gerados depois (ver seção 6). |
|
||
| `email` | TEXT NOT NULL | E-mail do convidado — único dado pedido ao criar o convite (junto com o tipo). |
|
||
| `registration_status` | ENUM `reseller_registration_status` (mesmos 5 valores de client) NOT NULL DEFAULT `rascunho` | Mesma máquina de estados. |
|
||
| `submitted_at`, `activated_at`, `internal_notes` | | |
|
||
| `company_name`, `trade_name`, `cnpj`, `state_registration`, `municipal_registration` | | Revenda é sempre PJ — não existe variante PF. |
|
||
| `address_zip/street/number/complement/neighborhood/city/state` | | Sem `address_country` (implicitamente Brasil). |
|
||
| `phone` | TEXT | Telefone único (não há principal/financeiro/técnico como em cliente). |
|
||
| `rep_full_name`, `rep_cpf`, `rep_role`, `rep_email`, `rep_mobile_phone`, `rep_authorization_ack` | | Mesmo padrão de `pj_rep_*` de client_registrations — substituído pelo primeiro sócio marcado como assinante, se houver. |
|
||
| `cnpj_format_valid` | BOOLEAN | |
|
||
| `reseller_id` | UUID FK `resellers(id)`, nullable | Setado **só na aprovação** — vincula ao cadastro operacional (tabela `resellers`), que passa a poder ser usado em Orçamentos. |
|
||
|
||
Não existe `cnpj_address_*` (endereço "oficial" separado) nesta tabela na migration original —
|
||
mas o formulário público (`ResellerRegistration.jsx`) tem a mesma UX de confirmação de
|
||
endereço do CNPJ; **o endereço final é sobrescrito diretamente em `address_*`** quando
|
||
confirmado (não há colunas próprias para armazenar o par lado a lado como em client — no
|
||
Eden, avaliar se replicar a assimetria ou unificar).
|
||
|
||
### 1.5 Tabela `reseller_registration_partners`
|
||
|
||
Idêntica em campos a `client_registration_partners`, FK para `reseller_registration_id`.
|
||
|
||
### 1.6 Tabela `reseller_registration_attachments`
|
||
|
||
Igual a `client_registration_attachments`, mas sem CHECK fixo de `category` (livre desde o
|
||
início) — `purpose` já nasce com CHECK `('signature', 'internal')` e default `'internal'`.
|
||
Usada, na prática, só para os PDFs de contrato/termo gerados no backoffice após aprovação
|
||
(sempre `purpose = 'signature'`); o cadastro público de revenda não tem upload de anexos.
|
||
|
||
### 1.7 Tabela `resellers`
|
||
|
||
Cadastro operacional da revenda (já existia antes do módulo de aprovação — usado em Orçamentos
|
||
e no vínculo de usuários).
|
||
|
||
| Campo | Tipo |
|
||
|---|---|
|
||
| `id` | UUID PK |
|
||
| `created_by_id` | UUID FK `users(id)` |
|
||
| `name` | TEXT NOT NULL |
|
||
| `document` | TEXT (CPF/CNPJ, opcional) |
|
||
| `contact_email` | TEXT |
|
||
| `active` | BOOLEAN NOT NULL DEFAULT true |
|
||
|
||
Referenciada por: `quotes.reseller_id` (NOT NULL — toda oferta pertence a uma revenda),
|
||
`users.reseller_id` (nullable — usuário vinculado a uma revenda, escopa o que ele vê),
|
||
`client_registrations.reseller_id`, `reseller_registrations.reseller_id`.
|
||
|
||
---
|
||
|
||
## 2. Fluxo público de cadastro de cliente
|
||
|
||
### 2.1 Como o link nasce
|
||
|
||
Toda oferta (`quotes`), ao ser fechada (`deal_status = 'fechado'`), dispara a criação de um
|
||
`client_registrations` via `POST /quotes/:id/client-registration`:
|
||
- Corpo: `{ is_portability: boolean, person_type: 'pf'|'pj' }` (vendedor escolhe antes).
|
||
- Servidor gera `token` (48 hex chars), insere a linha (`registration_status` default
|
||
`'rascunho'`), seta `quotes.client_registration_id` e `quotes.deal_status = 'fechado'` na
|
||
mesma transação. **Só pode existir um cadastro por oferta** (`quote_id` UNIQUE — 409 se já
|
||
existir um).
|
||
- Resposta inclui `public_link = "${baseUrl}/cadastro-cliente?token=${token}"`.
|
||
- **Atalho "reaproveitar cadastro ativo"** (`reuse_from_registration_id` no corpo): em vez de
|
||
nascer em branco, copia todos os campos de identidade/endereço/contato (lista fechada
|
||
`REUSE_COPY_FIELDS`) de um `client_registrations` **já `ativo`** escolhido pelo vendedor, e
|
||
o novo registro já nasce `ativo` (`submitted_at = now()`, `activated_at = now()`), **sem
|
||
token público a ser usado, sem e-mail, sem validação manual**. Regra de acesso: quem não é
|
||
admin/backoffice só pode reaproveitar cadastro que pertença à própria revenda
|
||
(`sourceReg.reseller_id === quote.reseller_id`). Sócios (`client_registration_partners`) são
|
||
copiados junto via `INSERT ... SELECT`.
|
||
|
||
O e-mail com o link pode ser (re)enviado a qualquer momento via `POST
|
||
/client-registrations/:id/send-email` (corpo `{ email }`) — dispara e-mail HTML com logo e
|
||
botão "Finalizar cadastro" (assunto fixo "Cadastro Handix").
|
||
|
||
### 2.2 Consulta do contexto (`GET /public-client-registration/:token`)
|
||
|
||
Sem autenticação. Retorna: `quote_number`, `reseller_name`, `is_portability`, `person_type`,
|
||
`registration_status`, `already_submitted` (= `registration_status !== 'rascunho'`). 404
|
||
genérico "Link inválido ou expirado" se o token não existir (não distingue "não existe" de
|
||
"expirado" — não há expiração real de token).
|
||
|
||
### 2.3 Preenchimento do formulário (front `ClientRegistration.jsx`)
|
||
|
||
Layout: seções (`Section`) — Identificação (PF) ou Dados da Empresa (PJ), Representante da
|
||
Empresa (PJ, condicional), Endereço, Contatos, Portabilidade (condicional), Anexos, botão
|
||
Enviar. Todo o texto/rótulo em português, marca Handix (logo fixo `/logo-handix.png`).
|
||
|
||
**Consulta automática de CEP** (`src/lib/cep.js`): ao digitar 8 dígitos no campo CEP, chama
|
||
ViaCEP (`https://viacep.com.br/ws/{cep}/json/`) direto do navegador (sem passar pelo backend);
|
||
se falhar, cai para BrasilAPI (`https://brasilapi.com.br/api/cep/v2/{cep}`). Preenche
|
||
logradouro/bairro/cidade/estado automaticamente; número/complemento continuam manuais.
|
||
|
||
**Consulta automática de CNPJ** (`src/lib/cnpjLookup.js`, só PJ): ao completar 14 dígitos,
|
||
chama cnpj.ws (`https://publica.cnpj.ws/cnpj/{cnpj}`) como principal (traz Inscrição Estadual,
|
||
que a BrasilAPI não retorna), cai para BrasilAPI
|
||
(`https://brasilapi.com.br/api/cnpj/v1/{cnpj}`) em caso de falha. Ambas gratuitas, CORS
|
||
liberado, chamadas direto do navegador. Preenche: razão social, nome fantasia, data de
|
||
abertura, natureza jurídica, situação cadastral, atividade principal, inscrição estadual
|
||
(se veio), endereço completo (guardado à parte em `cnpjAddress`, não aplicado direto ao
|
||
formulário) e lista de sócios (QSA) com `cpf`/`will_sign`/`signature_email` inicializados
|
||
vazios/false.
|
||
|
||
**Confirmação de endereço do CNPJ**: se a consulta trouxe endereço, o formulário mostra
|
||
"O endereço encontrado está correto?" com dois botões (Sim/Não).
|
||
- Sim → endereço final = cópia do endereço do CNPJ (país forçado "Brasil").
|
||
- Não → endereço final some, cliente preenche do zero (com CEP-lookup disponível de novo).
|
||
Campo obrigatório antes de enviar (`localErrors.push("Confirme se o endereço encontrado está
|
||
correto.")` se `cnpjAddress` existe e `addressConfirmed === null`).
|
||
|
||
**Inscrição Estadual/Municipal**: se a consulta de CNPJ já trouxe IE, o campo aparece
|
||
preenchido direto (sem perguntar). Se não trouxe, pergunta "Possui Inscrição Estadual?"
|
||
(Sim/Não) — "Não" preenche automaticamente `"Isento"`. Inscrição Municipal **nunca** vem de
|
||
consulta automática — a pergunta Sim/Não sempre aparece.
|
||
|
||
**Sócios (QSA) e assinatura**: se a consulta trouxe sócios, aparece um bloco "Sócios/
|
||
administradores identificados (Receita Federal)" listando cada um com checkbox "Vai assinar o
|
||
contrato"; ao marcar, abrem campos CPF completo + e-mail para assinatura eletrônica. Se **ao
|
||
menos um** sócio está marcado, o bloco "Representante da Empresa" inteiro desaparece do
|
||
formulário (não é mais preenchido pelo cliente).
|
||
|
||
**Representante da Empresa** (só aparece se PJ e nenhum sócio marcado para assinar):
|
||
nome completo*, CPF*, cargo/função, e-mail, telefone celular, checkbox obrigatório "Declaro que
|
||
possuo poderes para representar esta empresa".
|
||
|
||
**Contatos**: PJ pede os três blocos (principal, financeiro*, técnico*) — PF só pede o
|
||
financeiro* (nome/e-mail obrigatórios, telefone opcional).
|
||
|
||
**Portabilidade — Números a Portar** (só se `context.is_portability`): lista dinâmica de
|
||
números avulsos (input + botão "Adicionar número", cada linha removível) e, opcionalmente,
|
||
"Range (faixa de números)" — checkbox que revela pares `{first, last}` também dinâmicos.
|
||
Validação: pelo menos um número OU um range válido é obrigatório.
|
||
|
||
**Anexos**:
|
||
- "Cópia da fatura" — obrigatório **somente se** `is_portability = true`.
|
||
- "Contrato social ou última alteração consolidada" — só aparece se PJ; obrigatório **somente
|
||
se** a consulta de CNPJ não retornou sócios (`partners.length === 0`) — ter os sócios da
|
||
Receita já supre essa necessidade.
|
||
- "Documentos diversos" (`outros`) — múltiplos arquivos, sempre opcional.
|
||
- Cada slot mostra nome do arquivo selecionado com botão de remover (X).
|
||
|
||
**Nenhuma captura de assinatura (desenho/rubrica) ocorre neste formulário.** O campo
|
||
`signature_email` só grava um e-mail de contato para onde a assinatura eletrônica será
|
||
enviada depois, num módulo separado (envelopes de assinatura, fora do escopo deste
|
||
documento — mencionado apenas como "Fase 3" nos comentários do código).
|
||
|
||
### 2.4 Envio (`POST /public-client-registration/:token`)
|
||
|
||
Sem autenticação (rate-limited — ver seção 9). Multipart/form-data (`multer`, campos de
|
||
arquivo: `fatura` maxCount 1, `contrato_social` maxCount 1, `outros` maxCount 10; limite
|
||
10MB/arquivo).
|
||
|
||
Pré-condição: `registration_status === 'rascunho'` — senão 409 "Este cadastro já foi enviado e
|
||
não pode ser alterado" (**o formulário não pode ser reenviado/editado depois do primeiro
|
||
envio**, nem pelo próprio cliente nem reabrindo o link).
|
||
|
||
**Validação — sempre revalidada no backend, nunca confia no navegador** (comentário explícito
|
||
no código):
|
||
- `person_type` deve ser `'pf'` ou `'pj'`.
|
||
- PF: nome completo obrigatório; CPF válido (dígito verificador, `isValidCPF`); e-mail
|
||
obrigatório; celular obrigatório.
|
||
- PJ: razão social obrigatória; CNPJ válido (`isValidCNPJ`); se há sócio(s) marcado(s) para
|
||
assinar, CPF de cada um validado; senão, nome do representante obrigatório, CPF do
|
||
representante válido, e o ack de poderes de representação (`pj_rep_authorization_ack`)
|
||
obrigatoriamente `true`.
|
||
- Contato financeiro (nome+email) sempre obrigatório; contato técnico (nome+email)
|
||
obrigatório só em PJ.
|
||
- Portabilidade: se `is_portability`, exige ao menos um número OU range preenchido.
|
||
- Anexos: fatura obrigatória se `is_portability`; contrato social obrigatório se PJ **e**
|
||
nenhum sócio veio da consulta de CNPJ.
|
||
- Erros retornam **400** com `{ error, field_errors: [...], missing_attachments: [...] }` —
|
||
o front usa essas duas listas para renderizar um banner vermelho com bullets.
|
||
|
||
**Persistência (transação única)**:
|
||
1. `UPDATE client_registrations` com todos os campos capturados + `registration_status =
|
||
'pendente_validacao'` + `submitted_at = now()`.
|
||
2. Cada arquivo recebido é enviado ao storage de objetos (ver seção 8) sob a chave
|
||
`client-registrations/{folder}/{category}/{uuid}-{nome-original}` e uma linha é inserida em
|
||
`client_registration_attachments` (sem `purpose`/`uploaded_by_id` — default `internal`/NULL,
|
||
já que veio do próprio cliente).
|
||
3. Se PJ e houve sócios (vindos da consulta OU não — na prática só se vieram da consulta, já
|
||
que o form só monta esse array quando `partners.length > 0`), cada um é inserido em
|
||
`client_registration_partners` com o CPF completo (não mascarado) e o flag `will_sign`
|
||
digitados pelo cliente.
|
||
4. `quotes.client_name/client_company/client_email/client_phone` são atualizados a partir dos
|
||
dados recém-capturados (mas **não** o status do cadastro na oferta — isso só acontece
|
||
quando o backoffice aprova, via `syncActiveRegistrationToQuote`).
|
||
|
||
Resposta: `{ ok: true }`. Front mostra tela de sucesso ("Cadastro recebido... já está em
|
||
validação pela nossa equipe").
|
||
|
||
### 2.5 Revisão/aprovação pelo backoffice (`Clientes.jsx` → `ClienteDetail`)
|
||
|
||
Tela interna (autenticada, feature `clientes_todos`) mostra todos os campos capturados,
|
||
editáveis (exceto CPF/CNPJ, sempre somente-leitura ali) para quem tem `edit`. Ações:
|
||
- Trocar `registration_status` via `<select>` livre (não há botões "Aprovar"/"Recusar"
|
||
dedicados como no fluxo de revenda — é um PATCH genérico).
|
||
- Preencher `ixc_client_id` e `ixc_contract_number` manualmente.
|
||
- Editar/completar números de portabilidade.
|
||
- Ver e corrigir CPF completo de cada sócio + marcar quem assina + e-mail de assinatura
|
||
(`PATCH /client-registrations/:id/partners/:partnerId`, exclusivo `requireAdminOrBackoffice`).
|
||
- Ver/baixar anexos enviados pelo cliente; anexos internos (`purpose='internal'` E
|
||
`uploaded_by_id` preenchido) só aparecem para admin/backoffice, nunca para o dono da oferta.
|
||
- Fazer upload de novo anexo (categoria livre + finalidade signature/internal).
|
||
- Excluir anexo — **exclusivo super_admin** (irreversível, remove do storage e do banco).
|
||
- Botão dedicado "Inativar/Reativar Cliente" (atalho de PATCH `registration_status`).
|
||
- Se há anexos `purpose='signature'`, botão "Criar Envelope de Assinatura" (módulo de
|
||
assinatura eletrônica, fora do escopo aqui).
|
||
|
||
### 2.6 Cadastro "direto" (sem oferta)
|
||
|
||
`POST /client-registrations/direct` (autenticado) cria um `client_registrations` com `quote_id
|
||
= NULL`, usando o **mesmo mecanismo de token/formulário público**. Corpo: `{ is_portability,
|
||
person_type, reseller_id? }`. Quem não é admin/backoffice só cria para a própria revenda
|
||
(`req.user.reseller_id`); admin/backoffice pode opcionalmente indicar `reseller_id`. Usado
|
||
hoje pela tela de "Cessão" (fora do escopo) e pelo botão "Novo" em Clientes — abre uma aba nova
|
||
já no `public_link` retornado, para o próprio operador (ou o cliente, se repassado) preencher.
|
||
|
||
---
|
||
|
||
## 3. Pessoa Física vs Pessoa Jurídica — diferenças
|
||
|
||
| Aspecto | PF | PJ |
|
||
|---|---|---|
|
||
| Documento principal | CPF (`pf_cpf`) | CNPJ (`pj_cnpj`) |
|
||
| Seção de identificação | Nome, CPF, nascimento, e-mail, celular, tel. alternativo, nome social, documento de identidade + emissor | Razão social, nome fantasia, IE, IM, data abertura, natureza jurídica, situação cadastral, atividade principal |
|
||
| Consulta automática | Nenhuma (não existe "lookup de CPF" público equivalente) | Consulta de CNPJ (cnpj.ws/BrasilAPI) preenche empresa, endereço e sócios |
|
||
| Representante/assinante | Não existe — o próprio titular assina | Obrigatório: representante legal OU sócio marcado como assinante |
|
||
| Sócios (QSA) | N/A | Tabela `client_registration_partners`, populável pela consulta de CNPJ |
|
||
| Confirmação de endereço da Receita | N/A | Fluxo "endereço encontrado está correto?" |
|
||
| Contatos exigidos | Só financeiro (nome+e-mail) | Financeiro **e** técnico (nome+e-mail) obrigatórios; principal opcional |
|
||
| Contrato social (anexo) | N/A | Obrigatório **se** a consulta de CNPJ não trouxe sócios |
|
||
| Inscrição Estadual/Municipal | N/A | Perguntadas explicitamente (Sim/Não) quando a consulta não trouxe o dado |
|
||
|
||
---
|
||
|
||
## 4. Sócios/Partners
|
||
|
||
- Exigidos **apenas em PJ**, e só aparecem no formulário se a consulta pública de CNPJ
|
||
(cnpj.ws → fallback BrasilAPI) retornar o quadro societário (QSA). Não há como o cliente
|
||
adicionar um sócio manualmente que não veio da consulta.
|
||
- Campos por sócio: `name`, `document` (mascarado, vindo da fonte), `qualification`
|
||
(ex: "Sócio-Administrador"), `source` (`'brasilapi'` fixo hoje, mesmo quando veio do
|
||
cnpj.ws — nome do campo não foi atualizado), `cpf` (completo, digitado pelo cliente **só se**
|
||
for assinar), `will_sign` (bool), `signature_email`.
|
||
- Assinatura por sócio: cada sócio tem um checkbox "Vai assinar o contrato". Marcando, exige
|
||
CPF completo (validado no backend) e e-mail de assinatura. **O primeiro sócio marcado
|
||
substitui inteiramente o representante da empresa** — os campos `pj_rep_*` no cadastro
|
||
recebem os dados desse sócio (name→pj_rep_full_name, cpf→pj_rep_cpf, qualification→
|
||
pj_rep_role, signature_email→pj_rep_email); se **nenhum** sócio é marcado, o bloco
|
||
"Representante da Empresa" é obrigatório e preenchido do zero.
|
||
- Pós-submissão, backoffice pode corrigir CPF/will_sign/signature_email de cada sócio
|
||
individualmente (`PATCH /client-registrations/:id/partners/:partnerId`), útil quando o
|
||
cliente esqueceu de marcar/preencher no formulário.
|
||
- Mesmíssimo modelo e mesma UI (componentizada e duplicada) se aplicam a
|
||
`reseller_registration_partners` no cadastro de revenda — o representante legal da revenda
|
||
segue a mesma regra de substituição pelo primeiro sócio assinante.
|
||
|
||
---
|
||
|
||
## 5. Sincronização com IXC / com a Oferta
|
||
|
||
**Não existe integração automática/API com o IXC** neste código — toda referência a "IXC" é
|
||
um campo de texto livre preenchido manualmente pelo backoffice depois de criar/formalizar o
|
||
cliente no ERP externo (fora deste sistema):
|
||
- `client_registrations.ixc_client_id` — preenchido na validação do cadastro.
|
||
- `client_registrations.ixc_contract_number` — preenchido depois, quando o contrato IXC existe.
|
||
- `quotes.ixc_client_code` — referência opcional preenchida pelo vendedor, ainda na oferta,
|
||
antes de tudo (pode ou não bater com `ixc_client_id`).
|
||
|
||
O que existe é `syncActiveRegistrationToQuote(reg)` (`server/src/lib/clientRegistrationSync.js`),
|
||
chamada sempre que:
|
||
(a) `PATCH /client-registrations/:id` deixa o registro em `registration_status = 'ativo'`;
|
||
(b) uma oferta reaproveita um cadastro já ativo (`reuse_from_registration_id`).
|
||
|
||
Ela **não faz nada se `reg.registration_status !== 'ativo'`**. Quando ativo, roda:
|
||
```sql
|
||
UPDATE quotes SET
|
||
client_company = COALESCE($1, client_company), -- pj_trade_name || pj_company_name, ou pf_full_name
|
||
client_document = COALESCE($2, client_document), -- pj_cnpj ou pf_cpf
|
||
client_code = COALESCE($3, client_code), -- client_registrations.client_code
|
||
ixc_client_code = COALESCE($4, ixc_client_code) -- client_registrations.ixc_client_id
|
||
WHERE id = quote_id
|
||
```
|
||
Ou seja: replica identificação do cliente **para a oferta** (não o contrário), só sobrescreve
|
||
campos que estavam vazios (`COALESCE`), e é oportunista — roda a cada PATCH que resulte em
|
||
`ativo`, não só na primeira ativação.
|
||
|
||
No Eden, isso deve ser modelado como um efeito colateral do PATCH de status (ou um evento/hook
|
||
"registration activated"), não como uma integração externa de verdade — não há chamada de rede
|
||
nenhuma aqui, é só um UPDATE no mesmo banco.
|
||
|
||
---
|
||
|
||
## 6. Fluxo de cadastro/aprovação de Revenda (Programa de Canais)
|
||
|
||
Estrutura quase idêntica à de cliente, mas iniciada de forma diferente: **não** nasce de uma
|
||
oferta fechada — nasce de um convite manual.
|
||
|
||
### 6.1 Criação do convite
|
||
|
||
`POST /reseller-registrations` (autenticado, feature `revendas_cadastro` nível `edit`). Corpo
|
||
mínimo: `{ email, reseller_type }` onde `reseller_type ∈ {'finder', 'recorrente'}`. Gera token,
|
||
insere linha `rascunho`, retorna `{ registration, public_link }`.
|
||
- **Finder**: revenda pontual/indicação — gera só "Contrato Canal Finder".
|
||
- **Recorrente**: parceiro recorrente — gera **sempre os dois documentos juntos**: "Contrato
|
||
Canal Recorrente" + "Termo de Adesão Recorrente".
|
||
|
||
E-mail de convite (`POST /reseller-registrations/:id/send-email`) — assunto "Cadastro Handix",
|
||
texto "Você foi convidado(a) a fazer parte do Programa de Canais Handix", botão "Completar
|
||
cadastro".
|
||
|
||
### 6.2 Preenchimento público (`GET`/`POST /public-reseller-registration/:token`)
|
||
|
||
Mesmo contrato de contexto (`already_submitted`, 404 genérico para token inválido, 409 se já
|
||
enviado). Formulário (`ResellerRegistration.jsx`) é um subconjunto do de cliente-PJ: CNPJ
|
||
(com mesma consulta cnpj.ws→BrasilAPI, mesmo preenchimento automático de empresa/endereço/
|
||
sócios), razão social, nome fantasia, telefone (único, não triplo como em cliente), IE/IM com
|
||
mesma pergunta condicional Sim/Não, bloco Representante (mesma regra: sumido se houver sócio
|
||
assinante), confirmação de endereço do CNPJ, endereço. **Não tem** seção de contatos
|
||
(financeiro/técnico), **não tem** anexos, **não tem** portabilidade (revenda não porta
|
||
número).
|
||
|
||
Validação no backend igual em espírito à de cliente: razão social, CNPJ válido, telefone,
|
||
(sócio assinante com CPF válido) OU (representante com nome+CPF válido+ack de poderes).
|
||
|
||
Persistência: `UPDATE reseller_registrations` com todos os campos + `registration_status =
|
||
'pendente_validacao'` + `submitted_at = now()`; sócios inseridos em
|
||
`reseller_registration_partners`. Sem uploads (tabela de anexos existe no schema desde o
|
||
início, mas só passa a ser usada depois da aprovação, pelo backoffice).
|
||
|
||
### 6.3 Aprovação (`ResellerRegistrations.jsx`, feature `revendas_cadastro`)
|
||
|
||
Diferente do cliente, aqui **há botões de transição dedicados** (não só um select genérico):
|
||
- Em `pendente_validacao`: **Aprovar** (→ `ativo`) ou **Bloquear** (→ `bloqueado`).
|
||
- Em `ativo`: **Inativar** (→ `inativo`).
|
||
- Em `bloqueado`/`inativo`: **Reativar** (→ `ativo`).
|
||
- Em `rascunho`: **Reenviar link** (reenvia o e-mail de convite).
|
||
|
||
Toda transição passa pelo mesmo `PATCH /reseller-registrations/:id` genérico (corpo
|
||
`{registration_status: ...}` — o front só decide qual botão mostrar).
|
||
|
||
**Efeito colateral crítico da transição de status** — `syncOperationalReseller(client,
|
||
registration, userId)`, chamado sempre que o PATCH inclui `registration_status`:
|
||
- Se o novo status é `'ativo'`:
|
||
- Se `registration.reseller_id` já existe → `UPDATE resellers SET name=company_name,
|
||
document=cnpj, contact_email=email, active=true`.
|
||
- Senão → `INSERT INTO resellers (...) VALUES (...)`, e grava o novo id de volta em
|
||
`reseller_registrations.reseller_id`.
|
||
- Se o novo status **não** é `'ativo'` (bloqueado/inativo) e `reseller_id` existe → `UPDATE
|
||
resellers SET active = false`. **Nunca exclui** a linha de `resellers` (pode já estar
|
||
referenciada em orçamentos).
|
||
|
||
Ou seja: **a aprovação de um `reseller_registrations` cria/reativa automaticamente a linha
|
||
operacional em `resellers`** (a mesma tabela usada em Orçamentos/usuários), e desativá-lo
|
||
desativa a revenda operacional também. Isso é o ponto de junção entre o módulo de aprovação
|
||
(Fase 1, cadastro) e o módulo operacional pré-existente.
|
||
|
||
### 6.4 Geração de documentos e assinatura (pós-aprovação)
|
||
|
||
Quando `registration_status === 'ativo'`, o backoffice pode clicar "Gerar Contrato" (finder) ou
|
||
"Gerar Contrato + Termo" (recorrente). Isso chama um pipeline de geração de PDF via template
|
||
publicado (`base44.documents.generate({ template_key, reseller_registration_id })` →
|
||
Chromium/HTML no backend — módulo de Templates de Documento, fora deste escopo), baixa o PDF
|
||
gerado e o arquiva via `POST /reseller-registrations/:id/attachments` (sempre `purpose:
|
||
'signature'`). Documentos arquivados então alimentam "Criar Envelope de Assinatura"
|
||
(mesmo componente reaproveitado do fluxo de cliente, módulo de assinatura eletrônica — fora
|
||
do escopo).
|
||
|
||
---
|
||
|
||
## 7. Gestão de revendas existentes (`resellers.routes.js` / `Resellers.jsx`)
|
||
|
||
CRUD simples e direto, **exclusivo de `requireAdmin`** (roles `admin`/`super_admin` — nota:
|
||
mais permissivo que o cadastro/aprovação, que usa a feature `revendas_cadastro`):
|
||
- `GET /resellers` — lista com filtros por `id`/`active`/`name`, ordenável por
|
||
`created_date`/`updated_date`/`name` (`auth` simples, qualquer usuário autenticado pode
|
||
listar — usado por outras telas, ex: seletor de revenda em Orçamentos).
|
||
- `POST /resellers` — cria (`name` obrigatório; `document`, `contact_email`, `active` default
|
||
`true`).
|
||
- `PATCH /resellers/:id` — atualiza campos (todos `COALESCE`, ou seja, PATCH parcial natural).
|
||
- `DELETE /resellers/:id` — hard delete, sem checagem de vínculo no backend (a tela
|
||
`Resellers.jsx` bloqueia no front se houver usuários vinculados, mas isso é só UX — a API
|
||
não impede).
|
||
|
||
**Vínculo com quotes**: `quotes.reseller_id` é `NOT NULL` — toda oferta pertence
|
||
obrigatoriamente a uma revenda (a do vendedor que a criou, tipicamente `req.user.reseller_id`,
|
||
fora do escopo deste documento mas citado para contexto).
|
||
|
||
**Vínculo com usuários**: `users.reseller_id` (nullable) — a tela `Resellers.jsx` permite
|
||
atribuir/reatribuir cada usuário do sistema a uma revenda via `<select>` inline (`PATCH
|
||
/users/:id { reseller_id }`), inclusive listando "Usuários sem revenda" à parte.
|
||
|
||
### 7.1 `Clientes.jsx` vs `ClientesRevenda.jsx` — duas visões distintas
|
||
|
||
Confirmado pela leitura do código: são páginas com propósito e permissão **diferentes**, não
|
||
duplicatas:
|
||
|
||
**`Clientes.jsx`** (feature `clientes_todos`) — visão **backoffice/admin completa**:
|
||
- Lista todos os `client_registrations` que o usuário pode ver (escopo: quem tem
|
||
`clientes_todos` view vê tudo; senão só os que **ele mesmo criou**, `created_by_id =
|
||
user.id` — não é escopado por revenda aqui, e sim por autoria).
|
||
- Detalhe completo, editável, com todos os campos, anexos, sócios, ações administrativas
|
||
(mudar status, gerar envelope de assinatura, upload/exclusão de anexo, etc.) — é a tela
|
||
operacional "de verdade".
|
||
- Rota separada `GET /client-registrations/:id` (não a `/directory`).
|
||
|
||
**`ClientesRevenda.jsx`** (feature `clientes_revenda`) — visão **mínima, somente-leitura, por
|
||
revenda**:
|
||
- Consome `GET /client-registrations/directory`, que devolve **só 4 campos**: `id`,
|
||
`client_code`, `name` (calculado: `pf_full_name` ou `pj_trade_name`/`pj_company_name`),
|
||
`registration_status`. Nenhum dado sensível (sem CPF/CNPJ, sem contatos, sem endereço).
|
||
- Escopo no backend: se o usuário é admin/backoffice, vê tudo; senão, filtrado por
|
||
`cr.reseller_id = req.user.reseller_id` — **por revenda do usuário**, diferente do escopo
|
||
por autoria de `Clientes.jsx`.
|
||
- Sem detalhe, sem edição — é literalmente um "diretório" (nome + código + status, buscável
|
||
por texto).
|
||
- Rota é `GET /client-registrations/directory` (fixa antes de `/:id` na definição de rotas,
|
||
para não ser interpretada como um id).
|
||
|
||
No Eden, replicar essa distinção: a tela de revenda existe para o **vendedor da revenda** (que
|
||
não tem acesso ao cadastro completo de clientes de outras revendas nem pode editar), e a tela
|
||
"Clientes" completa é exclusiva do backoffice/admin.
|
||
|
||
---
|
||
|
||
## 8. Armazenamento de anexos (`clientStorage.js` + `s3.js`)
|
||
|
||
**Storage**: objetos ficam em bucket S3 (ou compatível — ver `server/src/lib/s3.js`, não
|
||
detalhado aqui, mas a API usada é `uploadObject(key, buffer, mimetype)`, `getObjectStream(key)`,
|
||
`deleteObject(key)` — abstração simples de put/get/delete por chave).
|
||
|
||
**Nomeação/organização de pastas** (`clientDocFolder`):
|
||
```js
|
||
function clientDocFolder({ person_type, pf_cpf, pj_cnpj, id }) {
|
||
const doc = person_type === 'pj' ? pj_cnpj : pf_cpf;
|
||
return doc || id;
|
||
}
|
||
```
|
||
A pasta do cliente é o **CPF (PF) ou CNPJ (PJ)**, não um UUID — agrupa todos os arquivos do
|
||
mesmo titular numa única pasta, mais fácil de localizar/auditar manualmente no bucket. Só cai
|
||
de volta para o `id` do registro no caso raro de upload interno **antes** do cliente ter
|
||
preenchido CPF/CNPJ (registro ainda em `rascunho`).
|
||
|
||
Chaves completas:
|
||
- Upload do cliente no formulário público: `client-registrations/{folder}/{category}/{uuid}-{nome-original}` onde `category ∈ {fatura, contrato_social, outros}`.
|
||
- Upload interno pelo backoffice: `client-registrations/{folder}/staff/{uuid}-{nome-original}`.
|
||
- Revenda: `reseller-registrations/{registration_id}/{uuid}-{nome-original}` (aqui usa sempre
|
||
o id do registro, não CNPJ — `resellerDocFolder({cnpj, id})` existe em `clientStorage.js` mas
|
||
não é usada nas rotas de revenda lidas; a rota usa `req.params.id` direto).
|
||
|
||
**Controle de acesso**: nenhum arquivo é servido por URL pública direta/assinada de longa
|
||
duração. Todo download passa pelo backend autenticado:
|
||
`GET /client-registrations/:id/attachments/:attId` — carrega o registro pai com a mesma regra
|
||
de escopo de leitura do cadastro (`loadForRead`), verifica `visibleToUser(att, user)` (anexos
|
||
`purpose='internal'` com `uploaded_by_id` preenchido só para admin/backoffice), e faz stream do
|
||
objeto do S3 direto na resposta HTTP (`Content-Disposition: attachment`), nunca expõe a URL do
|
||
bucket. Front sempre baixa via `fetch(..., {headers: {Authorization: Bearer <jwt>}})` e cria um
|
||
blob URL local — nunca um `<a href>` direto para o S3.
|
||
|
||
---
|
||
|
||
## 9. Segurança das rotas públicas (sem auth)
|
||
|
||
Duas rotas nascem sem autenticação por natureza — precisam ser acessadas pelo cliente/revenda
|
||
final, que não tem login no sistema:
|
||
- `GET/POST /public-client-registration/:token`
|
||
- `GET/POST /public-reseller-registration/:token`
|
||
|
||
Mecanismos de proteção efetivamente implementados:
|
||
|
||
1. **Token opaco de alta entropia como capacidade de acesso** — `crypto.randomBytes(24).toString('hex')`
|
||
= 96 bits de aleatoriedade, 48 caracteres hex, não adivinhável por força bruta em prazo
|
||
razoável. Não é um JWT nem carrega claims — é só uma chave estrangeira direta para a linha
|
||
no banco (`SELECT ... WHERE token = $1`). **Um token só pode ser usado por um único
|
||
`client_registrations`/`reseller_registrations`** (relação 1:1 físico: o token vive como
|
||
coluna UNIQUE na própria linha, não numa tabela de sessões à parte).
|
||
|
||
2. **Uso único, reforçado no backend, não no token em si** — o token **não expira por tempo**
|
||
(não há checagem de `token_created_at` contra um TTL em lugar nenhum do código lido). A
|
||
proteção real é de **estado**: o `POST` de submissão exige `registration_status ===
|
||
'rascunho'`; qualquer tentativa de reenvio depois do primeiro submit recebe `409 "Este
|
||
cadastro já foi enviado e não pode ser alterado"`. Ou seja, o token continua "vivo" para
|
||
sempre (o `GET` de contexto sempre funciona, retornando `already_submitted: true`), mas
|
||
**o formulário se torna somente-leitura de fato** depois do primeiro envio bem-sucedido —
|
||
não há re-edição pelo cliente, só pelo backoffice autenticado depois.
|
||
|
||
3. **Rate limiting por IP**, aplicado a nível de router inteiro
|
||
(`router.use(publicClientRegistrationLimiter)` / `publicResellerRegistrationLimiter`,
|
||
ambos definidos em `server/src/middleware/rateLimit.js`):
|
||
`windowMs: 15 * 60 * 1000` (15 min), `max: 30` requisições por IP nessa janela,
|
||
`standardHeaders: true`, handler JSON customizado (não a página HTML padrão do
|
||
express-rate-limit). Aplica-se a **todas** as rotas do router (GET de contexto + POST de
|
||
submissão), então 30 requisições/15min cobre tanto tentativas de adivinhar token via GET
|
||
quanto tentativas de submissão.
|
||
|
||
4. **Validação de negócio sempre revalidada no servidor** — CPF/CNPJ, obrigatoriedade de
|
||
campos e de anexos são checados de novo no backend mesmo que o front já tenha validado
|
||
(comentário explícito no código: "Autoridade da validação de CPF/CNPJ é sempre o backend —
|
||
nunca confia no que o navegador já validou"). Isso não é anti-abuso por si, mas evita que
|
||
um cliente malicioso escreva dados inválidos contornando o JS do navegador.
|
||
|
||
5. **404 genérico e indistinguível** para token inexistente **e** para link que nunca chegou a
|
||
existir — não vaza se um token "quase certo" existe ou não (mensagem fixa "Link inválido ou
|
||
expirado", mesma em ambos os casos, mesmo a expiração não existindo de fato).
|
||
|
||
6. **Nenhum CAPTCHA, nenhuma verificação de e-mail/OTP antes do preenchimento** — o único
|
||
controle de "quem pode preencher" é posse do link (enviado por e-mail pelo vendedor ao
|
||
cliente/revenda). Não há verificação de que quem preencheu é de fato o titular além da
|
||
validação de CPF/CNPJ (formato apenas).
|
||
|
||
7. **Limite de tamanho/quantidade de arquivo** via `multer`: 10MB por arquivo, até 12 arquivos
|
||
por submissão no form de cliente (fatura=1, contrato_social=1, outros=10) — previne abuso
|
||
de armazenamento/DoS por upload.
|
||
|
||
**Para o Eden replicar fielmente**: (a) gerar token com ≥96 bits de entropia via CSPRNG,
|
||
armazenado como coluna única na própria linha do registro (não uma tabela de sessão
|
||
separada); (b) não implementar expiração por tempo — a trava real é o campo de status
|
||
(`rascunho` → qualquer outro estado bloqueia edição via o link); (c) aplicar rate limit por
|
||
IP nas duas rotas públicas (sugestão: mesma janela/limite, 30 req/15min, é generosa mas
|
||
suficiente para um fluxo de preenchimento humano único); (d) sempre revalidar toda regra de
|
||
negócio (documento, obrigatoriedade, anexos) no backend, tratando o cliente como não confiável;
|
||
(e) mensagem de erro 404 idêntica para "não existe" e "já usado" territorialmente — mas
|
||
diferenciar (com 409, não 404) quando o token existe e já foi consumido, retornando contexto
|
||
mínimo (`already_submitted: true`) para a UI mostrar "cadastro já recebido" em vez de "link
|
||
inválido".
|
||
|
||
---
|
||
|
||
## 10. Referência rápida de endpoints
|
||
|
||
### Cliente (autenticado — `server/src/routes/clientRegistrations.routes.js`)
|
||
| Método | Rota | Guarda |
|
||
|---|---|---|
|
||
| GET | `/client-registrations` | `auth` — escopo por autoria/feature `clientes_todos` |
|
||
| GET | `/client-registrations/directory` | `auth` — escopo por revenda (diretório mínimo) |
|
||
| POST | `/client-registrations/direct` | `auth` — cadastro sem oferta |
|
||
| GET | `/client-registrations/:id` | `auth` + mesmo escopo do GET lista |
|
||
| GET | `/client-registrations/:id/partners` | `auth` |
|
||
| PATCH | `/client-registrations/:id/partners/:partnerId` | `requireAdminOrBackoffice` |
|
||
| GET | `/client-registrations/:id/attachments` | `auth` + `visibleToUser` |
|
||
| GET | `/client-registrations/:id/attachments/:attId` | idem, stream do S3 |
|
||
| POST | `/client-registrations/:id/attachments` | `requireFeatureOrSuperAdmin('clientes_todos','edit')` |
|
||
| DELETE | `/client-registrations/:id/attachments/:attId` | `requireSuperAdmin` |
|
||
| PATCH | `/client-registrations/:id` | `requireFeatureOrSuperAdmin('clientes_todos','edit')` |
|
||
| POST | `/client-registrations/:id/send-email` | `auth` |
|
||
|
||
### Cliente (público — `publicClientRegistration.routes.js`, rate-limited)
|
||
| Método | Rota |
|
||
|---|---|
|
||
| GET | `/public-client-registration/:token` |
|
||
| POST | `/public-client-registration/:token` |
|
||
|
||
### Revenda — cadastro/aprovação (autenticado — `resellerRegistrations.routes.js`)
|
||
| Método | Rota | Guarda |
|
||
|---|---|---|
|
||
| GET | `/reseller-registrations` | `revendas_cadastro:view` |
|
||
| GET | `/reseller-registrations/:id` | `revendas_cadastro:view` |
|
||
| GET | `/reseller-registrations/:id/partners` | `revendas_cadastro:view` |
|
||
| POST | `/reseller-registrations` | `revendas_cadastro:edit` |
|
||
| POST | `/reseller-registrations/:id/send-email` | `revendas_cadastro:view` |
|
||
| PATCH | `/reseller-registrations/:id` | `revendas_cadastro:edit` |
|
||
| GET | `/reseller-registrations/:id/attachments` | `revendas_cadastro:view` |
|
||
| GET | `/reseller-registrations/:id/attachments/:attId` | `revendas_cadastro:view` |
|
||
| POST | `/reseller-registrations/:id/attachments` | `revendas_cadastro:edit` |
|
||
|
||
### Revenda (público — `publicResellerRegistration.routes.js`, rate-limited)
|
||
| Método | Rota |
|
||
|---|---|
|
||
| GET | `/public-reseller-registration/:token` |
|
||
| POST | `/public-reseller-registration/:token` |
|
||
|
||
### Revenda — operacional (autenticado — `resellers.routes.js`)
|
||
| Método | Rota | Guarda |
|
||
|---|---|---|
|
||
| GET | `/resellers` | `auth` (qualquer usuário) |
|
||
| POST | `/resellers` | `requireAdmin` |
|
||
| PATCH | `/resellers/:id` | `requireAdmin` |
|
||
| DELETE | `/resellers/:id` | `requireAdmin` |
|
||
|
||
---
|
||
|
||
## 11. Notas para o Eden (decisões a preservar ou revisitar)
|
||
|
||
- **Preservar**: separação nítida entre cadastro "leve" de revenda (convite por e-mail + tipo)
|
||
e cadastro completo de cliente (nasce de oferta fechada); vocabulário de status idêntico nas
|
||
duas entidades (`rascunho/pendente_validacao/ativo/bloqueado/inativo`); trava de CPF/CNPJ
|
||
imutável fora do fluxo de submissão original; regra "sócio assinante substitui
|
||
representante"; separação de anexo `internal` vs `signature` com visibilidade condicionada a
|
||
quem fez o upload; pasta de storage por documento (CPF/CNPJ) em vez de por UUID.
|
||
- **Avaliar/decidir no Eden**: se vale introduzir expiração real de token (hoje não existe —
|
||
só o estado `rascunho` protege); se a assimetria de endereço "oficial vs final" (existe em
|
||
cliente, não em revenda) deve ser unificada; se a exclusão de anexo deveria checar mais que
|
||
"é super_admin" (hoje não há auditoria de exclusão além do próprio log de aplicação); se
|
||
`DELETE /resellers/:id` deveria ser bloqueado no backend quando há vínculos (hoje só o front
|
||
impede).
|
||
- **Fora do escopo deste documento, mas referenciado**: geração de PDF de contrato/termo a
|
||
partir de template publicado (Chromium/HTML), envelopes de assinatura eletrônica com OTP por
|
||
e-mail (`signature_envelopes`, `signature_signers`, `signature_otp_challenges`), módulo de
|
||
Contratos (usa `activated_at` para vencimento). Esses módulos consomem dados produzidos aqui
|
||
(anexos `purpose='signature'`, campos `pj_rep_*`/sócios) mas têm modelo de dados próprio.
|
||
|
||
|
||
---
|
||
|
||
|
||
# 4. Templates de Documentos, Geração de PDF e Assinatura Eletrônica
|
||
|
||
|
||
> Documentação de referência do sistema OrçaFácil (fonte da verdade), para reconstrução fiel em "Eden".
|
||
> Escopo: `document_templates`/`document_template_versions`/`document_generations`, motor de merge de variáveis, geração de PDF via Chromium, e o módulo completo de assinatura eletrônica (`signature_envelopes` e tabelas satélite), incluindo OTP, cadeia de auditoria hash-chain, finalização/certificado e verificação pública.
|
||
|
||
---
|
||
|
||
## 0. Visão geral do pipeline
|
||
|
||
```
|
||
Editor visual (Tiptap JSON)
|
||
│ (rascunho ↔ publicado, versionado)
|
||
▼
|
||
document_template_versions.editor_content (JSONB)
|
||
│
|
||
│ renderToHtml() ──► HTML com <span class="merge-field-chip"> (preview "com variáveis")
|
||
│
|
||
│ resolveDocument() [templateEngine.js]
|
||
│ 1. valida required faltando
|
||
│ 2. aplica condicionais (nó `conditional`)
|
||
│ 3. expande linhas de tabela `repeat`
|
||
│ 4. substitui merge fields pelo valor formatado do contexto
|
||
▼
|
||
HTML final (sem merge field pendente)
|
||
│
|
||
│ sanitizeDocumentHtml() [sanitize-html, allowlist estrita]
|
||
▼
|
||
│ renderPdf() [chromiumPdfRenderer.js — Playwright/Chromium headless]
|
||
▼
|
||
PDF (buffer) ──► SHA-256 ──► upload S3 ──► document_generations (snapshot imutável)
|
||
```
|
||
|
||
Para documentos que exigem assinatura, o PDF gerado (ou um anexo já existente no cadastro) é "congelado" dentro de um `signature_envelope`, percorre uma máquina de estados (convite → visualização → confirmação de dados → aceite → OTP → assinatura), e ao final é consolidado num PDF único + certificado de autenticidade, com uma cadeia de auditoria hash-chain (SHA-256) cobrindo cada evento do processo.
|
||
|
||
---
|
||
|
||
## 1. Modelo de dados
|
||
|
||
### 1.1 `document_templates`
|
||
Um modelo de documento (ex.: "Contrato de Software"). Nunca guarda o conteúdo atual diretamente — aponta para uma versão.
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| `id` | UUID PK | `gen_random_uuid()` |
|
||
| `created_date` / `updated_date` | TIMESTAMPTZ | trigger `set_updated_date()` no UPDATE |
|
||
| `key` | TEXT UNIQUE | identificador estável, ex. `client_software_contract` — fixo pelo sistema, nunca editável pelo usuário |
|
||
| `name` | TEXT | (coluna renomeada de `label` numa migration posterior) rótulo exibido |
|
||
| `doc_group` | TEXT | `'cliente'` ou `'revenda'` — agrupa a listagem na UI |
|
||
| `active` | BOOLEAN DEFAULT true | |
|
||
| `current_version_id` | UUID FK → `document_template_versions(id)` | aponta pra versão **publicada** vigente; `NULL` se nunca publicado |
|
||
| `created_by_id` | UUID FK → `users` | |
|
||
| `body` | TEXT DEFAULT '' | **legado**: texto puro com placeholders `{{variavel}}` da geração anterior (client-side, html2canvas+jsPDF) — mantido só para compatibilidade retroativa das rotas antigas (`PATCH /:key`), não usado pelo editor visual novo |
|
||
| `updated_by_id` | UUID FK → `users` | dono da última edição do campo `body` legado |
|
||
|
||
Registros existentes hoje (query real ao banco): 7 templates —
|
||
|
||
| key | name | doc_group |
|
||
|---|---|---|
|
||
| `client_software_contract` | Contrato de Software | cliente |
|
||
| `client_cession_term` | Termo de Cessão | cliente |
|
||
| `client_stfc_term` | Termo de Contratação STFC | cliente |
|
||
| `client_portability_term` | Termo de Portabilidade | cliente |
|
||
| `reseller_contract_finder` | Contrato Canal Finder | revenda |
|
||
| `reseller_contract_recorrente` | Contrato Canal Recorrente | revenda |
|
||
| `reseller_adhesion_term` | Termo de Adesão (Recorrente) | revenda |
|
||
|
||
Todos os 7 têm `current_version_id` preenchido (já publicados).
|
||
|
||
### 1.2 `document_template_versions`
|
||
Versionamento **imutável** assim que `status='published'`.
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| `id` | UUID PK | |
|
||
| `created_date` | TIMESTAMPTZ | |
|
||
| `template_id` | UUID FK → `document_templates` ON DELETE CASCADE | |
|
||
| `version` | INTEGER | sequencial por template, `UNIQUE(template_id, version)` |
|
||
| `status` | TEXT CHECK IN `('draft','published')` | |
|
||
| `editor_content` | JSONB NOT NULL | documento Tiptap (árvore de nós) — fonte da verdade do conteúdo |
|
||
| `html_content` | TEXT DEFAULT '' | HTML já renderizado a partir de `editor_content` (com chips de merge field visuais, não resolvidos), cacheado para exibição rápida sem recalcular |
|
||
| `css_content` | TEXT | opcional |
|
||
| `page_config` | JSONB DEFAULT '{}' | margens, `headerHtml`/`footerHtml` do PDF |
|
||
| `created_by_id` | UUID FK → `users` | |
|
||
| `published_at` | TIMESTAMPTZ | |
|
||
| `published_by_id` | UUID FK → `users` | |
|
||
|
||
**Restrição-chave**: `CREATE UNIQUE INDEX uq_document_template_versions_draft ON document_template_versions(template_id) WHERE status = 'draft'` — só pode existir **1 rascunho por vez** por template. Isso é o que a UI chama de "Criar nova versão".
|
||
|
||
Ciclo de vida:
|
||
1. `POST /:id/draft` — cria um novo registro `status='draft'`, clonando `editor_content`/`css_content`/`page_config` da versão atualmente publicada (`current_version_id`), ou um documento Tiptap vazio (`{type:'doc', content:[{type:'paragraph', content:[]}]}`) se o template nunca foi publicado. `version` = `MAX(version)+1` (nunca reaproveita número). Recusa com 409 se já existe draft.
|
||
2. `PATCH /:id/draft` — sobrescreve `editor_content`/`css_content`/`page_config` do draft existente (nunca cria, nunca toca em versão publicada). A cada save, recalcula `html_content = sanitizeDocumentHtml(renderToHtml(editor_content))` — cache sempre coerente com o conteúdo salvo.
|
||
3. `POST /:id/publish` — transação: marca o draft como `status='published', published_at=now(), published_by_id=user` e atualiza `document_templates.current_version_id` para apontar pra ele. A partir daqui a versão é imutável (nenhuma rota permite editar uma versão `published`).
|
||
|
||
### 1.3 `document_generations`
|
||
Snapshot de cada PDF definitivo já emitido — nunca recalculado depois.
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| `id` | UUID PK | |
|
||
| `generated_date` | TIMESTAMPTZ | |
|
||
| `template_id` | UUID FK → `document_templates` | |
|
||
| `template_version_id` | UUID FK → `document_template_versions` | a versão exata usada |
|
||
| `client_registration_id` | UUID FK (nullable) | |
|
||
| `reseller_registration_id` | UUID FK (nullable) | |
|
||
| `render_context` | JSONB DEFAULT '{}' | o mapa `{chave: valor formatado}` usado na geração — congelado |
|
||
| `rendered_html` | TEXT | HTML final (já resolvido, sanitizado) |
|
||
| `pdf_s3_key` | TEXT | |
|
||
| `pdf_sha256` | TEXT | SHA-256 do PDF gerado |
|
||
| `pdf_size_bytes` | INTEGER | |
|
||
| `status` | TEXT CHECK IN `('generating','final','error')` | |
|
||
| `error_message` | TEXT | |
|
||
| `generated_by_id` | UUID FK → `users` | |
|
||
| CHECK `chk_document_generations_one_source` | | exatamente uma de `client_registration_id`/`reseller_registration_id` (soma dos booleanos IS NOT NULL = 1) — Termo de Cessão é exceção: indexado pelo **cedente**, a cessionária não tem coluna própria (segue só em `render_context` e no PDF) |
|
||
|
||
Geração é **idempotente por padrão**: mesma `template_version_id` + mesma origem (cliente/revenda) já gerada com `status='final'` → devolve o registro existente em vez de chamar o Chromium de novo, a menos que `force:true` seja passado ou existam `extra_fields` (dados manuais como operadora doadora — nesse caso nunca reusa, pois cada geração pode ter dados diferentes).
|
||
|
||
### 1.4 `signature_envelopes`
|
||
O "pacote" de assinatura — um conjunto de documentos + signatários passando por um fluxo único.
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| `id` | UUID PK | |
|
||
| `created_date`/`updated_date` | TIMESTAMPTZ | trigger de `updated_date` |
|
||
| `envelope_number` | TEXT UNIQUE | formato `SIG-{ano}-{seq6digitos}`, gerado por `nextval('signature_envelope_seq')` |
|
||
| `client_registration_id` | UUID FK (nullable) | |
|
||
| `reseller_registration_id` | UUID FK (nullable) | |
|
||
| CHECK `chk_signature_envelopes_one_source` | | exatamente um dos dois preenchido |
|
||
| `status` | TEXT CHECK (lista de 19 estados — ver §5.1) | default `'DRAFT'` |
|
||
| `created_by_id` | UUID FK → `users` | |
|
||
| `verification_id` | TEXT UNIQUE (nullable) | gerado só na finalização — 16 bytes aleatórios em hex (`crypto.randomBytes(16).toString('hex')`), usado na URL pública de verificação |
|
||
| `final_s3_key` | TEXT | PDF consolidado final (documentos + certificado) |
|
||
| `final_pdf_sha256` | TEXT | SHA-256 do PDF final consolidado |
|
||
| `certificate_s3_key` | TEXT | PDF do certificado de autenticidade isolado |
|
||
| `manifest_id`/`manifest_s3_key`/`manifest_sig_s3_key` | UUID/TEXT | reservadas para uma futura camada de manifesto assinado digitalmente — **não usadas hoje** (sempre NULL; nenhuma rota as popula) |
|
||
| `evidence_checkpoint_hash` | TEXT | hash do último evento de auditoria no momento do selamento (ver §7) |
|
||
| `envelope_hash` | TEXT | hash canônico do envelope inteiro (documentos+signatários+checkpoint) — ver §7 |
|
||
| `sent_at`/`viewed_at`/`completed_at`/`declined_at`/`cancelled_at`/`expired_at`/`signed_at` | TIMESTAMPTZ | marcos do ciclo de vida |
|
||
|
||
### 1.5 `signature_signers`
|
||
Um signatário dentro de um envelope — **snapshot**, nunca FK viva para o cadastro do cliente (para que uma edição posterior do cadastro não altere uma assinatura em andamento/concluída).
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| `id` | UUID PK | |
|
||
| `envelope_id` | UUID FK ON DELETE CASCADE | |
|
||
| `sort_order` | INTEGER | |
|
||
| `party` | TEXT CHECK IN `('contratada','contratante')` | default `'contratante'` |
|
||
| `person_type` | TEXT CHECK IN `('pf','pj')` | |
|
||
| `full_name`, `cpf`, `email`, `phone`, `company_name`, `cnpj`, `role_title` | TEXT | dados congelados no momento da criação do envelope |
|
||
| `status` | TEXT CHECK (ver §5.2) | default `'PENDING'` |
|
||
| `signed_at` | TIMESTAMPTZ | |
|
||
| `secure_token_hash` | TEXT UNIQUE | **SHA-256 do token do link público** — nunca o token em claro |
|
||
| `secure_token_created_at`/`_expires_at`/`_revoked_at`/`_first_used_at`/`_last_used_at` | TIMESTAMPTZ | |
|
||
|
||
Todo envelope tem sempre exatamente **um signatário `party='contratada'`** — o representante legal da empresa (populado automaticamente de `companies.legal_rep_name/cpf/email/role`, nunca escolhido na hora de criar o envelope) — além de um ou mais signatários `party='contratante'` informados no formulário. `createEnvelope` recusa criar o envelope se a empresa não tiver `legal_rep_name`/`legal_rep_cpf`/`legal_rep_email` cadastrados.
|
||
|
||
### 1.6 `signature_documents`
|
||
Um documento congelado (cópia imutável) dentro do envelope.
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| `id` | UUID PK | |
|
||
| `envelope_id` | UUID FK ON DELETE CASCADE | |
|
||
| `source_attachment_id` | UUID (sem FK estrita, relaxada numa migration posterior para aceitar tanto `client_registration_attachments` quanto `reseller_registration_attachments`) | só referência histórica, nunca usado operacionalmente depois do congelamento |
|
||
| `document_type` | TEXT | mapeado de `attachment.category` (ver DOCUMENT_TYPE_BY_CATEGORY) |
|
||
| `title`, `sort_order`, `document_version` (default 1) | | |
|
||
| `source_filename`, `source_mime_type`, `source_s3_key` | | `source_s3_key` é a **cópia congelada**, nunca o `s3_key` mutável do anexo original |
|
||
| `original_sha256` | TEXT NOT NULL | SHA-256 calculado no momento do congelamento |
|
||
| `page_count` | INTEGER (nullable) | via `pdf-lib`; `NULL` se o arquivo não for um PDF válido — não bloqueia o congelamento |
|
||
| `status` | TEXT CHECK IN `('FROZEN','SUPERSEDED')` | |
|
||
| `superseded_by_id` | UUID FK auto-referência | |
|
||
| `frozen_at` | TIMESTAMPTZ | |
|
||
|
||
### 1.7 `signature_otp_challenges`
|
||
Ver §6 (OTP) para o detalhamento de segurança.
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| `id` | UUID PK | |
|
||
| `envelope_id`, `signer_id` | UUID FK ON DELETE CASCADE | |
|
||
| `channel` | TEXT default `'EMAIL'` | único canal implementado |
|
||
| `email_snapshot` | TEXT | e-mail no momento do envio (não o e-mail atual do signatário) |
|
||
| `otp_protected_value` | TEXT NOT NULL | **HMAC-SHA256** do código, nunca o código puro |
|
||
| `created_at`, `expires_at`, `sent_at`, `verified_at`, `invalidated_at` | TIMESTAMPTZ | |
|
||
| `attempts_count` INTEGER default 0 | | |
|
||
| `max_attempts` INTEGER default 5 | | |
|
||
| `request_ip`, `verification_ip`, `request_user_agent`, `verification_user_agent` | TEXT | |
|
||
| `status` | TEXT CHECK IN `('CREATED','SENT','VERIFIED','EXPIRED','INVALIDATED','BLOCKED')` | |
|
||
|
||
### 1.8 `signature_audit_events` — a cadeia de auditoria
|
||
Ver §7 para o algoritmo completo. Estrutura:
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| `id` | UUID PK | |
|
||
| `seq` | BIGSERIAL UNIQUE NOT NULL | **é isto, não `timestamp_utc`, que define a ordem real da cadeia** — dentro de uma mesma transação o Postgres congela `now()`, então vários eventos gravados juntos podem ter timestamp idêntico |
|
||
| `envelope_id` | UUID FK ON DELETE CASCADE | |
|
||
| `document_id`, `signer_id` | UUID FK (nullable) | |
|
||
| `event_type` | TEXT | catálogo aberto (ver §7.4) |
|
||
| `actor_type` | TEXT CHECK IN `('SIGNER','BACKOFFICE','ADMIN','SUPER_ADMIN','SYSTEM','SERVICE')` | |
|
||
| `actor_id` | UUID FK → `users` (nullable — nulo para `SIGNER`/`SYSTEM`) | |
|
||
| `actor_role` | TEXT | |
|
||
| `timestamp_utc` | TIMESTAMPTZ default now() | |
|
||
| `ip_address`, `user_agent`, `browser`, `operating_system`, `device_type` | TEXT | extraídos da requisição, parsing leve de UA (não usa lib de fingerprinting) |
|
||
| `session_id` | TEXT | primeiros 12 chars do `secure_token_hash` do signatário — nunca o token, só um identificador de agrupamento visual na auditoria |
|
||
| `correlation_id` | TEXT | não usado atualmente (sempre NULL) |
|
||
| `metadata_json` | JSONB | dados extras específicos do evento |
|
||
| `previous_event_hash` | TEXT | encadeamento |
|
||
| `event_hash` | TEXT NOT NULL | ver algoritmo §7.2 |
|
||
| `hash_algorithm_version` | INTEGER default 2 | ver §7.3 |
|
||
|
||
**Append-only reforçado em nível de banco**: triggers `BEFORE UPDATE`/`BEFORE DELETE` chamam `prevent_audit_event_mutation()`, que lança exceção a menos que a sessão tenha explicitamente executado `SET LOCAL orcafacil.allow_audit_mutation = 'true'` — nunca feito pela aplicação em uso normal (só usado internamente por `deleteEnvelope`, ver §1.9). Bloqueia inclusive `super_admin` via SQL bruto.
|
||
|
||
### 1.9 Exclusão vs. cancelamento de envelope
|
||
- `cancelEnvelope`: só muda `status → CANCELLED`, preserva tudo (documentos, signatários, auditoria) — reversível em termos de dados, é o caminho normal de desistência.
|
||
- `deleteEnvelope`: remoção física (`DELETE FROM signature_envelopes`, cascade) — **só permitida se o envelope ainda não estiver em `SIGNED`/`FINALIZING`/`COMPLETED`** (a partir do momento em que há manifestação de vontade registrada, só cancelamento é permitido, nunca exclusão). Usa a escotilha `allow_audit_mutation` de propósito (é exclusão do envelope inteiro, não edição seletiva de histórico que continua de pé). Os PDFs congelados no S3 são apagados depois do commit da transação (best-effort, `.catch(() => {})`).
|
||
|
||
---
|
||
|
||
## 2. Templates de documento
|
||
|
||
### 2.1 Tipos existentes
|
||
Ver tabela em §1.1. Dois grupos (`doc_group`): **cliente** (Contrato de Software, Termo de Cessão, Termo de Contratação STFC, Termo de Portabilidade) e **revenda** (Contrato Canal Finder, Contrato Canal Recorrente, Termo de Adesão Recorrente).
|
||
|
||
Nota histórica importante: o **Termo de Contratação STFC** foi deliberadamente deixado FORA da primeira migration de templates editáveis de cliente — é tratado como formulário regulatório (RGC/Anatel 765/2023), com campos computados a partir da oferta (checkboxes sim/não, linhas condicionais de portabilidade/franquia), e só depois (migration `1786190000000`) ganhou versão editável no novo motor — decisão consciente de que texto contratual solto pode ser editado livremente, mas um formulário regulatório precisa ser tratado com mais cuidado (variáveis calculadas, não texto livre).
|
||
|
||
### 2.2 Conteúdo rico: formato de armazenamento
|
||
`editor_content` é um documento **Tiptap** (JSON, mesma árvore de nós que o editor `@tiptap/*` do React produz) — NÃO Markdown, NÃO HTML bruto salvo diretamente. Nós conhecidos pelo motor de renderização (`renderEditorContent.js`), escrito à mão sem depender de `@tiptap/html` (o backend não pode depender de bibliotecas do editor/frontend):
|
||
|
||
- `doc` (raiz) → array de blocos
|
||
- `paragraph` (com `attrs.textAlign`), `heading` (`attrs.level`, 1-3+)
|
||
- `bulletList`/`orderedList`/`listItem`
|
||
- `image` (`attrs.src`/`attrs.alt`)
|
||
- `pageBreak` — vira `<div style="break-after:page;page-break-after:always;">`
|
||
- `table` → gera `<thead>` de verdade (não só a 1ª linha) quando há linhas com célula `tableHeader` — isso é o que faz o Chromium repetir o cabeçalho em cada página quando a tabela atravessa uma quebra
|
||
- `tableRow` (`attrs.repeat` — chave de collection, ver §3.3), `tableHeader` (`attrs.colspan`), `tableCell` (`attrs.colspan`)
|
||
- `conditional` — nó de exibição condicional (ver §3.2), avaliado só pelo `templateEngine`
|
||
- Inline: `text` com `marks`: `bold`→`<strong>`, `italic`→`<em>`, `underline`→`<u>`, `strike`→`<s>`, `textStyle` com `attrs.fontSize` → `<span style="font-size:...">`
|
||
- `mergeField` (inline, `attrs.key`/`attrs.label`) — o placeholder de variável
|
||
|
||
Todo texto é escapado (`escapeHtml`) antes de virar HTML — proteção de primeira linha contra XSS, reforçada depois pelo sanitizador (§4.2).
|
||
|
||
### 2.3 Quem pode editar
|
||
Feature key: **`documentos_modelos`**, dois níveis (`view`/`edit`) via `requireFeatureOrSuperAdmin`. `super_admin` sempre passa. Sem esse nível, o usuário nem vê a tela (frontend checa `hasFeatureAccess(user, "documentos_modelos", "view")`).
|
||
|
||
### 2.4 Rascunho vs. publicação — e a pegadinha de "qual versão renderiza"
|
||
Regra central, implementada em `getEditableVersion(templateId)` (`documentTemplates.routes.js`):
|
||
|
||
```js
|
||
async function getEditableVersion(templateId) {
|
||
// 1. Se existe um draft para este template, SEMPRE prioriza o draft.
|
||
const draft = SELECT * FROM document_template_versions WHERE template_id=$1 AND status='draft';
|
||
if (draft) return draft;
|
||
// 2. Senão, cai para current_version_id (a versão publicada).
|
||
return SELECT current_version_id → SELECT essa versão;
|
||
}
|
||
```
|
||
|
||
Essa função é usada por: `GET /:id/used-variables`, `POST /:id/preview` ("Visualizar com dados") e `POST /:id/test-pdf`. **Ou seja: enquanto existir um rascunho em andamento, TODA visualização/teste do editor usa o rascunho — nunca a versão publicada — mesmo que o rascunho ainda não tenha sido publicado.** Isso é intencional para o autor do template revisar o que está editando, mas é a "pegadinha" documentada: se o preview parecer desatualizado ou "errado" em relação ao que está publicado, o motivo mais provável é um draft esquecido que está "sombreando" a versão publicada.
|
||
|
||
Importante: a **geração definitiva** (`POST /document-generation/generate`, cliente final) usa exclusivamente `document_templates.current_version_id` via JOIN direto — nunca vê ou usa um draft. Um rascunho não publicado jamais aparece em um documento gerado para um cliente/revenda real; a "sombra" acontece apenas nas telas internas de edição/preview/teste.
|
||
|
||
### 2.5 Fluxo de publicação
|
||
1. `POST /:id/draft` cria o rascunho clonando a versão publicada atual (ou documento em branco).
|
||
2. Autor edita no editor visual, salvando incrementalmente via `PATCH /:id/draft` (frontend marca "Alterações não salvas" com `dirty` state até o próximo save).
|
||
3. `POST /:id/publish`: transação — marca a versão como `published` e reaponta `current_version_id`. A partir daí a versão anterior fica congelada como histórico; qualquer novo documento gerado (e qualquer `document_generations` novo) referencia a nova `template_version_id`. **Publicações anteriores nunca são apagadas** — todo o histórico de versões fica visível em `GET /:id/detail`.
|
||
4. Publicar com alterações não salvas é bloqueado no frontend (`if (dirty) return error`).
|
||
|
||
---
|
||
|
||
## 3. Motor de templates (`templateEngine.js` + `variableRegistry.js`)
|
||
|
||
### 3.1 Sintaxe das variáveis
|
||
Variáveis **não são texto livre com chaves duplas** no editor visual — são nós inline `mergeField` inseridos pela UI a partir de um catálogo fechado (whitelist), nunca digitados livremente pelo usuário. A chave (`attrs.key`) segue o padrão `namespace.campo`, ex.: `cliente.razao_social`, `oferta.valor_mensal`, `contrato.vigencia_meses`.
|
||
|
||
(Exceção histórica: os templates legados que ainda usam o campo `body` de texto puro — pré-editor-visual — usam placeholders literais `{{variavel}}`, mas esse caminho é para o sistema de geração antigo/paralelo e não passa pelo `templateEngine.js`/Tiptap.)
|
||
|
||
Sintaxes especiais no documento estruturado:
|
||
- **Condicional**: nó `{ type: 'conditional', attrs: { field, operator, value }, content: [...] }` — o bloco interno só é incluído no HTML final se a condição bater; senão é removido inteiramente. Operadores suportados (fixos, `OPERATORS` map — **nunca `eval()`**): `equals`, `not_equals`, `exists`, `not_exists`, `greater_than`, `greater_or_equal`, `less_than`, `less_or_equal`, `contains`. Avaliado contra o `rawContext` (valores crus, não formatados) — ex. `oferta.fidelidade greater_than 0`.
|
||
- **Repetição de linha de tabela**: `tableRow.attrs.repeat = 'produtos'` — a linha inteira é expandida, uma cópia por item da collection, substituindo os `mergeField` de escopo `collection` (`item.*`) pelo valor daquele item específico. Depois de expandida a linha vira texto puro; o atributo `repeat` é removido do HTML final.
|
||
|
||
### 3.2 Pipeline de resolução (`resolveDocument`)
|
||
1. `collectMergeFieldKeys(doc)` — varre a árvore e separa chaves em `documentKeys` (escopo documento) vs. `collections` (dentro de uma `tableRow` com `repeat`).
|
||
2. Valida **campos obrigatórios** (`getVariable(key).required`) ausentes do contexto → lança erro `MISSING_REQUIRED_FIELDS` (HTTP 422) com a lista de labels faltando, **sem seguir adiante** (não gera PDF parcial).
|
||
3. `applyConditionals` — remove nós `conditional` cuja condição não bate.
|
||
4. `expandRepeatRows` — expande linhas `repeat` por item da collection.
|
||
5. `renderToHtml(working, resolveMergeField)` — serializa para HTML, chamando `resolveMergeField(key)` para cada `mergeField` restante; se a chave não existir no `context`, empilha em `unresolved` e retorna string vazia.
|
||
6. Se sobrou algo em `unresolved` → lança `UNRESOLVED_VARIABLES` (HTTP 422) com a lista de chaves — nunca deixa `{{...}}`/chip pendente vazar para o PDF final.
|
||
|
||
### 3.3 Catálogo de variáveis (`VARIABLE_CATALOG`) — foco em documento legal
|
||
Cada entrada: `{ key, label, category, type, required, scope, source? }`. `scope: 'document'` resolve uma vez; `scope: 'collection'` só dentro de uma linha `repeat`; `scope: 'renderer'` é resolvido só pelo Chromium (paginação), nunca pelo engine.
|
||
|
||
**Categorias relevantes a documento legal/contratual:**
|
||
|
||
- **Cliente** (`cliente.*`): razão social, nome fantasia, CNPJ/CPF (`type: cpf_or_cnpj` — detecta pelo nº de dígitos qual formatar), e-mail, telefone, endereço completo, IE/RG, dados do responsável legal (nome/CPF/telefone). Resolve tanto de `client_registrations` (PF/PJ) quanto de `reseller_registrations`, dependendo da origem do documento — mesmo namespace `cliente.*` para os dois.
|
||
- **Cedente / Cessionária** (`cedente.*` / `cessionario.*`): mesmo shape de `cliente.*`, usado só no Termo de Cessão (dois clientes no mesmo documento) — reaproveita `resolveClienteRaw` para não duplicar/divergir catálogos.
|
||
- **Empresa** (`empresa.*`): dados da emissora (tabela `companies`) — razão social, CNPJ, IE/IM, endereço, `empresa.logo` (não é texto — resolvido à parte como data URI, nunca passa pelo mapa de contexto textual).
|
||
- **Oferta** (`oferta.*`): número, data, validade, **fidelidade** (meses), valor mensal/implantação/total, `vigencia_texto`, datas de início/fim de comercialização, `destinacao` e `combo`/`permanencia_aplicavel` já resolvidos como texto de checkbox pronto (`"(X) SIM ( ) NÃO"`), franquia de minutos, desconto/benefício de fidelidade. Também a tabela de tarifação STFC por minuto (`oferta.tarifa_{lc|ldn|vc1|vc2|vc3|ldi}_{normal|reduzida}`), puxada automaticamente de `products.tariff_rates` do produto medido da oferta (não digitada manualmente).
|
||
- **Contrato** (`contrato.*`): não existe tabela própria de "contrato" — mapeia para `client_registrations.ixc_contract_number` + `quotes.contract_period`/`fidelity_period` + datas derivadas. `contrato.vigencia_meses`/`data_termino` usam **`fidelity_period`** (prazo de permanência real assinado) quando preenchido e a exceção já está `approval_status='approved'` — senão cai para `contract_period` (a faixa de preço). Essa é a mesma regra de negócio usada no cálculo financeiro (fidelidade contratual pode ser reduzida/negociada separadamente da faixa de preço). `contrato.numeros_portar`/`quantidade_portar` vêm de `client_registrations.portability_numbers`/`portability_ranges`.
|
||
- **Portabilidade** (`portabilidade.*`, `source: 'manual'`): prestadora doadora, CNPJ da doadora, titularidade — **sem cadastro próprio**, preenchidos manualmente na hora de gerar (via `extra_fields` no corpo da requisição), nunca persistidos como coluna.
|
||
- **Cessão** (`cessao.*`, `source: 'manual'`): código(s) de acesso cedido, números contratados — também manual.
|
||
- **Assinatura** (`assinatura.*`): nome/CPF/e-mail do signatário, data da assinatura, número do envelope, `assinatura.codigo_autenticidade` (o `evidence_checkpoint_hash` ou `verification_id` do envelope) — leitura do módulo de assinatura eletrônica dentro do próprio texto do documento (ex. para imprimir o código de autenticidade no rodapé de um contrato).
|
||
- **Documento** (`documento.*`): metadados do próprio doc sendo gerado — número, ano atual, data de geração (`documento.data_geracao_extenso` — formato por extenso em pt-BR: `"Segunda-feira, 2 de Setembro de 2026"`).
|
||
- **PDF/Renderer** (`pdf.pagina_atual`/`pdf.total_paginas`, `scope: 'renderer'`): resolvidos exclusivamente pelo Chromium via `headerTemplate`/`footerTemplate` (`<span class="pageNumber">`/`<span class="totalPages">`), nunca pelo `templateEngine` — existem no catálogo só para aparecerem no seletor de variáveis do editor com a categoria certa.
|
||
|
||
Todas as variáveis com valor "cru" nulo/vazio formatam para string vazia (`formatValue` retorna `''`), nunca `"null"`/`"undefined"` no texto.
|
||
|
||
### 3.4 Formatação por tipo (`formatValue`)
|
||
`currency` → `Intl`/`toLocaleString('pt-BR', {style:'currency', currency:'BRL'})`; `date` → `toLocaleDateString('pt-BR')`; `datetime`; `cpf`/`cnpj`/`cpf_or_cnpj` (detecção automática pelo nº de dígitos); `phone` (formata (XX) XXXXX-XXXX ou (XX) XXXX-XXXX); `cep` (XXXXX-XXX); `boolean` → "Sim"/"Não".
|
||
|
||
---
|
||
|
||
## 4. Geração de PDF
|
||
|
||
### 4.1 Chromium/Playwright (`chromiumPdfRenderer.js`)
|
||
- Usa `playwright` (`chromium.launch({ headless: true, args: ['--no-sandbox','--disable-dev-shm-usage'] })`) — **não headless-screenshot**: usa o motor de impressão nativo do navegador (`page.pdf()`), suportando quebra de página real, cabeçalho/rodapé repetido nativo, numeração de página.
|
||
- Browser único reaproveitado entre chamadas (relança sozinho se cair/desconectar).
|
||
- Fila/semáforo simples: no máximo `PDF_MAX_CONCURRENCY` (env, default 3) páginas do Chromium abertas simultaneamente; excedente espera em fila FIFO.
|
||
- Timeout configurável via `PDF_RENDER_TIMEOUT_SECONDS` (default 15s), aplicado tanto no `page.setContent` quanto no `page.pdf()`.
|
||
- **Proteção anti-SSRF por construção**: nunca chama `page.goto(url)` — só `page.setContent(html)` com HTML autossuficiente (imagens sempre embutidas como `data:` URI antes de chegar aqui). Reforço extra: `page.route('**/*', route => route.abort())` bloqueia qualquer requisição de rede que o HTML tentasse fazer, mesmo que nunca devesse.
|
||
- Formato A4, `printBackground: true`, margens configuráveis (`page_config.marginTop/Bottom/Left/Right`, default `15mm` cada). Nota de bug documentada no código: **não** usar `@page { margin: 0 }` no CSS interno — isso anula silenciosamente o `margin` passado para `page.pdf()` (o Chromium usa esse valor tanto para o tamanho quanto para a margem física do PDF).
|
||
- CSS embutido fixo: `body { font-family: Arial, Helvetica, sans-serif; font-size: 9.5px; }`, tabelas com borda 1px sólida preta, `thead { display: table-header-group }` (repete cabeçalho em cada página), `tr { break-inside: avoid }`, `h1-h3 { break-after: avoid }`.
|
||
- Cabeçalho/rodapé: só ativa `displayHeaderFooter` se `page_config.headerHtml`/`footerHtml` existir; os placeholders `{{pdf.pagina_atual}}`/`{{pdf.total_paginas}}` no template viram `<span class="pageNumber">`/`<span class="totalPages">` (classes especiais que o Chromium substitui automaticamente).
|
||
|
||
### 4.2 Sanitização de HTML (`sanitizeHtml.js`) — por que é necessária
|
||
Roda **duas vezes** no pipeline: sobre o HTML derivado de `editor_content` (cache `html_content`) e sobre o HTML final já resolvido com variáveis, sempre antes de ir para o Chromium ou ser servido ao navegador (defesa em profundidade — mesmo que o editor visual algum dia produza algo inesperado).
|
||
|
||
Por que é necessária: é a barreira contra **XSS/injeção de conteúdo malicioso** — mesmo que o conteúdo venha "só" de um editor rico controlado internamente, valores de variáveis (nome do cliente, endereço, etc.) são texto de usuário final propagado para dentro do HTML antes de virar PDF/renderizar no navegador; sem allowlist, um valor malicioso em algum campo do cadastro poderia injetar `<script>`/`<iframe>`/handlers `on*` que executam no contexto do Chromium (ainda que sandboxed) ou do preview no navegador.
|
||
|
||
Implementação: `sanitize-html` com allowlist estrita:
|
||
- **Tags permitidas**: `p, div, span, strong, b, em, i, u, s, h1-h4, ul, ol, li, table, thead, tbody, tfoot, tr, th, td, br, hr, img` — nada de `script/iframe/object/embed/form/input/button/textarea`.
|
||
- **Atributos permitidos**: `style`/`class`/`data-merge-field`/`data-repeat` em qualquer tag; `src/alt/width/height` só em `img`; `colspan/rowspan` só em `td`/`th`.
|
||
- **Estilos CSS permitidos** (regex allowlist): só `text-align` (left/center/right/justify), `font-size` (px/pt/em/rem), `break-after: page`, `page-break-after: always`.
|
||
- **Schemes de URL permitidos**: só `data:` — bloqueia `http(s):`, `javascript:`, `file:` em qualquer atributo que aceite URL (o pipeline sempre embute imagens como `data:` URI antes de chegar aqui, nunca URL externa — reforça o ponto anterior de "sem SSRF").
|
||
- `allowProtocolRelative: false`.
|
||
|
||
---
|
||
|
||
## 5. Fluxo de assinatura eletrônica — visão de estados
|
||
|
||
### 5.1 Estados do envelope e transições permitidas (`ALLOWED_TRANSITIONS`, `envelopeService.js`)
|
||
Máquina de estados centralizada no backend (nunca confiada ao frontend):
|
||
|
||
```
|
||
DRAFT → READY | CANCELLED
|
||
READY → SENT | CANCELLED
|
||
SENT → VIEWED | SIGNED | EXPIRED | CANCELLED
|
||
VIEWED → IDENTITY_PENDING | EXPIRED | CANCELLED
|
||
IDENTITY_PENDING → CONSENT_PENDING | EXPIRED | CANCELLED
|
||
CONSENT_PENDING → OTP_PENDING | EXPIRED | CANCELLED
|
||
OTP_PENDING → OTP_SENT | EXPIRED | CANCELLED
|
||
OTP_SENT → OTP_VERIFIED | OTP_PENDING | EXPIRED | CANCELLED
|
||
OTP_VERIFIED → READY_TO_SIGN | EXPIRED | CANCELLED
|
||
READY_TO_SIGN → SIGNING | EXPIRED | CANCELLED
|
||
SIGNING → SIGNED | ERROR
|
||
SIGNED → FINALIZING
|
||
FINALIZING → COMPLETED | ERROR
|
||
COMPLETED / DECLINED / EXPIRED / CANCELLED / SUPERSEDED / ERROR → (terminais, sem saída)
|
||
```
|
||
|
||
Nota importante de design: os estados intermediários finos (`VIEWED`→`OTP_VERIFIED` etc.) descrevem o progresso **por signatário** (rastreado em `signature_signers.status`, não no envelope). Como um envelope normalmente tem 2 signatários (Contratada + Contratante) assinando em momentos diferentes, o **envelope** de fato só pula direto de `SENT`→`SIGNED` quando todos os signatários concluem individualmente — nunca passa fisicamente por `VIEWED`/`OTP_VERIFIED` no nível do envelope (esse salto é explicitamente permitido na tabela). `assertTransition(current, next)` lança erro se a transição não estiver na lista — chamado antes de toda mudança de status no banco.
|
||
|
||
### 5.2 Estados do signatário (`signature_signers.status`)
|
||
`PENDING → LINK_SENT → VIEWED → DATA_CONFIRMED → CONSENTED → OTP_VERIFIED → SIGNED` (ou `DECLINED` a qualquer momento — não implementado nas rotas atuais, reservado).
|
||
|
||
### 5.3 Quem assina
|
||
- Sempre um signatário `party='contratada'` — representante legal da **empresa emissora** (Handix), auto-preenchido de `companies.legal_rep_*`.
|
||
- Um ou mais signatários `party='contratante'` — informados na criação do envelope (nome, CPF, e-mail obrigatórios; opcionalmente telefone/razão social/CNPJ/cargo para PJ). Tipicamente o cliente (ou sócio/representante da revenda, no caso de envelope de origem `reseller_registration`).
|
||
|
||
### 5.4 Criação do envelope (`createEnvelope`)
|
||
Transação única (tudo ou nada):
|
||
1. Resolve a origem (`resolveEnvelopeSource` — cliente OU revenda, nunca ambos, `resolveEnvelopeSource` valida isso explicitamente e lança erro descritivo).
|
||
2. Confirma que a empresa tem representante legal cadastrado.
|
||
3. Valida que os `attachment_ids` escolhidos pertencem ao cadastro E têm `purpose='signature'`.
|
||
4. Gera `envelope_number` sequencial (`SIG-{ano}-{seq6digitos}`).
|
||
5. Congela cada anexo escolhido (`freezeAttachment`, ver §1.6) — copia o arquivo do S3 para um caminho próprio e imutável (`customers/{docFolder}/signatures/{envelopeId}/frozen/{uuid}-{filename}`), calcula SHA-256, registra eventos `DOCUMENT_FROZEN`/`DOCUMENT_HASHED`.
|
||
6. Insere signatários (`contratante`s do formulário + `contratada` automático).
|
||
7. Transição `DRAFT → READY`.
|
||
|
||
Todo o processo é uma única transação Postgres — se qualquer etapa falhar, nada fica gravado.
|
||
|
||
### 5.5 Envio de convite (`sendInvite`)
|
||
- Ação explícita do backoffice/admin (`POST /:id/send`, `targets: ['contratada'|'contratante'|'both']`), nunca automática.
|
||
- Para cada signatário alvo que ainda não assinou: gera um **token novo** (`crypto.randomBytes(32).toString('hex')` — 32 bytes = 256 bits de entropia, hex = 64 chars), grava só o **hash SHA-256** do token (`secure_token_hash`), define expiração de **7 dias** (`Date.now() + 7*24*60*60*1000`), zera `secure_token_revoked_at`. **Gerar um token novo revoga implicitamente o anterior** — só o hash mais recente casa na consulta.
|
||
- Link enviado: `{baseUrl}/assinar/{token}` (token em claro só na URL do e-mail, nunca persistido em claro).
|
||
- Envelope `READY → SENT` na primeira vez que um convite é enviado.
|
||
- Reenviar não afeta quem já assinou (`if (signer.status === 'SIGNED') continue`).
|
||
|
||
---
|
||
|
||
## 6. OTP (One-Time Password) — `otpService.js`
|
||
|
||
Este é o ponto mais sensível de segurança do módulo. Detalhamento completo:
|
||
|
||
### 6.1 Geração do código
|
||
```js
|
||
function generateCode() {
|
||
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
||
}
|
||
```
|
||
- **6 dígitos numéricos**, `0` a `999999`, `padStart` para sempre ter 6 caracteres (`"000042"` é válido).
|
||
- `crypto.randomInt` — gerador criptograficamente seguro do Node (`node:crypto`), não `Math.random()`.
|
||
|
||
### 6.2 Hash do código — nunca gravado em claro
|
||
```js
|
||
function hashCode(code) {
|
||
return crypto.createHmac('sha256', getSecret()).update(code).digest('hex');
|
||
}
|
||
```
|
||
- **HMAC-SHA256** com um **segredo de servidor** — env var `SIGNATURE_OTP_SECRET`. Se a env var não estiver definida, `getSecret()` lança erro explícito ("defina essa variável de ambiente antes de usar OTP") — o serviço recusa operar sem segredo configurado, nunca cai para um default inseguro.
|
||
- O código puro **nunca** é: gravado no banco, logado, ou devolvido em qualquer resposta de API — só o HMAC vai para `signature_otp_challenges.otp_protected_value`.
|
||
- Usar HMAC (com segredo) em vez de hash simples (SHA-256 puro) é deliberado: um SHA-256 puro de um espaço de apenas 10⁶ valores seria trivialmente pré-computável (rainbow table de 1 milhão de entradas); o segredo de servidor torna essa pré-computação inviável sem acesso ao segredo.
|
||
|
||
### 6.3 TTL (expiração)
|
||
- `OTP_TTL_MS = 5 * 60 * 1000` — **5 minutos**, fixo no código (não configurável por env).
|
||
- Verificado em `verifyOtp`: se `expires_at < now()`, marca `status='EXPIRED'`, grava evento `OTP_EXPIRED`, e recusa com "Código expirado — solicite um novo."
|
||
|
||
### 6.4 Tentativas máximas
|
||
- `max_attempts` = 5 (coluna com default, por challenge).
|
||
- A cada tentativa incorreta, `attempts_count` incrementa; quando `attempts_count >= max_attempts`, o challenge muda para `status='BLOCKED'` e um evento `OTP_BLOCKED` é gravado — nenhuma tentativa adicional é aceita nesse challenge (mensagem: "Muitas tentativas incorretas — solicite um novo código"), o usuário precisa pedir um **novo** OTP (o que invalida o bloqueado e cria outro do zero).
|
||
|
||
### 6.5 Um único código ativo por vez
|
||
`requestOtp` sempre invalida qualquer challenge anterior ainda pendente (`status IN ('CREATED','SENT')`) do mesmo signatário antes de criar um novo (`status='INVALIDATED'`) — "novo OTP invalida o antigo" é uma regra de produto explícita, não apenas um efeito colateral.
|
||
|
||
### 6.6 Validação (`verifyOtp`)
|
||
1. Busca o challenge mais recente do signatário/envelope (`ORDER BY created_at DESC LIMIT 1`).
|
||
2. Recusa se: não existe nenhum; já `VERIFIED` ("Código já utilizado" — **uso único**, não pode reverificar o mesmo código); `BLOCKED`; expirado (checagem de tempo real, independente do `status` gravado).
|
||
3. Compara `hashCode(código informado) === otp_protected_value` — comparação de **strings de hash**, não do código puro (o servidor nunca decripta/recupera o código original, HMAC não é reversível).
|
||
4. Em caso de erro: incrementa tentativa, grava IP/UA da tentativa, evento `OTP_FAILED` (com `attempts` no metadata) — nunca revela quantas tentativas restam na mensagem de erro ao usuário nem o motivo específico além de "Código incorreto."
|
||
5. Em caso de sucesso: marca `VERIFIED`, grava `verified_at`/IP/UA de verificação, evento `OTP_VERIFIED`, e promove `signature_signers.status → 'OTP_VERIFIED'` (só se ainda não `SIGNED`).
|
||
|
||
### 6.7 Envio por e-mail
|
||
Template HTML simples com o código em destaque (`font-size:32px; letter-spacing:6px`), aviso explícito "expira em 5 minutos e só pode ser usado uma vez". Enviado via `sendMail` (mailer central), remetente `signatureMailFrom()`.
|
||
|
||
### 6.8 Rate limiting nas rotas OTP
|
||
Em `middleware/rateLimit.js`, chaveado **por token** (`byTokenKey = req.params.token`, não por IP — protege o signatário específico independente de onde vêm as tentativas):
|
||
- `otpRequestLimiter`: 5 requisições / 15 min (por token).
|
||
- `otpVerifyLimiter`: 15 requisições / 15 min (por token).
|
||
Além disso, `publicSignatureLimiter` (60/15min por IP) cobre toda a superfície pública de assinatura como proteção geral.
|
||
|
||
### 6.9 Geolocalização
|
||
Explicitamente **não implementada** nesta versão (comentário no código confirma) — só IP + user-agent (parse leve: browser/SO/tipo de dispositivo, sem lib de fingerprinting completa, por decisão de produto de não fazer fingerprinting invasivo).
|
||
|
||
---
|
||
|
||
## 7. Cadeia de auditoria (hash chain) — `auditChain.js`
|
||
|
||
### 7.1 Algoritmo de hash
|
||
**SHA-256** (`crypto.createHash('sha256')`), sempre.
|
||
|
||
### 7.2 O que entra no hash de cada elo
|
||
```js
|
||
event_hash = SHA256(previous_event_hash + canonical_json(campos_do_evento))
|
||
```
|
||
Onde `previous_event_hash` é o `event_hash` do evento anterior do **mesmo envelope** (`ORDER BY seq DESC LIMIT 1`), ou string vazia se for o primeiro evento (`previousEventHash || ''`).
|
||
|
||
Campos que entram na canonicalização (`eventFields`):
|
||
```
|
||
envelopeId, documentId, signerId, eventType, actorType, actorId, actorRole,
|
||
timestampUtc, ipAddress, userAgent, metadata
|
||
```
|
||
Note: `browser`/`operatingSystem`/`deviceType`/`session_id`/`correlation_id` são **gravados na linha mas NÃO entram no hash** — só os campos acima. `timestampUtc` é capturado como `new Date().toISOString()` no momento do `recordEvent`, antes do INSERT (não é o `now()` do Postgres — evita divergência entre o valor usado no cálculo e o valor gravado).
|
||
|
||
### 7.3 Canonicalização — versionada (v1 legado, v2 atual)
|
||
```js
|
||
CURRENT_HASH_ALGORITHM_VERSION = 2
|
||
```
|
||
- **v2 (atual)**: `sortDeep(fields)` — ordena as chaves **recursivamente em profundidade** (inclusive dentro de `metadata`), depois `JSON.stringify`.
|
||
- **v1 (legado)**: só ordenava o **nível de topo**; `metadata` era serializado como veio, sem reordenar internamente.
|
||
|
||
**Por que existe o versionamento**: bug real descoberto — o Postgres `jsonb` **não preserva a ordem original das chaves** de um objeto (reordena por tamanho da chave, depois alfabeticamente). A fórmula v1 não levava isso em conta para `metadata`, então eventos gravados antes da correção não recalculam igual com a fórmula nova. `hash_algorithm_version` (coluna por evento) registra com qual fórmula cada evento foi gravado, para a verificação usar a fórmula certa — sem isso, o hash-chain de qualquer envelope criado antes da correção ficaria permanentemente "inválido" por causa de um bug já corrigido, não de adulteração real. Todo evento novo grava explicitamente `hash_algorithm_version = 2`.
|
||
|
||
### 7.4 Catálogo de tipos de evento (`event_type`, não é um CHECK no banco — texto livre por design, mas com um conjunto fixo usado pela aplicação)
|
||
`ENVELOPE_CREATED`, `DOCUMENT_FROZEN`, `DOCUMENT_HASHED`, `ENVELOPE_READY`, `ENVELOPE_CANCELLED`, `INVITE_SENT`, `LINK_OPENED`, `DOCUMENT_VIEWED`, `DOCUMENT_READ_CONFIRMED`, `CUSTOMER_DATA_CONFIRMED`, `ELECTRONIC_SIGNATURE_TERMS_ACCEPTED`, `OTP_REQUESTED`, `OTP_SENT`, `OTP_FAILED`, `OTP_BLOCKED`, `OTP_VERIFIED`, `OTP_EXPIRED`, `SIGN_BUTTON_PRESSED`, `SIGNATURE_COMMITTED`, `ENVELOPE_SIGNED`, `EVIDENCE_CHECKPOINT_SEALED`, `ENVELOPE_HASH_CALCULATED`, `CERTIFICATE_GENERATED`, `CONSOLIDATED_PDF_GENERATED`, `FINAL_PDF_HASH_CALCULATED`, `FILES_STORED`, `ENVELOPE_COMPLETED`, `FINAL_DOCUMENT_EMAILED`.
|
||
|
||
### 7.5 Verificação da cadeia (`verifyChain`)
|
||
Duas checagens **independentes**, deliberadamente separadas:
|
||
|
||
1. **Linkage** — `previous_event_hash` de cada evento bate com o `event_hash` gravado do evento anterior. Prova que a **sequência** não foi adulterada (nada inserido/removido/reordenado no meio). Não depende da fórmula de canonicalização — **uma falha aqui é sempre tratada como adulteração real**, sem exceção de versão.
|
||
2. **Conteúdo** — o `event_hash` do próprio evento bate com os dados gravados, recalculado com a fórmula correspondente ao `hash_algorithm_version` daquele evento (v1 ou v2). Se um evento `hash_algorithm_version >= 2` não bater → **falha real** (é sempre tratado como possível adulteração, nunca "corrigido" silenciosamente). Se um evento `v1` não bater nem com a fórmula v1 (o jsonb pode ter perdido a ordem original de forma irrecuperável antes da correção existir) → entra em `legacyEvents[]`, um **aviso**, não uma falha (linkage confere, então a cadeia está íntegra; o motivo é um bug já corrigido, não adulteração).
|
||
|
||
Resultado: `{ valid, eventsChecked, legacyEvents[], firstEvent, lastEvent }` (ou `{ valid:false, failedAt, reason }` na primeira falha real encontrada).
|
||
|
||
Exposto via `POST /api/signature-envelopes/:id/verify-audit` (autenticado, `edit`) e reusado dentro da verificação pública (§8).
|
||
|
||
---
|
||
|
||
## 8. Finalização do envelope (`finalizeEnvelope.js`) — dois passos deliberados
|
||
|
||
### 8.1 Por que dois passos
|
||
O `verification_id` e o `evidence_checkpoint_hash` (e o `envelope_hash`) precisam existir **antes** do certificado ser desenhado no navegador (React → html2canvas → jsPDF, mesma técnica de todo PDF client-side deste sistema), porque esses valores são **impressos no próprio certificado** (inclusive como QR code apontando para a página pública de verificação). Por isso o processo é sempre: gerar/reservar os identificadores primeiro (passo 1, no servidor), depois desenhar e enviar o certificado (passo 2).
|
||
|
||
### 8.2 Passo 1 — `sealEvidenceCheckpoint` (`POST /:id/seal-checkpoint`)
|
||
Pré-condição: `envelope.status === 'SIGNED'` (todos os signatários já concluíram `commitSignature`).
|
||
1. Pega o `event_hash` do último evento de auditoria do envelope até este momento → `evidence_checkpoint_hash`. **Não é o hash "final absoluto"** da auditoria inteira — a auditoria continua recebendo eventos depois disso (ex. `CERTIFICATE_GENERATED`); é só um checkpoint do que existia até aqui.
|
||
2. Gera `verification_id = crypto.randomBytes(16).toString('hex')` (16 bytes = 128 bits, hex = 32 chars) — usado na URL pública `/verify/{verificationId}`.
|
||
3. Calcula `envelope_hash = computeEnvelopeHash(...)` (ver §8.4) usando os documentos e signatários **já congelados/concluídos** neste ponto (os fatos não mudam mais daqui em diante).
|
||
4. Grava os três valores em `signature_envelopes`, registra eventos `EVIDENCE_CHECKPOINT_SEALED` e `ENVELOPE_HASH_CALCULATED`.
|
||
5. Transição `SIGNED → FINALIZING` — **a partir daqui o envelope está comprometido a finalizar** (não há caminho de volta para `SIGNED`).
|
||
|
||
### 8.3 Passo 2 — `finalizeEnvelope` (`POST /:id/finalize`, multipart, campo `certificate`)
|
||
Pré-condição: `envelope.status === 'FINALIZING'`.
|
||
1. Recebe o PDF do certificado já pronto (gerado no navegador) via upload multipart (`multer`, limite 10MB), sobe para S3 (`.../signatures/{envelopeId}/evidence/certificate.pdf`), grava `certificate_s3_key`, evento `CERTIFICATE_GENERATED`.
|
||
2. **Consolida o PDF final**: usando `pdf-lib`, cria um novo `PDFDocument`, copia todas as páginas de cada `signature_documents` (na ordem `sort_order`) + as páginas do certificado no final.
|
||
3. Calcula `SHA-256` do buffer consolidado → `final_pdf_sha256`. Upload para `.../signatures/{envelopeId}/final/envelope-final.pdf` → `final_s3_key`.
|
||
4. Eventos `CONSOLIDATED_PDF_GENERATED` (com `page_count`) e `FINAL_PDF_HASH_CALCULATED`.
|
||
5. Arquiva uma cópia do PDF final como um novo anexo interno na pasta de documentos do cadastro de origem (mesma tabela usada pelos demais anexos daquele cliente/revenda) — reaproveita o mesmo objeto S3, sem duplicar armazenamento; nome `assinado_{envelope_number}_{timestamp}.pdf`, `purpose='internal'`. Evento `FILES_STORED`.
|
||
6. Transição `FINALIZING → COMPLETED`, grava `completed_at`.
|
||
7. **Commit da transação.**
|
||
8. **Fora da transação** (melhor esforço, não desfaz a finalização se falhar): envia e-mail ao(s) signatário(s) `contratante` com o PDF final anexado. `sendMail` já trata/loga falhas internamente sem lançar — uma falha de envio de e-mail nunca reverte um envelope já `COMPLETED`. Evento `FINAL_DOCUMENT_EMAILED`.
|
||
|
||
Tudo dentro de uma transação (exceto o e-mail final) — se qualquer passo falhar, nada fica gravado pela metade; o envelope permanece "preso" em `FINALIZING` até uma nova tentativa, **nunca fica silenciosamente `COMPLETED` sem terminar** de verdade.
|
||
|
||
### 8.4 `computeEnvelopeHash` — terceira camada de integridade
|
||
```js
|
||
canonical = {
|
||
envelope_number, evidence_checkpoint_hash,
|
||
documents: documents.map(d => ({ title: d.title, version: d.document_version, sha256: d.original_sha256 })),
|
||
signers: signers.map(s => ({ party: s.party, full_name: s.full_name, cpf: s.cpf, email: s.email, status: s.status })),
|
||
};
|
||
envelope_hash = SHA256(JSON.stringify(canonical))
|
||
```
|
||
`documents`/`signers` sempre lidos ordenados por `sort_order` (coluna estável) — resultado determinístico ao calcular e ao reverificar, recomputável a qualquer momento sem depender de nenhuma coluna `jsonb` (que não preserva ordem — exatamente o problema já visto no hash-chain de eventos).
|
||
|
||
Cobre os **fatos lógicos** da transação (quais documentos, com qual hash, quais signatários, com qual status) — independente da renderização em bytes do PDF final. É uma camada adicional às outras duas: SHA-256 por documento individual (`original_sha256`) e SHA-256 do PDF final consolidado (`final_pdf_sha256`). Se alguém alegasse um conjunto diferente de documentos/signatários para o mesmo envelope, esse hash não bateria — mesmo sem comparar o PDF em si.
|
||
|
||
### 8.5 Certificado de conclusão
|
||
Gerado client-side (`SignatureCertificatePDFTemplate.jsx` → html2canvas → jsPDF, mesmo padrão de todo PDF do sistema). Conteúdo:
|
||
- Identificação da transação (nº do envelope, cliente, status "ASSINADO E ÍNTEGRO", método = "Código de uso único (OTP) enviado por e-mail").
|
||
- Tabela de documentos integrantes (título, versão, nº de páginas, SHA-256 completo em monoespaçada).
|
||
- Um bloco por signatário: nome, CPF/CNPJ, e-mail **mascarado** (`ab***@dominio.com`), empresa/cargo se PJ, status, data/hora, e uma **cronologia** extraída dos eventos de auditoria (convite enviado → link acessado → dados confirmados → termo aceito → OTP validado → "Assinar e Concordar" acionado) — **nunca imprime o código OTP em si**, só a confirmação de que foi validado.
|
||
- Bloco "Manifestação de Vontade" com o texto de consentimento.
|
||
- Bloco "Integridade e Autenticidade": `envelope_hash`, `evidence_checkpoint_hash`, `verification_id`, URL pública de verificação, QR code apontando para ela.
|
||
- Rodapé legal explícito: cita **MP 2.200-2/2001** (art. 10 §2º) e **Lei 14.063/2020**, e declara textualmente que **NÃO é certificação ICP-Brasil nem carimbo de tempo de autoridade externa** — decisão de produto deliberada de nunca simular uma certificação que a empresa não possui.
|
||
|
||
---
|
||
|
||
## 9. Verificação pública (`publicVerify.routes.js` + `PublicVerify.jsx`)
|
||
|
||
Rota pública (sem autenticação), `GET /api/public-verify/:verificationId`, protegida só por `publicSignatureLimiter` (rate limit por IP).
|
||
|
||
1. Busca o envelope por `verification_id` — só retorna algo se `status === 'COMPLETED'` (envelopes em qualquer outro estado retornam 404 "Registro não encontrado", mesmo que existam).
|
||
2. Roda `verifyChain` (a mesma verificação da cadeia de auditoria completa, §7.5).
|
||
3. **Recalcula** `envelope_hash` a partir dos dados **atuais** de `signature_documents`/`signature_signers` e compara com o `envelope_hash` gravado no momento da finalização — prova de integridade independente do PDF em si; se alguém alterasse esses registros no banco depois da conclusão, essa comparação falharia mesmo sem olhar o PDF.
|
||
4. Resposta pública inclui: número do envelope, **nome do cliente mascarado** (`maskName`: só a primeira letra de cada palavra do nome, ex. "M***** S*****" — sem CPF, sem e-mail, nada além do necessário para confirmar o registro), data de conclusão, lista de documentos (título/versão/páginas, sem SHA-256 individual), lista de signatários (nome mascarado + papel), `integrity_valid` (booleano combinando linkage da cadeia + hash do envelope), contagem de eventos verificados, `envelope_hash`/`final_pdf_sha256`/`evidence_checkpoint_hash`/`verification_id` (esses sim em claro — são identificadores públicos de verificação, não dados pessoais), e `method: "Assinatura eletrônica — código de uso único (OTP) por e-mail"`.
|
||
5. Página `PublicVerify.jsx`: exibe um selo verde/vermelho de íntegro/violado, os dados acima, e o mesmo aviso legal do certificado (MP 2.200-2/2001, Lei 14.063/2020, não é ICP-Brasil).
|
||
|
||
---
|
||
|
||
## 10. Segurança das rotas públicas de assinatura (`publicSignature.routes.js` + `publicSignFlow.js`)
|
||
|
||
### 10.1 Autorização só pelo token do link
|
||
Todas as rotas do fluxo público (`GET/POST /api/public-signature/:token/...`) passam por `loadSigner` → `resolveToken(token)`:
|
||
```js
|
||
const tokenHash = SHA256(token);
|
||
SELECT ... FROM signature_signers WHERE secure_token_hash = $1
|
||
```
|
||
- **Nunca busca pelo token em claro** — só pelo hash (`hashToken` = SHA-256 simples, sem HMAC — diferente do OTP; aqui o "segredo" é a alta entropia do próprio token de 256 bits, não um HMAC contra brute force de um espaço pequeno como o OTP de 6 dígitos).
|
||
- Recusa (`error: 'Link inválido.'`) se não encontrar, se `secure_token_revoked_at` estiver preenchido ("Este link foi revogado"), se `secure_token_expires_at < now()` ("Este link expirou — solicite um novo convite"), ou se o envelope já estiver `CANCELLED`/`EXPIRED`.
|
||
- `envelope_id`/`signer_id` **nunca são aceitos do corpo da requisição** para fins de autorização — tudo deriva exclusivamente do que o token comprova (`req.signer`), mesmo que o payload JSON contenha esses campos.
|
||
- Cada rota downstream registra sempre `req.signer.id`/`envelope_id` (do token resolvido), nunca de input do cliente.
|
||
|
||
### 10.2 Por que ninguém assina no lugar de outro sem o link certo
|
||
- O token tem 256 bits de entropia (`crypto.randomBytes(32)`) — impraticável de adivinhar por força bruta.
|
||
- Um token novo é gerado a cada reenvio de convite, revogando implicitamente o anterior (só o hash mais recente casa).
|
||
- O link expira em 7 dias (`secure_token_expires_at`), independente de uso.
|
||
- Cada signatário tem seu **próprio** token — o link de um `contratante` nunca autoriza ações do `contratada` nem de outro `contratante` do mesmo envelope.
|
||
- Cada ação sensível (`confirm-data`, `consent`, `otp/request`, `otp/verify`, `sign`) exige o **estado anterior correto** no banco (ex. `commitSignature` recusa se `status !== 'OTP_VERIFIED'`; `acceptConsent` recusa se nem todos os documentos foram confirmados como lidos) — não é possível pular etapas mesmo manipulando as chamadas de API diretamente, porque cada passo valida o estado gravado no servidor, nunca confia em o frontend ter "passado pela etapa anterior" na tela.
|
||
|
||
### 10.3 Rate limiting nas rotas públicas
|
||
- `publicSignatureLimiter`: 60 requisições / 15 min por IP, aplicado a **todo** o router `/api/public-signature` e também a `/api/public-verify` — proteção padrão de DoS/abuso.
|
||
- `otpRequestLimiter` (5/15min) e `otpVerifyLimiter` (15/15min), ambos chaveados **por token** (não por IP) — protegem cada signatário individualmente contra força bruta do próprio OTP, independente de de onde vêm as tentativas (ver §6.8).
|
||
|
||
### 10.4 "Expiração de sessão de assinatura"
|
||
Não existe uma sessão HTTP tradicional (cookie/JWT) no fluxo público — cada requisição é autorizada de novo pelo token do link (que por si carrega a expiração de 7 dias). Não há timeout adicional de "sessão ativa" além disso; o progresso (`signature_signers.status`) fica persistido no banco entre acessos, então reabrir o link depois de fechar a aba **retoma exatamente de onde parou** (`stepForStatus(status)` no frontend deriva a etapa a partir do status persistido, nunca reinicia do zero) — até o token expirar/ser revogado ou o envelope ser cancelado.
|
||
|
||
### 10.5 Mascaramento de dados sensíveis nas respostas
|
||
- `GET /api/public-signature/:token` retorna o signatário com `serializeSignatureSigner(row, { mask: true })` — CPF e e-mail mascarados (mesmo quando é o "seu próprio" dado sendo exibido de volta na tela "Confira seus dados" — nota: a tela usa o valor não mascarado vindo diretamente de `data.signer`, então na prática CPF/e-mail aparecem completos ali; o mascaramento serve para outros contextos de exposição, ex. o certificado de conclusão).
|
||
- Internamente (rotas autenticadas, `SignatureEnvelopeDetail.jsx`/`GET /api/signature-envelopes/:id`), `serializeSignatureSigner(row, { mask: false })` — dados completos, só para quem já tem permissão `assinaturas:view`.
|
||
|
||
---
|
||
|
||
## 11. Permissões (feature keys)
|
||
|
||
| Feature key | Cobre | Níveis |
|
||
|---|---|---|
|
||
| `documentos_modelos` | CRUD de `document_templates`/versões, preview, test-pdf | `view`/`edit`, `super_admin` sempre passa |
|
||
| `assinaturas` | CRUD de envelopes, envio, cancelamento, exclusão, selamento/finalização, timeline de auditoria, verificação de integridade | `view`/`edit`, `super_admin` sempre passa |
|
||
| Geração de documento definitiva (`document-generation` routes) | não tem feature própria — depende da origem: `contratos_cessao:edit` (cessão), `clientes_todos:edit` (cliente), `revendas_cadastro:edit` (revenda); `super_admin` sempre passa |
|
||
|
||
Todas checadas via `requireFeatureOrSuperAdmin(key, minLevel)` (rotas autenticadas) ou `hasFeatureAccess(user, key, level)` (checagem manual, ex. geração de documento).
|
||
|
||
---
|
||
|
||
## 12. Resumo de decisões de segurança/negócio a preservar no Eden
|
||
|
||
1. **Nunca gravar segredos em claro**: OTP puro nunca persiste (só HMAC-SHA256 com segredo de servidor); token de link público nunca persiste (só SHA-256 do token).
|
||
2. **HMAC (com segredo) para OTP, hash simples para token de link** — a diferença é intencional: OTP tem espaço pequeno (10⁶) e precisa de resistência a rainbow table via segredo; o token de link já tem 256 bits de entropia, hash simples basta.
|
||
3. **Append-only real da auditoria**, reforçado em nível de banco (trigger), não só por "a aplicação nunca chama UPDATE".
|
||
4. **`seq` (serial), nunca `timestamp`, define a ordem da cadeia** — timestamps podem colidir dentro de uma transação.
|
||
5. **Canonicalização de hash versionada** — nunca "corrigir" retroativamente hashes antigos; versionar o algoritmo e reverificar cada evento com a fórmula que ele usou.
|
||
6. **Duas verificações independentes na cadeia** (linkage vs. conteúdo) — uma falha de linkage é sempre adulteração; uma falha de conteúdo só em eventos da versão atual do algoritmo é adulteração (eventos legados servem de aviso, não erro).
|
||
7. **Identificadores de finalização reservados ANTES do certificado ser desenhado** (para poderem ser impressos nele) — processo em dois passos deliberado.
|
||
8. **Snapshot, nunca referência viva**: signatários e documentos assinados são cópias congeladas no momento da criação do envelope — uma edição posterior do cadastro do cliente ou do anexo original nunca altera uma assinatura em andamento/concluída.
|
||
9. **Draft sombreia publicado nas telas internas de edição/preview**, mas a geração real para o cliente sempre usa a versão publicada (`current_version_id`) — nunca o draft.
|
||
10. **Sanitização de HTML em duas camadas antes de qualquer renderização/Chromium** — allowlist estrita de tags/atributos/estilos/schemes de URL.
|
||
11. **Chromium sem navegação de rede** (`page.setContent` apenas, `page.route` bloqueando tudo) — elimina SSRF por construção, não por allowlist de URLs.
|
||
12. **Nunca alegar certificação que não existe** — texto legal explícito em certificado e página de verificação afirmando que não é ICP-Brasil/carimbo de tempo externo.
|
||
13. **Rate limiting em duas dimensões**: por IP (rotas públicas em geral) e por token/identidade (OTP) — proteções complementares, não substitutas.
|
||
|
||
|
||
---
|
||
|
||
|
||
# 5. Módulo Fiscal (NCM, CFOP, Municípios)
|
||
|
||
|
||
Documentação do módulo Fiscal do OrçaFácil (ERP de revendas de telecom/ISP), para reimplementação equivalente no sistema "Eden". Cobre: catálogos fiscais brasileiros, sincronização automática/manual, e configuração fiscal por produto.
|
||
|
||
---
|
||
|
||
## 1. O que é esse módulo e por que existe
|
||
|
||
O OrçaFácil vende serviços de telecom (STFC — telefonia fixa, SVA em locação, PABX, software) para clientes finais, através de revendas. Para emitir documentos fiscais desses serviços no Brasil — **NFCom** (Nota Fiscal de Serviços de Comunicação), **NFS-e** (Nota Fiscal de Serviço eletrônica, para ISS municipal) e eventualmente **NF-e** — o sistema precisa de tabelas de referência oficiais (os "cadastros fiscais" ou "catálogos") que compõem os campos exigidos pelos layouts desses documentos: NCM, CFOP, códigos de tributação de ICMS/PIS/COFINS/IPI, códigos específicos de NFCom e NFS-e, e a futura reforma tributária (IBS/CBS — "IVA dual" que substitui parte do sistema atual).
|
||
|
||
**Importante: este módulo é só a camada de cadastros de referência + configuração fiscal do produto.** Não existe (ainda) um motor de emissão de nota fiscal nem resolução automática de regra fiscal por operação — há uma tabela `fiscal_rules` com a estrutura pronta para isso, mas sem tela de gestão ("não implementar resolução automática completa agora", conforme comentário no código). O que existe hoje:
|
||
|
||
1. Um catálogo genérico e extensível de ~19 tabelas de referência fiscal (NCM, CFOP, CEST, CST de vários tributos, municípios, cClass NFCom, códigos de tributação NFS-e, CST/classificação IBS-CBS, naturezas de operação).
|
||
2. Importação desses catálogos a partir de fontes oficiais — automática (fetch HTTP direto, hoje só NCM e Municípios) ou manual (upload de arquivo CSV/XLSX/JSON com mapeamento de colunas).
|
||
3. Um log de sincronização (`fiscal_catalog_sync`) que registra cada execução de importação.
|
||
4. Uma tabela `product_fiscal_profiles` que liga cada produto comercial (tabela `products`) aos códigos fiscais aplicáveis a ele, separada por componente de faturamento (mensalidade recorrente vs. implantação).
|
||
|
||
Regra de design central, repetida nos comentários do código: **nunca hardcodar os campos de cada catálogo espalhados pela tela** — existe um "registry" central (`fiscalCatalogs.js`, espelhado em back e front) que descreve cada catálogo (tabela, campos, tipo de cada campo, colunas de busca), e uma tela/rota genérica os usa para renderizar CRUD e formulário sem duplicar 19 telas quase idênticas.
|
||
|
||
---
|
||
|
||
## 2. Modelo de dados
|
||
|
||
### 2.1 Convenção de nomenclatura
|
||
|
||
Todas as tabelas do módulo fiscal usam `created_at`/`updated_at` (e não `created_date`/`updated_date`, que é a convenção do resto do schema do OrçaFácil) — decisão deliberada documentada no código como "seguindo literalmente o que o spec pediu para esse módulo novo, convenção diferente do resto do schema".
|
||
|
||
Todas as tabelas de catálogo (exceto `fiscal_catalog_sync`, que é append-only) têm um trigger `set_updated_at()` (função `plpgsql` genérica, criada uma vez) que atualiza `updated_at = now()` em qualquer UPDATE.
|
||
|
||
### 2.2 Tabelas "catálogo simples" (mesmo shape)
|
||
|
||
Tabelas: `fiscal_ncm`, `fiscal_cfop`, `fiscal_product_origin`, `fiscal_icms_cst`, `fiscal_icms_csosn`, `fiscal_pis_cst`, `fiscal_cofins_cst`, `fiscal_ipi_cst`, `fiscal_lc116_service`, `fiscal_nbs`, `fiscal_ibs_cbs_cst`, `fiscal_ibs_cbs_credit_class`.
|
||
|
||
Shape comum:
|
||
|
||
```
|
||
id UUID PK default gen_random_uuid()
|
||
code TEXT NOT NULL -- código oficial (ex: NCM de 8 dígitos)
|
||
description TEXT NOT NULL
|
||
valid_from DATE
|
||
valid_until DATE
|
||
active BOOLEAN NOT NULL DEFAULT true
|
||
source TEXT -- nome da fonte oficial
|
||
source_version TEXT -- versão/ato normativo da fonte
|
||
source_updated_at TIMESTAMPTZ
|
||
metadata JSONB
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
```
|
||
- índice único em `code`; índice normal em `active`.
|
||
|
||
### 2.3 Tabelas "catálogo estendido" (campos extras específicos)
|
||
|
||
- **`fiscal_cfop`** — além do shape simples, tem `direction TEXT` e `scope TEXT` (adicionados em migration separada — ver seção 4).
|
||
- **`fiscal_cest`** — `segment TEXT`, `ncm_id UUID REFERENCES fiscal_ncm(id)` (em vez de `source_updated_at`).
|
||
- **`fiscal_cbenef`** (benefícios fiscais de ICMS, variam por UF) — `state TEXT NOT NULL`, `code`, `description`, `related_cst TEXT`; chave única é `(state, code)`, não `code` sozinho.
|
||
- **`fiscal_nfcom_cclass`** (código de classificação do item na NFCom) — `group_code TEXT`, `group_description TEXT`, além do shape simples.
|
||
- **`fiscal_municipality`** — tabela de municípios/IBGE. Shape: `id, code (IBGE), name, state (UF), active, source, source_version, created_at, updated_at`. **Sem `valid_from`/`valid_until`/`metadata`** — "não faz sentido um município expirar". Índice único em `code`, índice em `state`.
|
||
- **`fiscal_nfse_trib_nacional`** (Código de Tributação Nacional — cTribNac da NFS-e) — `lc116_item_id UUID REFERENCES fiscal_lc116_service(id)`, `nbs_id UUID REFERENCES fiscal_nbs(id)`.
|
||
- **`fiscal_nfse_trib_municipal`** (Código de Tributação Municipal — cTribMun) — `municipality_id UUID NOT NULL REFERENCES fiscal_municipality(id)`, `national_tax_code_id UUID REFERENCES fiscal_nfse_trib_nacional(id)`; chave única `(municipality_id, code)` — o mesmo código pode existir em municípios diferentes.
|
||
- **`fiscal_ibs_cbs_classification`** (cClassTrib da reforma tributária) — `cst_id UUID REFERENCES fiscal_ibs_cbs_cst(id)`, `legal_basis TEXT`.
|
||
- **`fiscal_ibs_cbs_standard_rate`** (alíquotas IBS/CBS) — `tax_type TEXT NOT NULL`, `rate NUMERIC(7,4) NOT NULL`, `valid_from/until`, `legal_basis`, `source*`, `active`. **Sem chave natural única** — pode ter múltiplas vigências para o mesmo `tax_type` (histórico de alíquotas); por isso fica fora da importação automática/manual genérica, cadastro só pela tela normal.
|
||
- **`fiscal_operation_nature`** (naturezas de operação — cadastro interno, não uma fonte externa) — `code UNIQUE`, `name`, `description`, `active`. Seed inicial via `INSERT ... ON CONFLICT DO NOTHING` com valores como `VENDA`, `REMESSA_LOCACAO`, `RETORNO_LOCACAO`, `REMESSA_MANUTENCAO`, `RETORNO_MANUTENCAO`, `STFC_RECORRENTE`, `SVA_RECORRENTE`, `PABX_RECORRENTE`, `PABX_IMPLANTACAO`, `SOFTWARE_RECORRENTE`, `SOFTWARE_IMPLANTACAO`.
|
||
|
||
### 2.4 `fiscal_catalog_sync` — log de sincronização
|
||
|
||
Tabela **append-only** (cada linha é uma execução de import; nunca é atualizada após `finished_at` — por isso não tem `updated_at`, ao contrário das demais tabelas fiscais).
|
||
|
||
```
|
||
id UUID PK
|
||
catalog_name TEXT NOT NULL -- catalogKey, ex: 'ncm', 'municipality'
|
||
source_name TEXT
|
||
source_url TEXT
|
||
source_version TEXT
|
||
started_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
finished_at TIMESTAMPTZ
|
||
status TEXT NOT NULL CHECK IN ('RUNNING','SUCCESS','FAILED','PARTIAL','NO_CHANGE')
|
||
records_received INTEGER
|
||
records_inserted INTEGER
|
||
records_updated INTEGER
|
||
records_inactivated INTEGER
|
||
records_ignored INTEGER
|
||
checksum TEXT -- sha256 do payload bruto recebido
|
||
error_message TEXT
|
||
error_details JSONB
|
||
triggered_by_user_id UUID REFERENCES users(id)
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
```
|
||
Índice em `(catalog_name, started_at DESC)` para consultar histórico rápido.
|
||
|
||
### 2.5 `product_fiscal_profiles` — configuração fiscal do produto
|
||
|
||
Liga um produto comercial (`products`) aos códigos fiscais que se aplicam a ele. **Deliberadamente separada da tabela `products`** — nunca sobrepõe/duplica campos comerciais (`price_*`, `impl_unit_price`, `metered`, `tariff_rates`, `has_ldi` continuam exclusivamente em `products`).
|
||
|
||
```
|
||
id UUID PK
|
||
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE
|
||
billing_component TEXT NOT NULL CHECK IN ('RECURRING','IMPLEMENTATION')
|
||
document_type TEXT NOT NULL DEFAULT 'NONE' CHECK IN ('NFCOM','NFSE','NFE','NONE')
|
||
nfcom_cclass_id UUID REFERENCES fiscal_nfcom_cclass(id)
|
||
nfse_trib_nacional_id UUID REFERENCES fiscal_nfse_trib_nacional(id)
|
||
nfse_trib_municipal_id UUID REFERENCES fiscal_nfse_trib_municipal(id)
|
||
lc116_service_id UUID REFERENCES fiscal_lc116_service(id)
|
||
nbs_id UUID REFERENCES fiscal_nbs(id)
|
||
ncm_id UUID REFERENCES fiscal_ncm(id)
|
||
cest_id UUID REFERENCES fiscal_cest(id)
|
||
product_origin_id UUID REFERENCES fiscal_product_origin(id)
|
||
ibs_cbs_cst_id UUID REFERENCES fiscal_ibs_cbs_cst(id)
|
||
ibs_cbs_classification_id UUID REFERENCES fiscal_ibs_cbs_classification(id)
|
||
ibs_cbs_credit_class_id UUID REFERENCES fiscal_ibs_cbs_credit_class(id)
|
||
valid_from / valid_until DATE
|
||
active BOOLEAN NOT NULL DEFAULT true
|
||
created_at / updated_at TIMESTAMPTZ
|
||
```
|
||
|
||
Regra de negócio central: **um produto tem no máximo um profile ATIVO por `billing_component`** — implementado como índice único parcial:
|
||
```sql
|
||
CREATE UNIQUE INDEX idx_product_fiscal_profiles_active_component
|
||
ON product_fiscal_profiles(product_id, billing_component) WHERE active = true;
|
||
```
|
||
Isso permite manter histórico de profiles inativados (nunca se faz DELETE/UPDATE destrutivo de vigência — cria-se um novo profile e inativa o anterior).
|
||
|
||
Um produto pode ter até 2 profiles ativos: um `RECURRING` (mensalidade) e um `IMPLEMENTATION` (implantação/instalação, só relevante se o produto tiver `impl_unit_price > 0`).
|
||
|
||
**Backfill na migration**: ao criar a tabela, populou-se automaticamente um profile `RECURRING` para cada produto já existente (e `IMPLEMENTATION` para os que tinham `impl_unit_price > 0`), com `document_type` derivado do `service_type` do produto (ver seção 6 — `fiscalMapping.js`). As classificações específicas (cClass, cTribNac, NCM etc.) ficam `NULL` até confirmação manual — o backfill nunca inventa esses códigos.
|
||
|
||
### 2.6 `fiscal_rules` — estrutura para motor fiscal futuro (sem UI ainda)
|
||
|
||
Tabela criada "pronta" mas sem tela de gestão nesta etapa. Campos: `product_id`, `product_fiscal_profile_id`, `company_id`, `establishment_id` (sem FK — não existe cadastro de estabelecimentos no sistema ainda), `operation_nature_id` (→ `fiscal_operation_nature`), `origin_state`, `destination_state`, `document_type`, `tax_regime`, e referências a praticamente todos os catálogos (CFOP, ICMS CST/CSOSN, cBenef, PIS/COFINS/IPI CST, cClass NFCom, tributação NFS-e nacional/municipal, IBS/CBS CST/classificação/crédito), mais `priority INTEGER`, `valid_from/until`, `active`. Existe apenas como schema — não é consumida por nenhuma rota/tela ainda.
|
||
|
||
---
|
||
|
||
## 3. NCM (Nomenclatura Comum do Mercosul)
|
||
|
||
- Fonte oficial: **Receita Federal / Siscomex** — `https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json`. Tipo `AUTO` (testada de verdade e confirmada estável).
|
||
- Importador: `ncmImporter.js`, função `runNcmImport({ dryRun, triggeredByUserId })`.
|
||
- Timeout configurável via env `FISCAL_SYNC_TIMEOUT_MS` (default 60000ms).
|
||
- Fluxo:
|
||
1. Registra início do sync (`startSync`) com status `RUNNING`.
|
||
2. Faz `fetch` com `AbortController` para o timeout. Erros de rede/timeout viram mensagem amigável.
|
||
3. Se a resposta HTTP não for OK, tenta extrair `message` de um JSON de erro estruturado do Siscomex (limpa tags HTML) — senão usa `HTTP {status}`.
|
||
4. Faz parse do JSON e valida que existe `payload.Nomenclaturas` como array. **Importante**: o Siscomex tem uma janela de manutenção diária (01h-03h) em que responde HTTP 200 mas com um JSON de erro (`{message, code, severity}`) em vez dos dados — esse caso é tratado à parte, repassando a mensagem real.
|
||
5. Calcula um checksum sha256 do texto bruto da resposta. Se bater com o checksum do último sync bem-sucedido (`lastSuccessfulSync`), marca como `NO_CHANGE` e não faz upsert (evita trabalho desnecessário quando a fonte não mudou).
|
||
6. Normaliza cada item: `code` = `Codigo` só com dígitos (`\D` removido), `description` = `Descricao`, `valid_from`/`valid_until` convertidos de `DD/MM/AAAA` para `AAAA-MM-DD` (`parseBrDate` — datas com ano `9999`, ou seja "sem data-fim", viram `null`), `active: true`, `source`/`source_version` (= `Ato` ou `Data_Ultima_Atualizacao_NCM` do payload), `source_updated_at` = timestamp da importação.
|
||
7. **Trava de segurança**: se vierem menos de 1.000 linhas (Brasil tem ~10.500 NCMs de 8 dígitos), a importação é **recusada** — provável payload incompleto/corrompido.
|
||
8. Upsert via `upsertCatalogRows` com `inactivateMissing: true` — NCMs que não vieram na nova leva são marcados `active = false` (nunca DELETE).
|
||
9. Loga o resultado (`finishSync`) com status `SUCCESS`/`FAILED`, `sourceVersion`, `checksum` e contagens.
|
||
|
||
---
|
||
|
||
## 4. CFOP (Código Fiscal de Operações e Prestações)
|
||
|
||
- Fonte oficial declarada: "Portal Nacional NF-e", mas **tipo `MANUAL`** — não há endpoint estruturado confiável testado; cadastro é por upload de arquivo oficial ou pela tela.
|
||
- **`direction`** (Direção: entrada/saída) e **`scope`** (Âmbito: estadual/interestadual) são campos de texto livre (`TEXT`, sem `CHECK`/enum) adicionados **numa migration de correção separada** (`add-cfop-direction-scope`) — o comentário no código explica que esses dois campos já estavam previstos no spec original do módulo, mas foram esquecidos na migration que criou `fiscal_cfop` como catálogo "simples" genérico; foram descobertos faltando ao testar a importação manual de arquivo, e corrigidos com um `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`.
|
||
- Conceitualmente, CFOP no Brasil codifica a natureza da operação fiscal por 4 dígitos: 1º dígito indica se é entrada ou saída e o âmbito (1xxx=entrada estadual, 2xxx=entrada interestadual, 3xxx=entrada exterior, 5xxx=saída estadual, 6xxx=saída interestadual, 7xxx=saída exterior). No OrçaFácil isso não é derivado automaticamente do código — `direction` e `scope` são apenas campos de texto preenchidos manualmente/via importação, sem validação ou parsing do código CFOP.
|
||
- Não há importador automático (`AUTO_RUNNERS` só tem `ncm` e `municipality`); CFOP só é importado via `manualFileImporter.js` ou cadastrado direto pela tela genérica.
|
||
|
||
---
|
||
|
||
## 5. Municípios
|
||
|
||
- Fonte oficial: **IBGE** — `https://servicodados.ibge.gov.br/api/v1/localidades/municipios`. Tipo `AUTO`.
|
||
- Uso de negócio: base para o **ISS** (Imposto sobre Serviços) na emissão de **NFS-e** — a tributação municipal (`fiscal_nfse_trib_municipal`) é vinculada a um `municipality_id`, já que cada município define suas próprias regras/códigos de tributação de ISS.
|
||
- Importador: `municipalityImporter.js`, função `runMunicipalityImport`.
|
||
- Timeout maior que o padrão: env `FISCAL_SYNC_TIMEOUT_MS_MUNICIPALITY` (default 120000ms) — comentário explica que a IBGE respondeu devagar em teste real (~80s para 2.4MB).
|
||
- Fluxo similar ao NCM:
|
||
1. `fetch` com abort/timeout, tratamento de erro HTTP e de payload não-array.
|
||
2. Checksum sha256 do corpo bruto; se igual ao último sync bem-sucedido, `NO_CHANGE`.
|
||
3. Mapeamento de cada item: `code` = `m.id` (código IBGE), `name` = `m.nome`, `state` = extraído de `m.microrregiao.mesorregiao.UF.sigla` ou, como fallback, `m['regiao-imediata']['regiao-intermediaria'].UF.sigla` (a API do IBGE mudou de estrutura entre versões; o importador tenta as duas). Filtra linhas sem `code`/`name`/`state`.
|
||
4. **Trava de segurança**: mínimo esperado de 5.000 linhas (Brasil tem 5.570 municípios) — abaixo disso, importação recusada.
|
||
5. Upsert com `inactivateMissing: true`.
|
||
|
||
---
|
||
|
||
## 6. Configuração fiscal de produto
|
||
|
||
Um produto (`products`) pode ter até dois `product_fiscal_profiles` ativos (um por `billing_component`: `RECURRING` e `IMPLEMENTATION`). Cada profile carrega:
|
||
|
||
- **`document_type`**: `NFCOM` | `NFSE` | `NFE` | `NONE` — qual documento fiscal deve ser emitido para esse componente de faturamento do produto.
|
||
- Referências (nullable, todas por `id` de UUID) para os catálogos: NCM, CEST, Origem da Mercadoria, cClass NFCom, cTribNac/cTribMun de NFS-e, item LC 116, NBS, CST/classificação/crédito de IBS-CBS.
|
||
- `valid_from`/`valid_until`, `active`.
|
||
|
||
Além disso, a tabela `products` em si tem (fora de `product_fiscal_profiles`, adicionadas em migrations próprias):
|
||
|
||
- **`ixc_product_code TEXT`** (migration `add-product-ixc-code`) — código do mesmo produto no sistema externo **IXC** (provavelmente o ERP/billing de provedores de internet "IXC Soft", usado para conciliar produtos entre os dois sistemas). Campo de texto livre, editável na tela de Produtos.
|
||
- **`product_code INTEGER`** (migration `add-product-code`) — código sequencial próprio do OrçaFácil, gerado automaticamente a partir de uma `SEQUENCE` (`products_product_code_seq START 1`), com `UNIQUE` constraint. Mesmo padrão usado em `client_registrations.client_code`. Distinto do UUID interno (`id`) e do `ixc_product_code` (que é um campo aberto, do sistema externo). Exibido na UI como `#123`.
|
||
|
||
### 6.1 Sugestão de tipo de documento por `service_type` (`fiscalMapping.js`)
|
||
|
||
Arquivo `server/src/lib/fiscalMapping.js` é a **única fonte** do mapeamento sugerido `service_type → document_type`, para não espalhar essa lógica por outros arquivos:
|
||
|
||
```js
|
||
STFC -> NFCOM
|
||
'Serviço PABX' -> NFSE
|
||
'SVA (locação)' -> NFCOM
|
||
'Serviço software'-> NFSE
|
||
(qualquer outro) -> NONE
|
||
```
|
||
|
||
`suggestedFiscalDefaults(serviceType)` retorna `{ billing_component: 'RECURRING', document_type: <sugerido> }`. **É só uma sugestão/default** exibida na UI (aba "Fiscal" da tela de Produtos, endpoint `GET /fiscal/suggested-mapping/:serviceType`) — o valor real e efetivo fica sempre no profile gravado em `product_fiscal_profiles`; a sugestão nunca sobrescreve automaticamente.
|
||
|
||
---
|
||
|
||
## 7. Importação manual vs. sincronização automática
|
||
|
||
### 7.1 Registro de fontes (`fiscalSources.js`)
|
||
|
||
Arquivo único que centraliza, por `catalogKey`, `{ name, url, type }`:
|
||
- `type: 'AUTO'` → existe importador que baixa e sincroniza sozinho (hoje só `ncm` e `municipality` — as únicas fontes testadas de verdade com fetch real e confirmadas estáveis).
|
||
- `type: 'MANUAL'` → fonte sem endpoint estruturado confiável verificado (as URLs de NFCom/SVRS e NBS/MDIC citadas no spec original retornaram 404/403 em teste real) — cadastro por upload de arquivo oficial ou pela tela do catálogo. Isso cobre CFOP, cClass NFCom, NBS, cTribNac/cTribMun de NFS-e, LC 116, CST/classificação/crédito/alíquota de IBS-CBS, CEST, cBenef, CST de ICMS/PIS/COFINS/IPI, Origem da Mercadoria, e Naturezas de Operação (esta última é cadastro puramente interno, sem fonte externa).
|
||
|
||
### 7.2 Importação automática (NCM, Municípios)
|
||
|
||
Ver seções 3 e 5. Padrão comum: fetch com timeout/abort, checksum sha256 para detectar "sem mudança", validação de volume mínimo esperado (proteção contra payload truncado/corrompido), upsert com `inactivateMissing: true`.
|
||
|
||
### 7.3 Importação manual de arquivo (`manualFileImporter.js`)
|
||
|
||
Reusa o mesmo registry de catálogos (`fiscalCatalogs.js`) para qualquer catálogo que tenha `uniqueBy` definido (chave de upsert). Fluxo (`runManualFileImport`):
|
||
|
||
1. Recebe `catalogKey`, `buffer` do arquivo, `format` (`csv`|`xlsx`|`json`), `columnMapping` (objeto `{ campoDoCatalogo: nomeDaColunaNoArquivo }`), `dryRun`, `fileName`.
|
||
2. Rejeita se o catálogo não tem `uniqueBy` (ex.: `ibs_cbs_standard_rate`, que permite múltiplas vigências) — mensagem: "cadastro continua manual pela tela".
|
||
3. **Parse do arquivo**:
|
||
- `json`: precisa ser um array de objetos.
|
||
- `csv`: `csv-parse/sync`, `columns: true` (usa primeira linha como header), `bom: true`.
|
||
- `xlsx`: `ExcelJS`, lê a primeira planilha, primeira linha como header.
|
||
4. **Mapeamento de linha** (`mapRow`): para cada campo do catálogo, pega o valor da coluna indicada em `columnMapping`; ignora vazio/undefined/null; converte `boolean` (aceita `'true'|'1'|'sim'|'ativo'`, case-insensitive), `number` (`Number(...)`), senão `String(...).trim()`.
|
||
5. Filtra linhas onde algum campo de `uniqueBy` ficou vazio após o mapeamento.
|
||
6. Se `dryRun`: retorna preview (`{ status: 'DRY_RUN', recordsReceived, recordsMapped, sample: primeiras 5 linhas }`) **sem gravar nada nem criar log de sync**.
|
||
7. Se não for dry run: cria registro em `fiscal_catalog_sync` (`sourceName: "Arquivo manual (nome.ext ou formato)"`, sem `sourceUrl`), faz upsert via `upsertCatalogRows` com **`inactivateMissing: false`** (diferença crucial vs. os importadores automáticos — upload manual costuma ser incremental/parcial, não a tabela oficial inteira; inativar em massa aqui seria perigoso), e loga o resultado.
|
||
8. Front (`FiscalImportFileDialog.jsx`): sempre roda "Testar (prévia)" (dry-run) antes de habilitar "Confirmar e importar" — o botão de confirmar fica desabilitado até existir um `preview`. Detecta formato pela extensão do arquivo selecionado. Tem botão de "baixar modelo" (`FiscalSyncPanel.jsx` → `buildTemplateCsv`) que gera um CSV com o header = chaves dos campos do catálogo (exceto `jsonb`) e uma linha de exemplo tipada.
|
||
|
||
### 7.4 Upsert genérico (`upsertCatalog.js`)
|
||
|
||
`upsertCatalogRows({ catalogKey, rows, inactivateMissing })` — reusado por todo importador (automático ou manual). Roda tudo em **uma transação** (BEGIN/COMMIT/ROLLBACK) — nunca grava pela metade.
|
||
|
||
- **Proteção de inativação em massa**: se `inactivateMissing` e a tabela já tem mais de 20 registros (`INACTIVATION_SAFETY_MIN_EXISTING`) e a nova leva trouxe menos de 50% (`INACTIVATION_SAFETY_RATIO`) do que já existia, a importação inteira é **rejeitada** com `FiscalThresholdError` (código `FISCAL_THRESHOLD`) e **nada é alterado** — proteção explícita contra "a fonte respondeu 10 registros onde antes tinha milhares".
|
||
- Para cada linha: monta um `INSERT ... ON CONFLICT (uniqueBy...) DO UPDATE SET col = EXCLUDED.col` dinamicamente a partir dos campos presentes na linha (campos de `uniqueBy` nunca entram no `SET`). Usa `RETURNING (xmax = 0) AS was_insert` para diferenciar insert de update no PostgreSQL (truque padrão: `xmax = 0` é verdadeiro só em linhas recém-inseridas na mesma transação).
|
||
- Linhas sem todas as chaves de `uniqueBy` preenchidas são contadas como `ignored`, não tentam upsert.
|
||
- Se `inactivateMissing`: depois do loop de upsert, roda um único `UPDATE ... SET active = false WHERE active = true AND (uniqueBy...) NOT IN (tuplas vistas)` — **nunca faz DELETE**. Nota: a lista de tuplas é interpolada diretamente na query (escapando aspas simples manualmente) em vez de parametrizada — funciona porque os valores já vêm normalizados/validados a montante, mas é um padrão a rever numa reimplementação.
|
||
- Retorna `{ recordsReceived, recordsInserted, recordsUpdated, recordsInactivated, recordsIgnored }` — usado tanto para a resposta da API quanto para popular o log de sync.
|
||
|
||
### 7.5 Log de sincronização (`syncLog.js`)
|
||
|
||
- `startSync({ catalogName, sourceName, sourceUrl, triggeredByUserId })` → INSERT com `status: 'RUNNING'`.
|
||
- `finishSync(id, { status, sourceVersion, checksum, counts, errorMessage, errorDetails })` → UPDATE do registro, setando `finished_at = now()` e todos os contadores/erro.
|
||
- `lastSuccessfulSync(catalogName)` → último registro com status `SUCCESS` ou `NO_CHANGE` (usado para comparar checksum e decidir "sem mudança").
|
||
- `lastSync(catalogName)` → último registro de qualquer status (usado no painel de status).
|
||
- `syncHistory(catalogName, limit=20)` → histórico paginado por catálogo.
|
||
|
||
---
|
||
|
||
## 8. Rotas
|
||
|
||
Ambos os arquivos de rota exigem autenticação (`auth`) e usam a mesma feature de permissão `fiscal` com dois níveis: `requireFeatureOrSuperAdmin('fiscal', 'view')` e `('fiscal', 'edit')`.
|
||
|
||
### 8.1 `fiscal.routes.js` — CRUD genérico de catálogo
|
||
|
||
- **`GET /fiscal/catalogs/:catalogKey`** (view) — lista paginada. Resolve `:catalogKey` **sempre** via `getFiscalCatalog()` (nunca interpola nome de tabela vindo da URL direto em SQL — proteção contra SQL injection via nome de tabela dinâmico). Query params: `q` (busca ILIKE OR nas `searchColumns` do catálogo), `active` (`'true'`/`'false'`), `page`, `limit` (máx. 100, default 20). Retorna `{ rows, total, page, limit }`, ordenado por `code ASC NULLS LAST`.
|
||
- **`GET /fiscal/catalogs/:catalogKey/:id`** (view) — busca um registro por `id`.
|
||
- **`POST /fiscal/catalogs/:catalogKey`** (edit) — cria registro. Valida `required` de cada campo do catálogo; `409` se violar unicidade (`code` postgres `23505`); `jsonb` é `JSON.stringify`ado, `number` é `Number()`ado antes de gravar.
|
||
- **`PATCH /fiscal/catalogs/:catalogKey/:id`** (edit) — atualiza campos parciais (só os presentes no body); mesmas validações de `required`/unicidade.
|
||
- **`GET /fiscal/suggested-mapping/:serviceType`** (view) — retorna `suggestedFiscalDefaults(serviceType)` de `fiscalMapping.js`.
|
||
|
||
### 8.2 `fiscalSync.routes.js` — sincronização/importação
|
||
|
||
- **`GET /fiscal/sync/status`** (view) — para cada catálogo em `FISCAL_OFFICIAL_SOURCES`, retorna `{ catalogKey, label, sourceName, sourceUrl, sourceType, recordCount (count(*) da tabela), lastSync (via lastSync()) }`. É a base do painel "Atualização de Tabelas".
|
||
- **`GET /fiscal/sync/history/:catalogName`** (view) — últimas 20 execuções de sync desse catálogo.
|
||
- **`POST /fiscal/sync/run/:catalogName?dryRun=true|false`** (edit) — dispara importação automática de UM catálogo. Só funciona se `source.type === 'AUTO'` e existir um runner registrado em `AUTO_RUNNERS` (hoje: `{ ncm: runNcmImport, municipality: runMunicipalityImport }`); senão `400` orientando a usar importação manual de arquivo. Erros do importador viram `422` com a mensagem.
|
||
- **`POST /fiscal/sync/run-all?dryRun=true|false`** (edit) — roda **sequencialmente** (não em paralelo) todos os runners de `AUTO_RUNNERS`, um endpoint só orquestrado pelo backend — o frontend não dispara N requests paralelos. Cada resultado (sucesso ou falha) é acumulado num objeto por `catalogKey` e retornado junto.
|
||
- **`POST /fiscal/sync/import-file/:catalogKey?dryRun=true|false`** (edit) — multipart/form-data via `multer` (memória, limite 20MB). Campos do form: `file`, `format` (`csv`|`xlsx`|`json`), `columnMapping` (JSON stringificado). Chama `runManualFileImport`; erros viram `422`.
|
||
|
||
### 8.3 Rotas de configuração fiscal do produto (dentro de `products.routes.js`)
|
||
|
||
- **`GET /products/:id/fiscal-profiles`** (view) — lista todos os profiles (ativos e inativos, histórico) de um produto, ordenados por `billing_component, created_at`.
|
||
- **`POST /products/:id/fiscal-profiles`** (edit) — cria um novo profile. Valida `billing_component` (`RECURRING`|`IMPLEMENTATION`) e `document_type` (`NFCOM`|`NFSE`|`NFE`|`NONE`). Aceita opcionalmente qualquer um dos 11 campos de referência fiscal (`FISCAL_PROFILE_REF_FIELDS`) e `valid_from`/`valid_until`. `409` se já existir um profile ativo do mesmo `billing_component` para o produto (viola o índice único parcial) — mensagem orienta a inativar o atual antes.
|
||
- **`PATCH /products/:id/fiscal-profiles/:profileId`** (edit) — atualiza campos parciais (`document_type`, `valid_from`, `valid_until`, `active`, + os 11 campos de referência). `409` na mesma condição de unicidade.
|
||
|
||
---
|
||
|
||
## 9. Frontend
|
||
|
||
### 9.1 Página `Fiscal.jsx` (`/Fiscal`)
|
||
|
||
Acesso restrito a `super_admin` ou usuários com feature `fiscal`/`view`. Layout: sidebar lateral fixa com um item "Atualização de Tabelas" (painel de sync) + os demais catálogos agrupados por `FISCAL_GROUPS`:
|
||
- `cadastros` → "Cadastros Fiscais" (NCM, CFOP, CEST, cBenef, Origem, CST ICMS/CSOSN/PIS/COFINS/IPI, Municípios)
|
||
- `nfcom` → "NFCom" (cClass)
|
||
- `nfse` → "NFS-e" (LC 116, NBS, cTribNac, cTribMun)
|
||
- `ibs_cbs` → "IBS / CBS" (CST, cCredPres, cClassTrib, alíquotas)
|
||
- `regras` → "Regras Fiscais" (Naturezas de Operação)
|
||
|
||
Navegação via query string `?catalogo=<key>` (ou `_sync` para o painel de sincronização). `canEdit` (feature `fiscal`/`edit` ou `super_admin`) controla se botões de criar/editar/importar aparecem.
|
||
|
||
### 9.2 `src/lib/fiscalCatalogs.js` — registry espelhado no frontend
|
||
|
||
**Deliberadamente duplicado** do `server/src/lib/fiscalCatalogs.js` — comentário explica que back e front são pacotes separados sem import compartilhado (mesmo padrão usado em `documentValidation.js`). Uma reimplementação em Eden deveria decidir se compartilha esse registry (ex.: pacote comum) ou mantém a duplicação deliberada.
|
||
|
||
### 9.3 Componentes
|
||
|
||
- **`FiscalCatalogManager.jsx`** — tela CRUD genérica reutilizada por todos os catálogos: busca (`q`, debounce via submit de form), paginação (20/página), lista simples (código + descrição + badge ativo/inativo + botão editar), dialog de criar/editar que renderiza um `<FieldInput>` por campo do catálogo, decidindo o widget pelo `field.type` (`text`, `date`, `number`, `boolean` → checkbox, `jsonb` → textarea com `JSON.stringify`/`JSON.parse`, `ref` → `<FiscalAutocomplete>`).
|
||
- **`FiscalAutocomplete.jsx`** — combobox com busca **debounced (300ms)**, nunca carrega o catálogo inteiro (alguns têm milhares de registros) — sempre busca por código/descrição sob demanda via `catalogList({ q, limit: 15 })`. Quando um valor já está selecionado, resolve o label via `catalogGet` e mostra chip com opção "trocar"/limpar.
|
||
- **`FiscalSyncPanel.jsx`** — painel "Atualização de Tabelas": tabela com uma linha por catálogo (label, origem automática/manual, última sincronização com badge de status colorido, contagem de registros atuais, ações). Para catálogos `AUTO`: botão "Atualizar" (chama `run/:catalogName`). Para `MANUAL`: botão "Importar arquivo" (abre `FiscalImportFileDialog`) + botão de baixar modelo CSV. Botão global "Atualizar todas" chama `run-all`. Histórico expansível por linha (`toggleHistory`).
|
||
- **`FiscalImportFileDialog.jsx`** — upload de arquivo com seleção de formato (auto-detectado pela extensão), formulário de mapeamento de colunas (um input de texto por campo do catálogo, exceto `jsonb`), fluxo obrigatório de dry-run → preview → confirmar (botão de confirmar só habilita depois de gerar preview).
|
||
|
||
### 9.4 Integração na tela de Produtos (`src/pages/Products.jsx`)
|
||
|
||
Aba "Fiscal" dentro do formulário de produto, com sub-abas: `resumo` (mostra documento sugerido via `suggestedMapping` + status fiscal calculado), `mensalidade` (profile RECURRING), `implantacao` (profile IMPLEMENTATION, só se o produto tiver preço de implantação — com botão para copiar a config da mensalidade), `ibscbs`. Cada sub-aba de profile usa `FiscalAutocomplete` para os campos de referência. Campos `ixc_product_code` (texto livre) e `product_code` (somente leitura, exibido como `#123`) aparecem na tela de listagem/edição do produto.
|
||
|
||
---
|
||
|
||
## 10. Pontos a decidir/simplificar numa reimplementação (observações, não requisitos)
|
||
|
||
- A tabela de tuplas em `upsertCatalogRows` para o `NOT IN` de inativação é montada por interpolação de string com escape manual de aspas — funciona pois os valores já passaram por normalização, mas o ideal seria parametrizar (ex.: `= ANY($n)` com array de arrays, ou tabela temporária).
|
||
- `fiscal_rules` existe só como schema, sem nenhuma rota/lógica consumindo — decidir se vale portar como está (placeholder) ou só recriar quando o motor de resolução fiscal for de fato implementado.
|
||
- O registry de catálogos é duplicado entre back e front por decisão de arquitetura (pacotes sem import compartilhado); numa stack nova isso pode ser um único módulo compartilhado.
|
||
- `direction`/`scope` do CFOP são texto livre sem enum/validação — a lógica real de CFOP (1º dígito = direção+âmbito) não é derivada automaticamente do código em nenhum lugar do sistema.
|
||
|
||
|
||
---
|
||
|
||
|
||
# 6. Módulo de Ponto Eletrônico (Timeclock)
|
||
|
||
|
||
> Fonte: sistema OrçaFácil (repo em `/opt/orcafacil`), stack original Express + PostgreSQL no
|
||
> backend e React+Vite no frontend. Este documento descreve o módulo "RH > Controle de Ponto"
|
||
> como um sistema praticamente standalone dentro do ERP, cobrindo modelo de dados, integração
|
||
> com relógios de ponto Control iD, parsing/validação de arquivo AFD, motor de apuração de
|
||
> jornada (regras CLT parametrizáveis), fechamento de período, ajustes manuais, banco de horas
|
||
> e relatórios. O objetivo é permitir reconstrução fiel em outra stack.
|
||
|
||
## Visão geral / fluxo de dados
|
||
|
||
```
|
||
Relógio de ponto (Control iD REP iDClass)
|
||
│ bater ponto (biometria/cartão) → grava internamente, gera arquivo AFD incremental
|
||
│
|
||
├─(A) Sincronização ao vivo: GET /get_afd.fcgi?mode=671 (via login autenticado)
|
||
│ → baixa AFD bruto incremental a partir do último NSR já importado
|
||
│
|
||
└─(B) Exportação manual: operador extrai arquivo .afd/.txt do equipamento
|
||
→ upload manual na tela de Importações AFD
|
||
|
||
Em ambos os casos, o arquivo bruto (texto ISO-8859-1) é:
|
||
1. parseado linha a linha (parser.js) — layout oficial AFD Portaria MTP 671/2021 v004
|
||
2. cada linha validada por CRC-16/KERMIT (registros tipo 2/3/4) ou hash próprio (tipo 7)
|
||
3. persistido IMUTAVELMENTE em afd_records (nunca há UPDATE/DELETE aplicativo nessa tabela)
|
||
4. o arquivo original é guardado em S3 e referenciado por afd_imports (idempotente por SHA-256)
|
||
|
||
A apuração (apuracao.js) roda sob demanda ("Calcular"), por funcionário/dia, comparando
|
||
marcações reais (afd_records + ajustes aprovados) contra a jornada (work_schedule) e persiste
|
||
o resultado em timeclock_daily_calculations + lançamentos no banco de horas
|
||
(timeclock_time_bank_entries), respeitando o status de fechamento do período (period_closures).
|
||
|
||
Ajustes administrativos (timeclock_punch_adjustments) NUNCA alteram afd_records — são sempre
|
||
um registro novo, opcionalmente referenciando a marcação original por FK, com fluxo de
|
||
aprovação PENDING → APPROVED/REJECTED (permissão de aprovar segregada da de criar).
|
||
```
|
||
|
||
Todas as datas/horas de negócio assumem fuso fixo `America/Sao_Paulo` (Brasil não tem mais
|
||
horário de verão desde 2019, então o offset é sempre `-03:00`), independente do timezone do
|
||
servidor.
|
||
|
||
---
|
||
|
||
## 1. Modelo de dados
|
||
|
||
Todas as tabelas usam `id UUID PRIMARY KEY DEFAULT gen_random_uuid()`. Datas de auditoria
|
||
(`created_date`, `updated_date`) seguem o padrão do resto do sistema (trigger
|
||
`set_updated_date()` em `BEFORE UPDATE`).
|
||
|
||
### 1.1 `timeclock_devices` — Equipamentos (relógios de ponto Control iD REP iDClass)
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| created_date/updated_date | TIMESTAMPTZ | trigger auto-update |
|
||
| created_by_id | UUID → users | |
|
||
| company_id | UUID → companies, nullable | `NULL` = equipamento "sem empresa" (compartilhado) |
|
||
| name | TEXT NOT NULL | nome de exibição |
|
||
| description | TEXT | |
|
||
| branch | TEXT | filial |
|
||
| host | TEXT NOT NULL | IP/hostname |
|
||
| port | INTEGER NOT NULL DEFAULT 443 | |
|
||
| use_https | BOOLEAN NOT NULL DEFAULT true | |
|
||
| username | TEXT NOT NULL | usuário de login no equipamento |
|
||
| password_encrypted | TEXT NOT NULL | **AES-256-GCM**, nunca texto puro nem hash — precisa ser reversível pois a aplicação autentica no equipamento sob demanda |
|
||
| serial_number, model, firmware_version | TEXT | preenchidos por `testConnection` (via `/get_about.fcgi`) |
|
||
| timezone | TEXT NOT NULL DEFAULT 'America/Sao_Paulo' | |
|
||
| status | TEXT NOT NULL DEFAULT 'UNKNOWN' | `UNKNOWN` \| `ONLINE` \| `OFFLINE` \| `ERROR` |
|
||
| last_seen_at, last_sync_at | TIMESTAMPTZ | |
|
||
| last_error | TEXT | última mensagem de erro de comunicação |
|
||
| last_afd_nsr | BIGINT | cursor de sincronização incremental do AFD (adicionado em migration posterior) |
|
||
| active | BOOLEAN NOT NULL DEFAULT true | |
|
||
|
||
Índice: `idx_timeclock_devices_company (company_id)`.
|
||
|
||
### 1.2 `timeclock_employees` — Funcionários do módulo de ponto
|
||
|
||
Cadastro **separado** dos `users` do ERP (mas pode opcionalmente linkar a um `user_id`).
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| created_date/updated_date | TIMESTAMPTZ | |
|
||
| created_by_id | UUID → users | |
|
||
| company_id | UUID → companies, nullable | |
|
||
| user_id | UUID → users, nullable | vínculo opcional com login do ERP |
|
||
| full_name | TEXT NOT NULL | |
|
||
| cpf | TEXT NOT NULL | 11 dígitos, sem máscara, validado (dígito verificador) no backend |
|
||
| registration | TEXT | matrícula — também vira `code` no payload da API Control iD |
|
||
| work_schedule_id | UUID → timeclock_work_schedules, nullable | jornada vinculada (adicionado em migration da Fase 5) |
|
||
| active | BOOLEAN NOT NULL DEFAULT true | inativar = "demissão": remove de todos os equipamentos automaticamente |
|
||
|
||
Índice único: `(company_id, cpf)` — CPF único por empresa (não globalmente).
|
||
|
||
**Importante — identidade no relógio**: no modo 671 da API Control iD, o **CPF é o próprio
|
||
identificador do usuário no equipamento**. Não existe um "controlid_user_id" separado — por
|
||
isso não há coluna dedicada para isso.
|
||
|
||
Campos agregados calculados na query de listagem (não persistidos):
|
||
- `sync_status_agg`: pior status entre os equipamentos vinculados — `error` > `pending` >
|
||
`synced` > `NULL` (nunca vinculado; front trata como "Pendente").
|
||
- `last_sync_at`: max entre os vínculos.
|
||
- `last_error`: do vínculo com status `error` mais recente.
|
||
- `biometry_status_agg`: `enrolled` se algum vínculo tem `templates_count > 0`, senão `pending`
|
||
se algum tem `templates_count = 0`, senão `NULL`.
|
||
|
||
### 1.3 `timeclock_employee_devices` — Vínculo funcionário × equipamento (status de sync)
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| employee_id | UUID NOT NULL → timeclock_employees, ON DELETE CASCADE | |
|
||
| device_id | UUID NOT NULL → timeclock_devices, ON DELETE CASCADE | |
|
||
| sync_status | TEXT NOT NULL DEFAULT 'pending' | `pending` \| `synced` \| `error` \| `removed` |
|
||
| last_sync_at | TIMESTAMPTZ | |
|
||
| last_error | TEXT | |
|
||
| templates_count | (adicionado depois, sem migration própria no código lido, usado por checkBiometryStatus) | contagem de templates biométricos cadastrados no equipamento |
|
||
|
||
UNIQUE `(employee_id, device_id)`.
|
||
|
||
### 1.4 `afd_imports` — Metadados de cada importação de arquivo AFD
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| created_date | TIMESTAMPTZ | |
|
||
| company_id | UUID → companies, nullable | |
|
||
| device_id | UUID → timeclock_devices, nullable | |
|
||
| source | TEXT NOT NULL | `manual_upload` \| `live_sync` |
|
||
| original_filename | TEXT | nullable (sync ao vivo não tem nome de arquivo) |
|
||
| afd_layout_version | TEXT | sempre `'004'` |
|
||
| file_sha256 | TEXT NOT NULL | hash do arquivo bruto — usado pra idempotência |
|
||
| file_s3_key | TEXT NOT NULL | chave no S3: `timeclock/afd/{deviceId ou 'manual'}/{timestamp}-{uuid}.afd` |
|
||
| file_size | INTEGER | |
|
||
| first_nsr, last_nsr | BIGINT | do arquivo inteiro, incluindo o NSR sentinela do trailer |
|
||
| records_count | INTEGER | total de linhas parseadas |
|
||
| status | TEXT NOT NULL | `VALID` \| `WARNING` \| `INVALID` \| `UNSUPPORTED_LAYOUT` (status agregado do arquivo) |
|
||
| validation_result | JSONB | o `summary` completo do parser (by_type, contagens, etc.) |
|
||
| imported_by_id | UUID → users | |
|
||
|
||
Índice único: `file_sha256` (garante idempotência — reimportar o mesmo arquivo bytes-a-bytes
|
||
não duplica nada, retorna `already_imported: true`).
|
||
|
||
### 1.5 `afd_records` — Um registro por LINHA do arquivo AFD (imutável)
|
||
|
||
**Nunca há rota de UPDATE/DELETE aplicativo para esta tabela.** É o dado bruto legal, precisa
|
||
ser preservado como recebido, mesmo se inválido.
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| created_date | TIMESTAMPTZ | |
|
||
| afd_import_id | UUID NOT NULL → afd_imports | |
|
||
| device_id | UUID → timeclock_devices, nullable | |
|
||
| company_id | UUID → companies, nullable | |
|
||
| nsr | BIGINT, nullable | Número Sequencial de Registro. Nullable de propósito: NSR ausente/não-numérico é inválido mas precisa ser preservado — **nunca inventar 0/autoincrement no lugar** |
|
||
| record_type | TEXT NOT NULL | `'1'`..`'7'`, `'9'`, ou algo não reconhecido |
|
||
| event_datetime | TIMESTAMPTZ, nullable | parseado |
|
||
| event_datetime_raw | TEXT | texto original do campo de data/hora |
|
||
| employee_cpf_raw | TEXT | CPF exatamente como veio no AFD — **sempre string, nunca number, nunca perde zero à esquerda** |
|
||
| employee_id | UUID → timeclock_employees, nullable | resolvido por CPF; `NULL` se órfão (ver religação abaixo) |
|
||
| raw_line | TEXT NOT NULL | linha original completa — nunca omitida, é o dado de auditoria |
|
||
| crc_received, crc_calculated | TEXT | hex de 4 dígitos |
|
||
| crc_valid | TEXT NOT NULL DEFAULT 'NOT_APPLICABLE' | `VALID` \| `INVALID` \| `NOT_APPLICABLE` \| `NOT_VALIDATED` |
|
||
| parsed_data | JSONB | campos decodificados específicos do tipo de registro |
|
||
| validation_status | TEXT NOT NULL | `VALID` \| `WARNING` \| `INVALID` \| `UNSUPPORTED_LAYOUT` |
|
||
| validation_notes | TEXT | |
|
||
|
||
Índice único: `(device_id, nsr)` — chave de idempotência por registro (`ON CONFLICT DO
|
||
NOTHING` na importação). Índices auxiliares por `employee_id`, `company_id`, `record_type`,
|
||
`event_datetime`, `afd_import_id`.
|
||
|
||
### 1.6 `timeclock_work_schedules` — Jornadas de trabalho
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| created_date/updated_date | TIMESTAMPTZ | |
|
||
| created_by_id | UUID → users | |
|
||
| company_id | UUID → companies, nullable | |
|
||
| name | TEXT NOT NULL | |
|
||
| description | TEXT | |
|
||
| active | BOOLEAN NOT NULL DEFAULT true | |
|
||
| tolerance_minutes | INTEGER NOT NULL DEFAULT 5 | tolerância por evento (Art. 58 §1º CLT) |
|
||
| daily_tolerance_cap_minutes | INTEGER NOT NULL DEFAULT 10 | teto diário acumulado de tolerância |
|
||
| overtime_multiplier | NUMERIC(4,2) NOT NULL DEFAULT 1.50 | multiplicador de hora extra (Art. 59 CLT, mínimo 50%) — **não usado no cálculo de minutos hoje**, é metadado de referência/futuro (ver observação na seção 5) |
|
||
| apply_multiplier_to_time_bank | BOOLEAN NOT NULL DEFAULT false | idem — flag de configuração, não aplicada no motor de cálculo atual |
|
||
|
||
### 1.7 `timeclock_work_schedule_days` — Horário por dia da semana
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| work_schedule_id | UUID NOT NULL → timeclock_work_schedules, ON DELETE CASCADE | |
|
||
| weekday | SMALLINT NOT NULL, CHECK 0..6 | `0` = domingo ... `6` = sábado |
|
||
| is_day_off | BOOLEAN NOT NULL DEFAULT false | folga fixa nesse dia da semana |
|
||
| entry_time | TIME | hora de entrada |
|
||
| break_start | TIME | início do intervalo |
|
||
| break_end | TIME | fim do intervalo |
|
||
| exit_time | TIME | hora de saída |
|
||
|
||
UNIQUE `(work_schedule_id, weekday)` — um registro por dia da semana por jornada.
|
||
|
||
Jornada prevista do dia = `(exit_time - entry_time) - (break_end - break_start)`, ou `0` se
|
||
`is_day_off` ou se faltar entrada/saída.
|
||
|
||
### 1.8 `timeclock_holidays` — Feriados
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| created_date | TIMESTAMPTZ | |
|
||
| created_by_id | UUID → users | |
|
||
| company_id | UUID → companies, nullable | usado só quando `scope = 'empresa'` |
|
||
| scope | TEXT NOT NULL | `nacional` \| `estadual` \| `municipal` \| `empresa` |
|
||
| state | TEXT | UF, usado quando `scope = 'estadual'` |
|
||
| city | TEXT | usado quando `scope = 'municipal'` |
|
||
| name | TEXT NOT NULL | |
|
||
| holiday_date | DATE NOT NULL | |
|
||
| active | BOOLEAN NOT NULL DEFAULT true | |
|
||
|
||
**Limitação conhecida e documentada no código**: o motor de apuração só considera feriados de
|
||
escopo `nacional` ou `empresa` (da empresa do funcionário). Feriados `estadual`/`municipal`
|
||
existem no cadastro mas **não entram no cálculo automático**, porque o cadastro de funcionário
|
||
não guarda UF/cidade de residência — decisão explícita de não inventar esse casamento sem o
|
||
dado.
|
||
|
||
### 1.9 `timeclock_punch_adjustments` — Ajustes/tratamentos administrativos de ponto
|
||
|
||
Nunca altera `afd_records` — é sempre um registro novo.
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| created_date | TIMESTAMPTZ | |
|
||
| company_id | UUID → companies, nullable | herdado do funcionário |
|
||
| employee_id | UUID NOT NULL → timeclock_employees | |
|
||
| reference_date | DATE NOT NULL | dia a que o ajuste se refere |
|
||
| adjustment_type | TEXT NOT NULL | ver enum abaixo |
|
||
| original_punch_id | UUID → afd_records, nullable | preenchido só em `ADMIN_CORRECTION` |
|
||
| new_timestamp | TIMESTAMPTZ, nullable | horário proposto/corrigido |
|
||
| reason | TEXT NOT NULL | motivo (obrigatório) |
|
||
| notes | TEXT | observações opcionais |
|
||
| created_by_id | UUID NOT NULL → users | |
|
||
| status | TEXT NOT NULL DEFAULT 'PENDING' | `PENDING` \| `APPROVED` \| `REJECTED` |
|
||
| approved_by_id | UUID → users | |
|
||
| approved_at | TIMESTAMPTZ | |
|
||
| rejection_reason | TEXT | obrigatório ao rejeitar |
|
||
|
||
`adjustment_type` (enum aplicativo, não CHECK no banco): `MISSING_PUNCH` (marcação esquecida),
|
||
`MANUAL_ENTRY` (lançamento manual), `JUSTIFICATION` (justificativa, sem novo horário),
|
||
`ABSENCE` (falta registrada/abonada com desconto), `EXCUSED_ABSENCE` (falta abonada sem
|
||
desconto), `ADMIN_CORRECTION` (correção de uma marcação original específica, com
|
||
`original_punch_id` preenchido).
|
||
|
||
Índices: `(employee_id, reference_date)`, `company_id`, `status`.
|
||
|
||
### 1.10 `timeclock_daily_calculations` — Resultado persistido da apuração diária
|
||
|
||
Uma linha por `(employee_id, calculation_date)` — recalcular substitui (UPSERT).
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| created_date | TIMESTAMPTZ | |
|
||
| company_id | UUID → companies, nullable | |
|
||
| employee_id | UUID NOT NULL → timeclock_employees | |
|
||
| calculation_date | DATE NOT NULL | |
|
||
| work_schedule_id | UUID → timeclock_work_schedules, nullable | |
|
||
| is_day_off | BOOLEAN NOT NULL DEFAULT false | |
|
||
| is_holiday | BOOLEAN NOT NULL DEFAULT false | |
|
||
| holiday_id | UUID → timeclock_holidays, nullable | |
|
||
| expected_minutes | INTEGER NOT NULL DEFAULT 0 | previsto |
|
||
| worked_minutes | INTEGER NOT NULL DEFAULT 0 | trabalhado (soma dos pares de marcação) |
|
||
| late_minutes | INTEGER NOT NULL DEFAULT 0 | atraso na entrada, já líquido de tolerância |
|
||
| early_leave_minutes | INTEGER NOT NULL DEFAULT 0 | saída antecipada, já líquido de tolerância |
|
||
| overtime_minutes | INTEGER NOT NULL DEFAULT 0 | = `max(0, balance)` |
|
||
| deficit_minutes | INTEGER NOT NULL DEFAULT 0 | = `max(0, -balance)` |
|
||
| balance_minutes | INTEGER NOT NULL DEFAULT 0 | `worked - expected` (pode ser negativo) |
|
||
| status | TEXT NOT NULL | ver enum de status abaixo |
|
||
| punches_used | JSONB | array `[{timestamp, source, source_id}]` — as marcações efetivamente usadas no cálculo, na ordem |
|
||
| calculated_by_id | UUID → users | |
|
||
| calculated_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
||
|
||
UNIQUE `(employee_id, calculation_date)`.
|
||
|
||
Enum de `status`: `OK`, `NO_SCHEDULE` (sem jornada vinculada, não dá pra comparar), `ODD_PUNCH_COUNT`
|
||
(número ímpar de marcações no dia — revisão manual necessária), `UNJUSTIFIED_ABSENCE` (dia
|
||
útil, zero marcações, sem ausência aprovada), `ABSENCE` (ausência aprovada com desconto),
|
||
`EXCUSED_ABSENCE` (ausência aprovada sem desconto).
|
||
|
||
### 1.11 `timeclock_time_bank_entries` — Banco de horas (livro-razão)
|
||
|
||
**O saldo nunca é uma coluna persistida** — é sempre `SUM(CREDIT) - SUM(DEBIT)` calculado na
|
||
consulta.
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| created_date | TIMESTAMPTZ | |
|
||
| company_id | UUID → companies, nullable | |
|
||
| employee_id | UUID NOT NULL → timeclock_employees | |
|
||
| entry_date | DATE NOT NULL | |
|
||
| entry_type | TEXT NOT NULL | `CREDIT` \| `DEBIT` |
|
||
| minutes | INTEGER NOT NULL | sempre positivo; o sinal vem de `entry_type` |
|
||
| source | TEXT NOT NULL | `DAILY_CALCULATION` (automático), `MANUAL_ADJUSTMENT` (lançamento manual), `COMPENSATION` (previsto no enum de labels do front, sem gerador automático encontrado no código lido) |
|
||
| reference_id | UUID, nullable | aponta para `timeclock_daily_calculations.id` quando `source = DAILY_CALCULATION` |
|
||
| notes | TEXT | obrigatório em lançamento manual |
|
||
| created_by_id | UUID → users | |
|
||
|
||
Índices: `(employee_id, entry_date)`, `(source, reference_id)`.
|
||
|
||
### 1.12 `timeclock_period_closures` — Fechamento de período (mensal)
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| company_id | UUID → companies, nullable | |
|
||
| period_month | DATE NOT NULL | sempre truncado pro primeiro dia do mês (`date_trunc('month', ...)`) |
|
||
| status | TEXT NOT NULL DEFAULT 'ABERTO' | `ABERTO` \| `EM_CONFERENCIA` \| `FECHADO` |
|
||
| closed_by_id, closed_at | UUID → users, TIMESTAMPTZ | preenchidos ao chegar em `FECHADO` |
|
||
| reopened_by_id, reopened_at, reopen_reason | UUID → users, TIMESTAMPTZ, TEXT | preenchidos na reabertura |
|
||
| updated_by_id, updated_at | UUID → users, TIMESTAMPTZ | |
|
||
|
||
UNIQUE `(company_id, period_month)`. **Ausência de linha para um mês = tratado como
|
||
`ABERTO`** — o sistema é aditivo, nunca precisa popular retroativamente todos os meses.
|
||
|
||
### 1.13 `time_audit_logs` — Trilha de auditoria genérica do módulo
|
||
|
||
Tabela de auditoria compartilhada por todas as ações administrativas do módulo (não confundir
|
||
com `afd_records`, que é o dado de ponto em si).
|
||
|
||
| Coluna | Tipo | Notas |
|
||
|---|---|---|
|
||
| id | UUID PK | |
|
||
| company_id | UUID → companies, nullable | |
|
||
| user_id | UUID → users, nullable | |
|
||
| action | TEXT NOT NULL | string livre, ex.: `AFD_IMPORTED`, `DEVICE_TEST_CONNECTION`, `EMPLOYEE_SYNCED`, `PUNCH_ADJUSTMENT_APPROVED`, `PERIOD_REOPENED`, etc. |
|
||
| entity | TEXT NOT NULL | ex.: `afd_import`, `timeclock_device`, `timeclock_employee`, `timeclock_punch_adjustment`, `timeclock_period_closure` |
|
||
| entity_id | UUID | |
|
||
| old_data, new_data | JSONB | snapshot serializado antes/depois |
|
||
| ip_address | TEXT | extraído de `X-Forwarded-For` (primeiro IP) ou `req.ip`/socket |
|
||
| user_agent | TEXT | |
|
||
| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
||
|
||
---
|
||
|
||
## 2. Integração com dispositivos Control iD (REP iDClass)
|
||
|
||
### 2.1 Protocolo de transporte — por que socket bruto, não `http.request`
|
||
|
||
O firmware do equipamento envia a status-line HTTP e a maioria dos headers terminados só em
|
||
`\n` (LF puro), mas ainda assim fecha os headers com `\r\n\r\n` antes do corpo. Isso viola
|
||
HTTP/1.1 estrito (que exige CRLF em toda linha) e faz o parser nativo do Node (`llhttp`)
|
||
rejeitar a resposta com `"Parse Error: Missing expected CR after response line"`. Confirmado
|
||
testando contra o equipamento real com socket cru — `curl` aceita (é tolerante a LF solto),
|
||
`llhttp` não.
|
||
|
||
**Solução**: comunicação via socket bruto (`net.connect` ou `tls.connect` se `use_https`),
|
||
montando a requisição manualmente:
|
||
|
||
```
|
||
POST {path}?{query} HTTP/1.1\r\n
|
||
Host: {device.host}\r\n
|
||
Content-Type: application/json\r\n
|
||
Content-Length: {tamanho}\r\n\r\n
|
||
{body JSON}
|
||
```
|
||
|
||
Sem `Connection: close` — testado que o equipamento simplesmente não responde com esse header.
|
||
O fim da resposta é detectado por `Content-Length` (quando presente) ou pelo fechamento da
|
||
conexão pelo equipamento (usado em `/get_afd.fcgi`, que não manda Content-Length).
|
||
|
||
Um parser tolerante próprio busca o boundary entre headers e corpo testando múltiplos
|
||
separadores (`\r\n\r\n`, `\n\n`, `\r\n\n`, `\n\r\n`) e monta `{statusCode, headers}` a partir
|
||
das linhas antes do boundary (split por `\r\n|\n`).
|
||
|
||
Query string: `session` (token de sessão) e `mode=671` (indica modo/versão da API REP-671) vão
|
||
sempre como query params, nunca no body, conforme documentação oficial Control iD.
|
||
|
||
Categorias de erro (`ControlIdError.category`): `AUTH`, `TIMEOUT`, `HTTP_ERROR`,
|
||
`INVALID_RESPONSE`, `UNREACHABLE`, `TLS`, `UNKNOWN` — usadas para mostrar mensagem
|
||
compreensível na UI sem vazar detalhe técnico cru. Timeout configurável via env
|
||
`CONTROLID_REQUEST_TIMEOUT` (padrão 8000ms).
|
||
|
||
### 2.2 Criptografia de credenciais (`crypto.js`)
|
||
|
||
Senha do equipamento armazenada com **AES-256-GCM**:
|
||
- Chave: `SHA-256(process.env.CONTROLID_ENCRYPTION_KEY)` — normaliza qualquer string de env em
|
||
32 bytes exatos, permitindo gerar a chave com `openssl rand -hex 32` (ou qualquer tamanho).
|
||
- IV: 12 bytes aleatórios por criptografia (`crypto.randomBytes(12)`).
|
||
- Formato de armazenamento: string `"{iv_base64}:{authTag_base64}:{ciphertext_base64}"` — um
|
||
único campo TEXT no banco (`password_encrypted`), sem colunas separadas para iv/tag.
|
||
- Decriptação usa `setAuthTag` (autenticação GCM) — falha se o texto foi adulterado.
|
||
|
||
Isso **não é hash** (irreversível) — é reversível de propósito, porque a aplicação precisa
|
||
recuperar a senha em texto puro para autenticar no equipamento a cada operação.
|
||
|
||
### 2.3 Autenticação (`auth.js`)
|
||
|
||
Login sob demanda a cada operação — **a sessão nunca é persistida** entre requisições da
|
||
aplicação. `POST /login.fcgi` com `{login: username, password: <decriptada>}`; resposta deve
|
||
conter `session` (token), senão lança `ControlIdError('Usuário ou senha inválidos.', 'AUTH')`.
|
||
|
||
### 2.4 Teste de conexão (`device.js`)
|
||
|
||
`testConnection(device)`: login + `GET/POST /get_about.fcgi` (autenticado via `session` na
|
||
query). Campos oficiais documentados: `mac`, `nSerie`, `versionFW`, `versionMRP`, `isFacial`. A
|
||
API não expõe um campo "modelo" — o tipo é inferido: `isFacial: true` → "REP iDClass Facial",
|
||
senão "REP iDClass". Atualiza `timeclock_devices.status` (`ONLINE`/`ERROR`), `last_seen_at`,
|
||
`last_error`, e faz `COALESCE` em `serial_number`/`firmware_version` (só sobrescreve se veio
|
||
valor). Toda chamada gera um registro em `time_audit_logs` (`DEVICE_TEST_CONNECTION`).
|
||
|
||
`testAllDevicesConnection`: roda sequencialmente (não em paralelo) sobre todos os equipamentos
|
||
ativos de uma empresa (ou todas) — usado sob demanda pelo relatório "Equipamentos offline"
|
||
(botão "Verificar Agora"), **não há polling automático de fundo** (decisão explícita: evita
|
||
tráfego contínuo pro equipamento real).
|
||
|
||
### 2.5 Gestão de usuários no equipamento (`employees.js`)
|
||
|
||
Endpoints usados (todos POST, `mode671: true` na query):
|
||
- `POST /add_users.fcgi` `{users: [{cpf: Number, name, registration?, code?}]}` — primeira vez.
|
||
- `POST /update_users.fcgi` — mesma forma, usado quando já havia vínculo `synced` anterior.
|
||
- `POST /remove_users.fcgi` `{users: [Number(cpf)]}`.
|
||
- `POST /load_users.fcgi` `{users_cpf: [...]}` OU paginado `{limit, offset}` (100 por página; o
|
||
equipamento **exige** limit/offset quando não filtra por CPF — testado: sem eles responde
|
||
erro `"'limit' deve ser do tipo inteiro"`).
|
||
|
||
**CPF trafega como `Number` na API Control iD** (campo documentado como integer) — isso perde
|
||
zeros à esquerda de propósito (limitação do formato JSON da API, não bug da aplicação). Ao
|
||
ler de volta (`load_users.fcgi`), o CPF vem como integer e é repadronizado para 11 dígitos com
|
||
`String(cpf).padStart(11, '0')` — mesma convenção de normalização usada no parser AFD.
|
||
|
||
`syncEmployeeToDevice(employee, device)`: decide `add` vs `update` consultando o **próprio
|
||
histórico** em `timeclock_employee_devices` (se já havia `sync_status = 'synced'`) — a API não
|
||
documenta o que acontece ao chamar `add_users.fcgi` para um CPF já existente, então a decisão é
|
||
feita pelo estado local em vez de inventar comportamento não documentado. Sequencial por
|
||
funcionário × cada equipamento (nunca paralelo).
|
||
|
||
`importEmployeesFromDevice(device)` — **sincronização reversa** (Control iD → ERP): lista todos
|
||
os usuários do equipamento via `load_users.fcgi` paginado; para cada CPF que já existe em
|
||
`timeclock_employees` (escopado por empresa), só confirma o vínculo como `synced`; para CPF
|
||
inexistente, **cria** um novo `timeclock_employees` (`full_name` default
|
||
`"Colaborador {cpf}"` se o equipamento não tiver nome) e tenta religar marcações AFD órfãs
|
||
(ver 2.7). O ERP continua sendo a origem administrativa principal — funcionário já existente
|
||
nunca é sobrescrito por essa sincronização reversa.
|
||
|
||
`checkBiometryStatus(employee)`: consulta `templates_count` via `load_users.fcgi` filtrado por
|
||
CPF, para cada equipamento onde o funcionário está `synced`. **Cadastro de digital só é
|
||
possível fisicamente no painel do equipamento** — confirmado ao vivo contra o equipamento real
|
||
(modelo não-Facial): `/remote_enroll.fcgi` responde "Função suportada apenas para modelos de
|
||
REP iDClass Facial". Esta função é só consulta, nunca tenta cadastrar biometria remotamente.
|
||
|
||
`removeEmployeeFromAllDevices(employee)`: chama `remove_users.fcgi` em todos os equipamentos
|
||
onde o funcionário está `synced`. **Disparado automaticamente ao inativar (demitir) um
|
||
funcionário** — para que ele não consiga mais bater ponto sem depender só do flag `active` no
|
||
ERP. Best-effort por dispositivo: falha em um não trava os outros; erros ficam registrados em
|
||
`sync_status = 'error'` + `last_error` no vínculo.
|
||
|
||
### 2.6 Auditoria (`audit.js`)
|
||
|
||
Função utilitária única `logAudit({companyId, userId, action, entity, entityId, oldData,
|
||
newData, req})` grava em `time_audit_logs`. Extrai IP considerando proxy reverso
|
||
(`X-Forwarded-For`, primeiro IP da lista, senão `req.ip`/`socket.remoteAddress`).
|
||
|
||
### 2.7 Religação de marcações órfãs (`relinkOrphanedAfdRecords`)
|
||
|
||
Cenário real recorrente: o relógio gera marcações continuamente; o cadastro do colaborador no
|
||
ERP (ou a correção do CPF dele) pode acontecer **depois**. Sem tratamento, essas marcações
|
||
ficariam para sempre com `employee_id = NULL` e nunca apareceriam na apuração.
|
||
|
||
```sql
|
||
UPDATE afd_records
|
||
SET employee_id = $1
|
||
WHERE employee_id IS NULL
|
||
AND company_id IS NOT DISTINCT FROM $2
|
||
AND employee_cpf_raw IS NOT NULL
|
||
AND RIGHT(regexp_replace(employee_cpf_raw, '\D', '', 'g'), 11) = $3
|
||
```
|
||
|
||
Chamada (best-effort, nunca bloqueia o fluxo principal) em três pontos: criação de funcionário,
|
||
edição de funcionário quando o CPF muda, e importação de funcionário a partir do equipamento.
|
||
|
||
---
|
||
|
||
## 3. Sincronização ao vivo (`afdLiveSync.js`)
|
||
|
||
`fetchAfdFromDevice(device)`:
|
||
|
||
1. Calcula `initial_nsr = (device.last_afd_nsr ?? -1) + 1` — cursor incremental por equipamento.
|
||
2. Login + `POST /get_afd.fcgi?mode=671` com `{initial_nsr}`, resposta é o **arquivo AFD bruto**
|
||
(não JSON — daí a flag `raw: true` no client, que devolve `Buffer` sem tentar `JSON.parse`).
|
||
3. Em caso de erro de comunicação: marca `timeclock_devices.status = 'ERROR'` +
|
||
`last_error`, retorna resultado estruturado `{ok: false, error, category}` — **nunca lança
|
||
exceção crua**, para não derrubar a tela com 500 quando o equipamento está offline.
|
||
4. Chama `importAfdFile(...)` com `source: 'live_sync'` (mesma função usada no upload manual —
|
||
ver seção 4.4).
|
||
5. **Avanço do cursor**: não usa o `last_nsr` do `summary` do parse direto (ele inclui o NSR
|
||
sentinela do trailer, `999999999`, e qualquer registro `INVALID`/`UNSUPPORTED_LAYOUT`, o que
|
||
travaria o sync incremental para sempre no próximo ciclo). Em vez disso, consulta o maior
|
||
NSR **realmente persistido e válido**:
|
||
```sql
|
||
SELECT max(nsr) FROM afd_records
|
||
WHERE device_id = $1 AND record_type != '9' AND validation_status IN ('VALID','WARNING')
|
||
```
|
||
e grava esse valor em `timeclock_devices.last_afd_nsr` + `last_sync_at = now()`. Se não há
|
||
`maxNsr` (nada válido importado), só atualiza `last_sync_at`.
|
||
|
||
Não há job/cron automático no código lido — o disparo é manual, por botão na tela de
|
||
Importações AFD (um botão por equipamento ativo), chamando `POST /afd-imports/fetch-live`.
|
||
|
||
---
|
||
|
||
## 4. Formato AFD
|
||
|
||
### 4.1 O que é
|
||
|
||
AFD = "Arquivo Fonte de Dados", formato legal brasileiro definido pela **Portaria MTP
|
||
671/2021** (anteriormente 1510/2009) para exportação de marcações de ponto eletrônico. O
|
||
sistema implementa o **leiaute versão 004**. É um arquivo texto **ISO-8859-1 (latin1)**, nunca
|
||
UTF-8, com linhas separadas por `\r\n`. Cada linha começa com um NSR (Número Sequencial de
|
||
Registro, 9 dígitos) seguido de 1 dígito de tipo de registro.
|
||
|
||
Tipos de registro suportados (posições 1-indexed e inclusivas no AFD.md original, convertidas
|
||
para slice 0-indexed exclusivo no código):
|
||
|
||
| Tipo | Nome | Campos decodificados com posição confirmada |
|
||
|---|---|---|
|
||
| 1 | Cabeçalho do arquivo | só NSR fixo `000000000` + tipo; resto vira `raw_tail` (posições internas não documentadas) |
|
||
| 2 | Inclusão/alteração do empregador no REP | `dh(24)` 011-034, `cpf_resp(14)` 035-048, `tipo_ident(1)` 049, `cnpj_empregador(14)` 050-063, `cno_caepf(14)` 064-077, `razao_social(150)` 078-227, `local(100)` 228-327, `crc(4)` 328-331 |
|
||
| 3 | **Marcação REP-C/REP-A** (o principal — batida de ponto convencional) | `dh(24)` 011-034, `cpf(12)` 035-046, `crc(4)` 047-050. Linha esperada com 50 chars. |
|
||
| 4 | Ajuste do relógio do REP | `dh_antes(24)` 011-034, `dh_ajustada(24)` 035-058, `cpf_resp(11)` 059-069, `crc(4)` 070-073 |
|
||
| 5 | Inclusão/alteração/exclusão de empregado no REP | `dh(24)` 011-034, `operacao(1, I/A/E)` 035; resto (CPF/nome/CRC) sem posição documentada → `raw_tail` |
|
||
| 6 | Evento sensível do REP | só `dh(24)` 011-034 confirmado; resto → `raw_tail` |
|
||
| 7 | **Marcação REP-P** (ponto via aplicativo/portal, sem REP físico) | `dh_marcacao(24)` 011-034, `cpf(12)` 035-046, `dh_gravacao(24)` 047-070, `coletor(2)` 071-072, `online_offline(1)` 073, `hash(64)` 074-137. Usa **hash próprio de encadeamento**, não CRC — nunca é substituído por hash calculado internamente. |
|
||
| 9 | Trailer (rodapé) | só NSR fixo `999999999`; contagens internas por tipo não têm posição documentada → conciliação fica marcada como `trailer_cross_check: 'not_implemented_no_documented_positions'` |
|
||
|
||
Datetime raw: formato `AAAA-MM-ddThh:mm:00ZZZZZ` (24 caracteres, ex.:
|
||
`"2026-08-06T08:30:00-0300"`) — parseado por regex e convertido para `Date` mantendo sempre o
|
||
texto original também (`event_datetime_raw`).
|
||
|
||
Princípio geral do parser: **só decodifica campos com posição exata documentada**; qualquer
|
||
coisa além disso vira `raw_tail` dentro de `parsed_data`, nunca é inventado/adivinhado.
|
||
|
||
### 4.2 CRC-16 (`crc16.js`)
|
||
|
||
**Algoritmo: CRC-16/KERMIT** — polinômio `0x1021` refletido (usado na forma `0x8408`), init
|
||
`0x0000`, sem XOR final.
|
||
|
||
```js
|
||
function crc16Kermit(buffer) {
|
||
let crc = 0x0000;
|
||
for (let i = 0; i < buffer.length; i++) {
|
||
crc ^= buffer[i];
|
||
for (let bit = 0; bit < 8; bit++) {
|
||
if (crc & 0x0001) crc = (crc >>> 1) ^ 0x8408;
|
||
else crc = crc >>> 1;
|
||
}
|
||
}
|
||
return crc & 0xffff;
|
||
}
|
||
```
|
||
|
||
`crc16Hex` retorna o hex de 4 dígitos, zero-padded. Validado contra registros tipo 3 reais do
|
||
equipamento — o CRC calculado bateu exatamente com o CRC gravado no AFD original.
|
||
|
||
O CRC é calculado sobre `Buffer.from(line.slice(0, dataEnd), 'latin1')` — ou seja, sobre os
|
||
bytes da linha **até o início do próprio campo de CRC**, e comparado (case-insensitive) contra
|
||
o CRC recebido nos últimos 4 caracteres da linha. Aplicável aos tipos 2, 3 e 4. O tipo 7 usa
|
||
hash próprio (`NOT_APPLICABLE`); tipos 1, 5, 6, 9 não têm CRC validável (`NOT_VALIDATED`).
|
||
|
||
### 4.3 Normalização de CPF do AFD
|
||
|
||
O campo de CPF nos tipos 3/7 tem 12 caracteres de largura na posição documentada, mas o CPF
|
||
real tem 11 dígitos — normalização: pega os **últimos 11 dígitos numéricos** do campo bruto
|
||
(`normalizeAfdCpf`), nunca assume formatação exata. Essa mesma regra é usada para casar
|
||
`employee_cpf_raw` com `timeclock_employees.cpf` na importação e na religação de órfãos.
|
||
|
||
### 4.4 Importação (`afdImportService.js`)
|
||
|
||
- **`analyzeAfdFile(buffer)`**: só faz o parse e devolve `{file_sha256, file_size, ...parsed}`
|
||
— **nunca grava nada** (nem no banco, nem no S3). Usado no botão "Analisar Arquivo" da UI
|
||
antes de confirmar a importação.
|
||
- **`importAfdFile({buffer, filename, deviceId, companyId, userId, req, source})`**:
|
||
1. Idempotência por arquivo: `SHA-256(buffer)`, se já existe uma linha em `afd_imports` com
|
||
esse hash, retorna `{already_imported: true, import_id, status}` sem reprocessar.
|
||
2. Parseia o buffer inteiro.
|
||
3. Faz upload do buffer original para o S3 (chave
|
||
`timeclock/afd/{deviceId||'manual'}/{Date.now()}-{uuid}.afd`, content-type
|
||
`application/octet-stream`) — **o arquivo original é sempre preservado tal como recebido**,
|
||
nunca reconstruído a partir dos dados parseados.
|
||
4. Resolve `employee_id` de cada CPF distinto encontrado no arquivo (via `normalizeAfdCpf`),
|
||
escopado pela empresa quando informada.
|
||
5. Insere uma linha em `afd_imports` (dentro de transação) com os metadados/summary.
|
||
6. Insere uma linha em `afd_records` por linha do arquivo, com `ON CONFLICT (device_id, nsr)
|
||
DO NOTHING` (idempotência por registro — reimportar não duplica mesmo se o hash do
|
||
arquivo mudasse por algum motivo).
|
||
7. Registra auditoria (`AFD_IMPORTED`) com contagem de registros parseados vs inseridos.
|
||
8. Retorna `{already_imported: false, import_id, summary, status, records_inserted}`.
|
||
|
||
Registros com `validation_status = INVALID` ou `UNSUPPORTED_LAYOUT` **são inseridos mesmo
|
||
assim** — nunca descartados — só ficam marcados para não entrar no cálculo de apuração
|
||
(ver seção 5, que filtra por `validation_status IN ('VALID','WARNING')`).
|
||
|
||
### 4.5 Rotas HTTP
|
||
|
||
- `GET /afd-imports` — lista (feature `rh_afd_importacoes.view`).
|
||
- `GET /afd-imports/:id` — detalhe.
|
||
- `GET /afd-imports/:id/download` — baixa de volta o arquivo original do S3 (stream), nunca
|
||
reconstrói a partir do parse.
|
||
- `POST /afd-imports/analyze` (multipart, campo `file` + `device_id`) — preview, retorna
|
||
`sample_records` (primeiros 50) em vez do array completo (arquivo pode ser grande).
|
||
- `POST /afd-imports/import` (multipart) — importação real.
|
||
- `POST /afd-imports/fetch-live` (`{device_id}`) — dispara `fetchAfdFromDevice`.
|
||
- `GET /afd-records` — consulta paginada (50/página) somente leitura de `afd_records`, com
|
||
filtros `device_id`, `employee_id`, `nsr`, `cpf` (ILIKE), `record_type`, `afd_import_id`,
|
||
`date_from`/`date_to` (date_to usa limite exclusivo do dia seguinte para incluir o dia
|
||
inteiro). Feature `rh_afd_auditoria.view` — **nunca há rota de escrita** para esta tabela.
|
||
- `GET /afd-records/:id` — detalhe (mostra `raw_line` completa e `parsed_data`).
|
||
|
||
Upload usa `multer` com `storage: memoryStorage()`, limite de 20MB.
|
||
|
||
---
|
||
|
||
## 5. Apuração de ponto (`apuracao.js`)
|
||
|
||
Motor calcula **um funcionário/um dia por vez** (`calculateEmployeeDay`), sem gravar nada — a
|
||
gravação é uma etapa separada (`persistCalculation`). `calculateRange` itera sobre uma lista de
|
||
funcionários × intervalo de datas, checando fechamento de período por dia.
|
||
|
||
### 5.1 Fuso horário
|
||
|
||
Fixo em `America/Sao_Paulo`, extraído via `Intl.DateTimeFormat` — nunca depende do timezone do
|
||
processo Node/servidor. `weekdayOf(dateStr)` usa `Date.UTC` sobre a data pura (sem hora) para
|
||
não sofrer deslocamento de fuso.
|
||
|
||
### 5.2 Passo a passo do cálculo
|
||
|
||
1. **Carrega jornada do dia**: se `employee.work_schedule_id` está setado, busca
|
||
`timeclock_work_schedule_days` (filtrado pelo `weekday` da data) e
|
||
`timeclock_work_schedules` em paralelo. `hasSchedule = !!(scheduleDay && scheduleConfig)`.
|
||
- **Sem jornada vinculada → `status = 'NO_SCHEDULE'`**. As marcações continuam sendo
|
||
buscadas e exibidas normalmente (marcação é fato registrado, independe de jornada) —
|
||
só não há comparação contra previsto (tudo fica zerado: `expected/late/early/overtime/
|
||
deficit = 0`, `balance = 0`).
|
||
2. **Verifica feriado**: nacional ou da própria empresa do funcionário (ver limitação de
|
||
escopo estadual/municipal na seção 1.8). Se achou, `is_holiday = true`.
|
||
3. **Calcula `expected_minutes`**: `0` se `!hasSchedule` ou dia de folga/feriado; senão
|
||
`(exit - entry) - max(0, break_end - break_start)`, sempre `>= 0`.
|
||
4. **Verifica ausência aprovada no dia** (`timeclock_punch_adjustments` com
|
||
`status='APPROVED'` e `adjustment_type IN ('ABSENCE','EXCUSED_ABSENCE')`, pega a mais
|
||
recente por `created_date DESC`). Se existe, **substitui completamente** o cálculo normal —
|
||
nunca tenta parear marcações nesse dia:
|
||
- `status = adjustment_type` (`'ABSENCE'` ou `'EXCUSED_ABSENCE'`)
|
||
- `deficit_minutes = expected_minutes` se `ABSENCE`, senão `0`
|
||
- `balance_minutes = -deficit_minutes`
|
||
- retorna imediatamente.
|
||
5. **Monta a lista de marcações efetivas do dia**, combinando duas fontes:
|
||
- `afd_records` do funcionário, `record_type IN ('3','7')`,
|
||
`validation_status IN ('VALID','WARNING')`, na data (convertida pro fuso local), **exceto**
|
||
as que já têm um `ADMIN_CORRECTION` aprovado apontando para elas (`original_punch_id`) —
|
||
nesse caso a marcação original é **excluída do cálculo** (mas continua visível na
|
||
Auditoria — não é apagada de `afd_records`, só não entra na apuração).
|
||
- `timeclock_punch_adjustments` aprovados do tipo `MISSING_PUNCH`, `MANUAL_ENTRY` ou
|
||
`ADMIN_CORRECTION` com `new_timestamp` preenchido, `DISTINCT ON
|
||
(COALESCE(original_punch_id, id))` pegando o mais recente por `created_date DESC` — trata
|
||
o caso de uma marcação corrigida mais de uma vez (só a correção aprovada mais recente
|
||
conta). `MISSING_PUNCH`/`MANUAL_ENTRY` não têm `original_punch_id` (cada uma é
|
||
independente, agrupa pelo próprio `id`).
|
||
- As duas listas são concatenadas e **ordenadas por timestamp**. Resultado fica em
|
||
`result.punches_used` como `[{timestamp, source: 'afd_record'|'adjustment', source_id}]`.
|
||
6. **Zero marcações**: se `hasSchedule && !dayOffOrHoliday` → `status =
|
||
'UNJUSTIFIED_ABSENCE'`, `deficit_minutes = expected_minutes`, `balance_minutes =
|
||
-expected_minutes`. Se não tem jornada, permanece `NO_SCHEDULE` (não inventa falta sem
|
||
saber se era dia útil).
|
||
7. **Quantidade ímpar de marcações**: se `hasSchedule`, marca `status = 'ODD_PUNCH_COUNT'` —
|
||
sinaliza para revisão manual, **nunca adivinha qual marcação está faltando** (isso é
|
||
trabalho do usuário criar um ajuste `MISSING_PUNCH`). Mesmo assim, calcula os pares
|
||
completos disponíveis.
|
||
8. **Pareamento**: agrupa as marcações ordenadas em pares consecutivos `[entrada, saída]`
|
||
(`punches[0]+punches[1]`, `punches[2]+punches[3]`, ...) — **não distingue semanticamente
|
||
entrada de saída nem intervalo**, é puramente pareamento posicional. `worked_minutes = soma
|
||
de max(0, (saída - entrada) em minutos)` de cada par.
|
||
9. **Atraso/saída antecipada com tolerância** (só se `hasSchedule && !dayOffOrHoliday`):
|
||
- `firstMin` = minuto do dia (hora local) da primeira marcação; `lastMin` = da última.
|
||
- `rawLate = max(0, firstMin - scheduledEntry)`; `rawEarly = max(0, scheduledExit - lastMin)`.
|
||
- **Tolerância (Art. 58 §1º CLT)**: `toleranceBudget` começa em
|
||
`scheduleConfig.daily_tolerance_cap_minutes` (teto diário acumulado). Para cada evento
|
||
(atraso, depois saída antecipada), perdoa `min(raw, tolerance_minutes, toleranceBudget)`
|
||
minutos e decrementa o orçamento restante. `late_minutes`/`early_leave_minutes` finais são
|
||
o valor bruto **menos** o perdoado (nunca negativo). Ou seja: até `tolerance_minutes` por
|
||
evento, respeitando um teto agregado no dia — configurável por jornada (não fixo no
|
||
código), consistente com convenção coletiva podendo ser diferente do padrão CLT.
|
||
10. **Saldo/extra/déficit** (só se `hasSchedule`): `balance_minutes = worked_minutes -
|
||
expected_minutes`; `overtime_minutes = max(0, balance)`; `deficit_minutes = max(0,
|
||
-balance)`.
|
||
|
||
**Observação importante**: `overtime_multiplier` e `apply_multiplier_to_time_bank` da jornada
|
||
são campos de configuração persistidos e expostos na UI (com nota explicando Art. 59 CLT), mas
|
||
**o motor de cálculo lido (`apuracao.js`) não os aplica** — `overtime_minutes` é gravado em
|
||
minutos brutos, sem multiplicação, e o banco de horas credita exatamente `balance_minutes`
|
||
(também sem multiplicador). Ou seja, esses campos existem no schema/UI como parametrização
|
||
prevista, mas o cálculo do valor monetário/multiplicado da hora extra fica fora do escopo
|
||
atual do motor — decisão a tomar explicitamente ao reconstruir (implementar a multiplicação ou
|
||
manter como metadado informativo).
|
||
|
||
### 5.3 Persistência (`persistCalculation`)
|
||
|
||
Transação que:
|
||
1. `INSERT ... ON CONFLICT (employee_id, calculation_date) DO UPDATE` em
|
||
`timeclock_daily_calculations` — **idempotente**, recalcular sempre substitui o resultado
|
||
anterior daquele dia.
|
||
2. **Sincroniza o banco de horas**: `DELETE FROM timeclock_time_bank_entries WHERE employee_id
|
||
= $1 AND entry_date = $2 AND source = 'DAILY_CALCULATION'` (remove o lançamento antigo
|
||
daquele dia, se houver) e, se `balance_minutes != 0` e `status IN ('OK', 'ABSENCE')`, insere
|
||
um novo lançamento (`CREDIT` se saldo positivo, `DEBIT` se negativo), com
|
||
`Math.abs(balance_minutes)` e `reference_id` apontando para o cálculo recém-gravado. **Só
|
||
posta no livro-razão em status "fechado"** — `UNJUSTIFIED_ABSENCE`/`ODD_PUNCH_COUNT`/
|
||
`NO_SCHEDULE` são pendências de revisão, nunca geram lançamento automático no banco de horas.
|
||
|
||
### 5.4 Cálculo em lote (`calculateRange`)
|
||
|
||
`calculateRange({employeeIds, dateFrom, dateTo, userId})`: se `employeeIds` vazio/nulo, roda
|
||
sobre todos os `timeclock_employees` ativos. Itera **sequencialmente** (funcionário × dia,
|
||
mesmo padrão de todo o módulo — evita sobrecarregar o banco com N conexões simultâneas). Para
|
||
cada combinação, primeiro checa `getClosureStatus(company_id, data)`; se `'FECHADO'`, **pula o
|
||
recálculo** (soma em `summary.CLOSED_PERIOD`, mas conta como processado) — usa um cache local
|
||
por `(company_id, mês)` para não repetir a query de status a cada dia. Retorna
|
||
`{processed, employees_count, days_count, summary}` onde `summary` é uma contagem por status
|
||
final (incluindo `CLOSED_PERIOD`).
|
||
|
||
### 5.5 Rotas HTTP
|
||
|
||
- `POST /timeclock-calculations/calculate` `{employee_ids?, date_from, date_to}` — dispara
|
||
`calculateRange` (feature `rh_apuracao.edit`).
|
||
- `GET /timeclock-calculations` — lista `timeclock_daily_calculations` com filtros
|
||
(`employee_id`, `status`, `date_from`, `date_to`) (feature `rh_apuracao.view`).
|
||
- `GET /timeclock-calculations/time-bank?employee_id=` — lista lançamentos + `balance_minutes`
|
||
somado.
|
||
- `POST /timeclock-calculations/time-bank/manual` — lançamento manual (feature
|
||
`rh_apuracao.edit`), bloqueado se o mês está `FECHADO`; exige `notes` (motivo).
|
||
|
||
---
|
||
|
||
## 6. Fechamento de período (`periodClosures.js` + rotas)
|
||
|
||
### 6.1 Máquina de estados
|
||
|
||
```
|
||
ABERTO ──(POST /timeclock-period-closures)──▶ EM_CONFERENCIA ──(mesma rota)──▶ FECHADO
|
||
▲ │
|
||
└──────────────────── POST /timeclock-period-closures/reopen ─────────────────┘
|
||
(feature separada rh_fechamento_reabertura, exige motivo)
|
||
```
|
||
|
||
`NEXT_STATUS = { ABERTO: 'EM_CONFERENCIA', EM_CONFERENCIA: 'FECHADO' }` — a rota de avanço só
|
||
sabe ir "um passo à frente"; não existe transição direta `ABERTO → FECHADO` nem regressão por
|
||
essa rota (regressão só via `/reopen`, que exige `status === 'FECHADO'` e vai direto para
|
||
`ABERTO`, pulando `EM_CONFERENCIA`).
|
||
|
||
`getClosureStatus(companyId, dateStr)`: `SELECT status FROM timeclock_period_closures WHERE
|
||
company_id IS NOT DISTINCT FROM $1 AND period_month = date_trunc('month', $2::date)` — **sem
|
||
linha para o mês = `'ABERTO'`** (aditivo).
|
||
|
||
### 6.2 O que trava quando `FECHADO`
|
||
|
||
- `calculateRange` pula silenciosamente o recálculo de dias em meses fechados (soma em
|
||
`CLOSED_PERIOD` no summary, mas não sobrescreve `timeclock_daily_calculations`).
|
||
- Aprovar um `timeclock_punch_adjustment` (`POST /timeclock-adjustments/:id/approve`) é
|
||
**bloqueado** com 409 se o `reference_date` do ajuste cai em mês fechado (mensagem: "Período
|
||
fechado — reabra o mês antes de aprovar este ajuste."). **Criar** um ajuste ainda é permitido
|
||
mesmo com o mês fechado (só a aprovação é bloqueada).
|
||
- Lançamento manual no banco de horas (`POST /timeclock-calculations/time-bank/manual`) é
|
||
bloqueado com 409 se `entry_date` cai em mês fechado.
|
||
|
||
### 6.3 Reabertura
|
||
|
||
`POST /timeclock-period-closures/reopen` `{company_id, period_month, reason}` — exige
|
||
`status === 'FECHADO'` atual (senão 409), exige `reason` não vazio, seta `status = 'ABERTO'`,
|
||
grava `reopened_by_id`/`reopened_at`/`reopen_reason`. Gate de permissão **separado**
|
||
(`rh_fechamento_reabertura.edit`) do gate de avançar (`rh_fechamento.edit`) — segregação de
|
||
função: quem pode fechar não necessariamente pode reabrir.
|
||
|
||
### 6.4 Listagem
|
||
|
||
`GET /timeclock-period-closures?year=&company_id=` retorna sempre os 12 meses do ano
|
||
solicitado, preenchendo com `{status: 'ABERTO', id: null, ...}` sintético os meses sem linha
|
||
persistida.
|
||
|
||
Toda transição é auditada (`PERIOD_STATUS_ADVANCED`, `PERIOD_REOPENED`) com snapshot
|
||
antes/depois.
|
||
|
||
---
|
||
|
||
## 7. Ajustes manuais (`timeclockAdjustments.routes.js`)
|
||
|
||
### 7.1 Quem pode ajustar e segregação de função
|
||
|
||
Duas permissões distintas e obrigatórias:
|
||
- `rh_ajustes.edit` — **criar** um ajuste (propor).
|
||
- `rh_ajustes_aprovacao.edit` — **aprovar/rejeitar** um ajuste (decidir). Uma pessoa com só a
|
||
primeira permissão não pode aprovar o próprio pedido nem o de ninguém.
|
||
|
||
Ambas cedem automaticamente para `role === 'super_admin'`.
|
||
|
||
### 7.2 Tipos de ajuste
|
||
|
||
`MISSING_PUNCH` (marcação esquecida — cria uma marcação nova via `new_timestamp`, sem
|
||
`original_punch_id`), `MANUAL_ENTRY` (lançamento manual, mesma forma), `JUSTIFICATION`
|
||
(justificativa textual, sem `new_timestamp`), `ABSENCE` (falta registrada — gera déficit no
|
||
cálculo do dia), `EXCUSED_ABSENCE` (falta abonada — não gera déficit), `ADMIN_CORRECTION`
|
||
(corrige uma marcação **existente e específica**, referenciada por `original_punch_id`; a
|
||
apuração exclui a marcação original do cálculo e usa só o `new_timestamp` corrigido).
|
||
|
||
### 7.3 Fluxo de criação
|
||
|
||
`POST /timeclock-adjustments` valida: `employee_id`, `reference_date`, `adjustment_type` (um
|
||
dos 6 válidos), `reason` (não vazio) obrigatórios. Sempre nasce com `status = 'PENDING'`. Não
|
||
verifica fechamento de período na criação (só na aprovação — ver 6.2).
|
||
|
||
### 7.4 Fluxo "Corrigir Marcação" (UI dedicada, gera `ADMIN_CORRECTION`)
|
||
|
||
Fluxo específico na tela de Ajustes: usuário escolhe funcionário + período, o front busca as
|
||
marcações reais (`afd_records` tipos 3/7) nesse intervalo via `GET /afd-records`, e para cada
|
||
marcação sem correção travada (nenhum ajuste `PENDING`/`APPROVED` já existente para ela —
|
||
`REJECTED` libera para tentar de novo) mostra um campo para digitar a hora correta + motivo
|
||
obrigatório, e um botão "Salvar" por linha que cria um `ADMIN_CORRECTION` individual vinculado
|
||
àquela marcação (`original_punch_id`). O horário é convertido de "hora local
|
||
America/Sao_Paulo" (`datetime-local` não carrega timezone) para timestamp com
|
||
offset `-03:00` fixo antes de enviar ao backend — evitando o bug real encontrado em produção de
|
||
interpretar a hora local como se já fosse UTC (3h de diferença).
|
||
|
||
### 7.5 Aprovação/rejeição
|
||
|
||
- `POST /timeclock-adjustments/:id/approve` — exige `status === 'PENDING'` (409 senão), exige
|
||
período não fechado (409 senão — ver 6.2), seta `APPROVED` + `approved_by_id`/`approved_at`.
|
||
- `POST /timeclock-adjustments/:id/reject` `{rejection_reason}` — exige `status === 'PENDING'`
|
||
e `rejection_reason` não vazio; seta `REJECTED` + motivo.
|
||
|
||
Ambas geram auditoria com snapshot antes/depois. Nenhuma das duas re-executa a apuração
|
||
automaticamente — o efeito só aparece na próxima vez que alguém rodar "Calcular" para aquele
|
||
dia (a apuração busca ajustes `APPROVED` em tempo real a cada cálculo).
|
||
|
||
---
|
||
|
||
## 8. Escalas/horários e feriados — efeito no cálculo
|
||
|
||
Já detalhado nas seções 1.6–1.8 e 5.2. Resumo do impacto:
|
||
- **Sem `work_schedule_id`** no funcionário → apuração nunca sai de `NO_SCHEDULE`, zero
|
||
comparação contra previsto, mas marcações continuam sendo mostradas.
|
||
- **`is_day_off` do dia da semana** ou **feriado batendo** → `expected_minutes = 0`, sem atraso
|
||
nem saída antecipada calculados (o bloco de tolerância só roda se `!dayOffOrHoliday`).
|
||
Marcações feitas nesse dia ainda são contabilizadas em `worked_minutes` normalmente — viram
|
||
saldo positivo (`overtime_minutes`) porque `expected = 0`.
|
||
- **Feriado com `scope='empresa'`**: só conta se `company_id` bate com o do funcionário.
|
||
`scope='nacional'`: conta sempre. `scope='estadual'/'municipal'`: cadastrável mas **não
|
||
entra no cálculo automático** (limitação documentada — falta UF/cidade no cadastro do
|
||
funcionário).
|
||
- **Tolerância** (`tolerance_minutes` por evento, `daily_tolerance_cap_minutes` por dia) é
|
||
configurada por jornada, não fixa — o valor default (5min/evento, 10min/dia teto) reflete o
|
||
Art. 58 §1º CLT mas pode ser ajustado por convenção coletiva.
|
||
|
||
---
|
||
|
||
## 9. Banco de horas — acúmulo e compensação
|
||
|
||
Modelo de **livro-razão** (double-entry simplificado): nunca existe uma coluna de "saldo atual"
|
||
persistida — todo saldo é `SUM(CASE WHEN entry_type='CREDIT' THEN minutes ELSE -minutes END)`
|
||
sobre `timeclock_time_bank_entries`, calculado na consulta (`GET
|
||
/timeclock-calculations/time-bank?employee_id=`).
|
||
|
||
Origens dos lançamentos (`source`):
|
||
- `DAILY_CALCULATION` — gerado automaticamente por `persistCalculation` sempre que uma apuração
|
||
diária fecha com `balance_minutes != 0` e `status IN ('OK','ABSENCE')`. Idempotente: a cada
|
||
recálculo daquele dia, o lançamento antigo (mesmo `employee_id`+`entry_date`+`source`) é
|
||
apagado e um novo é inserido — nunca duplica nem soma incrementalmente.
|
||
- `MANUAL_ADJUSTMENT` — lançamento manual via `POST /timeclock-calculations/time-bank/manual`
|
||
(feature `rh_apuracao.edit`), exige `entry_type` (`CREDIT`/`DEBIT`), `minutes` inteiro
|
||
positivo, e `notes` (motivo) obrigatório; bloqueado se o mês da `entry_date` está fechado.
|
||
- `COMPENSATION` — valor de enum previsto na label da UI do front, mas **não há gerador
|
||
automático** desse tipo de lançamento no código lido (nenhuma rota cria `source =
|
||
'COMPENSATION'` diretamente); provavelmente reservado para uma feature futura de
|
||
compensação formal de banco de horas.
|
||
|
||
Não há teto de saldo, expiração automática, nem conversão para pagamento em dinheiro
|
||
implementados no código lido — é puramente o livro-razão em minutos.
|
||
|
||
---
|
||
|
||
## 10. Relatórios (`timeclockReports.routes.js`)
|
||
|
||
Arquitetura orientada a um registro (`REPORTS`) mapeando `type` → `{label, needsPeriod, columns,
|
||
run}`. Rota única `GET /timeclock-reports?type=&company_id=&date_from=&date_to=&refresh=`
|
||
despacha para a função certa e devolve `{type, label, columns, rows}` (formato tabular
|
||
genérico, renderizado por uma tabela dinâmica no front). `GET /timeclock-reports/meta` lista os
|
||
tipos disponíveis (usado para montar o seletor). Todo relatório aceita `company_id` opcional —
|
||
sem filtro, agrega todas as empresas. Feature `rh_relatorios.view`.
|
||
|
||
Relatórios disponíveis:
|
||
|
||
| type | Nome | Período? | Fonte / lógica |
|
||
|---|---|---|---|
|
||
| `overtime` | Horas extras | sim | `SUM(overtime_minutes)` + contagem de dias com extra, por colaborador, de `timeclock_daily_calculations` |
|
||
| `late` | Atrasos | sim | `SUM(late_minutes)` + contagem de dias com atraso |
|
||
| `absences` | Faltas | sim | contagem `UNJUSTIFIED_ABSENCE` vs `ABSENCE` por colaborador |
|
||
| `time_bank` | Banco de horas | sim | saldo (`CREDIT - DEBIT`) por colaborador no período, de `timeclock_time_bank_entries` |
|
||
| `manual_punches` | Marcações manuais | sim | lista de ajustes `APPROVED` do tipo `MISSING_PUNCH`/`MANUAL_ENTRY` |
|
||
| `admin_changes` | Alterações administrativas | sim | lista de **todos** os ajustes (qualquer status/tipo) no período, com quem solicitou e quem decidiu |
|
||
| `no_punch_employees` | Colaboradores sem marcação | sim | contagem de dias `UNJUSTIFIED_ABSENCE` por colaborador |
|
||
| `offline_devices` | Equipamentos offline | não | `timeclock_devices` ativos com `status != 'ONLINE'`; parâmetro `refresh=true` dispara `testAllDevicesConnection` antes de consultar (botão "Verificar Agora" na UI) |
|
||
| `sync_failures` | Falhas de sincronização | não | `timeclock_employee_devices` com `sync_status = 'error'`, join funcionário+equipamento |
|
||
|
||
### 10.1 Espelho de Ponto (relatório individual, tela separada)
|
||
|
||
`TimeclockPunchReport.jsx` — página de impressão (não é uma rota de API própria, reusa `GET
|
||
/timeclock-calculations?employee_id=&date_from=&date_to=` já calculado). Aberta em nova aba a
|
||
partir do botão "Imprimir Relatório" da tela de Apuração, com os mesmos filtros aplicados.
|
||
Mostra por dia: marcações (horário de cada uma), previsto, trabalhado, atraso, saldo, status;
|
||
totais do período (previsto, trabalhado, saldo, dias pendentes); linhas de assinatura
|
||
(colaborador / responsável) para impressão física. CSS de impressão (`@media print`) esconde
|
||
os controles de UI. **Não recalcula nada** — se a apuração não foi rodada para aquele período,
|
||
mostra vazio com aviso "volte à tela de Apuração e clique em Calcular primeiro".
|
||
|
||
### 10.2 Dashboard (`GET /timeclock-reports/dashboard`)
|
||
|
||
Feature separada `rh_dashboard.view`. Números **ao vivo**, não dependem de ninguém ter clicado
|
||
em "Calcular" hoje:
|
||
- `funcionarios_ativos`: contagem de `timeclock_employees` ativos.
|
||
- `presentes_hoje`: `DISTINCT employee_id` de `afd_records` tipo 3/7 na data de hoje
|
||
(convertida para o fuso local antes de comparar).
|
||
- `sem_marcacao`: `funcionarios_ativos - presentes_hoje`.
|
||
- `horas_extras_hoje_minutos`: soma de `calculateEmployeeDay` (a função de cálculo **em
|
||
memória**, sem persistir) rodado para cada funcionário ativo na data de hoje — por isso
|
||
reflete o estado atual mesmo que a apuração persistida esteja desatualizada.
|
||
- `equipamentos_online` / `equipamentos_total`: contagem de `timeclock_devices` ativos por
|
||
`status`.
|
||
|
||
---
|
||
|
||
## 11. Permissões (features) usadas no módulo
|
||
|
||
Todas as rotas usam `requireFeatureOrSuperAdmin(featureKey, 'view'|'edit')` — `super_admin`
|
||
sempre passa; qualquer outro papel precisa ter a feature explicitamente concedida com o nível
|
||
certo. Chaves de feature encontradas:
|
||
|
||
| Feature key | Uso |
|
||
|---|---|
|
||
| `rh_dashboard` | Dashboard ao vivo |
|
||
| `rh_equipamentos` | CRUD de equipamentos Control iD, teste de conexão |
|
||
| `rh_funcionarios` | CRUD de colaboradores do ponto, sync com equipamento, importar do equipamento, verificar biometria |
|
||
| `rh_jornadas` | CRUD de jornadas/horários **e** feriados (compartilham a mesma feature) |
|
||
| `rh_afd_importacoes` | Upload/análise/importação de AFD, sync ao vivo, download do arquivo original |
|
||
| `rh_afd_auditoria` | Consulta somente-leitura de `afd_records` (NSR/AFD) |
|
||
| `rh_apuracao` | Calcular apuração, listar cálculos, banco de horas (view+edit) |
|
||
| `rh_ajustes` | Criar ajustes de ponto (propor) |
|
||
| `rh_ajustes_aprovacao` | Aprovar/rejeitar ajustes — **segregada** de `rh_ajustes` |
|
||
| `rh_fechamento` | Avançar status de fechamento de período |
|
||
| `rh_fechamento_reabertura` | Reabrir período fechado — **segregada** de `rh_fechamento` |
|
||
| `rh_relatorios` | Relatórios consolidados |
|
||
|
||
Todas as telas do front seguem o mesmo padrão: `base44.auth.me()` no mount, checagem
|
||
`role === 'super_admin' || hasFeatureAccess(user, featureKey, 'view')` para renderizar
|
||
conteúdo (senão tela "Acesso Restrito"), e `hasFeatureAccess(user, featureKey, 'edit')` para
|
||
habilitar ações de escrita (botões de criar/editar/aprovar ficam ocultos, não só desabilitados).
|
||
|
||
---
|
||
|
||
## 12. Decisões de design a preservar na reconstrução
|
||
|
||
Resumo dos princípios que aparecem repetidos nos comentários do código-fonte original e que
|
||
são estruturais para a integridade legal/auditável do módulo:
|
||
|
||
1. **`afd_records` é imutável** — nenhuma rota de UPDATE/DELETE deve existir para essa tabela
|
||
em nenhuma camada (nem admin). É o dado legal bruto.
|
||
2. **Nunca inventar dado ausente**: NSR ausente não vira 0/autoincrement; campo AFD sem posição
|
||
documentada não é adivinhado (vai para `raw_tail`); feriado estadual/municipal não é
|
||
aplicado sem o dado de localização do funcionário.
|
||
3. **Todo tratamento administrativo é aditivo**: ajustes de ponto são sempre registros novos
|
||
referenciando o original por FK opcional, nunca um UPDATE sobre a marcação original.
|
||
4. **Segregação de função** dupla: criar ajuste ≠ aprovar ajuste; fechar período ≠ reabrir
|
||
período — são permissões (features) distintas de propósito.
|
||
5. **Idempotência em duas camadas** na importação AFD: por arquivo inteiro (SHA-256) e por
|
||
registro individual (`device_id + nsr` único).
|
||
6. **Fechamento de período como veto, não histórico paralelo**: em vez de duplicar dados
|
||
"congelados", o sistema simplesmente recusa (409) recalcular/aprovar/lançar sobre datas de
|
||
um mês `FECHADO` — os dados computados antes do fechamento continuam sendo os dados
|
||
oficiais até uma reabertura explícita e auditada.
|
||
7. **Sequencialidade deliberada**: toda operação que bate no equipamento físico (sync,
|
||
apuração em lote, teste de conexão de todos os devices) roda sequencialmente, nunca em
|
||
paralelo — evita sobrecarregar equipamentos embarcados de baixo desempenho.
|
||
8. **Mensagens de erro de equipamento nunca vazam detalhe técnico cru** para o usuário final —
|
||
sempre categorizadas e traduzidas (`ControlIdError.category` → mensagem em português).
|
||
|
||
|
||
---
|
||
|
||
|
||
# 7. Backoffice diverso (Backup, Agenda, Welcome Page, Empresa, Dashboard)
|
||
|
||
|
||
Escopo deste documento: **Backup**, **Agenda** (salas de reunião e carros), **Página de Boas-vindas** (Welcome Page), **Empresa** (multi-empresa, visão operacional), **Management/ManagerDashboard** (hub administrativo) e o utilitário genérico `functions.routes.js`. Também cobre os serviços transversais de **e-mail** (`mailer.js`) e **armazenamento S3** (`s3.js`) usados por esses módulos (e por outros).
|
||
|
||
Convenção de permissão do sistema: toda rota autenticada (`auth`) e, além disso, a maioria exige `requireFeatureOrSuperAdmin(featureKey, nivel)`, onde `nivel` é `'view'` ou `'edit'`. `super_admin` sempre passa. As demais roles dependem da tela "Permissões por Papel" (Gestão), que atribui `view`/`edit` por feature a cada papel. Isso é o mecanismo geral de controle de acesso do sistema — repetido em todos os módulos abaixo.
|
||
|
||
---
|
||
|
||
## 1. Backup
|
||
|
||
### O que é
|
||
|
||
Módulo de **backup lógico do banco Postgres inteiro** — não é backup seletivo de tabelas nem dos arquivos do S3. Usa `pg_dump -Fc` (formato "custom", comprimido, permite restauração seletiva embora o sistema só restaure o dump inteiro) e `pg_restore --clean --if-exists --no-owner --no-privileges` para restaurar.
|
||
|
||
Explicitamente **fora do escopo do backup**: arquivos que já vivem no S3 (documentos assinados, anexos de cadastro de cliente/revenda, imagem da Welcome Page etc.) — esses já são duráveis por si mesmos no bucket, então o módulo de Backup não precisa cobri-los.
|
||
|
||
### Onde é armazenado
|
||
|
||
Bucket S3 (compatível com S3, configurado via env vars — ver seção 5). Chave (`s3_key`) no formato:
|
||
```
|
||
backups/backup-<ISO timestamp com : e . trocados por ->.dump
|
||
```
|
||
Gerado por `s3KeyForNow()` em `backupService.js`.
|
||
|
||
### Como o dump é feito (streaming, sem disco/memória local)
|
||
|
||
Ponto de design central, comentado explicitamente no código como reação a um incidente anterior de disco cheio (ver memória do usuário sobre `/opt/sistema_consulta`): **o dump nunca é materializado inteiro em disco nem em memória**.
|
||
|
||
- `pg_dump -Fc <DATABASE_URL>` roda como processo filho (`spawn`), stdout em modo `pipe`.
|
||
- O stdout é canalizado por um `PassThrough` que só conta bytes (para saber o tamanho final sem precisar de um `HeadObject` extra no S3).
|
||
- Esse stream é enviado direto para o S3 via `@aws-sdk/lib-storage` `Upload` (multipart upload), como `Body`.
|
||
- Restauração é o inverso: `getObjectStream` do S3 é canalizado (`.pipe`) diretamente para o stdin de `pg_restore`, também sem tocar disco.
|
||
|
||
### Modelo de dados
|
||
|
||
Tabelas (migration `1786000000000_add-backup-module.js`):
|
||
|
||
**`backup_runs`** — histórico de cada execução:
|
||
- `id` UUID PK, `created_date`
|
||
- `s3_key` TEXT (nullable) — chave do arquivo no bucket
|
||
- `size_bytes` BIGINT
|
||
- `status` ENUM `backup_status`: `running` | `success` | `failed`
|
||
- `error_message` TEXT
|
||
- `triggered_by` ENUM `backup_trigger`: `manual` | `auto`
|
||
- `triggered_by_user_id` UUID → users(id) (**FK removida** depois, ver abaixo)
|
||
- `started_at`, `finished_at`
|
||
|
||
**`backup_settings`** — singleton (padrão "sempre a linha mais recente por `created_date`", mesmo padrão usado em `companies`):
|
||
- `enabled` BOOLEAN default false
|
||
- `frequency` ENUM `backup_frequency`: `daily` | `weekly` | `monthly`
|
||
- `hour` INTEGER 0–23 (default 3 = 3h da manhã)
|
||
- `day_of_week` INTEGER 0–6 (usado só se frequency=weekly)
|
||
- `day_of_month` INTEGER 1–28 (usado só se frequency=monthly; limitado a 28 para não pular meses curtos)
|
||
- `retention_days` INTEGER > 0, default 30
|
||
- `updated_by_id` UUID → users(id) (**FK removida** também)
|
||
- trigger `set_updated_date()` mantém `updated_date` automático
|
||
|
||
### Agendamento (sem cron externo)
|
||
|
||
Não usa `node-cron` nem cron do SO. `startBackupScheduler()` roda uma vez no boot do processo:
|
||
1. Chama `healStuckRuns()` (ver abaixo) uma vez.
|
||
2. Registra um `setInterval` de 60s (`checkScheduledBackup`).
|
||
|
||
A cada minuto, `checkScheduledBackup()`:
|
||
- Lê `backup_settings` vigente; se `!enabled`, sai.
|
||
- Compara `now.getHours()` com `settings.hour` — só dispara na hora exata configurada.
|
||
- Se `frequency === 'weekly'`, também exige `now.getDay() === settings.day_of_week`.
|
||
- Se `frequency === 'monthly'`, exige `now.getDate() === settings.day_of_month`.
|
||
- Antes de disparar, checa se já existe uma linha `backup_runs` com `triggered_by = 'auto'` e `created_date::date = current_date` — **garante no máximo 1 disparo automático por dia**, mesmo rodando a checagem todo minuto dentro da hora certa.
|
||
|
||
### Retenção
|
||
|
||
Depois de cada backup bem-sucedido, `enforceRetention(retentionDays)` roda:
|
||
```sql
|
||
SELECT id, s3_key FROM backup_runs
|
||
WHERE s3_key IS NOT NULL AND created_date < now() - (retentionDays || ' days')::interval
|
||
```
|
||
Note: filtra por `s3_key IS NOT NULL`, **não** por `status = 'success'` — uma linha `failed` que ainda assim tem um arquivo real associado (caso descrito abaixo em `healStuckRuns`) também é limpa por idade. Cada linha encontrada tem seu objeto S3 deletado (`deleteObject`, erros ignorados) e a linha é removida do banco.
|
||
|
||
### Quem pode disparar/restaurar
|
||
|
||
- Toda a rota de backup exige no mínimo feature `backup:view` (lista, configurações, download).
|
||
- Ações de escrita — configurar (`PATCH /settings`), rodar (`POST /run`), importar (`POST /upload`), restaurar (`POST /:id/restore`), excluir (`DELETE /:id`) — exigem `backup:edit`.
|
||
- `super_admin` sempre pode tudo, independente de feature.
|
||
|
||
### Ações disponíveis (rotas em `backup.routes.js`, prefixo `/api/backup`)
|
||
|
||
| Rota | Nível | Descrição |
|
||
|---|---|---|
|
||
| `GET /` | view | Lista até 200 runs mais recentes, com nome de quem disparou (LEFT JOIN users) |
|
||
| `GET /settings` | view | Configuração vigente |
|
||
| `PATCH /settings` | edit | Atualiza agendamento/retenção; valida `frequency` ∈ {daily,weekly,monthly}, `hour` 0-23, `retention_days` > 0 |
|
||
| `POST /run` | edit | Dispara backup manual (`triggered_by='manual'`), roda em background, retorna a linha `running` imediatamente |
|
||
| `POST /upload` | edit | Importa um `.dump` já pronto (upload multipart, até 500MB), sobe pro S3 e já cria linha `success` |
|
||
| `GET /:id/download` | view | Stream do arquivo do S3 como `attachment` |
|
||
| `POST /:id/restore` | edit | Restaura — ver fluxo de confirmação abaixo |
|
||
| `DELETE /:id` | edit | Remove o arquivo do S3 e a linha |
|
||
|
||
Limite de upload: 500MB (justificado no código como "generoso" frente aos ~19MB do banco em dev na época).
|
||
|
||
### Fluxo de restauração (fricção deliberada)
|
||
|
||
1. Frontend mostra um diálogo de confirmação vermelho, com a `s3_key` exibida (com botão de copiar) e um campo de texto.
|
||
2. O operador precisa **digitar/colar a chave S3 exata** do backup no campo de confirmação.
|
||
3. O botão "Restaurar e reiniciar" só habilita quando o texto digitado bate exatamente com `run.s3_key`.
|
||
4. No backend, `POST /:id/restore` exige `req.body.confirm === run.s3_key` — senão 400.
|
||
5. `restoreBackup()` roda `pg_restore` contra o **mesmo** `DATABASE_URL` (não é um banco separado — o restore substitui o schema/dados do banco vivo).
|
||
6. Depois de responder ao cliente com sucesso, o processo Node se mata de propósito (`setTimeout(() => process.exit(0), 800)`), contando com o `--restart unless-stopped` do Docker para subir um processo novo, que roda as migrations pendentes no boot — trazendo o schema do dump restaurado para o estado atual do código.
|
||
7. Restauração aceita qualquer linha com `s3_key` não-nulo, **não exige `status='success'`** (mesmo raciocínio de `healStuckRuns`, ver abaixo).
|
||
|
||
### Por que a migration `drop-backup-user-fks` existe
|
||
|
||
Migration `1786010000000_drop-backup-user-fks.js`, logo após a criação do módulo, remove as FKs:
|
||
- `backup_runs.triggered_by_user_id` → `users(id)`
|
||
- `backup_settings.updated_by_id` → `users(id)`
|
||
|
||
**Raciocínio documentado no próprio comentário da migration** (importante: há uma nuance/inconsistência que vale registrar):
|
||
|
||
O comentário da migration afirma que `backup_runs`/`backup_settings` "ficam de fora do pg_dump de propósito" e que, enquanto essas tabelas mantiverem uma FK viva para `users`, o `pg_restore --clean` não conseguiria dropar/recriar a tabela `users` (nem os ENUMs/função de trigger compartilhados) durante uma restauração — porque a constraint continuaria existindo no banco vivo mesmo com as duas tabelas excluídas do dump.
|
||
|
||
Só que o comentário **em `backupService.js` (código atual)** conta uma história ligeiramente diferente: ali diz explicitamente que tentaram excluir `backup_runs`/`backup_settings` do dump via `--exclude-table`, mas isso **quebrou** o `--clean` do `pg_restore` (por causa de ENUMs/função de trigger compartilhados ainda em uso), e por isso **abandonaram essa abordagem** — hoje essas duas tabelas **entram no dump normalmente**, como qualquer outra tabela.
|
||
|
||
Ou seja: a migration de remoção de FK parece ter sido escrita para viabilizar uma estratégia (`--exclude-table`) que depois foi revertida no código, mas a remoção das FKs em si **permaneceu válida e útil por um motivo à parte**, que o código atual explica bem: os campos `triggered_by_user_id`/`updated_by_id` viraram **campos informativos, sem integridade referencial garantida pelo banco** — aceitável porque servem só para exibir "quem disparou/alterou" via `LEFT JOIN` nas rotas, não para lógica de negócio. Isso também evita erros caso um usuário seja deletado no futuro (o registro histórico do backup não quebra).
|
||
|
||
**Recomendação para o Eden**: replicar o comportamento final (sem FK real, join tolerante a usuário ausente), sem necessariamente replicar a mecânica de `--exclude-table` que foi abandonada — o dump/restore no Eden pode simplesmente incluir as tabelas de metadado do backup normalmente.
|
||
|
||
### Recuperação de execuções travadas — `healStuckRuns()`
|
||
|
||
Chamado uma vez no boot (dentro de `startBackupScheduler`). Resolve o problema de que `doRun()` só existe na memória do processo que a criou: se o processo reiniciar (deploy, crash, ou o próprio fluxo de restauração, que sempre termina em reinício), nenhuma outra instância vai terminar aquele dump.
|
||
|
||
Para cada linha com `status = 'running'` encontrada:
|
||
1. Faz `HeadObjectCommand` no S3 para a `s3_key` daquela linha.
|
||
2. Se o objeto **existe** (`ContentLength` retornado): marca a linha como `success` com o tamanho real — cobre o caso normal de uma restauração, em que o próprio `pg_dump` capturou sua própria linha `running` (a chave já foi reservada em `insertRun()` **antes** do dump terminar, então se aquele dump for capturado por outro backup enquanto roda, a linha "congela" em `running`, mas o arquivo já está 100% íntegro no S3).
|
||
3. Se o objeto **não existe**: marca como `failed` com mensagem "Interrompido por reinício do servidor." — caso de interrupção real no meio do dump.
|
||
|
||
Esse design é o motivo pelo qual `insertRun()` grava a `s3_key` **na criação** da linha `running`, não só ao final: garante que mesmo uma linha presa continue rastreável/baixável, em vez de deixar um objeto órfão no bucket sem nenhuma linha apontando para ele.
|
||
|
||
---
|
||
|
||
## 2. Agenda — Reserva de Salas de Reunião
|
||
|
||
### Modelo de dados
|
||
|
||
Migration `1786240000000_add-meeting-rooms.js`:
|
||
|
||
**`meeting_rooms`**:
|
||
- `id`, `created_date`, `updated_date`
|
||
- `name` TEXT NOT NULL, `location` TEXT, `capacity` INTEGER
|
||
- `active` BOOLEAN default true
|
||
- `created_by_id` → users(id)
|
||
|
||
**`meeting_room_reservations`**:
|
||
- `id`, `created_date`
|
||
- `room_id` → meeting_rooms(id) ON DELETE CASCADE
|
||
- `title` TEXT NOT NULL (assunto da reunião)
|
||
- `start_time`, `end_time` TIMESTAMPTZ, com `CHECK (end_time > start_time)`
|
||
- `created_by_id` → users(id) NOT NULL
|
||
- índice em `(room_id, start_time, end_time)`
|
||
|
||
### Conflito de horário
|
||
|
||
**Decisão de design explícita**: não usa `EXCLUDE`/GiST no banco (que exigiria a extensão `btree_gist`, considerada arriscada porque nunca usaram nenhuma extensão em produção). Em vez disso, checagem de conflito na aplicação, dentro de uma transação:
|
||
|
||
```
|
||
BEGIN
|
||
SELECT * FROM meeting_rooms WHERE id = $1 FOR UPDATE -- trava a sala
|
||
SELECT id FROM meeting_room_reservations
|
||
WHERE room_id = $1 AND start_time < novo_fim AND end_time > novo_início
|
||
FOR UPDATE -- trava reservas conflitantes
|
||
-- se achou conflito: ROLLBACK, 409
|
||
-- senão: INSERT, COMMIT
|
||
```
|
||
O `FOR UPDATE` nas reservas conflitantes serializa tentativas concorrentes de reservar o mesmo horário — a segunda transação espera a primeira commitar e então vê o conflito. Overlap clássico de intervalos: `start_time < B_end AND end_time > B_start`.
|
||
|
||
Reserva também é bloqueada se a sala estiver `active = false`.
|
||
|
||
### Quem pode reservar / gerenciar
|
||
|
||
Três níveis de permissão, todos via feature própria + `super_admin` bypass:
|
||
- **`agenda:view`** — ver lista de salas e calendário de reservas (leitura para todo mundo com acesso ao menu; sem filtro por usuário na leitura).
|
||
- **`agenda:edit`** — criar reserva, e cancelar (excluir) reserva **própria**. Cancelar reserva de outra pessoa exige `role === 'super_admin'` explicitamente (não basta ter `edit`) — regra hardcoded na rota, não vem de feature.
|
||
- **`agenda_salas:edit`** — permissão **separada**, para cadastrar/editar/(des)ativar salas em si (não fazer reservas). Pensada para quem administra o espaço físico, distinta de quem só agenda uma sala. Comentário no código: "quem só pode reservar (agenda:edit) não deve conseguir criar/desativar salas".
|
||
|
||
Rotas (`meetingRooms.routes.js`, prefixo `/api/meeting-rooms`):
|
||
- `GET /` (view) — lista salas
|
||
- `POST /` (agenda_salas:edit) — cria sala
|
||
- `PATCH /:id` (agenda_salas:edit) — edita/ativa/desativa sala
|
||
- `GET /reservations?start=&end=` (view) — reservas que interceptam o intervalo pedido (obrigatório informar `start`/`end` ISO 8601)
|
||
- `POST /reservations` (edit) — cria reserva com checagem de conflito
|
||
- `DELETE /reservations/:id` (edit + dono-ou-super_admin) — cancela
|
||
|
||
---
|
||
|
||
## 3. Agenda — Reserva de Carros
|
||
|
||
Feature recente (commit `aa09587`: "Adiciona reserva de carros na Agenda, separa permissão de cadastro de sala/carro e peso hierárquico de papéis"). **Espelha exatamente** o desenho de salas de reunião — mesmo padrão de tabelas, mesma lógica de conflito por transação + `FOR UPDATE`, mesmas regras de cancelamento — só troca o domínio (veículo em vez de sala) e os campos específicos.
|
||
|
||
### Modelo de dados
|
||
|
||
Migration `1786320000000_add-company-vehicles.js`:
|
||
|
||
**`company_vehicles`**:
|
||
- `id`, `created_date`, `updated_date`
|
||
- `name` TEXT NOT NULL (apelido do carro, ex.: "Carro 1")
|
||
- `plate` TEXT (placa), `model` TEXT (modelo, ex.: "Onix")
|
||
- `active` BOOLEAN default true
|
||
- `created_by_id` → users(id)
|
||
|
||
Note a diferença de campos frente a `meeting_rooms`: em vez de `location`/`capacity`, usa `plate`/`model`.
|
||
|
||
**`vehicle_reservations`**: idêntica estrutura a `meeting_room_reservations`, trocando `room_id` por `vehicle_id` → `company_vehicles(id)` ON DELETE CASCADE. `title` aqui representa "motivo/destino do uso" (não "assunto da reunião").
|
||
|
||
### Conflito de horário
|
||
|
||
Idêntico ao de salas: transação com `SELECT ... FOR UPDATE` no veículo e nas reservas conflitantes daquele veículo antes de inserir, checando `start_time < fim AND end_time > início`. Também sem `EXCLUDE`/GiST, mesmo motivo (sem extensão `btree_gist` em produção).
|
||
|
||
### Permissões — três níveis separados, paralelos aos de salas
|
||
|
||
- **`agenda_carros:view`** — ver lista de carros e calendário de reservas.
|
||
- **`agenda_carros:edit`** — criar reserva de carro; cancelar reserva própria (cancelar de terceiros exige `super_admin`, mesma regra hardcoded).
|
||
- **`agenda_carros_cadastro:edit`** — permissão separada para cadastrar/editar/(des)ativar carros da frota. Comentário no código: "mesma lógica de meetingRooms.routes.js (agenda_salas): quem só pode reservar não deve conseguir criar/desativar veículo da frota."
|
||
|
||
Ou seja, existem **quatro features independentes** cobrindo Agenda: `agenda` (view/edit de reserva de sala), `agenda_salas` (edit = cadastro de sala), `agenda_carros` (view/edit de reserva de carro), `agenda_carros_cadastro` (edit = cadastro de carro). Um usuário pode, por exemplo, ter permissão para reservar salas e carros mas não cadastrar nenhum dos dois; ou cadastrar carros mas não salas; qualquer combinação é possível via "Permissões por Papel".
|
||
|
||
Rotas (`vehicles.routes.js`, prefixo `/api/vehicles`) espelham exatamente as de `meetingRooms.routes.js`: `GET /`, `POST /`, `PATCH /:id`, `GET /reservations`, `POST /reservations`, `DELETE /reservations/:id`.
|
||
|
||
### Frontend — Agenda.jsx
|
||
|
||
Uma única página (`/Agenda`) com abas "Salas" / "Carros" (componente `Tabs`). Visibilidade de cada aba é independente:
|
||
- Se o usuário só enxerga `agenda_carros:view` (não `agenda:view`), a aba abre direto em "carros" e a UI de troca de aba nem aparece (só uma seção é renderizada).
|
||
- Se enxerga ambas, mostra as duas abas.
|
||
- Se não enxerga nenhuma, tela de acesso restrito.
|
||
|
||
Cada aba tem dois blocos: painel de cadastro (lista de salas/carros com botão "Nova Sala"/"Novo Carro", toggle ativar/desativar) e painel de reservas (navegação dia-a-dia com setas ◀▶ e "Hoje", input de data, lista de reservas do dia ordenada por horário, botão "Nova Reserva"). Reserva é sempre por **dia + horário HH:MM** (não intervalo multi-dia).
|
||
|
||
---
|
||
|
||
## 4. Envio de e-mail (`server/src/lib/mailer.js`)
|
||
|
||
### Provedor/protocolo
|
||
|
||
**SMTP genérico via `nodemailer`** — não é integração direta com SES/SendGrid, apenas SMTP puro. Configuração via env vars:
|
||
- `SMTP_HOST`, `SMTP_PORT` (default 465), `SMTP_USER`, `SMTP_PASS`
|
||
- `secure: true` automaticamente quando a porta é 465 (SMTPS implícito)
|
||
- Remetente default: `SMTP_FROM` ou, na ausência, `SMTP_USER`
|
||
|
||
Se `SMTP_HOST` não estiver configurado, `sendMail()` não lança erro — apenas loga `[mailer] SMTP não configurado — e-mail para X não enviado` no console e retorna silenciosamente. Isso permite rodar em dev/staging sem SMTP configurado sem quebrar os fluxos que disparam e-mail.
|
||
|
||
Erros de envio (SMTP configurado mas falha ao entregar) são capturados e logados, **não propagados** — nenhum fluxo de negócio falha por causa de e-mail não enviado.
|
||
|
||
### Assinatura de e-mail própria para o módulo de Assinatura Eletrônica
|
||
|
||
`signatureMailFrom()` retorna `Coleta de Assinatura Handix <mesmo SMTP_USER>` — mesma caixa de envio de todos os outros e-mails do sistema, mas com nome de exibição diferente do padrão "QuotePro Handix", para o destinatário identificar rapidamente do que se trata assim que abre a caixa de entrada.
|
||
|
||
### `baseUrlFromReq(req)`
|
||
|
||
Helper para montar links absolutos em e-mails (ex.: link de reset de senha): usa `PUBLIC_BASE_URL` se configurado, senão deriva de `req.headers['x-forwarded-proto']`/`req.protocol` + `req.headers.host`.
|
||
|
||
### Tipos de e-mail enviados pelo sistema (mapeados por ponto de disparo)
|
||
|
||
| Onde | Assunto | Propósito |
|
||
|---|---|---|
|
||
| `auth.routes.js` (`/forgot-password`) | "Redefinição de senha - QuotePro Handix" | Link de reset de senha, válido por 1h |
|
||
| `users.routes.js` (criação de usuário) | "Seu acesso ao QuotePro Handix" | E-mail + senha temporária + link de acesso, ao cadastrar um novo usuário do sistema |
|
||
| `resellerRegistrations.routes.js` | "Cadastro Handix" | Convite para completar cadastro de revenda (Programa de Canais Handix), com logo e botão de link |
|
||
| `clientRegistrations.routes.js` | "Cadastro Handix" | Link para o cliente final finalizar seu próprio cadastro após compra |
|
||
| `quotes.routes.js` | "Proposta Comercial — Oferta #N" | Envio da proposta comercial em PDF (anexo) para o cliente, com CC para o vendedor que enviou |
|
||
| `signature/otpService.js` | "Código para assinatura eletrônica — Handix" | Código OTP para assinatura eletrônica (usa `signatureMailFrom()`) |
|
||
| `signature/envelopeService.js` | (não lido em detalhe) | Notificação de envelope de assinatura pronto/enviado |
|
||
| `signature/finalizeEnvelope.js` | (não lido em detalhe) | Notificação de assinatura finalizada |
|
||
|
||
Todos usam HTML inline simples (sem template engine), geralmente com um botão estilizado (`background: #4f46e5`, indigo).
|
||
|
||
---
|
||
|
||
## 5. Armazenamento S3 (`server/src/lib/s3.js`)
|
||
|
||
### Configuração
|
||
|
||
Cliente `@aws-sdk/client-s3`, compatível com qualquer S3-like (MinIO, AWS S3, etc. — não é exclusivo da AWS):
|
||
- `S3_ENDPOINT` (obrigatório para o client existir — se ausente, todas as funções lançam `'S3 não configurado (S3_ENDPOINT ausente)'`)
|
||
- `S3_REGION` (default `us-east-1`)
|
||
- `S3_FORCE_PATH_STYLE` (default `true`, ou seja path-style por padrão — típico de MinIO/self-hosted; setar `'false'` explicitamente para usar virtual-hosted style, típico de AWS S3 puro)
|
||
- `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`
|
||
- `S3_BUCKET` — bucket único para tudo (não há bucket por tipo de dado)
|
||
|
||
Client é singleton (memoizado em módulo).
|
||
|
||
### API exposta
|
||
|
||
- `uploadObject(key, buffer, mimeType)` — PUT simples, buffer inteiro em memória (adequado para arquivos pequenos/médios: imagens, PDFs, anexos).
|
||
- `getObjectStream(key)` — GET retornando o objeto (com `.Body` como stream, usado tanto para servir download quanto para pipe direto).
|
||
- `deleteObject(key)`.
|
||
- `getClient()` / `getBucket()` — exportados brutos especificamente para o módulo de Backup, que precisa fazer upload via `@aws-sdk/lib-storage` `Upload` (multipart em streaming) em vez de `uploadObject` (que exige buffer inteiro em memória — inviável para um dump de banco).
|
||
|
||
### Não há URLs pré-assinadas (presigned URLs)
|
||
|
||
Todo acesso a objetos S3 passa **pelo backend**, que baixa do S3 e faz proxy/stream de volta ao cliente autenticado (com `auth` middleware de cada rota validando o token antes) — não há geração de `getSignedUrl`. Ou seja, controle de acesso é feito inteiramente na camada de aplicação (rota Express verifica permissão/feature, então busca do S3 e faz pipe), não delegado a URLs assinadas com expiração. Padrão visto em: download de backup, imagem da welcome page, anexos de cadastro de cliente/revenda, PDFs de assinatura.
|
||
|
||
### Tipos de arquivo armazenados (por prefixo de key observado)
|
||
|
||
- `backups/backup-<timestamp>.dump` — dumps do Postgres (módulo Backup)
|
||
- `welcome-page/<uuid>-<nome original>` — imagem da tela de boas-vindas
|
||
- Outros prefixos usados por módulos fora deste escopo (não detalhados aqui, mas presentes no grep): anexos de `clientRegistrations`, `resellerRegistrations`, documentos de assinatura eletrônica (`signature/*`), importações AFD (`afdImportService.js`), geração de documentos (`documentGeneration.routes.js`).
|
||
|
||
---
|
||
|
||
## 6. Welcome Page (Página de Boas-vindas)
|
||
|
||
### O que é
|
||
|
||
Tela **singleton** (uma linha só na tabela `welcome_page`) exibida como primeira tela após o login — rota `/` e `/Welcome` mapeiam para o mesmo componente `Welcome.jsx`. Conteúdo: uma imagem (opcional) + um bloco de texto rico ("novidades"), editável em Gestão → Página Inicial.
|
||
|
||
Comentário explícito no código (`Welcome.jsx`): **sem gate de feature na exibição** — é o "ponto de comunicação com TODOS os usuários, independente de papel/permissão", então qualquer usuário autenticado a vê, sem checar `hasFeatureAccess`.
|
||
|
||
### Modelo de dados
|
||
|
||
Migration `1786260000000_add-welcome-page.js`, tabela `welcome_page`:
|
||
- `id`, `created_date`, `updated_date`
|
||
- `image_s3_key` TEXT, `image_mime_type` TEXT
|
||
- `content_html` TEXT NOT NULL default `''`
|
||
- `updated_by_id` → users(id)
|
||
|
||
Sem tabela de histórico — sempre lê/edita a **primeira linha existente** (`ORDER BY created_date ASC LIMIT 1`); se não existir nenhuma, cria na primeira gravação.
|
||
|
||
### Rotas (`welcomePage.routes.js`, prefixo `/api/welcome-page`)
|
||
|
||
- `GET /` — **qualquer autenticado** (só `auth`, sem feature) — retorna `{ content_html, has_image, updated_date }`. Não retorna a imagem em si (evita payload grande no GET de estado).
|
||
- `GET /image` — stream da imagem (`Content-Type` = `image_mime_type` guardado), 404 se não houver.
|
||
- `PATCH /` — exige feature `pagina_inicial:edit`. Multipart (`multer`, limite 8MB), campos:
|
||
- `content_html` (opcional) — sanitizado via `sanitizeSimpleHtml()` antes de salvar (proteção contra XSS/HTML malicioso — mesmo helper usado em outros editores ricos do sistema).
|
||
- `image` (arquivo opcional) — se enviado, sobe pro S3 com key `welcome-page/<uuid>-<nome original>` e substitui a imagem.
|
||
- `remove_image=true` (opcional) — remove a imagem sem enviar outra.
|
||
- Lógica de "o que muda": um `imageChanged` flag decide se as colunas de imagem são tocadas no UPDATE — importante porque um PATCH que só atualiza texto (sem arquivo novo, sem `remove_image`) **não deve apagar** a imagem existente por engano. (Nota: a imagem antiga não é deletada do S3 quando substituída/removida — fica órfã; não há um `deleteObject` no fluxo de update.)
|
||
|
||
### Editor (`WelcomePageEditor.jsx`, rota `/WelcomePageEditor`)
|
||
|
||
- Visível no menu "Gestão" como "Página Inicial", gate por feature `pagina_inicial:view`/`edit`.
|
||
- Upload de imagem com preview local (`URL.createObjectURL`), botão de remover (X sobre a imagem).
|
||
- Editor de texto rico via componente compartilhado `SimpleRichTextEditor` (mesmo usado em outros lugares do sistema para conteúdo HTML simples).
|
||
- Salvar monta um `FormData` e faz `PATCH`.
|
||
|
||
### Quem vê
|
||
|
||
- **Leitura da tela de boas-vindas** (`Welcome.jsx`, rota `/`): todo usuário autenticado, sem exceção — não passa por feature.
|
||
- **Edição** (`WelcomePageEditor.jsx`, rota `/WelcomePageEditor`, menu Gestão): restrita à feature `pagina_inicial`.
|
||
|
||
---
|
||
|
||
## 7. Empresa / Companies (visão operacional)
|
||
|
||
### Propósito
|
||
|
||
Cadastro da(s) **empresa(s) emissora(s)** do sistema (ex.: "Handix Telecom LTDA") — dados que alimentam a geração de documentos/contratos (Termo de Portabilidade, Contrato de Software, etc.) via o motor de templates de documentos (`variableRegistry.js` / `resolveSource.js`), sob a chave de variável `{{empresa.*}}`.
|
||
|
||
Comentário na migration original: "base para uso futuro em geração de contratos" — hoje já é usado ativamente nesse papel (confirmado em `resolveSource.js`, que busca `SELECT * FROM companies WHERE is_active = true ORDER BY created_date LIMIT 1` como fonte para os documentos gerados, com fallback para a mais antiga se nenhuma estiver ativa).
|
||
|
||
Apesar do plural "companies" e do CRUD suportar múltiplas empresas, o uso prático em geração de documentos sempre pega **uma única empresa "ativa"** (a mais antiga entre as ativas) — não há seleção de qual empresa usar por contrato/revenda; é um singleton "ativo" em espírito, mesmo com a tabela permitindo várias linhas.
|
||
|
||
### Modelo de dados
|
||
|
||
Migration `1785606000001_add-companies.js` + `1785610000000_...company-website.js` (adiciona `website` depois), tabela `companies`:
|
||
|
||
- `id`, `created_date`, `updated_date`, `created_by_id` → users(id)
|
||
- **Identidade**: `company_name` (razão social, NOT NULL), `trade_name` (nome fantasia), `cnpj`, `state_registration` (IE), `municipal_registration` (IM)
|
||
- **Endereço**: `address_zip`, `address_street`, `address_number`, `address_complement`, `address_neighborhood`, `address_city`, `address_state`, `address_country` (default `'Brasil'`)
|
||
- **Contato**: `phone`, `email`, `website` (adicionado em migration posterior)
|
||
- **Representante legal** (para assinatura de contratos): `legal_rep_name`, `legal_rep_cpf`, `legal_rep_role` (cargo), `legal_rep_email` (recebe convite de assinatura eletrônica)
|
||
- `is_active` BOOLEAN default true
|
||
- trigger `set_updated_date()` em UPDATE
|
||
|
||
### Validações
|
||
|
||
- `company_name` obrigatório (não pode ficar em branco, inclusive em PATCH parcial).
|
||
- `cnpj`, se informado, validado via `isValidCNPJ()` (dígito verificador) — rejeita com 400 se inválido.
|
||
- CPF do representante legal só tem os dígitos normalizados (`onlyDigits`), sem validação de dígito verificador visível na rota.
|
||
|
||
### Rotas (`companies.routes.js`, prefixo `/api/companies`)
|
||
|
||
- `GET /`, `GET /:id` — **leitura aberta a qualquer autenticado**, sem gate de feature. Justificativa no comentário: "os dados não são sensíveis (aparecem em qualquer nota fiscal/contrato) e passam a ser necessários pra montar documentos como o Termo de Portabilidade a partir da tela da oferta, que qualquer dono/backoffice pode acessar."
|
||
- `POST /`, `PATCH /:id`, `DELETE /:id` — exigem feature `empresa:edit` (documentado como exclusivo de `super_admin` por padrão, mas delegável via Permissões por Papel).
|
||
|
||
### Frontend (`Empresa.jsx`, rota `/Empresa`, menu Gestão → "Empresa")
|
||
|
||
- Lista simples de empresas cadastradas (ícone prédio, nome fantasia/razão social, CNPJ, badge Ativa/Inativa).
|
||
- Modal de criar/editar com todos os campos acima, agrupados em seções: identidade, endereço, contato, representante legal.
|
||
- Integrações de conveniência no formulário:
|
||
- Botão de busca por CNPJ (`lookupCnpj`) — preenche razão social, nome fantasia, IE e endereço automaticamente.
|
||
- Botão de busca por CEP (`lookupCep`) — preenche logradouro/bairro/cidade/UF.
|
||
- Máscaras de CNPJ/CPF/CEP aplicadas nos inputs.
|
||
|
||
---
|
||
|
||
## 8. Management.jsx / ManagerDashboard.jsx — hub administrativo
|
||
|
||
Esses são **duas páginas distintas** (o nome "Management" no código não é o hub geral de todos os módulos deste documento — é especificamente a tela de gestão de usuários/revendas/permissões; o "Painel do Gestor" é uma dashboard de vendas separada).
|
||
|
||
### `Management.jsx` (rota `/Management`, menu "Cadastros" → "Usuários e Perfis")
|
||
|
||
Gate: feature `gestao_usuarios:view`/`edit` (ou `super_admin`).
|
||
|
||
Três abas internas (não sub-rotas, só troca de componente local via `useState`):
|
||
1. **"Usuários"** (`UsersManager`) — CRUD de usuários do sistema.
|
||
2. **"Revendas"** (`ResellersManager`) — CRUD de revendas (resellers).
|
||
3. **"Permissões por Papel"** (`RolePermissionsManager`) — **só visível para `super_admin`** (checagem hardcoded `role === "super_admin"`, não é feature) — é aqui que se configuram os pares feature→papel→nível (view/edit) usados por `hasFeatureAccess()` em todo o resto do sistema, incluindo todos os módulos deste documento.
|
||
|
||
Carrega ao montar: lista de `Reseller` e lista de `User` (para popular os selects dos outros managers).
|
||
|
||
### `ManagerDashboard.jsx` (rota `/ManagerDashboard`, menu Gestão → "Painel do Gestor")
|
||
|
||
Gate: feature `painel_gestor:view`.
|
||
|
||
Dashboard de vendas/KPIs sobre a entidade `Quote` (ofertas/orçamentos comerciais) — não é um "hub de navegação" para os outros módulos, é uma tela analítica:
|
||
|
||
- **KPIs** (cards): Total Negociado, Total Mensal (mensalidades), Total Fechado (contrato), Total Mensal Fechado, Ticket Médio (fechados), Total Perdido — calculados sobre `contract_total` e uma métrica derivada `effectiveMonthly` (usa `proposed_monthly_total` se for menor que `monthly_total`, senão `monthly_total` — reflete desconto especial proposto).
|
||
- **Filtros**: revenda (todas / todas exceto Handix / uma específica — há um ID hardcoded `HANDIX_RESELLER_ID` para o filtro "exceto Handix"), período do contrato (0/12/24/36/48 meses), status do negócio (`orcamento`/`fechado`/`perdido`), campo de data para filtrar (criação vs. modificação), intervalo de datas (com atalho "Mês atual").
|
||
- **Lista agrupada por revenda**, cada oferta clicável levando para `NewQuote?id=`.
|
||
- **Exportação CSV** (client-side, gera Blob) e **impressão** (`window.print()`, com classes `print:hidden`/`print:break-inside-avoid` para adaptar o layout).
|
||
|
||
### Como os módulos se conectam (visão de conjunto do menu)
|
||
|
||
O menu principal (`Layout.jsx`) organiza os módulos em grupos, cada item gated por uma feature própria (função `hasFeatureAccess(user, feature, 'view')`, com bypass total para `super_admin`); um grupo vira dropdown se tiver mais de um item visível, link direto se só um, e some inteiramente se zero:
|
||
|
||
- **Cadastros**: Produtos (`produtos`), Cliente da Revenda (`clientes_revenda`), Todos os Clientes (`clientes_todos`), Cadastro de Revendas (`revendas_cadastro`), Usuários e Perfis → Management (`gestao_usuarios`).
|
||
- **Contratos**: Todos os Contratos (`contratos`), Relatórios (`contratos_relatorios`), Assinaturas (`assinaturas`), Cessão (`contratos_cessao`).
|
||
- **Gestão**: Fiscal (`fiscal`), Painel do Gestor → ManagerDashboard (`painel_gestor`), Empresa (`empresa`), Backup (`backup`), Modelos de Documentos (`documentos_modelos`), Página Inicial → WelcomePageEditor (`pagina_inicial`).
|
||
- **RH / Controle de Ponto**: Dashboard, Colaboradores, Equipamentos, Importações AFD, Auditoria NSR/AFD, Jornadas, Feriados, Ajustes de Ponto, Apuração, Banco de Horas, Relatórios, Fechamento de Período (features `rh_*`) — fora do escopo deste documento.
|
||
- **Agenda** (item de nível superior, não dentro de um dos grupos acima): visível se o usuário tem `agenda:view` OU `agenda_carros:view` — único ponto de entrada para as duas sub-telas (Salas/Carros) descritas nas seções 2–3.
|
||
- **Welcome** (`/`): não aparece no menu — é a tela de entrada (landing após login), sem item de navegação próprio.
|
||
|
||
Esse padrão — 1 rota de página ↔ 1 feature key ↔ entrada de "Permissões por Papel" — é o mecanismo uniforme de controle de acesso do sistema inteiro, e deve ser replicado no Eden como o modelo geral de autorização por módulo.
|
||
|
||
---
|
||
|
||
## 9. `functions.routes.js`
|
||
|
||
Prefixo `/api/functions`. Hoje expõe **uma única rota**: `POST /validateQuotePhone`.
|
||
|
||
Não é um módulo de "funções genéricas" ativo — o nome/prefixo é um resquício de portabilidade da plataforma anterior (Base44): o comentário no topo do arquivo diz explicitamente "Porta de base44/functions/validateQuotePhone". Foi mantido como padrão de rota (`/api/functions/<nome>`) espelhando a convenção anterior, mas só essa função foi migrada/existe.
|
||
|
||
### O que `validateQuotePhone` faz
|
||
|
||
Detecta se um número de telefone informado numa oferta (`Quote`) já está em uso em outra oferta pertencente a uma **revenda diferente** — cenário de possível conflito comercial entre revendas disputando o mesmo cliente.
|
||
|
||
- Entrada: `{ phone, reseller_id, exclude_quote_id }`.
|
||
- Normaliza o telefone pelos **últimos 8 dígitos** (`getLast8Digits`) — ignora DDD/formatação, foca no número em si.
|
||
- Varre **todas** as `quotes` do sistema (não filtra por revenda antes de comparar) e todas as `resellers` — comentário explícito: "Propositalmente ignora o escopo por revenda (equivalente ao 'service role' do Base44) para detectar conflitos de telefone entre revendas diferentes." Ou seja, essa rota é uma exceção deliberada ao isolamento normal de dados por revenda que o resto do sistema aplica.
|
||
- Ignora a própria oferta sendo editada (`exclude_quote_id`).
|
||
- Só considera conflito se o `reseller_id` da oferta encontrada for **diferente** do informado.
|
||
- Resposta: `{ conflict: false }` ou `{ conflict: true, existing_reseller_id, existing_reseller_name, existing_quote_number, existing_client_name }`.
|
||
- Protegida só por `auth` (qualquer usuário autenticado, sem feature específica) — faz sentido dado que precisa rodar durante o preenchimento normal de uma oferta por qualquer vendedor.
|
||
|
||
**Para o Eden**: não é necessário recriar um "módulo de functions genérico" — basta portar esta validação específica de telefone cruzado entre revendas como um endpoint próprio, documentando a mesma regra de negócio (comparação por últimos 8 dígitos, escopo global cross-revenda, exclusão da oferta atual).
|