Files
eden/eden.md
Matheus (Handix) 44510bd019 Bootstrap EDEN: Fase 0 (arquitetura) e Fase 1 (monorepo + infra)
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>
2026-09-03 08:01:14 -03:00

318 KiB
Raw Blame History

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 14, 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 96256 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).

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.

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, 230 caracteres, apenas a-z0-9_).

weight: inteiro 099 (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).

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.

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

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:

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):

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:

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):

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):

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:

// 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:

{ "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:

{ "access_token": "<jwt>", "user": { "id": "...", "email": "...", "role": "...", "permissions": {...}, ... } }

4.2 JWT — geração e validação

Arquivo server/src/lib/jwt.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 1POST /api/auth/forgot-password (rate limited, pública):

{ "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 2POST /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):

{ "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):

{ "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):

{ "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 papelDELETE /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):

{
  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 ounit_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"

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):
    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):

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:

// 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 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):
    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ó:

// 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 o desconto de fidelidade, ignorando o desconto/acréscimo especial. Fórmula correta (vigente):

// 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):
    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 declient_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.

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:
    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):

// --- 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):

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):

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)

  • admin (isAdminRole = admin ou super_admin) pode mudar quotes.created_by_id de uma oferta existente.
  • No PATCH /quotes/:id:
    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:

-- 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)

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:
    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:

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:

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

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 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.jsxClienteDetail)

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:

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 statussyncOperationalReseller(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_idpor 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):

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 acessocrypto.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):

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.

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)

currencyIntl/toLocaleString('pt-BR', {style:'currency', currency:'BRL'}); datetoLocaleDateString('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 (VIEWEDOTP_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 SENTSIGNED 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 (contratantes 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

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

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 * 10005 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

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)

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. Linkageprevious_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 → FINALIZINGa 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.pdffinal_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

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)

Todas as rotas do fluxo público (GET/POST /api/public-signature/:token/...) passam por loadSignerresolveToken(token):

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.
  • 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_cestsegment 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:

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 / Siscomexhttps://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: IBGEhttps://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:

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.jsxbuildTemplateCsv) 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').

  • 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.stringifyado, 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.

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:
    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 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.

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.viewnunca 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 && !dayOffOrHolidaystatus = '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 aplicaovertime_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_calculationsidempotente, 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.editcriar um ajuste (propor).
  • rh_ajustes_aprovacao.editaprovar/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.61.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 batendoexpected_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 023 (default 3 = 3h da manhã)
  • day_of_week INTEGER 06 (usado só se frequency=weekly)
  • day_of_month INTEGER 128 (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():

  • 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_dategarante 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:

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_idusers(id)
  • backup_settings.updated_by_idusers(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_idcompany_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 /:idleitura 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 23.
  • 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).