# EDEN — Especificação Funcional Completa para Reconstrução do ERP > Este documento foi extraído por engenharia reversa do sistema legado **"OrçaFácil"** (ERP de > vendas/gestão para revendas de telecom/ISP, operado pela Handix), lendo o código-fonte > diretamente (rotas, migrations, componentes de frontend). O objetivo é servir de > **especificação funcional completa** para outra sessão do Claude construir, do zero, um > sistema equivalente chamado **"Eden"**, usando um frontend diferente. > > **Este arquivo NÃO contém código do Eden** — é documentação de regras de negócio, modelo de > dados, fluxos, cálculos e segurança do sistema atual, escrita para ser lida e implementada por > um agente de IA sem acesso ao repositório original. --- ## Stack do sistema de referência (OrçaFácil) — contexto, não obrigação O Eden pode (e provavelmente vai) usar outro frontend, conforme pedido. Para contexto, o sistema atual é: - **Frontend**: React 18 + Vite, React Router, `@tanstack/react-query` para cache/estado de chamadas, Tailwind CSS, Radix UI (componentes), React Hook Form + Zod, Tiptap (editor rico), `html2canvas` + `jsPDF` (geração de PDF client-side, usada em templates legados/certificado de assinatura) e Playwright/Chromium server-side (geração de PDF nova, mais robusta). - **Backend**: Node.js + Express, `express-async-errors`, `jsonwebtoken` (JWT HS256), `bcryptjs` (hash de senha), `express-rate-limit`, `pg` (Postgres) + `node-pg-migrate` (migrations versionadas em arquivo), `multer` (upload), `@aws-sdk/client-s3` (storage de objetos), `nodemailer` (e-mail via SMTP), `playwright` (PDF), `sanitize-html`. - **Banco**: PostgreSQL. Sem ORM — SQL parametrizado direto via `pg`. - **Comunicação front↔back**: REST simples via `fetch`, sem GraphQL/tRPC. JWT Bearer no header `Authorization`. - **Sem multi-tenant de infraestrutura**: é uma aplicação única servindo várias revendas (isolamento lógico via `reseller_id` + sistema de papéis/features, não por schema/banco separado). --- ## Recomendação de como usar este documento (agentes/etapas sugeridas) Este documento tem ~4500 linhas cobrindo 7 módulos. Para uma sessão do Claude construir o Eden a partir dele com qualidade, a recomendação é **não tentar implementar tudo numa passada só**. Sugestão de abordagem, da mesma forma que este documento foi produzido (paralelizar leitura, mas serializar decisão de arquitetura): 1. **Fase de arquitetura (1 sessão, modo de planejamento, não implementação)** — ler este documento inteiro (ou pelo menos as seções 1–4, que são o núcleo comercial) e definir: stack escolhida, schema de banco definitivo (pode divergir do legado onde este documento sinalizar "avaliar/decidir no Eden"), estrutura de pastas, estratégia de auth. Produzir um plano curto antes de escrever qualquer código. **Não pule esta fase** — o sistema tem dependências cruzadas fortes (ex.: fidelidade contratual afeta cálculo financeiro, texto de contrato E vencimento ao mesmo tempo). 2. **Ordem de implementação sugerida** (cada módulo depende dos anteriores): 1. **Autenticação, Papéis e Permissões** (`## 1 — Auth, Roles & Segurança`) — é a base de tudo, nenhum outro módulo funciona sem isso. 2. **Produtos, Ofertas e Contratos** (`## 2`) — o núcleo comercial do sistema. 3. **Cadastro de Cliente e Revenda** (`## 3`) — depende de Ofertas (uma oferta fechada gera um cadastro). 4. **Templates de Documento e Assinatura Eletrônica** (`## 4`) — depende de Cadastro (gera documentos a partir dos dados capturados) e de Empresa/Companies (dados do representante legal). 5. **Fiscal** (`## 5`) — módulo mais isolado, pode entrar em paralelo com o item 4. 6. **Ponto Eletrônico / Timeclock** (`## 6`) — é essencialmente um sistema à parte dentro do ERP (RH), sem dependência forte dos módulos comerciais — pode ser adiado ou paralelizado por um agente/sessão dedicado. 7. **Backoffice diverso** (Backup, Agenda, Welcome Page, Empresa) (`## 7`) — menor risco, deixar por último. 3. **Para cada módulo, um agente/sessão dedicado** (o mesmo padrão usado para produzir este documento): ao chegar num módulo, é razoável abrir uma sessão do Claude focada só naquele módulo, com a seção correspondente deste arquivo como contexto principal — evita diluir o contexto de uma única sessão gigante entre 7 domínios diferentes. Um agente/sessão à parte revisando consistência **entre** módulos (nomes de campo, formatos de data/moeda, papéis) no final é recomendável, já que cada seção foi originalmente pesquisada por um agente diferente e pode ter pequenas diferenças de nomenclatura. 4. **Segurança é transversal, não um módulo**: os detalhes de criptografia (bcrypt custo 10, JWT HS256, HMAC-SHA256 para OTP, SHA-256 hash-chain de auditoria, tokens de 96–256 bits) estão espalhados nas seções 1 e 4 — o agente responsável pela Fase de Arquitetura deve extrair isso num documento/checklist próprio de segurança antes de começar a implementar, e cada módulo subsequente deve ser verificado contra esse checklist. 5. **Não copiar lacunas de segurança conhecidas do legado sem decidir conscientemente**: este documento aponta explicitamente pontos como ausência de `helmet`/CORS configurado e ausência de rate limit em algumas rotas administrativas — o Eden deve decidir deliberadamente se corrige isso (recomendado) ou não, não herdar por omissão. --- ## Índice de módulos 1. Autenticação, Usuários, Papéis, Permissões e Segurança/Criptografia 2. Produtos, Ofertas (Quotes), Faixas de Preço/Fidelidade e Contratos 3. Cadastro de Cliente e Cadastro/Gestão de Revendas 4. Templates de Documentos, Geração de PDF e Assinatura Eletrônica 5. Módulo Fiscal (NCM, CFOP, Municípios) 6. Módulo de Ponto Eletrônico (Timeclock) 7. Backoffice diverso (Backup, Agenda, Welcome Page, Empresa, Dashboard) --- # 1. Autenticação, Usuários, Papéis, Permissões e Segurança > Documentação extraída do sistema legado "OrçaFácil" (server/src Express + Postgres, frontend React+Vite) para servir de referência à reconstrução ("Eden"). Escopo: auth, users, roles, role_permissions, companies, criptografia/segurança. --- ## 1. Modelo de dados ### 1.1 Tabela `users` Origem: `server/migrations/1785400000000_baseline-schema.js` (colunas base) + `1786250000000_add-custom-roles.js` (troca de `role` de enum para FK texto). ```sql CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), created_date TIMESTAMPTZ NOT NULL DEFAULT now(), updated_date TIMESTAMPTZ NOT NULL DEFAULT now(), -- atualizado por trigger set_updated_date() created_by_id UUID REFERENCES users(id), full_name TEXT, email TEXT NOT NULL UNIQUE, password_hash TEXT, -- bcrypt hash; NULL é possível (usuário sem senha definida ainda) role TEXT NOT NULL DEFAULT 'user' REFERENCES roles(key), -- era ENUM user_role, migrado p/ TEXT+FK reseller_id UUID REFERENCES resellers(id), can_approve_discount BOOLEAN NOT NULL DEFAULT false, reset_token TEXT, reset_token_expires TIMESTAMPTZ, token_version INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_users_reseller ON users(reseller_id); ``` Notas de campo: - `role`: originalmente `ENUM user_role ('admin','user')`, depois `ALTER TYPE ... ADD VALUE` para `'backoffice'` (migration `1785509417538_add-backoffice-role.js`) e `'super_admin'` (migration `1785606000000_add-super-admin-role.js`, com `pgm.noTransaction()` — ver seção "Gotchas"). Migration `1786250000000_add-custom-roles.js` converte a coluna de `user_role` (enum) para `TEXT REFERENCES roles(key)`, permitindo papéis customizados criados dinamicamente. - `password_hash`: bcrypt via `bcryptjs`, custo (`saltRounds`) **10**, gerado com `bcrypt.hash(senha, 10)`. Comparação com `bcrypt.compare`. - `token_version`: inteiro incrementado sempre que uma sessão precisa ser invalidada globalmente (logout, troca de senha, reset de senha, reset de senha via admin). O JWT carrega a versão vigente no momento da emissão (`ver`); no middleware de auth, se `users.token_version !== payload.ver`, o token é recusado mesmo sendo criptograficamente válido — é o mecanismo de "logout forçado / invalidação de sessão" já que JWT não tem estado no servidor por si só. - `reset_token` / `reset_token_expires`: token de texto (hex, 24 bytes → 48 chars) para fluxo de "esqueci minha senha", expira em 1h. - `can_approve_discount`: flag específica de negócio (fora do sistema de features) — permite ao usuário aprovar/reprovar condições especiais em ofertas (desconto especial, fidelidade reduzida). Setável manualmente por quem edita usuários. - `reseller_id`: amarra o usuário a uma revenda (tabela `resellers`, fora deste escopo mas relevante — é o "tenant operacional" do sistema, não `companies`). ### 1.2 Tabela `roles` Origem: `1786250000000_add-custom-roles.js` + `1786330000000_add-role-weight.js`. ```sql CREATE TABLE roles ( key TEXT PRIMARY KEY, -- ex.: 'user', 'admin', 'suporte' label TEXT NOT NULL, -- nome de exibição is_system BOOLEAN NOT NULL DEFAULT false, weight INTEGER NOT NULL DEFAULT 10, -- peso hierárquico created_date TIMESTAMPTZ NOT NULL DEFAULT now(), created_by_id UUID REFERENCES users(id) ); ``` Seed inicial (`is_system = true` para os 4 papéis nativos — não podem ser excluídos; `label` não editável se `is_system`): | key | label | is_system | weight | |---|---|---|---| | `user` | Usuário | true | 10 | | `backoffice` | Backoffice | true | 20 | | `admin` | Administrador | true | 80 | | `super_admin` | Super Administrador | true | 100 | Papel customizado de exemplo já existente em produção: `suporte` (weight 20, não é `is_system`). Regras de validação da chave (`key`), aplicadas tanto no backend (`roles.routes.js`) quanto espelhadas no frontend: regex `^[a-z][a-z0-9_]{1,29}$` (minúscula, começa com letra, 2–30 caracteres, apenas `a-z0-9_`). `weight`: inteiro 0–99 (`MAX_ASSIGNABLE_WEIGHT = 99`) para qualquer papel criado/editado via API — o peso de `super_admin` (100) é o teto fixo do sistema e nunca é atribuível/editável via UI (o próprio papel `super_admin` não pode ser editado nem excluído: `PATCH /api/roles/super_admin` retorna 400). ### 1.3 Tabela `role_permissions` Origem: `1786030000000_add-role-permissions.js` (criada com `role user_role PRIMARY KEY`) + `1786250000000_add-custom-roles.js` (coluna migrada para `TEXT REFERENCES roles(key) ON DELETE CASCADE`). ```sql CREATE TABLE role_permissions ( role TEXT PRIMARY KEY REFERENCES roles(key) ON DELETE CASCADE, permissions JSONB NOT NULL DEFAULT '{}'::jsonb, -- { feature_key: 'view' | 'edit' } updated_date TIMESTAMPTZ NOT NULL DEFAULT now(), updated_by_id UUID REFERENCES users(id) ); ``` - Uma linha por papel **exceto `super_admin`** (que nunca entra nesta tabela — acesso total hardcoded). - Ao criar um papel novo (`POST /api/roles`), uma linha `role_permissions` vazia (`{}`) é inserida na mesma transação — tudo começa como "sem acesso". - Ao excluir um papel, `ON DELETE CASCADE` remove a linha de `role_permissions` junto. - `permissions` é um objeto JSON plano `{ "": "view" | "edit" }`. Ausência de uma chave = sem acesso àquela feature. ### 1.4 Tabela `companies` Origem: `1785606000001_add-companies.js`. Representa a(s) empresa(s) **emissora(s)** de contrato (ex.: a própria Handix) — não é multi-tenant de clientes, é cadastro jurídico usado para montar documentos/contratos. ```sql CREATE TABLE companies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), created_date TIMESTAMPTZ NOT NULL DEFAULT now(), updated_date TIMESTAMPTZ NOT NULL DEFAULT now(), created_by_id UUID REFERENCES users(id), company_name TEXT NOT NULL, trade_name TEXT, cnpj TEXT, state_registration TEXT, municipal_registration TEXT, address_zip TEXT, address_street TEXT, address_number TEXT, address_complement TEXT, address_neighborhood TEXT, address_city TEXT, address_state TEXT, address_country TEXT NOT NULL DEFAULT 'Brasil', phone TEXT, email TEXT, website TEXT, -- (adicionada depois da migration original; ver companies.routes.js) legal_rep_name TEXT, legal_rep_cpf TEXT, legal_rep_role TEXT, legal_rep_email TEXT, -- (adicionada depois) is_active BOOLEAN NOT NULL DEFAULT true ); ``` Validação: `cnpj` e `legal_rep_cpf` são normalizados com `onlyDigits()` e validados com `isValidCNPJ()` (dígito verificador) antes de gravar — CNPJ inválido é rejeitado com 400. ### 1.5 Relacionamentos-chave ``` roles.key ←── users.role (FK) roles.key ←── role_permissions.role (FK, ON DELETE CASCADE) users.id ←── users.created_by_id (auto-referência: quem convidou) users.id ←── companies.created_by_id users.id ←── role_permissions.updated_by_id resellers.id ←── users.reseller_id ``` ### 1.6 Conceito de "role weight" (peso hierárquico) Introduzido em `1786330000000_add-role-weight.js`. Resolve o problema: um usuário com a feature `gestao_usuarios:edit` liberada (mas papel "fraco") não pode promover/editar/excluir alguém com papel "mais forte" que o seu, mesmo tendo a tela liberada. Regra aplicada em `server/src/routes/users.routes.js`: - **`assertCanAssignRole(req, res, targetRoleKey)`** — chamada em `POST /users/invite` e `PATCH /users/:id` antes de gravar `role`. Se `req.user.role === 'super_admin'`, sempre permite. Senão, busca `weight` do papel do ator e do papel alvo em `roles`; se `targetRole.weight > actorWeight`, bloqueia com 403 `"Você não pode atribuir um papel com peso maior que o seu."`. - **`assertCanActOnUser(req, res, targetUserId)`** — chamada em `PATCH /users/:id`, `DELETE /users/:id` e `POST /users/:id/reset-password`. Mesma lógica, mas compara o peso do papel **atual** do usuário-alvo (não o papel que se quer atribuir) contra o peso do ator. Bloqueia com 403 `"Você não pode alterar um usuário com papel de peso maior que o seu."`. `super_admin` sempre passa. Isso é **ortogonal** ao sistema de features: mesmo com `gestao_usuarios:edit`, um papel de peso baixo não consegue tocar em usuários/atribuir papéis de peso maior. `super_admin` ignora ambas as checagens (peso 100 é hardcoded como teto, nunca editável via UI). No frontend (`UsersManager.jsx`), a mesma regra é espelhada apenas para UX (esconder opções que o backend recusaria): `assignableRoles()` filtra o dropdown de papel a `role.weight <= myWeight` (ou tudo, se `currentUserRole === "super_admin"`), e `canActOnUser()` decide se as ações de editar/resetar senha/excluir aparecem para aquele usuário-linha. --- ## 2. Papéis do sistema | role key | label | is_system | weight padrão | Descrição | |---|---|---|---|---| | `user` | Usuário | true | 10 | Papel padrão de quem cria/edita as próprias ofertas. | | `backoffice` | Backoffice | true | 20 | Time de apoio operacional — por padrão acesso a ofertas (próprias + de outros, view), clientes, contratos (view), assinaturas. | | `admin` | Administrador | true | 80 | Acesso amplo — por padrão edita ofertas de todos, clientes, produtos, fiscal, gestão de usuários, vê painel do gestor. | | `super_admin` | Super Administrador | true | 100 | Bypass total e hardcoded — nunca passa por `role_permissions`, nunca editável via UI (nem seu label, nem seu weight, nem exclusão). | | `suporte` (exemplo) | Suporte | false | 20 | Papel customizado já criado em produção via a própria tela de Papéis. | ### 2.1 super_admin — como difere estruturalmente `super_admin` **não é apenas "mais um papel com todas as permissões marcadas"** — é um caso hardcoded no código, em múltiplos pontos: 1. `hasFeatureAccess(user, key, minLevel)` (`server/src/lib/roles.js`): primeira linha é `if (user.role === 'super_admin') return true;` — nunca consulta `role_permissions`. 2. `requireFeatureOrSuperAdmin(key, minLevel)` (middleware): `if (req.user.role === 'super_admin' || hasFeatureAccess(...)) return next();` — redundante com o item 1 mas explícito. 3. `requireSuperAdmin` (middleware dedicado): usado para as telas exclusivas — cadastro da empresa emissora (escrita), CRUD de papéis (exceto listagem), CRUD de Permissões por Papel (toda a rota `role_permissions.routes.js`). 4. `role_permissions` nunca tem linha para `super_admin` — a tabela é `PRIMARY KEY (role) REFERENCES roles(key)`, mas a rota de permissões filtra explicitamente `WHERE key != 'super_admin'` ao listar papéis "editáveis". 5. `assertCanAssignRole` / `assertCanActOnUser`: `super_admin` sempre retorna `true` de cara, sem consultar pesos. 6. Regra de negócio "oferta travada": depois que o cadastro de cliente é iniciado numa oferta (`quotes.client_registration_id` setado), **ninguém** pode mais editar a oferta — **exceto `super_admin`** (`server/src/routes/quotes.routes.js`, rota `PATCH /api/quotes/:id`). Comentário no código: permite reverter um "fechado" que caiu ou corrigir item lançado errado antes do cadastro do cliente terminar. 7. Papel `super_admin` é promovido apenas por migration/seed direto no banco (`1785606100000_promote-matheus-super-admin.js` — promove `matheus@handix.com.br` via `UPDATE users SET role = 'super_admin' WHERE LOWER(email) = ...`), nunca pela API de usuários — não existe UI para criar um segundo super_admin, teria que ser via banco/migration. ### 2.2 admin vs super_admin `isAdminRole(role)` = `role IN ('admin', 'super_admin')` — usado para checagens legadas mais grosseiras (antes do sistema de features por papel existir). `admin` **não** tem bypass automático; seu acesso é 100% dirigido pela linha correspondente em `role_permissions` (que, por padrão de seed, replica o que ele tinha "hardcoded" antes da feature existir — ver seção 3.4). Ou seja: hoje em dia `super_admin` só concede algumas capacidades genuinamente exclusivas (cadastro de empresa emissora, editar oferta travada, CRUD de papéis/permissões); todo o resto que `admin` "sempre teve" é, na prática, `role_permissions` do papel `admin` pré-configurado com tudo em `edit`. --- ## 3. Sistema de permissões por feature (`features.js`) Arquivo: `server/src/lib/features.js`. É o registro único de "telas gateáveis" — cada entrada vira uma linha na matriz de permissão (Sem acesso / Visualizar / Editar) que o `super_admin` configura por papel em Gestão > Usuários > Permissões por Papel. ### 3.1 Formato ```js export const FEATURES = [ { key: 'ofertas', label: 'Ofertas (próprias)', group: 'Ofertas' }, { key: 'ofertas_others', label: 'Ofertas de Outros Usuários', group: 'Ofertas' }, { key: 'clientes_revenda', label: 'Cliente da Revenda', group: 'Clientes' }, { key: 'clientes_todos', label: 'Todos os Clientes', group: 'Clientes' }, { key: 'contratos', label: 'Todos os Contratos', group: 'Contratos' }, { key: 'contratos_relatorios', label: 'Relatórios', group: 'Contratos' }, { key: 'assinaturas', label: 'Assinaturas', group: 'Contratos' }, { key: 'contratos_cessao', label: 'Cessão', group: 'Contratos' }, { key: 'produtos', label: 'Produtos', group: 'Gestão' }, { key: 'fiscal', label: 'Fiscal', group: 'Gestão' }, { key: 'gestao_usuarios', label: 'Gestão de Usuários', group: 'Gestão' }, { key: 'painel_gestor', label: 'Painel do Gestor', group: 'Gestão' }, { key: 'empresa', label: 'Empresa', group: 'Gestão' }, { key: 'backup', label: 'Backup', group: 'Gestão' }, { key: 'documentos_modelos', label: 'Modelos de Documentos', group: 'Gestão' }, { key: 'pagina_inicial', label: 'Página Inicial (Boas-vindas)', group: 'Gestão' }, { key: 'rh_equipamentos', label: 'Equipamentos (Controle de Ponto)', group: 'RH' }, { key: 'rh_funcionarios', label: 'Colaboradores (Controle de Ponto)', group: 'RH' }, { key: 'rh_afd_importacoes', label: 'Importações AFD (Controle de Ponto)', group: 'RH' }, { key: 'rh_afd_auditoria', label: 'Auditoria NSR/AFD (Controle de Ponto)', group: 'RH' }, { key: 'rh_jornadas', label: 'Jornadas e Feriados (Controle de Ponto)', group: 'RH' }, { key: 'rh_ajustes', label: 'Ajustes de Ponto (Controle de Ponto)', group: 'RH' }, { key: 'rh_ajustes_aprovacao', label: 'Aprovação de Ajustes de Ponto (Controle de Ponto)', group: 'RH' }, { key: 'rh_apuracao', label: 'Apuração e Banco de Horas (Controle de Ponto)', group: 'RH' }, { key: 'rh_dashboard', label: 'Dashboard (Controle de Ponto)', group: 'RH' }, { key: 'rh_relatorios', label: 'Relatórios (Controle de Ponto)', group: 'RH' }, { key: 'rh_fechamento', label: 'Fechamento de Período (Controle de Ponto)', group: 'RH' }, { key: 'rh_fechamento_reabertura', label: 'Reabertura de Período Fechado (Controle de Ponto)', group: 'RH' }, { key: 'revendas_cadastro', label: 'Cadastro de Revendas', group: 'Revendas' }, { key: 'agenda', label: 'Agenda (Salas de Reunião)', group: 'Agenda' }, { key: 'agenda_salas', label: 'Agenda — Cadastro de Salas', group: 'Agenda' }, { key: 'agenda_carros', label: 'Agenda (Carros da Empresa)', group: 'Agenda' }, { key: 'agenda_carros_cadastro', label: 'Agenda — Cadastro de Carros', group: 'Agenda' }, ]; export const FEATURE_KEYS = FEATURES.map((f) => f.key); ``` Cada feature tem só dois níveis (`view` e `edit`) além da ausência (= sem acesso). Não há um terceiro nível "delete" separado — deletar é coberto por `edit`. ### 3.2 Regra especial de "ofertas" — duas entradas para próprio vs. de outros `ofertas` controla acesso às **próprias** ofertas do usuário (todo papel edita as próprias por padrão — não depende de `role_permissions`, é incondicional no código de `quotes.routes.js` via `own && hasFeatureAccess(user,'ofertas','edit')`). `ofertas_others` controla acesso às ofertas **de outros usuários**: `view` = vê a lista completa, `edit` = também edita as de outros. É o mecanismo genérico de "próprio vs. todos", reaproveitado sem caso especial hardcoded em código (ver `canViewAllQuotes`/`canEditAllQuotes` em `roles.js`). ### 3.3 Como uma rota decide acesso Em `server/src/middleware/auth.js`: ```js export function requireFeatureOrSuperAdmin(key, minLevel = 'view') { return (req, res, next) => { if (req.user.role === 'super_admin' || hasFeatureAccess(req.user, key, minLevel)) return next(); return res.status(403).json({ error: 'Forbidden' }); }; } ``` `hasFeatureAccess` (em `server/src/lib/roles.js`): ```js const FEATURE_LEVEL_RANK = { view: 1, edit: 2 }; export function hasFeatureAccess(user, key, minLevel = 'view') { if (!user) return false; if (user.role === 'super_admin') return true; const level = user.role_permissions?.[key]; if (!level) return false; return (FEATURE_LEVEL_RANK[level] || 0) >= (FEATURE_LEVEL_RANK[minLevel] || 0); } ``` `user.role_permissions` chega já resolvido: o middleware `auth` faz `LEFT JOIN role_permissions rp ON rp.role = u.role` na query de carregamento do usuário autenticado, evitando query extra em cada rota: ```sql SELECT u.*, rp.permissions AS role_permissions FROM users u LEFT JOIN role_permissions rp ON rp.role = u.role WHERE u.id = $1 ``` Padrão de uso em rotas (exemplo `users.routes.js`): ```js const view = requireFeatureOrSuperAdmin('gestao_usuarios', 'view'); const edit = requireFeatureOrSuperAdmin('gestao_usuarios', 'edit'); router.get('/', auth, view, ...); router.patch('/:id', auth, edit, ...); ``` Frontend espelha exatamente a mesma função em `src/lib/roles.js` (`hasFeatureAccess`, `isAdminRole`, `isAdminOrBackofficeRole`, `canViewAllQuotes`, `canEditAllQuotes`) — mesmas assinaturas, para gate de página/UI (esconder botões de edição, redirecionar se `view` não concedido). Guard típico de página (`Management.jsx`): ```js if (role !== "super_admin" && !hasFeatureAccess(user, "gestao_usuarios", "view")) { /* bloqueia página */ } const canEdit = role === "super_admin" || hasFeatureAccess(user, "gestao_usuarios", "edit"); ``` Importante: o gate de frontend é só UX — a fonte da verdade é sempre o backend (toda rota de escrita/leitura sensível tem seu próprio `requireFeatureOrSuperAdmin`). ### 3.4 Como papéis customizados herdam/definem permissões Não há herança implícita nenhuma. Um papel novo (`POST /api/roles`) nasce com `role_permissions` vazio (`{}` = nenhuma feature liberada) — o `super_admin` precisa entrar em Permissões por Papel e conceder manualmente `view`/`edit` por feature. Não existe conceito de "papel pai" ou herança em cadeia — cada papel (exceto `super_admin`) é uma linha 100% independente em `role_permissions`. A migration `1786040000000_seed-role-permissions-defaults.js` documenta o valor inicial que replicava exatamente o comportamento anterior (quando o acesso era hardcoded por `role` no código) — isso foi feito uma única vez, no deploy que introduziu o sistema de features, para não quebrar o acesso de ninguém: ```json // user { "ofertas": "edit", "clientes_revenda": "view" } // backoffice { "ofertas": "edit", "ofertas_others": "view", "clientes_revenda": "edit", "clientes_todos": "edit", "contratos": "view", "contratos_relatorios": "view", "assinaturas": "edit" } // admin { "ofertas": "edit", "ofertas_others": "edit", "clientes_revenda": "edit", "clientes_todos": "edit", "contratos": "view", "contratos_relatorios": "view", "assinaturas": "edit", "produtos": "edit", "fiscal": "edit", "gestao_usuarios": "edit", "painel_gestor": "view" } ``` Como gatear uma tela nova por feature (processo documentado em comentário no próprio `features.js`, útil para replicar em Eden): 1. Adicionar `{ key, label, group }` em `FEATURES`. 2. Rota de leitura: middleware `requireFeatureOrSuperAdmin(key, 'view')`. 3. Rota de escrita: `requireFeatureOrSuperAdmin(key, 'edit')`. 4. Guard de página no front: `role !== "super_admin" && !hasFeatureAccess(user, key, "view")`; `canEdit = role === "super_admin" || hasFeatureAccess(user, key, "edit")`. 5. Migration de seed com o valor default para o papel que já tinha a tela — senão quem já usava perde acesso no deploy. --- ## 4. Autenticação ### 4.1 Login `POST /api/auth/login` (rota pública, com rate limit — ver seção 5.4) Request: ```json { "email": "user@empresa.com", "password": "senha" } ``` Fluxo (`server/src/routes/auth.routes.js`): 1. Valida presença de `email`/`password` (400 se faltando). 2. Busca usuário por `email.toLowerCase()` — e-mails são case-insensitive na prática (armazenados como veio, comparados em lowercase). 3. Se não existir usuário ou `password_hash` for nulo → 401 `"Credenciais inválidas"` (mensagem genérica, não revela qual dos dois falhou — nem se a conta existe). 4. `bcrypt.compare(password, user.password_hash)` — se falso, mesmo 401 genérico. 5. Sucesso: `signToken(user.id, user.token_version)` + `serializeUser(user)`. Response: ```json { "access_token": "", "user": { "id": "...", "email": "...", "role": "...", "permissions": {...}, ... } } ``` ### 4.2 JWT — geração e validação Arquivo `server/src/lib/jwt.js`: ```js import jwt from 'jsonwebtoken'; const SECRET = process.env.JWT_SECRET; if (!SECRET) throw new Error('JWT_SECRET env var is required'); // falha no boot se não setado export const signToken = (userId, tokenVersion) => jwt.sign({ sub: userId, ver: tokenVersion }, SECRET, { expiresIn: '30d' }); export const verifyToken = (token) => jwt.verify(token, SECRET); ``` - **Algoritmo**: padrão da lib `jsonwebtoken` quando só uma string é passada como secret → **HS256** (HMAC-SHA256, simétrico). Não há RS256/par de chaves. - **Payload**: apenas `{ sub: userId, ver: tokenVersion }` + claims padrão (`iat`, `exp`) — deliberadamente mínimo (não carrega role/permissions no token, essas são resolvidas a cada request via JOIN no banco, então mudanças de papel/permissão têm efeito imediato sem precisar reemitir token). - **Expiração**: 30 dias (`expiresIn: '30d'`), fixo, sem refresh token — o token só é renovado fazendo login de novo. Não há endpoint de refresh. - **Segredo**: variável de ambiente `JWT_SECRET`, obrigatória (processo derruba na subida se ausente). Sem valor default/hardcoded no código. Documentado em `.env.example`: gerar uma vez por ambiente com `openssl rand -hex 48`, nunca trocar depois (trocar invalida todas as sessões ativas). - **Verificação** (middleware `auth`, `server/src/middleware/auth.js`): 1. Extrai `Authorization: Bearer ` do header. 2. `verifyToken(token)` — lança se assinatura/expiração inválidas → 401. 3. Busca usuário por `payload.sub`, com o LEFT JOIN de `role_permissions` (ver seção 3.3). 4. Se usuário não existe → 401. 5. **Checagem de invalidação de sessão**: `if (rows[0].token_version !== payload.ver) return 401 "Sessão encerrada, faça login novamente"`. 6. Popula `req.user` (linha completa de `users` + `role_permissions` resolvido) e `req.tokenVersion`. Não há refresh token, nem rotação de token, nem blacklist explícita — a invalidação é 100% via `token_version` incremental. ### 4.3 `GET /api/auth/me` Requer `auth`. Retorna `serializeUser(req.user)` — usado pelo frontend (`AuthContext`) para revalidar sessão a cada carregamento de app (se não há token no localStorage, nem tenta; se há, chama `/auth/me` e, em caso de erro, limpa o token local). ### 4.4 Logout `POST /api/auth/logout` (requer `auth`). Incrementa `token_version` do usuário — invalida **o token atual e qualquer outro já emitido** (não existe "logout de uma sessão específica", é sempre logout de todas as sessões simultaneamente). Comentário no código: "não basta só apagar o token no cliente". Frontend também limpa o token do `localStorage` independente do resultado da chamada (best-effort). ### 4.5 Esqueci minha senha / reset **Passo 1** — `POST /api/auth/forgot-password` (rate limited, pública): ```json { "email": "user@empresa.com" } ``` - Sempre responde `{ "ok": true }`, **mesmo se o e-mail não existir** (evita enumeração de contas — confirmado também no frontend: `ForgotPassword.jsx` sempre mostra a mesma mensagem de sucesso, inclusive em erro de rede/validação). - Se o e-mail existir: gera `token = crypto.randomBytes(24).toString('hex')` (48 caracteres hex, 192 bits de entropia), `expires = now + 1h`, grava em `users.reset_token`/`reset_token_expires`, envia e-mail com link `${PUBLIC_BASE_URL}/reset-password?token=`. **Passo 2** — `POST /api/auth/reset-password` (pública, sem rate limit dedicado nesta rota especificamente — a proteção de força bruta acontece na etapa de solicitação, não na de confirmação; o token de 192 bits já é impraticável de adivinhar): ```json { "resetToken": "", "newPassword": "novaSenha123" } ``` - Valida `newPassword.length >= 6` (única regra de complexidade de senha em todo o sistema — não exige maiúscula/número/símbolo). - Busca usuário por `reset_token = $1 AND reset_token_expires > now()` — token errado ou expirado → 400 `"Link de redefinição inválido ou expirado"`. - Grava novo `password_hash` (bcrypt custo 10), limpa `reset_token`/`reset_token_expires`, **incrementa `token_version`** (derruba qualquer sessão ativa daquele usuário). ### 4.6 Troca de senha autenticado `POST /api/users/me/change-password` (requer `auth`, qualquer usuário sobre si mesmo): ```json { "current_password": "atual", "new_password": "nova123" } ``` Exige senha atual mesmo já autenticado (evita que uma sessão esquecida aberta troque a senha sem confirmação); mesma regra de tamanho mínimo (6); incrementa `token_version` ao final (exige novo login). ### 4.7 Reset de senha administrativo (por outro usuário) `POST /api/users/:id/reset-password` (requer `auth` + feature `gestao_usuarios:edit`, sujeito a `assertCanActOnUser`). Gera senha temporária aleatória (`crypto.randomBytes(9).toString('base64url')`, ~12 caracteres), grava hash bcrypt, **incrementa `token_version`** do alvo, e devolve a senha em texto plano na resposta (`temp_password`) — não envia e-mail (diferente do convite), o admin repassa manualmente (WhatsApp, telefone). Frontend mostra num modal com botão de copiar. ### 4.8 Convite / cadastro de usuário `POST /api/users/invite` (requer `auth` + `gestao_usuarios:edit`, sujeito a `assertCanAssignRole`): ```json { "email": "novo@empresa.com", "role": "user" } ``` - Rejeita se e-mail já cadastrado (409). - Gera senha temporária aleatória (`crypto.randomBytes(9).toString('base64url')`), hash bcrypt custo 10. - Cria o usuário com `full_name` derivado da parte antes do `@` do e-mail (placeholder), `role` (default `'user'` se omitido via `COALESCE`), `created_by_id` = ator. - Envia e-mail com a senha temporária em texto plano (link de login + credenciais no corpo do e-mail). - Retorna também `temp_password` na resposta da API (fallback caso o e-mail não chegue). ### 4.9 Rate limiting em rotas sensíveis Arquivo `server/src/middleware/rateLimit.js`, usando `express-rate-limit`. Handler padrão de estouro: `429 { "error": "Muitas tentativas. Aguarde alguns minutos e tente novamente." }`. | Limiter | Janela | Máximo | Chave | |---|---|---|---| | `loginIpLimiter` | 15 min | 20 | IP (padrão da lib) | | `loginUserLimiter` | 15 min | 5 | e-mail normalizado do body (`byEmailKey`) | | `forgotPasswordIpLimiter` | 15 min | 10 | IP | | `forgotPasswordUserLimiter` | 15 min | 3 | e-mail normalizado do body | | `publicClientRegistrationLimiter` | 15 min | 30 | IP | | `publicResellerRegistrationLimiter` | 15 min | 30 | IP | | `publicSignatureLimiter` | 15 min | 60 | IP | | `otpRequestLimiter` | 15 min | 5 | token do signatário (`req.params.token`) | | `otpVerifyLimiter` | 15 min | 15 | token do signatário | `POST /api/auth/login` usa **dois** limiters empilhados (`loginIpLimiter, loginUserLimiter`) — protege tanto contra um IP tentando várias contas (password spraying) quanto contra força bruta numa conta específica vinda de IPs diferentes. Mesmo padrão duplo em `forgot-password`. `app.set('trust proxy', 1)` em `server.js` — o servidor roda atrás de um único proxy reverso (nginx externo) e confia no `X-Forwarded-For` dele para que `req.ip` reflita o IP real do cliente (necessário para os limiters por IP funcionarem corretamente). **Observação de segurança**: `POST /api/auth/reset-password` (confirmação do reset) e `POST /api/users/:id/reset-password` (reset administrativo) e `POST /api/users/:id/reset-password` não têm rate limiter dedicado — mitigado pela alta entropia do token (192 bits) na primeira, e pela exigência de estar autenticado + ter a feature de gestão de usuários na segunda. --- ## 5. Criptografia e segurança ### 5.1 Hash de senha - Biblioteca: `bcryptjs` (`^2.4.3` — implementação JS pura do bcrypt, não a binding nativa `bcrypt`). - Custo (**salt rounds**): **10**, hardcoded em todos os pontos de hash (`bcrypt.hash(senha, 10)`) — login/invite/reset/change-password/seed do admin inicial, sem variação. - Comparação sempre via `bcrypt.compare`, nunca comparação manual de hash. - Nenhum "pepper" adicional (segredo extra além do salt do bcrypt) é usado. ### 5.2 JWT - Algoritmo: **HS256** (assinatura simétrica HMAC), implícito pela lib `jsonwebtoken` quando o secret é uma string simples. - Segredo: env var `JWT_SECRET`, sem valor default nem fallback — a aplicação recusa subir sem ela (`throw new Error` no import do módulo). Gerada manualmente por ambiente (`openssl rand -hex 48` sugerido no `.env.example`), nunca versionada em git (`.env` está no `.gitignore`). - Payload mínimo (`sub`, `ver`), expiração 30 dias, sem refresh token. - Invalidação server-side via `users.token_version` (não há JWT blacklist nem revogação por jti). ### 5.3 Outras criptografias em repouso no sistema (fora do escopo direto de auth, mas relevante como padrão a replicar) - `CONTROLID_ENCRYPTION_KEY` (env var, gerar com `openssl rand -hex 32`): usada para criptografar **de forma reversível** a senha admin de cada equipamento de ponto (Control iD) cadastrado — porque a aplicação precisa recuperar a senha em texto puro para autenticar no relógio de ponto (não pode ser hash unidirecional como senha de usuário). Trocar essa chave torna ilegíveis as senhas de equipamento já cadastradas. Este é o único caso de criptografia reversível de segredo identificado no sistema (fora do escopo de usuários/papéis, mas é o padrão de "quando não pode ser hash" usado no projeto). - Senhas de usuário (`users.password_hash`) e nada mais relacionado a auth usa criptografia reversível — é sempre hash unidirecional (bcrypt). ### 5.4 Sanitização de inputs - Não há biblioteca de sanitização genérica (tipo `express-validator`/`joi`/`zod`) usada nas rotas de auth/usuários/papéis — validação é manual, por rota, checando presença/formato/tamanho no próprio handler (ex.: regex de `key` de papel, `newPassword.length >= 6`, `isValidCNPJ`). - Todas as queries usam **parâmetros posicionais do `pg`** (`$1, $2, ...`) — não há concatenação de string SQL com input do usuário nas rotas revisadas (proteção padrão contra SQL injection via driver). - E-mails são normalizados com `.toLowerCase()` e `.trim()` antes de comparar/gravar. - CNPJ/CPF normalizados via `onlyDigits()` e validados por dígito verificador (`isValidCNPJ`) antes de persistir. ### 5.5 CORS e headers de segurança - **Não há middleware de CORS configurado** (`server.js` não importa nem usa o pacote `cors`; confirmado ausente em `server/package.json`) — a API assume que frontend e backend são servidos pela mesma origem (o Express serve os arquivos estáticos do build do Vite diretamente: `app.use(express.static(staticDir))` + fallback SPA `app.get('*', ...)`). Não há, portanto, política de CORS explícita para chamadas cross-origin — se algum client externo tentar chamar a API de outra origem, o comportamento é o padrão do browser (bloqueado, já que não há headers `Access-Control-Allow-Origin`). - **Não há `helmet` nem outro middleware de security headers** (ausente do `package.json`) — sem CSP, sem `X-Frame-Options`, `X-Content-Type-Options`, `Strict-Transport-Security`, etc. configurados explicitamente no Express. Isso é uma lacuna a considerar/decidir conscientemente ao reconstruir em Eden (adicionar `helmet` seria uma melhoria direta). - `express.json({ limit: '5mb' })` é o único middleware global de parsing/limite de payload. - Erros não tratados caem num handler global (`app.use((err, req, res, next) => ...)`) que loga no console e responde `500 { error: 'Erro interno do servidor' }` — evita vazar stack trace para o cliente (exceto casos especiais de `MulterError` para upload). ### 5.6 O que está em `.env` vs. hardcoded Variáveis de ambiente relevantes a este escopo (`.env.example`): - `DATABASE_URL` — connection string do Postgres. - `JWT_SECRET` — obrigatória, sem default. - `PORT` — porta do servidor (8006 dev / 8007 prod, por convenção, não travado em código). - `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` — usados só no primeiro boot com banco vazio (`seedAdmin()` em `server/src/lib/migrate.js`) para criar o primeiro `admin`. Se `SEED_ADMIN_PASSWORD` não definida, gera senha aleatória (`crypto.randomBytes(9).toString('base64url')`) e imprime nos **logs do container**. - `SMTP_HOST/PORT/USER/PASS/FROM` — envio de e-mail (reset de senha, convite, cadastro de cliente). Se `SMTP_HOST` ausente, e-mails não são enviados de verdade — apenas logados no console (`[mailer] SMTP não configurado — e-mail para X não enviado`), útil para dev sem quebrar o fluxo. - `PUBLIC_BASE_URL` — usada para montar links absolutos em e-mails (reset de senha, etc.); se ausente, o sistema tenta inferir a partir do host da requisição (`baseUrlFromReq`). - `CONTROLID_ENCRYPTION_KEY` — fora do escopo direto, mas é o outro segredo simétrico do sistema (ver 5.3). Hardcoded no código (não configurável por env): - Custo do bcrypt = 10. - Expiração do JWT = 30 dias. - Regras de rate limit (janelas/máximos da tabela da seção 4.9). - Regex de chave de papel, teto de peso (99), tamanho mínimo de senha (6). - E-mail do super_admin permanente inicial (`matheus@handix.com.br`), fixado na migration de promoção — não é uma env var, é uma migration versionada. ### 5.7 Seed do usuário administrador inicial `server/src/lib/migrate.js`, função `seedAdmin()`, chamada no boot do servidor (`start()` em `server.js`) **antes** de abrir a porta. Só executa se `users` estiver vazia (`SELECT count(*) FROM users`). Cria uma revenda "Handix" padrão e um usuário `role = 'admin'` (não `super_admin`) com `can_approve_discount = true`. O primeiro `super_admin` real do sistema é sempre promovido via migration de dados fixa (email hardcoded), não pelo seed automático. --- ## 6. Multi-empresa / `companies` Importante: **não é multi-tenant de clientes/dados isolados**. `companies` representa **empresa(s) emissora(s)** de documentos/contratos (ex.: a própria Handix e eventuais outras razões sociais sob as quais contratos são emitidos) — usada para montar campos de contratos/termos legais (CNPJ, endereço, representante legal), não para segmentar/particionar os dados de usuários, ofertas, clientes, etc. - **Leitura** (`GET /api/companies`, `GET /api/companies/:id`): aberta a qualquer usuário autenticado (`router.use(auth)` sem feature-gate na leitura) — dados considerados não sensíveis, aparecem em qualquer nota fiscal/contrato e são necessários para montar documentos (ex.: Termo de Portabilidade) a partir da tela de oferta, acessível a qualquer dono/backoffice. - **Escrita** (`POST`/`PATCH`/`DELETE /api/companies`): gateada pela feature `empresa` (`requireFeatureOrSuperAdmin('empresa', 'edit')`) — não é `requireSuperAdmin` puro, é feature configurável (mas por padrão de seed só `admin`/`super_admin` tinham acesso à tela "Empresa"). - Não há coluna `company_id` em `users`, `quotes`, `products`, etc. — ou seja, o isolamento real de "quem vê o quê" no sistema é feito por **`reseller_id`** (revenda) e pelo sistema de features/roles, não por `companies`. A tabela `resellers` (fora do escopo pedido, mas citada aqui por clareza) é o conceito mais próximo de "tenant operacional": cada oferta (`quotes.reseller_id`) e usuário (`users.reseller_id`) pertence a uma revenda, e regras de feature como `clientes_revenda` (cliente da própria revenda) vs. `clientes_todos` decidem o alcance de visualização. --- ## 7. Fluxos e regras de negócio não óbvias (síntese para Eden) 1. **Peso de papel > feature de gestão de usuários**: ter `gestao_usuarios:edit` não é suficiente para mexer em qualquer usuário — só em usuários/papéis de peso `<=` o peso do próprio papel do ator. `super_admin` (peso 100, hardcoded) sempre ignora essa checagem. 2. **`super_admin` nunca aparece em `role_permissions`** e nunca é listado como editável nas telas de Papéis/Permissões (`WHERE key != 'super_admin'` em toda consulta relevante). Não pode ser editado, seu `label`/`weight` são imutáveis via API, e não pode ser excluído. 3. **Oferta travada só é editável por `super_admin`**: assim que `quotes.client_registration_id` é setado (cadastro de cliente iniciado), a rota `PATCH /api/quotes/:id` bloqueia qualquer edição para todo mundo, exceto `role === 'super_admin'` — regra de negócio adicionada especificamente para permitir corrigir erros/reverter fechamentos indevidos sem reabrir o processo todo. 4. **`token_version` é o mecanismo universal de "encerrar sessão"** — usado em: logout explícito, troca de senha pelo próprio usuário, reset de senha via link de e-mail, e reset de senha administrativo. Sempre que a senha muda ou logout é pedido, todas as sessões (todos os tokens já emitidos daquele usuário) são invalidadas de uma vez — não há como derrubar "só uma sessão". 5. **Criar um papel novo não copia nada de outro papel** — nasce com `role_permissions = {}` (zero acesso); é responsabilidade do `super_admin` configurar cada feature manualmente depois. 6. **Excluir um papel exige zero usuários com aquele papel** — `DELETE /api/roles/:key` verifica `COUNT(*) FROM users WHERE role = key` e recusa com 409 se houver algum, listando a contagem na mensagem de erro. Papéis `is_system = true` nunca podem ser excluídos, independentemente de terem usuários. 7. **`ofertas` vs. `ofertas_others` é o padrão geral para "próprio vs. todos"** — reaproveitado sem caso especial no código; ao adicionar uma feature nova em Eden que precise dessa distinção, seguir o mesmo padrão de duas entradas em vez de lógica condicional hardcoded. 8. **Convite de usuário gera senha temporária e a envia por e-mail E devolve na resposta da API** — dupla via de entrega (o e-mail pode não chegar; a resposta da API é o fallback mostrado num modal com botão de copiar no frontend). 9. **`forgot-password` sempre responde sucesso** independentemente de o e-mail existir — evita enumeração de contas por e-mail. `reset-password` (confirmação) é a única rota que de fato revela se o token é válido, mas o token em si tem entropia alta o bastante para não ser adivinhável. 10. **E-mail é a chave natural de usuário** (`UNIQUE`, comparado sempre em lowercase) — não existe "username" separado. 11. **Migration gotcha documentado no próprio código** (relevante para quem for portar o schema): adicionar um valor a um enum Postgres (`ALTER TYPE ... ADD VALUE`) e usá-lo na mesma transação/lote de migrations falha em produção ("unsafe use of new value") — a migration que introduziu `super_admin` precisou de `pgm.noTransaction()` para isolar o commit do novo valor antes de outra migration do mesmo lote tentar usá-lo. Em Eden, se o modelo de papéis for uma tabela (`roles`) desde o início (como o sistema evoluiu para em `1786250000000_add-custom-roles.js`), esse problema simplesmente não existe — recomenda-se começar direto com `roles` como tabela, não enum. --- ## 8. Referência rápida de endpoints (escopo deste documento) | Método | Rota | Auth | Gate adicional | |---|---|---|---| | POST | `/api/auth/login` | não | rate limit (IP + e-mail) | | GET | `/api/auth/me` | sim | — | | POST | `/api/auth/logout` | sim | — | | POST | `/api/auth/forgot-password` | não | rate limit (IP + e-mail) | | POST | `/api/auth/reset-password` | não | token de 192 bits | | GET | `/api/users` | sim | `gestao_usuarios:view` | | POST | `/api/users/invite` | sim | `gestao_usuarios:edit` + peso | | PATCH | `/api/users/me` | sim | — (autoatualização) | | POST | `/api/users/me/change-password` | sim | exige senha atual | | POST | `/api/users/:id/reset-password` | sim | `gestao_usuarios:edit` + peso | | PATCH | `/api/users/:id` | sim | `gestao_usuarios:edit` + peso (atribuição e ação) | | DELETE | `/api/users/:id` | sim | `gestao_usuarios:edit` + peso | | GET | `/api/roles` | sim | `gestao_usuarios:view` | | POST/PATCH/DELETE | `/api/roles[/:key]` | sim | `requireSuperAdmin` | | GET/PATCH | `/api/role-permissions[...]` | sim | `requireSuperAdmin` (toda a rota) | | GET | `/api/companies[/:id]` | sim | — (aberto a qualquer autenticado) | | POST/PATCH/DELETE | `/api/companies[/:id]` | sim | `empresa:edit` | --- ## 9. Arquivos-fonte lidos (para rastreabilidade) - `server/src/middleware/auth.js` - `server/src/lib/jwt.js` - `server/src/lib/roles.js` - `server/src/lib/features.js` - `server/src/lib/serialize.js` - `server/src/lib/migrate.js` - `server/src/lib/mailer.js` - `server/src/routes/auth.routes.js` - `server/src/routes/users.routes.js` - `server/src/routes/roles.routes.js` - `server/src/routes/rolePermissions.routes.js` - `server/src/routes/companies.routes.js` - `server/src/routes/quotes.routes.js` (trecho da trava de oferta) - `server/src/middleware/rateLimit.js` - `server/src/server.js` - `server/package.json` - `.env.example` - `server/migrations/1785400000000_baseline-schema.js` - `server/migrations/1785509417538_add-backoffice-role.js` - `server/migrations/1785606000000_add-super-admin-role.js` - `server/migrations/1785606100000_promote-matheus-super-admin.js` - `server/migrations/1785606000001_add-companies.js` - `server/migrations/1786030000000_add-role-permissions.js` - `server/migrations/1786040000000_seed-role-permissions-defaults.js` - `server/migrations/1786250000000_add-custom-roles.js` - `server/migrations/1786330000000_add-role-weight.js` - `src/pages/Management.jsx` - `src/pages/Login.jsx` - `src/pages/ForgotPassword.jsx` - `src/pages/ResetPassword.jsx` - `src/components/management/UsersManager.jsx` - `src/components/management/RolePermissionsManager.jsx` - `src/api/base44Client.js` - `src/lib/roles.js` - `src/lib/AuthContext.jsx` - `src/components/ProtectedRoute.jsx` - `src/lib/authReturnTo.js` --- # 2. Produtos, Ofertas (Quotes), Faixas de Preço/Fidelidade e Contratos > Documentação de referência para reconstrução fiel no sistema "Eden". > Escopo: `products`, `quotes` e o conceito de "contrato" (que **não é uma > tabela própria** — é derivado de `client_registrations` + `quotes`). > Fiscal (`product_fiscal_profiles`, `fiscal_rules`, etc.) é coberto por > outro agente; aqui só é citado onde interage com o core comercial. --- ## 1. Modelo de dados ### 1.1 Tabela `products` Fonte: `server/migrations/1785400000000_baseline-schema.js` + `1785700000001_add-product-fiscal-config.js` (não relevante aqui) + `1785980000000_add-product-ixc-code.js` + `1785990000000_add-product-code.js` + `1786020000000_add-product-discontinued.js`. | Coluna | Tipo | Default/Regras | Observações | |---|---|---|---| | `id` | UUID PK | `gen_random_uuid()` | | | `created_date` / `updated_date` | TIMESTAMPTZ | `now()` / trigger `set_updated_date()` | | | `created_by_id` | UUID FK `users(id)` | | | | `name` | TEXT NOT NULL | | | | `description` | TEXT | | | | `category` | TEXT | | livre, texto | | `price_0` | NUMERIC(12,2) | | preço "sem fidelidade" (faixa 0 meses) | | `price_12` | NUMERIC(12,2) | | preço na faixa de 12 meses | | `price_24` | NUMERIC(12,2) | | preço na faixa de 24 meses | | `price_36` | NUMERIC(12,2) | | preço na faixa de 36 meses | | `price_48` | NUMERIC(12,2) | | preço na faixa de 48 meses | | `impl_unit_price` | NUMERIC(12,2) NOT NULL | default `0` | valor de implantação **por unidade**, multiplicado pela quantidade do item na oferta | | `unit` | TEXT NOT NULL | default `'unidade'` | | | `active` | BOOLEAN NOT NULL | default `true` | inativo não aparece no catálogo geral (`GET /products` filtra opcionalmente por `active`) | | `service_type` | TEXT NOT NULL | default `'STFC'`; whitelist `SERVICE_TYPES` (server: `server/src/lib/constants.js`) | usado para agrupar itens na oferta/PDF e para sugerir documento fiscal | | `metered` | BOOLEAN NOT NULL | default `false` | produto "tarifado" (chamadas) — habilita a tabela de tarifas | | `minutes_allowance` | NUMERIC(12,2) | | franquia de minutos (só relevante se `metered`) | | `tariff_rates` | JSONB NOT NULL | default `'{}'` | formato `{ [tipo]: { normal: number, reduced: number } }`, tipos: `LC, LDN, VC1, VC2, VC3, LDI` | | `has_ldi` | BOOLEAN NOT NULL | default `false` | inclui tarifa de Longa Distância Internacional | | `ixc_product_code` | TEXT | (migration `add-product-ixc-code`) | código do MESMO produto no sistema externo IXC — campo aberto, não sequencial | | `product_code` | INTEGER NOT NULL UNIQUE | `nextval('products_product_code_seq')`, sequência própria começando em 1 | código interno do OrçaFácil, gerado automaticamente, distinto do UUID e do `ixc_product_code` | | `discontinued` | BOOLEAN NOT NULL | default `false` | produto descontinuado some do seletor de produtos da oferta (`Product.filter({ active:true, discontinued:false })`), mas continua existindo (uso futuro em "Aditivo", ainda não implementado) | Não existe soft-delete: `DELETE /products/:id` remove a linha de fato. **Faixas de preço** = 5 colunas fixas (`price_0/12/24/36/48`) — **não é uma tabela relacionada**, é fixo por produto. Ver seção 2. Serialização (`serializeProduct`, `server/src/lib/serialize.js`) expõe todos os campos acima, convertendo os `price_*`/`impl_unit_price`/ `minutes_allowance` para `Number` (ou `null`). ### 1.2 Tabela `quotes` Fonte: `1785400000000_baseline-schema.js` + `1785598045226_add-partner-signature-fields-and-quote-sync.js` (adiciona `client_document`, `client_code`) + `1785513008756_add-client-registrations.js` (adiciona `client_registration_id`) + `1786340000000_add-quote-fidelity-period.js` (adiciona `fidelity_period`). | Coluna | Tipo | Default/Regras | Observações | |---|---|---|---| | `id` | UUID PK | | | | `created_date` / `updated_date` | TIMESTAMPTZ | trigger auto-update | | | `created_by_id` | UUID FK `users(id)` | | dono/vendedor responsável — só admin pode reatribuir (seção 9) | | `quote_number` | TEXT | gerado no frontend: `String(Date.now()).slice(-6)` ao criar uma oferta nova | não é sequencial garantido nem único no banco | | `reseller_id` | UUID NOT NULL FK `resellers(id)` | | revenda dona da oferta | | `client_name` | TEXT NOT NULL | | | | `client_company` | TEXT | | razão social/nome fantasia, se PJ | | `client_email` | TEXT | | | | `client_phone` | TEXT NOT NULL | | validado contra duplicidade entre revendas (`validateQuotePhone`, fora do escopo deste doc) | | `client_document` | TEXT | migration `add-partner-signature-fields...` | CPF/CNPJ — preenchido quando o cadastro do cliente é validado (`registration_status = 'ativo'`), copiado do cadastro | | `client_code` | INTEGER | idem | código sequencial interno do cliente, copiado do cadastro ativo | | `ixc_client_code` | TEXT | | código do cliente no sistema IXC | | `sales_rep_name` | TEXT | | nome de exibição de quem vendeu (texto livre, snapshot) | | `contract_period` | INTEGER NOT NULL | default `0`; CHECK `IN (0,12,24,36,48)` | **faixa de preço contratada** — determina qual `price_N` foi usado. NÃO necessariamente igual ao prazo de fidelidade real (ver `fidelity_period`) | | `fidelity_period` | INTEGER | nullable; CHECK `fidelity_period IS NULL OR (fidelity_period >= 0 AND fidelity_period <= contract_period)` | prazo de permanência **efetivamente assinado**, quando diferente (menor) da faixa de preço. `NULL` = sem exceção (fidelidade = `contract_period`, caso padrão) | | `items` | JSONB NOT NULL | default `'[]'` | array de itens da oferta — estrutura na seção 1.3 | | `implementation_fee` | NUMERIC(12,2) NOT NULL | default `0` | soma da implantação **calculada a partir dos produtos** (`impl_unit_price × quantity` de cada item) | | `impl_geral` | NUMERIC(12,2) NOT NULL | default `0` | valor de implantação adicional "geral do projeto", digitado à mão, não ligado a nenhum produto | | `monthly_total` | NUMERIC(12,2) | | total mensal "de tabela" (soma dos itens, sem condição especial) | | `contract_total` | NUMERIC(12,2) | | ver fórmula seção 8 | | `proposed_monthly_total` | NUMERIC(12,2) | | "Condição Especial" — valor mensal final negociado, quando diferente do total de tabela. `null`/vazio = sem condição especial | | `approval_status` | ENUM `approval_status` (`none, pending, approved, rejected`) | default `'none'` | cobre **duas exceções ao mesmo tempo**: desconto especial (`proposed_monthly_total < monthly_total`) e/ou fidelidade reduzida (`fidelity_period < contract_period`) — uma única aprovação vale para ambas quando as duas existirem juntas na mesma oferta | | `approval_notes` | TEXT | | observação livre do aprovador | | `approved_by_id` | UUID FK `users(id)` | nullable | quem aprovou; setado pelo backend, nunca aceito cru do client | | `notes` | TEXT | | observações gerais da oferta | | `status` | ENUM `quote_status` (`rascunho, enviado, aprovado, recusado`) | default `'rascunho'` | status do **documento/proposta** em si (independente do negócio) — na prática o frontend sempre grava `'rascunho'` (ver `buildQuoteData`); não há fluxo de UI que altere para os outros valores neste módulo | | `deal_status` | ENUM `deal_status` (`orcamento, fechado, perdido`) | default `'orcamento'` | status do **negócio** — máquina de estados, seção 5 | | `impl_payment_condition` | TEXT NOT NULL | default `'À vista'` | ver seção 7 | | `reseller_id` | (já listado acima) | | | | `client_registration_id` | UUID FK `client_registrations(id)` | nullable | quando setado, a oferta está **travada** (seção 5) | Índices: `idx_quotes_reseller`, `idx_quotes_created`, `idx_quotes_phone`, `idx_quotes_deal`. Serialização (`serializeQuote`) inclui também, via JOIN: `created_by` (email do criador), `approved_by_name` (nome de quem aprovou). ### 1.3 Estrutura de `quotes.items` (JSONB array) Cada item é um snapshot completo do produto no momento em que foi adicionado/atualizado na oferta (preços e metadados **não** são recalculados a partir de `products` depois de salvos — só na tela de edição, ao recarregar o catálogo, os campos de preço são reconciliados com o produto atual, ver `NewQuote.jsx` linhas ~201-217): ```js { product_id: "uuid", product_name: "string", // snapshot do nome no momento da adição quantity: number, unit_price: number, // preço vigente para o período selecionado total: number, // quantity * unit_price impl_unit_price: number, // snapshot de products.impl_unit_price impl_total: number, // quantity * impl_unit_price price_0: number, // snapshot de products.price_0 (usado no cálculo de desconto por fidelidade) price_12: number, price_24: number, price_36: number, price_48: number, service_type: string, // snapshot de products.service_type metered: boolean, minutes_allowance: number|null, tariff_rates: object, // snapshot de products.tariff_rates has_ldi: boolean, ixc_product_code: string, } ``` ### 1.4 Relação com "contrato" **Não existe tabela `contracts`.** Um "contrato" é a junção, em tempo de consulta, de `client_registrations` (status `'ativo'`) com a `quotes` que o originou (`client_registrations.quote_id`). Ver seção 10. --- ## 2. Faixas de preço por período de contrato - Cada produto tem **5 colunas fixas** de preço mensal: `price_0` (sem fidelidade), `price_12`, `price_24`, `price_36`, `price_48` — não é uma tabela relacionada de "price tiers", é hardcoded no schema. - `quotes.contract_period` grava qual faixa foi usada (`CHECK IN (0,12,24,36,48)`) — os 5 valores são as únicas faixas suportadas hoje. - Ao trocar de período na tela de oferta (`changePeriod` em `NewQuote.jsx`): 1. Para cada item já na oferta, `unit_price` é recalculado lendo `item[\`price_${novoPeriodo}\`]` (o snapshot do item já carrega os 5 preços); se ausente, mantém o `unit_price` atual. 2. `total` do item é recalculado (`quantity * novoPreço`). 3. **`fidelityPeriod` é resetado para `null`** — uma exceção de fidelidade é sempre relativa à faixa vigente; trocar de faixa invalida qualquer valor pendente. - Ao adicionar um produto (`addProduct`), o preço unitário inicial é `getPrice(product, period) = product[\`price_${period}\`] || 0`. - Produto descontinuado (`discontinued = true`) não aparece na lista de produtos disponíveis para adicionar a uma nova oferta (`Product.filter({ active: true, discontinued: false })`), mas continua em ofertas já existentes. --- ## 3. Fidelidade contratual reduzida (`fidelity_period`) ### 3.1 Conceito - `contract_period` = faixa de preço (define qual `price_N` foi usado). - `fidelity_period` = prazo de permanência **efetivamente assinado** pelo cliente, quando **menor** que `contract_period` (ex.: cliente fecha no preço da faixa de 48 meses, mas negocia permanecer só 3, 4, 5 ou 6 meses). - `fidelity_period = NULL` (padrão, sempre) significa "sem exceção" — a fidelidade real é igual a `contract_period`. - Regra de negócio: só é válido preencher um prazo **MENOR** que a faixa de preço contratada — não faz sentido negociar fidelidade maior que o período que definiu o preço. CHECK no banco: `ck_fidelity_period`: `fidelity_period IS NULL OR (fidelity_period >= 0 AND fidelity_period <= contract_period)`. - Validação espelhada no backend (`validateFidelityPeriod` em `quotes.routes.js`): inteiro entre `0` e `contract_period`. ### 3.2 Cálculo de "tem fidelidade reduzida" ```js hasReducedFidelity = fidelityPeriod != null && fidelityPeriod < period ``` (no frontend, `period` = `contract_period` corrente da tela) ### 3.3 Fluxo de aprovação - `hasReducedFidelity` entra no **mesmo** `needsApproval` do desconto especial de preço (seção 4): `needsApproval = isDiscount || hasReducedFidelity`. - Uma única `approval_status`/`approval_notes`/`approved_by_id` cobre as duas exceções quando ambas existem na mesma oferta simultaneamente — não há aprovação separada por tipo de exceção. - Quem pode aprovar/recusar (`canApproveDiscount`, backend e mirror no frontend): ```js function canApproveDiscount(user) { if (user.role === 'super_admin') return true; // sempre, sem flag return user.role === 'admin' && user.can_approve_discount === true; } ``` `admin` comum só pode aprovar se `users.can_approve_discount = true`. - Ao alterar `contract_period` (faixa) ou `fidelity_period` na tela, `approvalStatus` é resetado para `"none"` (nova negociação = nova aprovação necessária). - No `POST`/`PATCH` de `quotes`, se `b.approval_status` for `'approved'` ou `'rejected'`, o backend exige `canApproveDiscount(req.user)` (403 caso contrário) e define `approved_by_id` = usuário atual (se aprovado) ou `null` (se recusado) — nunca aceita `approved_by_id` vindo do client. ### 3.4 Como "vaza" para vencimento, multa e documentos — só depois de aprovado Regra idêntica em `variableRegistry.js` (`resolveOfertaRaw`, `resolveContratoRaw`), `BackofficeAnatelPDFTemplate.jsx`, `QuotePDFTemplate.jsx` e `contracts.routes.js` (`enrichContract`): ```js fidelityPeriod = (quote.fidelity_period != null && quote.approval_status === 'approved') ? quote.fidelity_period : (quote.contract_period || 0); ``` Ou seja: **enquanto `approval_status` não é `'approved'`, todo documento, cálculo de vigência, multa e vencimento de contrato usa `contract_period`** — nunca expõe (nem calcula) sobre uma exceção pendente. Só depois de aprovada é que o `fidelity_period` passa a valer para: - Texto de "Nome Comercial da Oferta" (`Oferta com fidelidade de N meses` / `Oferta sem fidelidade`). - "Prazo de Vigência" e "Prazo de Permanência" no PDF Backoffice ANATEL (conformidade RGC, arts. 3º/XIII, 26/27, 36). - Teto de multa por rescisão antecipada (art. 37) — seção 3.5. - Data de vencimento do contrato (seção 10). - Variável `contrato.vigencia_meses` / `oferta.fidelidade` no gerador de documentos (`variableRegistry.js`). ### 3.5 Multa (RGC art. 36/37) No `BackofficeAnatelPDFTemplate.jsx`: ```js // Desconto total mensal atribuível à fidelidade (NUNCA inclui o desconto // especial — é o valor legal do "benefício concedido"). totalDescontoMensal = Σ items[ (item.price_0 - item.unit_price) * item.quantity ] // Benefício total concedido pela fidelidade — base legal do teto de multa. beneficioTotalFidelidade = totalDescontoMensal * fidelityPeriod // fidelityPeriod já resolvido pela regra 3.4 // Texto exibido: "Multa por Rescisão Antecipada (art. 37)": fidelityPeriod > 0 ? `proporcional ao tempo restante, limitada a R$ ${beneficioTotalFidelidade.toFixed(2)} (benefício concedido)` : "Não aplicável" ``` Importante: o cálculo do teto de multa usa **só** o desconto de fidelidade — nunca soma o desconto/acréscimo especial (ver comentário no código: "não pode misturar com condição especial"). --- ## 4. Desconto / Condição Especial de preço (`proposed_monthly_total`) ### 4.1 Conceito - `monthlyTotal` = soma de tabela (`Σ item.total`, com os preços da faixa vigente). - `proposed_monthly_total` = valor mensal final negociado, quando diferente do total de tabela. - Detecção (idêntica em frontend `NewQuote.jsx` e nos templates de PDF): ```js hasSpecialPrice = proposedMonthly !== "" && Number(proposedMonthly) > 0 && Number(proposedMonthly) !== monthlyTotal; isDiscount = hasSpecialPrice && Number(proposedMonthly) < monthlyTotal; isMarkup = hasSpecialPrice && Number(proposedMonthly) > monthlyTotal; diffMonthly = hasSpecialPrice ? monthlyTotal - Number(proposedMonthly) : 0; // positivo=desconto, negativo=acréscimo effectiveMonthly = hasSpecialPrice ? Number(proposedMonthly) : monthlyTotal; ``` ### 4.2 Regra de aprovação — só desconto exige aprovação - **Desconto** (`isDiscount`, valor final menor que a tabela): entra em `needsApproval` — fica pendente até um admin autorizado aprovar (mesma função `canApproveDiscount` da seção 3.3, mesmos campos `approval_status` / `approval_notes` / `approved_by_id` compartilhados com a fidelidade reduzida). - **Acréscimo** (`isMarkup`, valor final maior que a tabela): aplicado **direto, sem necessidade de aprovação** — nunca passa por `approval_status`. - No servidor (`buildQuoteData`/rota), quando `needsApproval` é falso (isMarkup puro, sem fidelidade reduzida), `approval_status` é forçado para `'none'`. ### 4.3 Rateio proporcional por item Quando há condição especial ativa (aprovada, se desconto; sempre, se acréscimo), o valor final é rateado **proporcionalmente ao peso de cada item no total de tabela** — nunca abate um item só: ```js // QuotePDFTemplate.jsx — para exibição ao cliente share_i = (item.total / monthlyTotal) * (monthlyTotal - effectiveMonthly) item.total_final = item.total - share_i item.unit_price_final = item.total_final / item.quantity // BackofficeAnatelPDFTemplate.jsx — para o backoffice, com granularidade extra specialDiscount = monthlyTotal - proposedMonthlyTotal // positivo=desconto, negativo(markup) item.itemSpecialDiscTotal = (item.totalContr / monthlyTotal) * specialDiscount item.itemSpecialDiscUnit = item.itemSpecialDiscTotal / item.quantity item.totalDescGeral = item.descFidTotal + item.itemSpecialDiscTotal // desconto fidelidade + especial, por item item.priceFinal = item.priceContr - item.itemSpecialDiscUnit item.totalFinal = item.priceFinal * item.quantity ``` ### 4.4 Fórmula CORRETA de "economia mensal" e "economia total do contrato" (bug corrigido) Commit `154f588` corrigiu um bug em que esses dois campos do PDF Backoffice ANATEL somavam **só** o desconto de fidelidade, ignorando o desconto/acréscimo especial. Fórmula correta (vigente): ```js // Desconto de fidelidade (nunca inclui condição especial — é valor "legal" pro art.36/37) totalDescontoMensal = Σ items[ (item.price_0 - item.unit_price) * item.quantity ] // specialDiscount: positivo = desconto especial, negativo = acréscimo especial specialDiscount = hasSpecialPrice ? (monthlyTotal - proposedMonthlyTotal) : 0 // ECONOMIA MENSAL (soma dos dois — um acréscimo especial REDUZ a economia, // pois specialDiscount é negativo nesse caso): totalEconomiaMensal = totalDescontoMensal + specialDiscount // ECONOMIA TOTAL DO CONTRATO (exibida só se totalEconomiaMensal > 0 e contract_period > 0): economiaTotalContrato = totalEconomiaMensal * contract_period ``` Atenção: `beneficioTotalFidelidade` (usado no cálculo do teto de multa, seção 3.5) **continua sendo só** `totalDescontoMensal * fidelityPeriod` (fidelidade pura) — não deve ser confundido com `totalEconomiaMensal`, que é exibido no quadro "Economia Mensal"/"Economia total no contrato" e soma os dois tipos de desconto. São dois números distintos, calculados separadamente, para propósitos diferentes (informativo comercial vs. teto legal de multa). --- ## 5. Trava de edição de oferta e máquina de estados ### 5.1 Trava (`client_registration_id`) - Assim que `quotes.client_registration_id` é setado (cadastro do cliente iniciado — ver seção 6), a oferta **trava**: não pode mais ser editada por ninguém, **exceto `super_admin`**. - Checagem no backend (`PATCH /quotes/:id`, `quotes.routes.js`): ```js if (quote.client_registration_id && req.user.role !== 'super_admin') { return res.status(409).json({ error: 'Esta oferta está travada — o cadastro do cliente já foi iniciado.' }); } ``` - **`super_admin` pode editar tudo** na oferta travada (itens, cliente, valores, status), **exceto** `client_registration_id` em si — esse campo só muda pelas rotas de fechamento de negócio (`POST /quotes/:id/client-registration`), nunca pelo `PATCH` genérico. O update normal do `PATCH` simplesmente não inclui esse campo no `SET`. - No frontend, a trava aparece como `locked = !!clientRegistrationId`; o formulário inteiro fica num `
`. 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=`. - `COMMIT`. 3. Retorna `{ registration, public_link }`, onde `public_link = \`${baseUrl}/cadastro-cliente?token=${token}\`` — link público (sem autenticação) para o **próprio cliente** preencher seu cadastro completo (fora do escopo deste doc — módulo de `client_registrations`). 4. Frontend abre automaticamente um segundo diálogo (`showSendLinkDialog`) para copiar/enviar esse link por e-mail ou WhatsApp. ### 6.2 Atalho "cliente já possui cadastro ativo" (`reuse_from_registration_id`) Usado quando o mesmo cliente já comprou antes e tem um `client_registrations` com `registration_status = 'ativo'`. Body: `{ reuse_from_registration_id: uuid, person_type }`. 1. Busca o registro de origem; exige `registration_status === 'ativo'` (400 caso contrário). 2. Escopo: quem não é admin/backoffice só pode reaproveitar cadastro da **própria revenda** (`sourceReg.reseller_id === quote.reseller_id`, 403 caso contrário). 3. `BEGIN` transação: - `INSERT INTO client_registrations (... campos de identidade/endereço/ contato copiados de REUSE_COPY_FIELDS ...) SELECT ... FROM client_registrations WHERE id = origem` — nasce **já com** `registration_status = 'ativo'`, `submitted_at = now()`, `activated_at = now()` — sem token público, sem e-mail, sem validação manual. - `REUSE_COPY_FIELDS` (constante no topo do arquivo): endereço completo, todos os contatos (principal/financeiro/técnico), todos os campos PF/PJ, todos os campos de verificação de CPF/CNPJ, endereço do CNPJ. **Explicitamente fora da lista** (não herdado): `ixc_client_id`, `ixc_contract_number`, `client_code` (cada registro tem o seu, novo), `portability_numbers/ranges`, `internal_notes`. - Se a origem é PJ, copia também `client_registration_partners` (sócios/representantes) do registro de origem para o novo. - `UPDATE quotes SET deal_status='fechado', client_registration_id=`. - `COMMIT`. 4. Chama `syncActiveRegistrationToQuote(reg)` — sincroniza `client_document`/`client_code`/etc. de volta para a `quotes` (fora do escopo detalhado deste doc, mas é o mecanismo que preenche `quotes.client_document`/`quotes.client_code`). 5. Frontend recarrega a página inteira (`window.location.reload()`) — não mostra diálogo de link público (não é necessário, já está ativo). Em ambos os caminhos, o resultado final é: `quotes.deal_status = 'fechado'` e `quotes.client_registration_id` setado — o que trava a oferta (seção 5.1). --- ## 7. Condição de pagamento de implantação (`impl_payment_condition`) Fonte: `server/src/lib/paymentConditions.js`. ```js export const PAYMENT_CONDITIONS = ['À vista', '1+1', '1+2']; export function getAvailablePaymentConditions(implValue) { const value = implValue || 0; if (value <= 1500) return ['À vista']; if (value <= 3000) return ['À vista', '1+1']; return ['À vista', '1+1', '1+2']; } ``` - Progressivo por faixa de valor: quanto maior o total de implantação, mais opções de parcelamento ficam disponíveis. "À vista" está sempre disponível. - `implTotal` usado na checagem = `implementation_fee + impl_geral` (soma dos dois componentes da implantação, seção 8). - Validação no backend (`validatePaymentCondition`, `quotes.routes.js`), chamada tanto no `POST` quanto no `PATCH`: ```js function validatePaymentCondition(condition, implTotal) { if (condition === undefined) return null; // não veio no body, ignora const allowed = getAvailablePaymentConditions(implTotal); if (!allowed.includes(condition)) { return `Condição de pagamento da implantação inválida para o valor de implantação (R$ ${implTotal.toFixed(2)}). Opções disponíveis: ${allowed.join(', ')}`; } return null; } ``` Retorna 400 se inválida. - No frontend, um `useEffect` observa `totalImpl` e, se a condição selecionada deixar de estar disponível (ex.: implantação diminuiu), troca automaticamente para a **última** opção ainda válida (`availablePaymentConditions[availablePaymentConditions.length - 1]`). - Default no schema: `'À vista'`. --- ## 8. Cálculos financeiros exatos Variáveis-base (calculadas no frontend em `NewQuote.jsx`, e recalculadas de forma idêntica nos templates de PDF a partir dos dados salvos): ```js // --- Itens --- monthlyTotal = Σ items[ item.total ] // total "de tabela", soma dos itens no preço da faixa vigente implFromProducts = Σ items[ item.impl_total ] // Σ (item.impl_unit_price * item.quantity) totalImpl = implFromProducts + Number(impl_geral || 0) // implantação total (produtos + geral do projeto) // --- Condição especial (seção 4) --- hasSpecialPrice = proposedMonthly !== "" && Number(proposedMonthly) > 0 && Number(proposedMonthly) !== monthlyTotal isDiscount = hasSpecialPrice && Number(proposedMonthly) < monthlyTotal isMarkup = hasSpecialPrice && Number(proposedMonthly) > monthlyTotal diffMonthly = hasSpecialPrice ? monthlyTotal - Number(proposedMonthly) : 0 // > 0 desconto, < 0 acréscimo effectiveMonthly = hasSpecialPrice ? Number(proposedMonthly) : monthlyTotal // --- Fidelidade reduzida (seção 3) --- hasReducedFidelity = fidelityPeriod != null && fidelityPeriod < contractPeriod // --- Necessidade de aprovação --- needsApproval = isDiscount || hasReducedFidelity // --- Total do contrato --- contractTotal = effectiveMonthly * (contractPeriod || 1) + totalImpl ``` Observações importantes sobre `contractTotal`: - Usa `(contractPeriod || 1)` — se `contractPeriod = 0` (sem fidelidade), o multiplicador vira `1` (evita zerar o total; na prática representa "1 mês" como base do total exibido, mesmo sem fidelidade formal). - Usa **`contractPeriod`** (a faixa de preço), não `fidelityPeriod` — o "Total do Contrato" reflete o valor comercial pela faixa de preço contratada, não pelo prazo de permanência reduzido negociado à parte. - Usa `effectiveMonthly` (já considerando condição especial, se ativa) — isso é consistente tanto no momento de montar a oferta (`NewQuote.jsx`, onde qualquer `proposedMonthly` preenchido conta, mesmo pendente) quanto no PDF ao cliente (`QuotePDFTemplate.jsx`, onde só conta se `specialActive` = `isMarkup || (isDiscount && approval_status === 'approved')`). **Persistência** (payload salvo em `quotes` via `POST`/`PATCH`): ```js implementation_fee = implFromProducts // NÃO inclui impl_geral impl_geral = Number(impl_geral || 0) monthly_total = monthlyTotal contract_total = contractTotal proposed_monthly_total = hasSpecialPrice ? Number(proposedMonthly) : null fidelity_period = hasReducedFidelity ? fidelityPeriod : null // só persiste se for de fato menor que a faixa approval_status = needsApproval ? (approvalStatus === 'none' ? 'pending' : approvalStatus) : 'none' ``` **Economia mensal / total** (exibidos no PDF Backoffice ANATEL, seção 4.4): ```js totalDescontoMensal = Σ items[ (item.price_0 - item.unit_price) * item.quantity ] // desconto de fidelidade puro specialDiscount = hasSpecialPrice ? (monthlyTotal - proposedMonthlyTotal) : 0 // > 0 desconto especial, < 0 acréscimo especial totalEconomiaMensal = totalDescontoMensal + specialDiscount economiaTotalContrato = totalEconomiaMensal * contractPeriod // só exibida se totalEconomiaMensal > 0 e contractPeriod > 0 beneficioTotalFidelidade = totalDescontoMensal * fidelityPeriodResolvido // usado só no teto de multa (art. 37), NUNCA soma o especial ``` --- ## 9. Reatribuição de oferta (`created_by_id`) - Só **admin** (`isAdminRole` = `admin` ou `super_admin`) pode mudar `quotes.created_by_id` de uma oferta existente. - No `PATCH /quotes/:id`: ```js if (b.created_by_id !== undefined && b.created_by_id !== quote.created_by_id) { if (!isAdminRole(req.user.role)) return 403; const target = await pool.query('SELECT id FROM users WHERE id = $1', [b.created_by_id]); if (!target.rows[0]) return 400; // usuário de destino não encontrado createdById = b.created_by_id; } ``` - No frontend, só aparece o seletor de "Responsável Comercial" (dropdown com os usuários da mesma revenda) quando `isAdmin && editId && resellerUsers.length > 0` — para os demais, o campo é somente leitura (mostra `salesRepName`, texto snapshot). - Ao reatribuir, `sales_rep_name` também é atualizado no frontend para refletir o novo responsável (mas é só um snapshot de texto, não uma FK). --- ## 10. Contratos ### 10.1 Não existe tabela `contracts` "Contrato" é a junção, calculada em tempo de consulta, de `client_registrations` (ativos) com a `quotes` de origem: ```sql -- server/src/routes/contracts.routes.js, LIST_SELECT SELECT cr.id, cr.client_code, cr.person_type, cr.pf_full_name, cr.pj_company_name, cr.pj_trade_name, cr.pf_cpf, cr.pj_cnpj, cr.activated_at, cr.reseller_id, r.name AS reseller_name, q.id AS quote_id, q.quote_number, q.contract_period, q.fidelity_period, q.approval_status, q.monthly_total, q.contract_total, q.proposed_monthly_total, q.ixc_client_code FROM client_registrations cr JOIN quotes q ON q.id = cr.quote_id LEFT JOIN resellers r ON r.id = cr.reseller_id WHERE cr.registration_status = 'ativo' ``` ### 10.2 Enriquecimento (`enrichContract`) ```js clientName = person_type === 'pj' ? (pj_company_name || pj_trade_name) : pf_full_name clientDocument = person_type === 'pj' ? pj_cnpj : pf_cpf effectiveMonthly = (proposed_monthly_total != null && proposed_monthly_total > 0) ? proposed_monthly_total : (monthly_total || 0) // Fidelidade REAL (mesma regra da seção 3.4): usa fidelity_period só se // aprovado; senão cai para contract_period. fidelityPeriod = (fidelity_period != null && approval_status === 'approved') ? fidelity_period : contract_period dueDate = computeDueDate(activated_at, fidelityPeriod) // null se activated_at ausente OU fidelityPeriod é 0/falsy (sem fidelidade = indeterminado) // senão: new Date(activated_at) com +fidelityPeriod meses (setMonth) daysUntilDue = dueDate ? Math.ceil((dueDate - now) / 86400000) : null due_calculable = !!activated_at // cadastros ativados ANTES do rastreamento desta feature não têm activated_at e não têm vencimento inventado indeterminate = !fidelityPeriod // sem fidelidade = vencimento indeterminado, mesmo com activated_at ``` Campos retornados por contrato: `id, client_code, client_name, client_document, reseller_name, quote_id, quote_number, ixc_client_code, activated_at, contract_period, fidelity_period (já resolvido), monthly_value, contract_total, due_date, days_until_due, due_calculable, indeterminate`. ### 10.3 Rotas - `GET /contracts` (feature `contratos:view`) — lista todos, ordenados por `activated_at ASC NULLS LAST`. Usado por `src/pages/Contracts.jsx` (tabela com busca por cliente/documento/revenda/oferta, badge de vencimento colorido: vermelho se vencido/≤30 dias, âmbar se ≤60 dias, verde caso contrário; "Não calculável" se `!due_calculable`, "Indeterminado" se `indeterminate`). - `GET /contracts/report` (feature `contratos_relatorios:view`) — agregados para dashboard: ```js total_active_contracts = contracts.length total_active_mrr = Σ contracts[monthly_value] due_30/60/90 = { count, value_at_risk } para contratos com 0 <= days_until_due <= N (value_at_risk = Σ monthly_value dos que vencem na janela) by_reseller = agrupado por reseller_name: { count, mrr }, ordenado por mrr desc upcoming_renewals = contratos com days_until_due <= 90, ordenados asc, top 20 not_calculable_count = Σ !due_calculable indeterminate_count = Σ indeterminate ``` Usado por `src/pages/ContractReports.jsx` (cards de resumo + lista de próximos vencimentos + MRR por revenda). ### 10.4 Vencimento sempre pela fidelidade REAL, nunca pela faixa de preço Reforçando o ponto mais importante para reconstrução: em TODOS os lugares que calculam vigência/vencimento/multa (contratos, PDFs, gerador de documentos), a regra é idêntica e centralizada no padrão: ```js fidelidadeEfetiva = (quote.fidelity_period != null && quote.approval_status === 'approved') ? quote.fidelity_period : (quote.contract_period || 0) ``` `contract_period` continua sendo usado **apenas** para: (a) determinar qual `price_N` foi aplicado, e (b) calcular `contract_total` (que é sempre pelo período de preço contratado, seção 8) — nunca para vencimento/multa quando há uma exceção de fidelidade já aprovada. --- ## 11. Permissões e escopo (resumo transversal, relevante a Produtos/Ofertas/Contratos) Mirror exato entre `server/src/lib/roles.js` e `src/lib/roles.js`: ```js ADMIN_ROLES = ['admin', 'super_admin'] isAdminRole(role) = ADMIN_ROLES.includes(role) isAdminOrBackofficeRole(role) = isAdminRole(role) || role === 'backoffice' hasFeatureAccess(user, key, minLevel='view'): super_admin -> sempre true senão -> user.role_permissions[key] (mapa 'view'|'edit' por feature, configurado em Permissões por Papel) precisa rank >= minLevel (rank: view=1, edit=2) canViewAllQuotes(user) = super_admin || hasFeatureAccess(user, 'ofertas_others', 'view') canEditAllQuotes(user) = super_admin || hasFeatureAccess(user, 'ofertas_others', 'edit') ``` - Feature `produtos` (view/edit) controla CRUD de `products`. - Feature `ofertas` (view/edit) controla CRUD de `quotes` — mas por padrão cada usuário só vê/edita as **próprias** ofertas (`created_by_id = user.id`), a menos que tenha `ofertas_others`. - Feature `contratos`/`contratos_relatorios` controlam as rotas de contratos. - `GET /quotes` aplica escopo via `scopeClause`: sem `ofertas_others`, o `WHERE` inclui `q.created_by_id = $1` (usuário atual); com `ofertas_others`, vê tudo (sem filtro adicional, exceto os opcionais `id`/`reseller_id` da query string). - `POST /quotes`: usuário não-admin só pode criar oferta para a própria `reseller_id` (403 caso tente para outra revenda). - `DELETE /quotes/:id` e `DELETE /products/:id`: exigem `isAdminRole` (produtos) / idem para quotes. --- ## 12. Referência rápida de arquivos-fonte | Assunto | Arquivo | |---|---| | CRUD produtos | `server/src/routes/products.routes.js` | | CRUD ofertas, fechamento de negócio, envio de PDF por e-mail | `server/src/routes/quotes.routes.js` | | "Contratos" (derivado), relatórios | `server/src/routes/contracts.routes.js` | | Condição de pagamento de implantação | `server/src/lib/paymentConditions.js` | | Serialização de `products`/`quotes` | `server/src/lib/serialize.js` | | Variáveis calculadas para geração de documentos (fidelidade, multa, vigência, desconto) | `server/src/lib/documentTemplates/variableRegistry.js` | | Tela de criação/edição de oferta (toda a lógica de negócio do frontend) | `src/pages/NewQuote.jsx` | | Tela de produtos | `src/pages/Products.jsx` | | Listagem de ofertas | `src/pages/Quotes.jsx` | | Listagem de contratos | `src/pages/Contracts.jsx` | | Relatórios de contratos | `src/pages/ContractReports.jsx` | | PDF da oferta (visão cliente) | `src/components/quote/QuotePDFTemplate.jsx` | | PDF Backoffice/ANATEL (conformidade RGC, desconto, multa) | `src/components/quote/BackofficeAnatelPDFTemplate.jsx` | | Papéis/permissões (mirror front/back) | `server/src/lib/roles.js`, `src/lib/roles.js` | | Schema base | `server/migrations/1785400000000_baseline-schema.js` | | `client_document`/`client_code` em quotes | `server/migrations/1785598045226_add-partner-signature-fields-and-quote-sync.js` | | `client_registration_id` em quotes + tabela `client_registrations` | `server/migrations/1785513008756_add-client-registrations.js` | | `products.ixc_product_code` | `server/migrations/1785980000000_add-product-ixc-code.js` | | `products.product_code` (sequencial) | `server/migrations/1785990000000_add-product-code.js` | | `products.discontinued` | `server/migrations/1786020000000_add-product-discontinued.js` | | `quotes.fidelity_period` + CHECK | `server/migrations/1786340000000_add-quote-fidelity-period.js` | | Bugfix "economia mensal/total soma desconto especial" | commit `154f588` | | Trava de edição + exceção super_admin | commit `b20bf2f` | | Fidelidade reduzida (feature completa) | commit `d3697bb` | --- # 3. Cadastro de Cliente e Cadastro/Gestão de Revendas Documentação de referência do sistema atual (OrçaFácil — Handix), extraída do código-fonte (`server/src/routes/*.js`, `server/src/lib/*.js`, `server/migrations/*.js`, `src/pages/*.jsx`, `src/lib/*.js`), para reconstrução fiel em outro stack. Convenção de leitura: nomes de coluna/campo são citados literalmente (snake_case, como no Postgres/JSON da API) porque o Eden deve preservar o mesmo vocabulário de domínio, mesmo usando outro schema físico. --- ## 1. Modelo de dados ### 1.1 Tabela `client_registrations` Cadastro de um cliente final (pessoa física ou jurídica) que fechou uma oferta (quote) — ou, mais raramente, criado "avulso" sem oferta (ver seção 2.6). É o registro central de todo o fluxo; tudo gira em torno dele (anexos, sócios, sincronização com a oferta, sincronização com IXC). Chave primária `id UUID DEFAULT gen_random_uuid()`. Timestamps `created_date`/`updated_date` (trigger `set_updated_date()` atualiza `updated_date` a cada UPDATE). **Vínculos e controle** | Campo | Tipo | Observação | |---|---|---| | `quote_id` | UUID, FK `quotes(id)`, UNIQUE, **nullable** | 1:1 com a oferta que originou o cadastro. Nullable desde a migration `1786310000000` (permite cadastro "direto", sem oferta — ver 2.6). UNIQUE permite múltiplos NULL (Postgres). | | `created_by_id` | UUID, FK `users(id)` | Vendedor/usuário que gerou o link. | | `reseller_id` | UUID, FK `resellers(id)`, nullable | Snapshot da revenda dona da oferta no momento da criação — gravado à parte (não só via join com `quotes`) para manter histórico correto mesmo se a oferta for reatribuída depois. Populado por `UPDATE ... SET reseller_id = q.reseller_id FROM quotes` na migration que introduziu a coluna. | | `token` | TEXT, UNIQUE, NOT NULL | `crypto.randomBytes(24).toString('hex')` — 48 caracteres hex. É a credencial do link público (ver seção 9). | | `token_created_at` | TIMESTAMPTZ | Não há expiração automática — token vale até o cadastro deixar de estar `rascunho` (ver seção 9). | | `client_code` | INTEGER, UNIQUE, NOT NULL, DEFAULT `nextval('client_registrations_client_code_seq')` | Código sequencial interno do cliente (visível ao usuário, ex: "Cliente #42"), começa em 1, sequência própria (não reaproveita id). | **Estado do cadastro** | Campo | Tipo | Observação | |---|---|---| | `is_portability` | BOOLEAN NOT NULL | Definido na criação (pelo vendedor/backoffice), não pelo cliente — controla se a seção de portabilidade numérica aparece no formulário público e se a fatura anexa é obrigatória. | | `person_type` | ENUM `client_person_type` (`pf`, `pj`) NOT NULL | Definido na criação; o cliente pode escolher/confirmar no formulário público (pré-selecionado, mas ainda editável — ver `ClientRegistration.jsx`). | | `registration_status` | ENUM `client_registration_status` (`rascunho`, `pendente_validacao`, `ativo`, `bloqueado`, `inativo`) NOT NULL DEFAULT `rascunho` | Máquina de estados — ver 1.1.1. | | `submitted_at` | TIMESTAMPTZ | Setado quando o cliente envia o formulário público (rascunho → pendente_validacao). | | `activated_at` | TIMESTAMPTZ | Setado **só na primeira vez** que o status vira `ativo` (`COALESCE(activated_at, now())` — nunca sobrescrito numa reativação). Usado pelo módulo Contratos para calcular vencimento (data de início de vigência). | | `internal_notes` | TEXT | Observações internas do backoffice, nunca visível ao cliente/vendedor no formulário público. | **Endereço final (compartilhado PF/PJ)** — preenchido pelo cliente no formulário público, editável depois pelo backoffice: `address_zip`, `address_street`, `address_number`, `address_complement`, `address_neighborhood`, `address_city`, `address_state`, `address_country` (TEXT NOT NULL DEFAULT `'Brasil'`). **Endereço "oficial" da Receita Federal (só PJ)** — trazido pela consulta pública de CNPJ, guardado **à parte** do endereço final, para o backoffice comparar os dois lado a lado (migration `1785592240556`): `cnpj_address_confirmed` (BOOLEAN — true se o cliente confirmou que o endereço da Receita está correto), `cnpj_address_zip`, `cnpj_address_street`, `cnpj_address_number`, `cnpj_address_complement`, `cnpj_address_neighborhood`, `cnpj_address_city`, `cnpj_address_state`. Quando `cnpj_address_confirmed = true`, o endereço final é copiado do endereço da Receita; quando `false`, o cliente preenche um endereço final diferente do zero. **Contatos (compartilhado; PF só usa o financeiro, PJ usa os três)**: `contact_principal_name/email/phone`, `contact_financial_name/email/phone`, `contact_technical_name/email/phone`. **Pessoa Física**: `pf_full_name`, `pf_cpf`, `pf_birth_date` (DATE), `pf_email`, `pf_mobile_phone`, `pf_alt_phone`, `pf_social_name` (nome social), `pf_id_document` (nº do documento de identidade — RG etc.), `pf_id_issuer` (órgão emissor). **Pessoa Jurídica**: `pj_cnpj`, `pj_company_name` (razão social), `pj_trade_name` (nome fantasia), `pj_state_registration` (Inscrição Estadual — ou "Isento"), `pj_municipal_registration` (Inscrição Municipal — ou "Isento"), `pj_opening_date` (DATE — data de abertura), `pj_legal_nature` (natureza jurídica), `pj_cnpj_situation` (situação cadastral, ex: "ATIVA"), `pj_main_activity` (atividade principal/CNAE descrição), `pj_rep_full_name`, `pj_rep_cpf`, `pj_rep_role` (cargo), `pj_rep_email`, `pj_rep_mobile_phone`, `pj_rep_authorization_ack` (BOOLEAN NOT NULL DEFAULT false — declaração "possuo poderes para representar a empresa"). **Verificação de documento** (formato apenas — nenhuma verificação externa real implementada; campos `*_externally_verified` deixados prontos para integração futura, ex: Receita Federal): Por CPF: `cpf_format_valid` (BOOLEAN), `cpf_externally_verified` (BOOLEAN NOT NULL DEFAULT false), `cpf_verified_at`, `cpf_verification_provider`, `cpf_verification_reference`, `cpf_verification_status`, `cpf_verification_details` (JSONB). Por CNPJ: campos espelhados com prefixo `cnpj_*`. Hoje só `cpf_format_valid`/`cnpj_format_valid` são gravados (sempre `true` no submit — se o dígito verificador falhasse, o request já teria sido rejeitado com 400 antes). **Portabilidade numérica** (migration `1785610000000`): `portability_numbers JSONB NOT NULL DEFAULT '[]'` — array de strings (números avulsos, ex: `["(48) 3433-0582"]`). `portability_ranges JSONB NOT NULL DEFAULT '[]'` — array de objetos `{first, last}` (faixas de números). Só relevante quando `is_portability = true`; a validação no submit exige ao menos um item entre os dois arrays. **Integração IXC**: `ixc_client_id` TEXT — ID do cliente no IXC, preenchido pelo backoffice **no momento da validação** do cadastro (quando o cliente é efetivamente criado no ERP externo). Diferente de `ixc_contract_number` TEXT — nº do contrato no IXC, também preenchido manualmente pelo backoffice depois de formalizado. E diferente de `quotes.ixc_client_code` — referência opcional preenchida pelo vendedor ainda na oferta, antes de tudo isso. ### 1.1.1 Máquina de estados de `registration_status` ``` rascunho ──(cliente envia o formulário público)──▶ pendente_validacao pendente_validacao ──(backoffice aprova)──▶ ativo pendente_validacao ──(backoffice recusa, via PATCH registration_status)──▶ bloqueado ativo ──▶ inativo (toggle "Inativar Cliente" no backoffice) inativo ──▶ ativo (toggle "Reativar Cliente") bloqueado/inativo ──▶ ativo (reativação manual) ``` Não há endpoint dedicado de transição — todas as mudanças passam por `PATCH /client-registrations/:id` com `registration_status` no corpo (rota genérica, ver 1.1.2). O front (`Clientes.jsx`) expõe um `` livre (não há botões "Aprovar"/"Recusar" dedicados como no fluxo de revenda — é um PATCH genérico). - Preencher `ixc_client_id` e `ixc_contract_number` manualmente. - Editar/completar números de portabilidade. - Ver e corrigir CPF completo de cada sócio + marcar quem assina + e-mail de assinatura (`PATCH /client-registrations/:id/partners/:partnerId`, exclusivo `requireAdminOrBackoffice`). - Ver/baixar anexos enviados pelo cliente; anexos internos (`purpose='internal'` E `uploaded_by_id` preenchido) só aparecem para admin/backoffice, nunca para o dono da oferta. - Fazer upload de novo anexo (categoria livre + finalidade signature/internal). - Excluir anexo — **exclusivo super_admin** (irreversível, remove do storage e do banco). - Botão dedicado "Inativar/Reativar Cliente" (atalho de PATCH `registration_status`). - Se há anexos `purpose='signature'`, botão "Criar Envelope de Assinatura" (módulo de assinatura eletrônica, fora do escopo aqui). ### 2.6 Cadastro "direto" (sem oferta) `POST /client-registrations/direct` (autenticado) cria um `client_registrations` com `quote_id = NULL`, usando o **mesmo mecanismo de token/formulário público**. Corpo: `{ is_portability, person_type, reseller_id? }`. Quem não é admin/backoffice só cria para a própria revenda (`req.user.reseller_id`); admin/backoffice pode opcionalmente indicar `reseller_id`. Usado hoje pela tela de "Cessão" (fora do escopo) e pelo botão "Novo" em Clientes — abre uma aba nova já no `public_link` retornado, para o próprio operador (ou o cliente, se repassado) preencher. --- ## 3. Pessoa Física vs Pessoa Jurídica — diferenças | Aspecto | PF | PJ | |---|---|---| | Documento principal | CPF (`pf_cpf`) | CNPJ (`pj_cnpj`) | | Seção de identificação | Nome, CPF, nascimento, e-mail, celular, tel. alternativo, nome social, documento de identidade + emissor | Razão social, nome fantasia, IE, IM, data abertura, natureza jurídica, situação cadastral, atividade principal | | Consulta automática | Nenhuma (não existe "lookup de CPF" público equivalente) | Consulta de CNPJ (cnpj.ws/BrasilAPI) preenche empresa, endereço e sócios | | Representante/assinante | Não existe — o próprio titular assina | Obrigatório: representante legal OU sócio marcado como assinante | | Sócios (QSA) | N/A | Tabela `client_registration_partners`, populável pela consulta de CNPJ | | Confirmação de endereço da Receita | N/A | Fluxo "endereço encontrado está correto?" | | Contatos exigidos | Só financeiro (nome+e-mail) | Financeiro **e** técnico (nome+e-mail) obrigatórios; principal opcional | | Contrato social (anexo) | N/A | Obrigatório **se** a consulta de CNPJ não trouxe sócios | | Inscrição Estadual/Municipal | N/A | Perguntadas explicitamente (Sim/Não) quando a consulta não trouxe o dado | --- ## 4. Sócios/Partners - Exigidos **apenas em PJ**, e só aparecem no formulário se a consulta pública de CNPJ (cnpj.ws → fallback BrasilAPI) retornar o quadro societário (QSA). Não há como o cliente adicionar um sócio manualmente que não veio da consulta. - Campos por sócio: `name`, `document` (mascarado, vindo da fonte), `qualification` (ex: "Sócio-Administrador"), `source` (`'brasilapi'` fixo hoje, mesmo quando veio do cnpj.ws — nome do campo não foi atualizado), `cpf` (completo, digitado pelo cliente **só se** for assinar), `will_sign` (bool), `signature_email`. - Assinatura por sócio: cada sócio tem um checkbox "Vai assinar o contrato". Marcando, exige CPF completo (validado no backend) e e-mail de assinatura. **O primeiro sócio marcado substitui inteiramente o representante da empresa** — os campos `pj_rep_*` no cadastro recebem os dados desse sócio (name→pj_rep_full_name, cpf→pj_rep_cpf, qualification→ pj_rep_role, signature_email→pj_rep_email); se **nenhum** sócio é marcado, o bloco "Representante da Empresa" é obrigatório e preenchido do zero. - Pós-submissão, backoffice pode corrigir CPF/will_sign/signature_email de cada sócio individualmente (`PATCH /client-registrations/:id/partners/:partnerId`), útil quando o cliente esqueceu de marcar/preencher no formulário. - Mesmíssimo modelo e mesma UI (componentizada e duplicada) se aplicam a `reseller_registration_partners` no cadastro de revenda — o representante legal da revenda segue a mesma regra de substituição pelo primeiro sócio assinante. --- ## 5. Sincronização com IXC / com a Oferta **Não existe integração automática/API com o IXC** neste código — toda referência a "IXC" é um campo de texto livre preenchido manualmente pelo backoffice depois de criar/formalizar o cliente no ERP externo (fora deste sistema): - `client_registrations.ixc_client_id` — preenchido na validação do cadastro. - `client_registrations.ixc_contract_number` — preenchido depois, quando o contrato IXC existe. - `quotes.ixc_client_code` — referência opcional preenchida pelo vendedor, ainda na oferta, antes de tudo (pode ou não bater com `ixc_client_id`). O que existe é `syncActiveRegistrationToQuote(reg)` (`server/src/lib/clientRegistrationSync.js`), chamada sempre que: (a) `PATCH /client-registrations/:id` deixa o registro em `registration_status = 'ativo'`; (b) uma oferta reaproveita um cadastro já ativo (`reuse_from_registration_id`). Ela **não faz nada se `reg.registration_status !== 'ativo'`**. Quando ativo, roda: ```sql UPDATE quotes SET client_company = COALESCE($1, client_company), -- pj_trade_name || pj_company_name, ou pf_full_name client_document = COALESCE($2, client_document), -- pj_cnpj ou pf_cpf client_code = COALESCE($3, client_code), -- client_registrations.client_code ixc_client_code = COALESCE($4, ixc_client_code) -- client_registrations.ixc_client_id WHERE id = quote_id ``` Ou seja: replica identificação do cliente **para a oferta** (não o contrário), só sobrescreve campos que estavam vazios (`COALESCE`), e é oportunista — roda a cada PATCH que resulte em `ativo`, não só na primeira ativação. No Eden, isso deve ser modelado como um efeito colateral do PATCH de status (ou um evento/hook "registration activated"), não como uma integração externa de verdade — não há chamada de rede nenhuma aqui, é só um UPDATE no mesmo banco. --- ## 6. Fluxo de cadastro/aprovação de Revenda (Programa de Canais) Estrutura quase idêntica à de cliente, mas iniciada de forma diferente: **não** nasce de uma oferta fechada — nasce de um convite manual. ### 6.1 Criação do convite `POST /reseller-registrations` (autenticado, feature `revendas_cadastro` nível `edit`). Corpo mínimo: `{ email, reseller_type }` onde `reseller_type ∈ {'finder', 'recorrente'}`. Gera token, insere linha `rascunho`, retorna `{ registration, public_link }`. - **Finder**: revenda pontual/indicação — gera só "Contrato Canal Finder". - **Recorrente**: parceiro recorrente — gera **sempre os dois documentos juntos**: "Contrato Canal Recorrente" + "Termo de Adesão Recorrente". E-mail de convite (`POST /reseller-registrations/:id/send-email`) — assunto "Cadastro Handix", texto "Você foi convidado(a) a fazer parte do Programa de Canais Handix", botão "Completar cadastro". ### 6.2 Preenchimento público (`GET`/`POST /public-reseller-registration/:token`) Mesmo contrato de contexto (`already_submitted`, 404 genérico para token inválido, 409 se já enviado). Formulário (`ResellerRegistration.jsx`) é um subconjunto do de cliente-PJ: CNPJ (com mesma consulta cnpj.ws→BrasilAPI, mesmo preenchimento automático de empresa/endereço/ sócios), razão social, nome fantasia, telefone (único, não triplo como em cliente), IE/IM com mesma pergunta condicional Sim/Não, bloco Representante (mesma regra: sumido se houver sócio assinante), confirmação de endereço do CNPJ, endereço. **Não tem** seção de contatos (financeiro/técnico), **não tem** anexos, **não tem** portabilidade (revenda não porta número). Validação no backend igual em espírito à de cliente: razão social, CNPJ válido, telefone, (sócio assinante com CPF válido) OU (representante com nome+CPF válido+ack de poderes). Persistência: `UPDATE reseller_registrations` com todos os campos + `registration_status = 'pendente_validacao'` + `submitted_at = now()`; sócios inseridos em `reseller_registration_partners`. Sem uploads (tabela de anexos existe no schema desde o início, mas só passa a ser usada depois da aprovação, pelo backoffice). ### 6.3 Aprovação (`ResellerRegistrations.jsx`, feature `revendas_cadastro`) Diferente do cliente, aqui **há botões de transição dedicados** (não só um select genérico): - Em `pendente_validacao`: **Aprovar** (→ `ativo`) ou **Bloquear** (→ `bloqueado`). - Em `ativo`: **Inativar** (→ `inativo`). - Em `bloqueado`/`inativo`: **Reativar** (→ `ativo`). - Em `rascunho`: **Reenviar link** (reenvia o e-mail de convite). Toda transição passa pelo mesmo `PATCH /reseller-registrations/:id` genérico (corpo `{registration_status: ...}` — o front só decide qual botão mostrar). **Efeito colateral crítico da transição de status** — `syncOperationalReseller(client, registration, userId)`, chamado sempre que o PATCH inclui `registration_status`: - Se o novo status é `'ativo'`: - Se `registration.reseller_id` já existe → `UPDATE resellers SET name=company_name, document=cnpj, contact_email=email, active=true`. - Senão → `INSERT INTO resellers (...) VALUES (...)`, e grava o novo id de volta em `reseller_registrations.reseller_id`. - Se o novo status **não** é `'ativo'` (bloqueado/inativo) e `reseller_id` existe → `UPDATE resellers SET active = false`. **Nunca exclui** a linha de `resellers` (pode já estar referenciada em orçamentos). Ou seja: **a aprovação de um `reseller_registrations` cria/reativa automaticamente a linha operacional em `resellers`** (a mesma tabela usada em Orçamentos/usuários), e desativá-lo desativa a revenda operacional também. Isso é o ponto de junção entre o módulo de aprovação (Fase 1, cadastro) e o módulo operacional pré-existente. ### 6.4 Geração de documentos e assinatura (pós-aprovação) Quando `registration_status === 'ativo'`, o backoffice pode clicar "Gerar Contrato" (finder) ou "Gerar Contrato + Termo" (recorrente). Isso chama um pipeline de geração de PDF via template publicado (`base44.documents.generate({ template_key, reseller_registration_id })` → Chromium/HTML no backend — módulo de Templates de Documento, fora deste escopo), baixa o PDF gerado e o arquiva via `POST /reseller-registrations/:id/attachments` (sempre `purpose: 'signature'`). Documentos arquivados então alimentam "Criar Envelope de Assinatura" (mesmo componente reaproveitado do fluxo de cliente, módulo de assinatura eletrônica — fora do escopo). --- ## 7. Gestão de revendas existentes (`resellers.routes.js` / `Resellers.jsx`) CRUD simples e direto, **exclusivo de `requireAdmin`** (roles `admin`/`super_admin` — nota: mais permissivo que o cadastro/aprovação, que usa a feature `revendas_cadastro`): - `GET /resellers` — lista com filtros por `id`/`active`/`name`, ordenável por `created_date`/`updated_date`/`name` (`auth` simples, qualquer usuário autenticado pode listar — usado por outras telas, ex: seletor de revenda em Orçamentos). - `POST /resellers` — cria (`name` obrigatório; `document`, `contact_email`, `active` default `true`). - `PATCH /resellers/:id` — atualiza campos (todos `COALESCE`, ou seja, PATCH parcial natural). - `DELETE /resellers/:id` — hard delete, sem checagem de vínculo no backend (a tela `Resellers.jsx` bloqueia no front se houver usuários vinculados, mas isso é só UX — a API não impede). **Vínculo com quotes**: `quotes.reseller_id` é `NOT NULL` — toda oferta pertence obrigatoriamente a uma revenda (a do vendedor que a criou, tipicamente `req.user.reseller_id`, fora do escopo deste documento mas citado para contexto). **Vínculo com usuários**: `users.reseller_id` (nullable) — a tela `Resellers.jsx` permite atribuir/reatribuir cada usuário do sistema a uma revenda via `