Ponto de partida
Oito decisões
Empresa é entidade dentro do tenant, não tenant filho. Company nasce obrigatória e sempre existe, mesmo quando é uma só; TenantSettings.cnpj morre. A plataforma já sabia agrupar organizações — o que ela não sabia era que uma organização tem mais de um CNPJ.
Os dois modos de isolamento são o mesmo modelo. Tenant com uma Company é o modo isolado; tenant com N é o consolidado. O domínio é escrito uma vez, para o consolidado — no isolado o segundo filtro simplesmente não recorta nada. Sem if, sem duas trilhas, sem dois jeitos de emitir um pedido.
O tenant Group é eixo de acesso, nunca de dados. Nenhuma linha de nenhuma tabela pertence a um tenant do tipo grupo. No minuto em que ele guardar dado, todo filtro do sistema vira "tenant ou pai do tenant" — e um filtro que aceita o pai é um filtro que vaza.
Dois níveis de CNPJ, espelhando o fornecedor. Company é a pessoa jurídica, chaveada pela raiz de 8; Establishment é o CNPJ de 14, matriz ou filial. É o mesmo corte de Supplier e SupplierEstablishment: um CNPJ tem um modelo mental só no produto, de dentro e de fora.
O escopo por empresa é do Nexio, não da plataforma. UserCompanyScope guarda quais empresas cada usuário alcança, referenciando o userId do gryd sem chave estrangeira — exatamente como CostCenterAssignment já faz. Ausência de linha = todas as empresas. O gryd não ganha coluna nenhuma, e não deveria: ele não sabe o que é uma Company.
O documento aponta para o estabelecimento; a empresa é derivada. Quem emite pedido e recebe nota é um CNPJ de 14, mas alçada, orçamento e conta contábil raciocinam em empresa. Um campo na interface, dois congelados no documento.
Plano de contas no tenant, habilitação e código externo por empresa. Grupo econômico brasileiro usa plano unificado; o que varia é o código no ERP de cada CNPJ. Um plano por empresa multiplicaria manutenção por N e faria o consolidado depender de casar códigos por convenção.
NumberSequence configurável, com empresa como padrão. Grupo brasileiro quase sempre quer pedido numerado por empresa; parte quer por filial; quase ninguém quer número global. O escopo é dado, não código.
Fronteira
O que a plataforma já resolve
A camada gryd nunca entrou no mapa de domínio, e foi por isso que empresa quase foi modelada duas vezes. Validado contra Design/Tenants Handoff, Usuarios Handoff e Roles Handoff.
| Já existe | O que é | O que o Nexio faz com isso |
|---|---|---|
Tenant | A fronteira de isolamento. Type = Company | Group, ParentTenantId, hierarquia de um nível. | Um tenant = um cliente contratante. O Nexio não usa ParentTenantId no modo consolidado. |
User | Global — não pertence a tenant nenhum. | Intacto. Nenhum campo novo. |
UserTenant | A matriz de acesso: uma linha por organização, com vigência, isDefault e isActive. | intacto A preferência de estabelecimento padrão vive do lado do Nexio. |
UserRole | O papel dentro daquele tenant. Não existe papel global. | intacto O recorte por empresa é UserCompanyScope, do Nexio. |
Role · Permission | Catálogo por tenant. | Intactos. Os papéis são do tenant, não da empresa — o que varia é onde o papel vale. |
TenantSettings.cnpj | Um CNPJ por organização. | removido — substituído por Company. |
Duas palavras, dois sentidos — e um deles muda. A tela de Tenants rotula o tenant filho como "Empresa". A partir desta spec, "empresa" significa Company e nada mais: o tenant passa a ser chamado de organização em tela, spec e conversa. É troca de rótulo, não de modelo — a tela já se chama "Tenants · Organizações" e só precisa parar de se contradizer. O valor de enum TenantType.Company permanece: é membro de enum, sempre qualificado, e não colide com a classe Company do domínio.
Tenancy
Os dois modos
Modo isolado · N organizações, uma empresa cada
Cada CNPJ vira um tenant, opcionalmente sob um tenant Group. O isolamento é estrutural: o dado da empresa A não é filtrado do dado da empresa B, ele está em outra fronteira. É o modo para quem tem restrição de verdade — empresas concorrentes dentro do mesmo grupo, sociedade com terceiros, exigência de auditoria independente.
O que custa: catálogo, fornecedores homologados, estrutura de centro de custo, papéis e alçadas são recadastrados por empresa, e não sincronizam. Aprovação consolidada não existe. Relatório do grupo exige leitura cross-tenant.
Modo consolidado · uma organização, N empresas
O tenant é o grupo econômico. Cadastro mestre é único e compartilhado; documento, orçamento, conta contábil e alçada são por empresa. O isolamento entre empresas passa a ser escopo — companyId vindo do token, nunca do cliente.
O que ganha: matriz aprova pedido de filial com uma política, não com integração. Fornecedor é homologado uma vez. Consolidado do grupo é consulta comum.
O critério de qual vender
Não é tamanho do grupo nem número de CNPJs. É uma pergunta só: as empresas do grupo podem ver o cadastro mestre umas das outras? Catálogo, fornecedores, estrutura de centro de custo, tabela de preço. Sim — que é o caso da maioria dos grupos brasileiros, porque a estrutura é espelhada mesmo — consolidado. Não — tenants separados, e o cliente aceita duplicar cadastro.
A pergunta é sobre mestre, não sobre documento. Esconder as requisições da empresa B do usuário da empresa A o modo consolidado faz bem; o que ele não faz é esconder que a empresa B existe.
Na dúvida, consolidado
Um tenant consolidado consegue se comportar como isolado: escopa todo usuário a uma empresa, nunca cria registro corporativo, e na prática ninguém vê nada da outra. O inverso não existe sem cross-tenant. Migração entre os dois é dolorosa nos dois sentidos — juntar catálogos com códigos colidindo, ou partir documentos — então erra-se para o lado reversível por configuração.
O que não pode acontecer. O modo isolado não pode pular a Company e continuar lendo um CNPJ de TenantSettings. Duas formas de descobrir de que CNPJ um documento é significa dois modelos, dois caminhos de código e dois conjuntos de bug. Company é obrigatória nos dois modos; no isolado ela simplesmente tem uma linha.
Modelo
Mapa de entidades
Convenção de nomes: o documento é escrito em português e todo identificador é em inglês — entidade, atributo, valor de enum, rota, permissão e código de erro. A exceção são os dados de negócio brasileiros, que ficam como são no mundo real: cnpj, stateRegistration, suframaCode, primaryCnae e os nomes de regime tributário (SimplesNacional, LucroReal) — esses são conteúdo, não código.
Group agrega organizações e nunca guarda dado.
userId do gryd sem FK. Nulo — e a ausência da linha — = todas.
legalEntityId vira companyId. A regra do nó corporativo já estava escrita e não muda.
Domínio · Company
Campos · Company
Legenda: obrig sempre · cond conforme a operação · opc opcional · gerado calculado pelo sistema ou pelo banco.
Identificação
| Campo | Tipo | Regra | |
|---|---|---|---|
| id | Guid | gerado | Atribuído pelo agregado, não pelo banco. |
| tenantId | Guid | gerado | Injetado a partir do token. Nunca aceito do cliente. |
| code | string(20) | obrig | Trim e maiúsculas, apenas [A-Za-z0-9_-], único por tenant, imutável. É o que a importação, a política de aprovação e a integração referenciam. |
| cnpjRoot | varchar(8) | obrig | A raiz do CNPJ. Nunca numérico — aceita A–Z e dígitos. Único por tenant, imutável. Duas empresas do mesmo tenant não compartilham raiz: se compartilhassem, seriam a mesma pessoa jurídica. |
| legalName | string(200) | obrig | Razão social. Trim; vazio é 400. |
| tradeName | string(160) | opc | Nome fantasia. É o que aparece no seletor de documento — quando ausente, cai para legalName. |
| externalCode | string(40) | opc | O código da mesma empresa no ERP do cliente. Único por tenant quando preenchido. |
Natureza e regime
| Campo | Tipo | Regra | |
|---|---|---|---|
| taxRegime | enum | obrig | SimplesNacional | MEI | LucroPresumido | LucroReal | TaxExempt | NotApplicable — o mesmo enum de Supplier.taxRegime, e não um paralelo: regime tributário é o mesmo conceito visto dos dois lados. Do lado comprador, NotApplicable é recusado. Vive na empresa, não na filial — o Simples é opção da pessoa jurídica inteira. É o campo que decide se a compra gera crédito. |
| legalNature | varchar(4) | opc | Código de natureza jurídica da tabela do IBGE/Receita (2062, 2054…). Metadado; nenhuma regra depende dele hoje. |
| shareCapital | decimal(18,2) | opc | Capital social. Informativo, e usado por alguns clientes como referência de porte na alçada. |
| functionalCurrency | char(3) | opc | ISO 4217, padrão BRL. A moeda em que a empresa fecha; compra em moeda estrangeira converte para ela. |
| foundedOn | date | opc | Data de abertura. |
| closedOn | date | opc | Data de baixa. Preenchida, força isActive = false e bloqueia documento novo com data posterior. |
Estado e derivados
| Campo | Tipo | Regra | |
|---|---|---|---|
| isActive | bool | gerado | Alterado só por /activate e /deactivate, nunca por PUT. Desativar com estabelecimento ativo é 409. |
| headquartersId | Guid? | gerado | O estabelecimento matriz. Derivado, não digitado — é o único com type = Headquarters. |
| establishmentCount | int | gerado | Exibido na lista. Ativos e inativos separados no detalhe. |
O que não está aqui, de propósito. Certidões, quadro societário, situação cadastral consultada na Receita, CNAE secundário e regime de retenção são do fornecedor, não da empresa compradora: o Nexio faz diligência de quem vende, não de si mesmo. Se um dia o produto virar portal e a empresa compradora precisar se apresentar a alguém, esses campos entram — e entram no mesmo formato que já existe em Supplier.
Domínio · Establishment
Campos · Establishment
A empresa é quem assina; o estabelecimento é quem recebe a nota. Tudo o que muda de filial para filial vive aqui.
Identificação
| Campo | Tipo | Regra | |
|---|---|---|---|
| id | Guid | gerado | — |
| tenantId | Guid | gerado | Do token. Denormalizado para caber no filtro de linha sem join. |
| companyId | Guid | obrig | Dono. Imutável — um estabelecimento não muda de pessoa jurídica; isso seria baixa e abertura. |
| code | string(20) | obrig | Único por empresa, imutável. Cliente vindo de Protheus digita 0001, 0002; cliente novo digita MATRIZ, CD-REC. As duas convenções cabem. |
| cnpj | varchar(14) | obrig | CNPJ completo, sem máscara. Único por tenant. Os 8 primeiros caracteres têm que ser o cnpjRoot da empresa — divergência é 422, não 400: o dado é bem formado e semanticamente impossível. |
| type | enum | obrig | Headquarters · Branch. Exatamente um Headquarters por empresa, sempre. Não se deriva da ordem 0001 do CNPJ: a ordem é convenção da Receita, e a matriz do cadastro é decisão do cliente. Difere do fornecedor de propósito: lá establishmentType é derivado da ordem 0001, porque o dado vem da Receita e ninguém o digita; aqui é escolha de quem cadastra a própria empresa. |
| name | string(160) | obrig | Como a filial é chamada internamente. É o que aparece no seletor, sob o nome da empresa. |
| externalCode | string(40) | opc | O código da filial no ERP. Único por (tenant, empresa) quando preenchido. |
Fiscal
| Campo | Tipo | Regra | |
|---|---|---|---|
| icmsTaxpayerStatus | enum | obrig | Taxpayer · Exempt · NonTaxpayer. É o indIEDest da NF-e (1 · 2 · 9) visto do lado do destinatário. Decide se stateRegistration é exigida e como o fornecedor tributa a operação. |
| stateRegistration | varchar(20) | cond | Inscrição estadual. Obrigatória quando icmsTaxpayerStatus = Taxpayer; recusada nos outros dois. Formato varia por UF — validação de formato é por estado e fica na camada fiscal, não aqui. |
| municipalRegistration | varchar(20) | opc | Inscrição municipal. Necessária para tomar serviço com retenção de ISS. |
| suframaCode | varchar(9) | opc | Inscrição Suframa. Presente, muda a tributação de entrada — e é justamente o campo que some quando alguém modela filial como endereço. |
| primaryCnae | varchar(7) | opc | CNAE principal do estabelecimento. Pode divergir entre filiais da mesma empresa. |
| registrationStatus | enum | opc | Active · Suspended · Inactive · Closed. Situação cadastral do estabelecimento: uma filial pode estar baixada com a matriz ativa, e o sistema precisa saber disso antes de emitir um pedido em nome dela. |
Endereço fiscal
| Campo | Tipo | Regra | |
|---|---|---|---|
| zipCode | varchar(8) | obrig | Sem máscara. |
| street · number · complement · district | string | cond | Logradouro e bairro obrigatórios; número aceita S/N. |
| cityIbgeCode | varchar(7) | obrig | Código do município, não o nome. É o que a NF-e carrega e o que resolve homônimo entre estados. O nome é derivado dele, nunca digitado livre. |
| stateCode | char(2) | gerado | Derivado do código IBGE. É a dimensão que muda o ICMS interestadual — por isso não pode ser digitada em separado e divergir do município. |
| countryCode | char(2) | opc | ISO 3166-1, padrão BR. Reservado; estabelecimento fora do Brasil está fora de escopo. |
Operação
| Campo | Tipo | Regra | |
|---|---|---|---|
| allowsPurchasing | bool | opc | Padrão true. Falso, o estabelecimento não aparece no seletor de documento — mas continua podendo ser destino de entrega. Escritório administrativo que não compra é caso real. |
| allowsReceiving | bool | opc | Padrão true. Falso, não pode ser destinatário de nota. |
| isActive | bool | gerado | Só por /activate e /deactivate. Desativar o último estabelecimento ativo de uma empresa ativa é 409: empresa sem estabelecimento não consegue emitir nada e vira um registro morto que ninguém percebe. |
Transversal
Escopo e segurança
A pergunta que esta seção responde: no modo consolidado, o que impede o usuário da empresa A de ver o documento da empresa B.
A garantia muda de natureza — e isso precisa ser dito
No modo isolado a separação é estrutural: é outro tenant. No consolidado ela vira filtro, e filtro é aplicação. É um rebaixamento real, e o desenho abaixo existe para devolver a força perdida — não para fingir que ela não se perdeu.
O conjunto permitido é resolvido no servidor, a cada requisição
O token é do GrydAuth e não muda — ele traz userId, tenantId, papéis e permissões, e não traz empresa. A partir desses dois primeiros, o nexio-service resolve o conjunto de empresas permitidas no mesmo ponto em que já monta o contexto do tenant: uma leitura indexada em UserCompanyScope, dentro da requisição, sem cache entre requisições até que se prove gargalo.
O cliente pode mandar um establishmentId — ele precisa, porque o usuário escolhe no formulário. O que ele não pode é que o valor seja aceito: em escrita, é validado contra o conjunto resolvido e fora dele responde 404; em listagem, o conjunto resolvido é o filtro, e um companyId na query apenas intersecta com ele. Nunca substitui.
Se o tenant é RLS, a empresa entra na mesma política
Onde o isolamento de tenant já é row-level security no Postgres, o conjunto resolvido entra na mesma policy — SET LOCAL na transação e a política lendo current_setting — e a separação volta a ser estrutural, sem depender de claim. Onde o filtro de tenant é aplicação, a empresa não piora nada: é o mesmo nível de risco que já se aceita hoje.
Fora de escopo responde 404, não 403
Empresa que existe mas não está no escopo do usuário se comporta como empresa que não existe. É a mesma decisão já tomada em COST_CENTER_NOT_FOUND — "não existe neste tenant, inclusive quando existe em outro". 403 confirmaria a existência do registro, que é metade do que um atacante quer.
A tabela só estreita — nunca concede
UserCompanyScope não dá organização, não dá papel e não dá permissão: quem decide isso continua sendo o token do GrydAuth. Ela só responde em quais empresas daquele tenant o que o token já concedeu tem efeito. A consequência prática importa: uma linha órfã — usuário removido da organização no gryd — é inerte, porque o token nunca mais traz aquele tenant. Não existe escalonamento de privilégio possível por este caminho, e por isso a ausência de chave estrangeira para o gryd não é uma fragilidade.
O que precisa ser garantido é o outro lado: companyId tem que ser de uma empresa do mesmo tenant da linha. Aí a chave é composta e local, dentro do esquema do Nexio: (tenantId, companyId) referenciando companies(tenantId, id), com índice único do lado da empresa. O banco recusa; ninguém precisa lembrar de validar.
Nulo = todas. Em toda parte.
UserCompanyScope.companyId nulo é acesso a todas as empresas. CostCenter.companyId nulo é nó corporativo. GLAccountCompany ausente é conta habilitada. Uma convenção só, em três lugares, em vez de três invenções — e nunca "nenhum" nem "não sei".
Nulo é escolha explícita, não o valor que sobra. No cadastro, "vale para todas as empresas" é uma opção marcada, com o efeito escrito ao lado — nunca o comportamento de um campo que ninguém preencheu.
UserCompanyScope
| Campo | Tipo | Regra | |
|---|---|---|---|
| id | Guid | gerado | — |
| tenantId | Guid | gerado | Do token. Nunca aceito do cliente. |
| userId | Guid | obrig | O userId do GrydAuth, sem chave estrangeira — é outro serviço. Validado contra o diretório na escrita (GET /users/directory), nunca na leitura: leitura com id órfão simplesmente não casa com token nenhum. |
| companyId | Guid? | opc | Nulo = todas as empresas do tenant. FK composta (tenantId, companyId) → companies(tenantId, id): empresa de outra organização é recusada pelo banco. |
| roleId | Guid? | reservado | Recusado quando preenchido nesta versão — 422. Nasce na tabela para que ligar o escopo por papel um dia não exija migração de esquema. Ver o que ele custaria. |
| validFrom / validTo | date? | opc | Mesma mecânica de vigência do CostCenterAssignment. Fora dela, a linha não entra na resolução e continua legível no histórico. |
| isActive | bool | gerado | Vigência é fato de calendário; isActive é decisão administrativa. Eixos diferentes, os dois existem. |
Unicidade em dois índices parciais, não um: (tenantId, userId) where companyId is null, e (tenantId, userId, companyId) where companyId is not null. Um índice único simples não serviria — no Postgres dois nulos não colidem, e "todas as empresas" duplicado passaria batido.
Como o conjunto é resolvido
| Situação | Conjunto resolvido |
|---|---|
| Nenhuma linha para o usuário | Todas as empresas ativas do tenant. É a mesma convenção de GLAccountCompany: ausência de linha é permissivo, não restritivo |
Alguma linha vigente com companyId nulo | Todas as empresas ativas do tenant. As demais linhas não subtraem |
| Linhas vigentes com empresa | A união delas, menos as empresas inativas |
| União resulta vazia | O usuário não opera. A interface diz por quê — "as empresas do seu acesso estão inativas" — em vez de mostrar telas vazias |
Por que ausência de linha é permissivo. O caminho contrário parece mais seguro e é pior na prática: o cliente cadastra a segunda empresa e, no mesmo instante, todo o time perde acesso a tudo — inclusive quem cadastrou. Como a tabela só estreita, o padrão permissivo não amplia nada além do que o token já concedeu naquele tenant. A tela de administração compensa no lugar certo: ao criar a segunda empresa, ela pergunta quem enxerga o quê, com "todos" pré-selecionado.
Estabelecimento padrão — sem tabela
A pré-seleção do cabeçalho do documento é preferência de interface, e fica em localStorage. É o precedente que o próprio gryd já usa para essa classe de coisa — a tela de Permissões persiste ali a escolha entre visão de categorias e grade.
Uma tabela UserPreference no Nexio seria pior por dois motivos. "Preferências do usuário" é conceito da plataforma: o menu Preferências da conta do shell já existe, com tema, idioma e densidade de tabela — criar uma tabela homônima do lado do produto é convidar duas fontes para a mesma pergunta. E tabela com nome genérico vira gaveta: em seis meses tem oito campos que ninguém sabe quem lê.
O custo aceito é que o padrão não acompanha quem troca de máquina. É pequeno: o campo só aparece para quem tem mais de uma opção, e é sempre visível e editável — o pior caso é escolher de novo. Se um dia precisar seguir a pessoa, nasce UserDefaultEstablishment, com esse nome e esse único propósito. Nunca UserPreference.
Onde a empresa entra, e onde não entra
| Agregado | Empresa | Por quê |
|---|---|---|
Item · ItemType · CategoryNode | nenhuma | Catálogo é vocabulário do grupo. Um item que existe só numa empresa é um item que ninguém mais acha. |
UnitOfMeasure | nenhuma | Quilo é quilo. |
Supplier · SupplierEstablishment · ItemSupplier | nenhuma | Diligência é sobre quem vende, não sobre quem compra. Cadastrar o mesmo fornecedor N vezes é o custo que o modo consolidado existe para eliminar. |
Role · Permission | nenhuma | O catálogo de papéis é do tenant. O que varia é onde o papel vale — e isso está em UserRole. |
GLAccount | nenhuma | Plano unificado. A variação por empresa está em GLAccountCompany. |
CostCenter | anulável | Nó corporativo ou nó de uma empresa. Regra já escrita na v2.0. |
ApprovalPolicy | critério | Sem coluna de empresa: o recorte é o critério Empresa em lista, que já existia esperando o campo do documento. Uma coluna criaria um segundo mecanismo para a mesma pergunta. |
UserRole · UserTenant | nenhuma | São do gryd e ficam intactos. O recorte por empresa mora em UserCompanyScope, do Nexio |
UserCompanyScope | anulável | Nulo = todas as empresas do tenant. Ausência de linha para o usuário também. |
GLAccountCompany · Budget | obrigatória | São, por definição, o recorte por empresa. |
Requisition · PurchaseOrder · Receipt · Invoice | estabelecimento | Apontam para o estabelecimento e congelam a empresa derivada. Documento não muda de significado depois de emitido — nem se o cadastro mudar. |
Domínio · GLAccount
Plano de contas
O eixo que apareceu como pendência explícita na spec de Item ("mapeamento categoria → conta contábil … provavelmente deveria estar na próxima") e como fatia do rateio na de Centro de Custo. Fecha aqui.
A terceira árvore — e a mesma mecânica
Centro de custo e categoria já são árvores com caminho materializado, código imutável e ancestrais derivados sem recursão. Plano de contas é a terceira, e reusa a mecânica inteira: parentId, path com separador nas duas pontas, depth, duas portas de status com cascata assimétrica, path como token de concorrência. Inventar uma quarta forma de fazer árvore neste produto seria erro por escolha, não por descuido.
| Campo | Tipo | Regra | |
|---|---|---|---|
| code | string(30) | obrig | Único por tenant, imutável. Contas contábeis brasileiras já são hierárquicas no próprio código (3.1.01.001) — o produto não deriva o pai do código: quem manda é parentId, e o código é rótulo. |
| name | string(160) | obrig | Único entre irmãos. |
| parentId | Guid? | opc | Nulo = raiz. Ciclo é recusado. Movimentação só por PATCH /{id}/parent. |
| path · depth | varchar · int | gerado | Idênticos aos do centro de custo, inclusive o escape do separador. |
| accountType | enum | obrig | Asset · Liability · Equity · Revenue · Expense. Herdado do pai por padrão; divergir do pai é 422. |
| allowsPosting | bool | opc | Verdadeiro em folha, falso em nó com filhos — a mesma regra do centro de custo. Nó intermediário existe para consolidar, não para receber lançamento. |
| validFrom · validTo | date? | opc | Plano de contas muda de exercício para exercício. Conta fora de vigência não é oferecida em documento novo e continua legível em documento antigo. |
| isActive | bool | gerado | Duas portas, cascata assimétrica. |
| Campo | Tipo | Regra | |
|---|---|---|---|
| glAccountId · companyId | Guid | obrig | Chave composta única. |
| isEnabled | bool | obrig | Conta habilitada naquela empresa. Ausência de linha = habilitada — o padrão é o plano inteiro valer para todas, e a linha existe para restringir ou para carregar o código externo. |
| externalCode | string(40) | opc | O código da mesma conta no ERP daquela empresa. É a razão de existir desta tabela: plano unificado no Nexio, códigos diferentes lá fora. |
De onde vem a conta da linha
Sugerida em cascata, escolhida por gente
A conta da fatia de rateio é sugerida por, nesta ordem: 1 Item.defaultGlAccountId, 2 CategoryNode.defaultGlAccountId do nó do item, subindo a árvore de categoria até achar, 3 vazio. Sempre editável por quem tem permissão, e sempre com a origem visível na interface — "sugerida pela categoria Materiais de escritório" é a diferença entre um campo que o usuário confere e um campo que ele ignora.
Nenhuma das duas árvores manda na outra
Centro de custo responde quem paga; conta contábil responde que natureza é o gasto. A linha carrega as duas, e derivar uma da outra é o erro que transforma relatório contábil em relatório gerencial errado. Os dois eixos se cruzam na fatia de rateio e em nenhum outro lugar.
O Nexio classifica, não escritura
A conta existe para que o documento saia classificado e a integração com o ERP não precise adivinhar. Partida dobrada, encerramento, razão e balancete continuam do outro lado. Essa fronteira precisa estar escrita porque é a primeira coisa que um controller pede na demo.
Transversal
Numeração
| Campo | Tipo | Regra | |
|---|---|---|---|
| documentType | enum | obrig | Requisition · QuotationRequest · PurchaseOrder · Receipt. Cresce com a espinha transacional. |
| scope | enum | obrig | Tenant · Company · Establishment. Padrão Company. |
| companyId · establishmentId | Guid? | cond | Exigidos conforme o scope; preenchidos fora dele é 422. Uma sequência por combinação — a unicidade é (tenant, documentType, scope, companyId, establishmentId). |
| prefix | string(10) | opc | Texto fixo antes do número. Cliente que quer PC- ou a sigla da filial resolve aqui. |
| padding | int | opc | Zeros à esquerda. Padrão 6. |
| resetPolicy | enum | opc | Never · Yearly · Monthly. Padrão Yearly, que é o hábito do mercado. |
| nextValue | bigint | gerado | Incrementado por UPDATE … RETURNING atômico no banco, nunca por leitura seguida de escrita na aplicação. Dois POST simultâneos com o mesmo número é o defeito clássico deste componente. |
O número pode ter buracos, e isso não é defeito. Transação abortada consome o número e não o devolve. Prometer sequência sem furo obrigaria a serializar a emissão do documento inteiro, e o cliente que precisa de sequência contínua está pensando em NF-e — que o Nexio não emite: quem emite é o fornecedor. Está escrito aqui para não virar chamado.
Operação
Compra centralizada
Matriz comprando para a filial é rotina em grupo brasileiro, e a diferença entre suportar isso e não suportar são dois campos criados agora — contra uma mudança de chave em agregado já povoado, depois.
| Campo do documento | O que responde | Regra |
|---|---|---|
establishmentId | Quem compra, emite e recebe a nota | Obrigatório. Define a empresa do documento, a numeração e a política de aprovação. |
requestingEstablishmentId | Quem pediu | Nulo quando igual ao anterior. Preenchido, é o que faz o pedido aparecer para quem solicitou e o que alimenta o rateio. |
companyId | Empresa do documento | gerado — derivada de establishmentId e congelada. Documento não muda de dono porque o cadastro mudou. |
Quando os dois são de empresas diferentes
Permitido, e só permitido, quando o usuário tem escopo nas duas empresas. É a única regra necessária: quem pode ver as duas pode relacionar as duas.
A transferência contábil fica fora
Empresa A comprou para a empresa B gera, no mundo real, um acerto entre elas — nota de transferência, rateio intercompany, ou um lançamento manual. Isso é ERP e continua sendo ERP. O que o Nexio faz é registrar o fato com precisão suficiente para que o acerto seja possível, o que é exatamente o que o campo entrega.
Contexto
Gancho fiscal
Duas mudanças de 2026 que tornam o estabelecimento obrigatório, e não uma conveniência de modelagem.
CNPJ alfanumérico — em produção desde 31/07/2026
Coluna varchar, nunca numérica, dos dois lados do produto. Os 12 primeiros caracteres aceitam A–Z; os 2 dígitos verificadores continuam numéricos e o cálculo usa ASCII − 48. Já era regra em Supplier; entra idêntica em Company e Establishment.
IBS e CBS obrigatórios na NF-e desde 03/08/2026
Documento fiscal eletrônico não é autorizado sem os campos de IBS e CBS preenchidos, com a alíquota de teste de 1% (0,1% IBS e 0,9% CBS) durante 2026. O Nexio não emite NF-e — mas recebe, e toda nota que chegar já vem com esses campos. O módulo de nota e o three-way match nascem tendo que lê-los.
Para esta spec a consequência é uma só, e é a razão de o estabelecimento existir: os campos que decidem tributação e crédito estão espalhados nos dois níveis — regime na empresa, contribuinte de ICMS, Suframa e município IBGE no estabelecimento. Modelar filial como endereço perde metade deles.
O que continua fora
Parametrização fiscal completa — CFOP, CST, substituição tributária, benefícios estaduais, regra de crédito por operação — é spec própria, junto com a nota fiscal. Aqui nascem apenas os portadores de identidade fiscal, e eles nascem certos.
Integração
Relação com os demais domínios
| Domínio | Mudança | Detalhe |
|---|---|---|
| Centro de Custo v2.1 | feito | legalEntityId → companyId no nó e em legalEntityOf(nodeId) → companyOf(nodeId). A regra — nó corporativo, nó de empresa, filho não muda de empresa, linha da empresa A só aponta para corporativo ou A — já estava escrita e não muda uma vírgula. Rename de coluna sem dado para migrar, aplicado em 01/09/2026. A pendência que a v2.0 deixou registrada — apontar para a empresa ou para o estabelecimento — foi resolvida no mesmo dia: aponta para a empresa; apontar para o estabelecimento multiplicaria a árvore por CNPJ, que é o que o nó corporativo existe para evitar. |
| Fluxo de Aprovação v2.1 | feito | O critério "Empresa em lista" lê companyId do documento, derivado do estabelecimento. A política não ganha coluna de empresa: o critério já faz o recorte, e uma Base do grupo convive com uma Complementar de uma empresa só. Novo critério irmão por establishmentId, e um nível que resolve para aprovador sem escopo na empresa do documento passa a cair no fallback. |
| Cadastro de Item v3.1 | feito | Item.defaultGlAccountId e CategoryNode.defaultGlAccountId, ambos opcionais, aplicados em 01/09/2026. Encerra as duas pendências que a spec carregava: o eixo contábil ausente e o multi-empresa. Nenhuma mudança de dimensionalidade: item continua sem empresa. |
| Fornecedor v2.0 | nenhuma | Fornecedor é do tenant. Fica declarado o gancho SupplierQualification.companyId anulável — homologar um fornecedor só para uma empresa do grupo é pedido plausível, e o campo tem o lugar reservado sem ser especificado agora. |
Plataforma gryd | nenhuma | Tenant, User, UserTenant, UserRole, Role e Permission ficam intactos. A plataforma não conhece Company e não deve conhecer — colocar companyId num agregado do gryd inverteria a dependência, fazendo a plataforma depender de uma tabela de um produto que roda sobre ela. O único consumo novo é de leitura: GET /users/directory, que o nexio-service já usa no seletor de gestor de centro de custo.O que o roleId reservado custaria. Escopo por papel × empresa exigiria o Nexio consumir e cachear o catálogo role → permission do GrydAuth, porque o SessionDto entrega permissions já achatado no tenant e não dá para recompor por papel. É uma integração a mais, não um impedimento — e fica adiada porque o caso que a justificaria (aprovar numa empresa e requisitar em outra) já é resolvido por CostCenterAssignment, que é por nó e o nó tem empresa. |
| Unidade de Medida v1.0 | nenhuma | Escopo global ou de tenant, como está. |
| Poda do webapp v1.0 | acrescenta | Empresa e estabelecimento entram na lista do que o admin precisa ter na primeira versão — sem eles, o cadastro de centro de custo por empresa não tem o que oferecer no seletor. |
Camada API
Endpoints
Prefixo api/v{version}/nexio. Todas exigem autenticação e declaram exatamente uma permissão. Toda listagem já vem recortada pelo escopo do usuário — não há parâmetro para pedir fora dele.
| Rota | Permissão | Retorno | Erros |
|---|---|---|---|
| GET /companies | read:companies | PagedResult<CompanyDto> | 400 |
| GET /companies/lookup | read:companies | array puro | 400 |
| GET /companies/{id} | read:companies | CompanyDetailDto | 404 |
| POST /companies | create:companies | CompanyDetailDto | 400 · 409 · 422 |
| PUT /companies/{id} | update:companies | CompanyDetailDto | 400 · 404 · 409 |
| POST /companies/{id}/activate | update:companies | 204 | 404 · 409 |
| POST /companies/{id}/deactivate | update:companies | 204 | 404 · 409 |
| Rota | Permissão | Retorno | Erros |
|---|---|---|---|
| GET /companies/{id}/establishments | read:companies | array puro | 404 |
| GET /establishments/lookup | read:companies | agrupado por empresa | 400 |
| GET /establishments/{id} | read:companies | EstablishmentDetailDto | 404 |
| POST /companies/{id}/establishments | create:companies | EstablishmentDetailDto | 400 · 404 · 409 · 422 |
| PUT /establishments/{id} | update:companies | EstablishmentDetailDto | 400 · 404 · 409 · 422 |
| POST /establishments/{id}/activate | update:companies | 204 | 404 · 409 |
| POST /establishments/{id}/deactivate | update:companies | 204 | 404 · 409 |
GET /establishments/lookup é o endpoint que a interface inteira consome. Devolve só o que aquele usuário pode usar, já agrupado por empresa, já filtrado por isActive e allowsPurchasing, com defaultEstablishmentId marcado. Um item na resposta significa que o formulário não mostra o campo — a decisão de exibir o seletor é derivada do tamanho desta lista, nunca de uma configuração que alguém precisa lembrar de ligar.
| Rota | Permissão | Retorno | Erros |
|---|---|---|---|
| GET /gl-accounts | read:gl-accounts | PagedResult<GLAccountDto> | 400 |
| GET /gl-accounts/lookup | read:gl-accounts | só allowsPosting e vigentes | 400 |
| GET /gl-accounts/{id}/children | read:gl-accounts | PagedResult<GLAccountDto> | 400 · 404 |
| POST /gl-accounts | create:gl-accounts | GLAccountDetailDto | 400 · 409 · 422 |
| PUT /gl-accounts/{id} | update:gl-accounts | GLAccountDetailDto | 400 · 404 · 409 |
| PATCH /gl-accounts/{id}/parent | update:gl-accounts | GLAccountDetailDto | 404 · 409 · 422 |
| PUT /gl-accounts/{id}/companies/{companyId} | update:gl-accounts | habilitação e código externo | 404 · 422 |
| GET /number-sequences | manage:number-sequences | array puro | 400 |
| PUT /number-sequences/{id} | manage:number-sequences | NumberSequenceDto | 404 · 422 |
| Rota | Permissão | Retorno | Erros |
|---|---|---|---|
| GET /users/{userId}/company-scopes | read:company-scopes | array puro | 400 · 404 |
| PUT /users/{userId}/company-scopes | manage:company-scopes | substitui o conjunto | 400 · 404 · 409 · 422 |
| GET /me/company-scope | autenticado | o conjunto resolvido do usuário atual | — |
PUT substitui o conjunto inteiro, não faz merge. É a mesma semântica que a tela de Papéis do gryd já usa — "salvar substitui permissões" — e é o que torna a tela de escopo uma lista de checkboxes honesta em vez de um diff que ninguém consegue conferir. GET /me/company-scope devolve o conjunto já resolvido, com a razão (Explicito ou SemRestricao), porque a interface precisa saber a diferença entre "você tem uma empresa" e "você tem uma empresa por enquanto".
Contrato
Contrato de erros
O status HTTP é derivado do sufixo do código, nunca da mensagem. _NOT_FOUND → 404 · _CONFLICT e _ALREADY_EXISTS → 409 · _UNPROCESSABLE → 422 · sem sufixo → 400.
| Código | HTTP | Quando |
|---|---|---|
| COMPANY_NOT_FOUND | 404 | Não existe neste tenant — ou não está no escopo do usuário. Os dois casos respondem igual, de propósito |
| COMPANY_CODE_ALREADY_EXISTS | 409 | Código já usado no tenant. A mensagem carrega o código |
| COMPANY_CNPJ_ROOT_ALREADY_EXISTS | 409 | Já existe empresa com essa raiz. Duas empresas com a mesma raiz seriam a mesma pessoa jurídica |
| COMPANY_HAS_ACTIVE_ESTABLISHMENTS_CONFLICT | 409 | Desativação com estabelecimento ativo |
| COMPANY_HAS_DOCUMENTS_CONFLICT | 409 | Exclusão com documento emitido. Empresa com histórico se desativa, não se apaga |
| ESTABLISHMENT_NOT_FOUND | 404 | Idem — fora de escopo responde como inexistente |
| ESTABLISHMENT_CNPJ_ALREADY_EXISTS | 409 | CNPJ de 14 repetido no tenant |
| ESTABLISHMENT_CODE_ALREADY_EXISTS | 409 | Código repetido dentro da mesma empresa |
| ESTABLISHMENT_CNPJ_ROOT_MISMATCH_UNPROCESSABLE | 422 | O CNPJ não pertence à raiz da empresa. O dado é bem formado e semanticamente impossível — por isso 422 e não 400 |
| ESTABLISHMENT_HEADQUARTERS_ALREADY_EXISTS | 409 | Segunda matriz na mesma empresa |
| ESTABLISHMENT_HEADQUARTERS_REQUIRED_UNPROCESSABLE | 422 | Tentativa de deixar a empresa sem matriz |
| ESTABLISHMENT_LAST_ACTIVE_CONFLICT | 409 | Desativação do último estabelecimento ativo de uma empresa ativa |
| ESTABLISHMENT_STATE_REGISTRATION_REQUIRED_UNPROCESSABLE | 422 | icmsTaxpayerStatus = Taxpayer sem inscrição estadual |
| GL_ACCOUNT_NOT_FOUND | 404 | — |
| GL_ACCOUNT_CODE_ALREADY_EXISTS | 409 | — |
| GL_ACCOUNT_POSTING_NOT_ALLOWED_UNPROCESSABLE | 422 | Fatia de rateio apontando para conta que não aceita lançamento |
| GL_ACCOUNT_TYPE_MISMATCH_UNPROCESSABLE | 422 | accountType divergente do pai |
| GL_ACCOUNT_CONCURRENT_CHANGE_CONFLICT | 409 | A linha não carrega mais o path em que foi lida |
| USER_COMPANY_SCOPE_NOT_FOUND | 404 | Linha inexistente, ou de um tenant que não é o do token |
| USER_COMPANY_SCOPE_DUPLICATE_CONFLICT | 409 | Duas linhas para a mesma pessoa e empresa, ou dois "todas as empresas" |
| USER_COMPANY_SCOPE_ROLE_NOT_SUPPORTED_UNPROCESSABLE | 422 | roleId preenchido. A coluna existe e a versão não a resolve |
| USER_NOT_IN_TENANT_UNPROCESSABLE | 422 | O userId não tem vínculo ativo com esta organização no GrydAuth. Verificado só na escrita |
| NUMBER_SEQUENCE_SCOPE_UNPROCESSABLE | 422 | companyId ou establishmentId incoerente com o scope declarado |
Autorização
Permissões
| Permissão | Alcance | Observação |
|---|---|---|
| read:companies | Ler empresas e estabelecimentos | Recortada pelo escopo do usuário. Quase todo papel operacional precisa dela para o seletor funcionar |
| create:companies | Criar empresa e estabelecimento | Administrativa. Criar empresa muda a forma da interface para todo mundo do tenant |
| update:companies | Editar, ativar e desativar | Administrativa |
| read:gl-accounts | Ler o plano de contas | Necessária para preencher a fatia de rateio |
| create:gl-accounts | Criar conta | Controladoria |
| update:gl-accounts | Editar, mover, habilitar por empresa | Controladoria |
| read:company-scopes | Ler o escopo por empresa dos usuários | Administrativa. Não é necessária para operar — GET /me/company-scope não exige permissão |
| manage:company-scopes | Definir quais empresas cada pessoa alcança | Administrativa e sensível: é o que separa as empresas do grupo umas das outras |
| manage:number-sequences | Configurar numeração | Administrativa. Mudar escopo de numeração com documento emitido é permitido e não renumera nada |
Escopo não é permissão — é o alcance dela. A permissão, que vem do gryd, diz o que a pessoa pode fazer; o UserCompanyScope, que é do Nexio, diz onde. Confundir os dois leva a criar read:companies:alfa no catálogo do tenant, que é a forma mais rápida de tornar um catálogo de papéis impossível de manter — e a razão de o recorte por empresa não ter virado permissão.
Invariantes
Regras de negócio
| Regra | Onde é garantida |
|---|---|
| Todo tenant tem pelo menos uma empresa ativa | Seed na criação do tenant. Não há estado válido sem empresa |
| Toda empresa tem exatamente uma matriz | Índice único parcial em (companyId) filtrado por type = Headquarters |
| Todo estabelecimento carrega a raiz da sua empresa | Validação no agregado, 422. Coluna gerada left(cnpj, 8) com check contra cnpjRoot |
| Empresa ativa não fica sem estabelecimento ativo | Verificação na desativação, 409 |
cnpjRoot, code e companyId são imutáveis | Ignorados no PUT. Não é erro pedir para mudar — é campo que não existe na atualização |
companyId nunca vem do cliente | Injetado do claim em toda escrita, como tenantId |
| Documento aponta para estabelecimento e congela a empresa | Regra da linha do documento. Alteração de cadastro não reescreve documento emitido |
| Linha da empresa A só usa centro de custo corporativo ou de A | Já especificado no Centro de Custo v2.0, validado na linha com o companyId efetivo do nó |
Conta sem allowsPosting não recebe rateio | Validação na fatia, 422 |
UserCompanyScope só estreita, nunca concede | Desenho. A tabela não dá organização, papel nem permissão — o token do GrydAuth continua decidindo isso. Linha órfã é inerte |
| Ausência de linha = todas as empresas | Resolvedor. Mesma convenção de GLAccountCompany |
| Nenhuma tabela do gryd é alterada | Fronteira de camada. A plataforma não conhece Company, e não deve conhecer |
CNPJ é varchar nos dois lados do produto | Esquema. Validação de DV com ASCII − 48 |
Implantação
Migração
Sem cliente em produção e com o único módulo desenvolvido — centro de custo — autorizado a ser recriado. Esta é a janela em que a mudança custa quase nada.
1 · Seed a partir do que já existe
Para cada tenant: uma Company com code = "0001", cnpjRoot extraída de TenantSettings.cnpj e legalName do nome do tenant; um Establishment Headquarters com o CNPJ completo e code = "0001". Campos fiscais entram como pendência de preenchimento, não como bloqueio.
2 · TenantSettings.cnpj sai no mesmo release
Manter os dois por um ciclo cria a segunda fonte de verdade que a Decisão 2 existe para impedir. Sai junto ou não sai.
3 · Renomes sem dado
cost_centers.legal_entity_id → company_id. Nenhuma tabela do gryd é tocada. Nasce uma tabela no esquema do Nexio, user_company_scopes, vazia — e vazia significa "todo mundo enxerga todas as empresas", que é exatamente o comportamento de hoje. ApprovalPolicy também não ganha coluna: o critério resolve. Todos anuláveis e todos vazios: nenhum backfill, nenhuma janela.
4 · Tenants existentes não mudam de comportamento
Com uma empresa por tenant, o seletor não aparece, a numeração continua única e o escopo não recorta nada. A migração é invisível para quem tem um CNPJ só — que é o teste de que o modelo está certo.
Validação
Referência de mercado
| Nexio | Oracle Fusion | SAP | Coupa | Protheus |
|---|---|---|---|---|
| Tenant | Enterprise | Client | Instance | Ambiente |
Company | Legal Entity | Company Code | Legal Entity | Empresa |
Establishment | não existe — o site não tem identificação fiscal própria | Business Place · Local de Negócio, construto criado para o Brasil | não existe | Filial |
GLAccount | Natural Account | GL Account | Account | Plano de contas |
NumberSequence | Document Sequence | Number Range | — | Numeração por filial |
A linha que importa é a do estabelecimento. Oracle e Coupa não têm o conceito: para eles o site é uma unidade operacional sem CNPJ, e o identificador fiscal mora na entidade legal. Quem implanta esses produtos no Brasil resolve com campo livre, código de site colado no CNPJ ou uma entidade legal por filial — as três saídas ruins. A SAP precisou inventar o Business Place especificamente para a legislação brasileira. O Nexio nasce com o construto certo porque nasce aqui — e é exatamente a mesma vantagem que a spec de Fornecedor identificou ao derivar grupo econômico da raiz de 8.
Limites
Fora de escopo
| O que | Por quê |
|---|---|
| Escrituração contábil | O Nexio classifica o gasto; partida dobrada, razão e balancete continuam no ERP |
| Transferência intercompany | O documento registra quem comprou e quem pediu; o acerto entre as empresas é lançamento no ERP |
| Consolidação cross-tenant | Se um dia existir, é serviço de leitura com permissão própria — nunca um filtro de tenant relaxado |
Herança de cadastro do tenant Group para os filhos | Contraria a Decisão 3. Quem quer cadastro compartilhado usa o modo consolidado |
| Hierarquia entre empresas do mesmo tenant | Holding e controlada dentro do tenant: lista plana até que consolidação societária exista, o que não está no roteiro. parentCompanyId não é reservado |
| Estabelecimento fora do Brasil | countryCode reservado, sem regra. Fornecedor estrangeiro já é suportado; comprar como empresa estrangeira, não |
| Parametrização fiscal por operação | CFOP, CST, ST, benefícios e regra de crédito nascem com a nota fiscal |
DeliveryLocation e Budget | Specs próprias. As duas dependem desta e nenhuma das duas depende da outra |