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ó; o CNPJ solto do tenant morre — e TenantSettings, que esta spec tratava como entidade da plataforma, não existe no Gryd.IO. 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 recorta a única empresa que existe — e mesmo ali só alcança quem tem linha nela. 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 = nenhuma empresa, qualquer que seja o número de empresas do tenant, e não existe concessão "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. |
| Configuração de tenant | A plataforma não tem TenantSettings. O CNPJ único por tenant que o Nexio lia era do próprio Nexio. Mas tem Tenant.Settings, e a recusa precisa dizer isso: um string? mapeado como documento semiestruturado, com UpdateSettings e leitura por JsonNode na mão — a única leitura tipada hoje é a política de MFA. O JSON genérico que a decisão recusa já está lá, a uma linha de distância, e alguém vai encontrá-lo. Recusá-lo é escolha: parâmetro do Nexio ali seria configuração sem esquema, sem validação de domínio e sem migration, num campo de outro módulo — e o primeiro a ler valida, ou não valida ninguém. | removido — o CNPJ vive em Company.cnpjRoot e Establishment.cnpj. Os parâmetros por tenant do Nexio (teto de profundidade das árvores, materialidade do rateio, expediente e lista de dias não úteis) ganham entidade própria — pendência registrada no mapa de domínio. O calendário não pede entidade: virou campo do tenant no fluxo de aprovação, porque o SLA é o único consumidor de dia útil no produto. |
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 derivado no servidor do establishmentId validado contra o escopo do usuário; nunca aceito do cliente e nunca lido do token, que não traz empresa.
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 solto do tenant. 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.
NumberSequenceCounter.
userId do gryd sem FK. Uma linha por empresa alcançada, com vigência. Ausência de linha = nenhuma empresa.
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: 8 caracteres, 0–9 e A–Z, nunca numérico. Normalizada antes de validar — trim, sem máscara, letras em maiúsculas; fora do formato, 400 COMPANY_CNPJ_ROOT_INVALID. Ú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. O documento congela o regime na submissão (RN-EMP-37): o Simples é opção anual, e mudar o regime pelo PUT vale para documento novo, sem reescrever o que já foi submetido. |
| 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) | gerado | ISO 4217. Na v1 é sempre BRL: a criação grava BRL, outro valor no POST é 422 COMPANY_FUNCTIONAL_CURRENCY_UNPROCESSABLE, e o PUT ignora o campo — ele é imutável, e continuará imutável depois do primeiro Budget quando outra moeda funcional for admitida. É coerente com o CNPJ e com a escrituração, que são em reais. Documentos, preços, limites de alçada e cotações seguem em qualquer ISO 4217: a comparação converte para a moeda do limite, com a taxa congelada na submissão — regra da Moeda e Câmbio, e o limite carrega a própria moeda no Centro de Custo. A spec de Orçamento copia este valor para Budget.currency na criação do exercício e o congela. |
| foundedOn | date | opc | Data de abertura. |
| closedOn | date | opc | Data de baixa. Fato de calendário: documento novo com data posterior é recusado com 422 COMPANY_CLOSED_UNPROCESSABLE, e o campo não mexe em isActive — quem desativa é PATCH /deactivate, e só ele. Preenchido em empresa ainda ativa, o PUT grava e a resposta avisa que falta desativar. |
Estado e derivados
| Campo | Tipo | Regra | |
|---|---|---|---|
| isActive | bool | gerado | Alterado só por /activate e /deactivate, nunca por PUT. A desativação cascateia para os estabelecimentos ativos, na mesma transação. Desativar não emite documento novo; documento em andamento conclui. Com isActive = false, a empresa não é oferecida nem aceita em documento novo — criar ou submeter documento em estabelecimento dela é recusado —, e todo documento já submetido nela segue até o fim: aprovação, pedido, recebimento, nota, devolução e estorno. A desativação não é recusada por haver documento em aberto. O que a empresa emitiu continua legível para quem a alcança. Ver Escopo e segurança. |
| headquartersId | Guid | gerado | O estabelecimento matriz. Derivado, não digitado — é o único com type = Headquarters. Nunca nulo: a matriz nasce na mesma transação que a empresa, então não existe empresa sem matriz nem por uma chamada. Regravado pela troca de matriz (RN-EMP-38). |
| establishmentCount | int | gerado | Exibido na lista. Ativos e inativos separados no detalhe. |
| version | int | gerado | Token de concorrência. PUT, /activate, /deactivate e /headquarters exigem expectedVersion; divergente, 409 COMPANY_CONCURRENT_CHANGE_CONFLICT (RN-EMP-40). |
O que não está aqui, de propósito. Certidões, quadro societário, 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. A exceção é a situação cadastral do estabelecimento, porque ela decide se um documento pode ser emitido em nome dele — por isso está aqui, com a mesma fonte do fornecedor. Se um dia o produto virar portal e a empresa compradora precisar se apresentar a alguém, os demais campos 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. Normalizado antes de validar: trim, sem máscara (saem ., / e -), letras em maiúsculas. Depois, 14 caracteres — 12 em 0–9A–Z e 2 dígitos verificadores — e DV conferido; formato ou DV inválido é 400 ESTABLISHMENT_CNPJ_INVALID. Ú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. |
| cnpjRoot | varchar(8) | gerado | Os 8 primeiros caracteres do cnpj. Existe para a chave estrangeira composta (tenantId, companyId, cnpjRoot) → companies(tenantId, id, cnpjRoot), que faz o banco recusar estabelecimento com raiz de outra empresa (RN-EMP-03). Coluna gerada é exemplo de implementação; o equivalente é a aplicação preencher e a chave conferir. |
| type | enum | gerado | Headquarters · Branch, persistido como texto. Exatamente um Headquarters por empresa, sempre. A matriz nasce com a empresa; estabelecimento criado depois nasce Branch; o PUT ignora o campo; trocar a matriz é só PATCH /companies/{id}/headquarters, atômico (RN-EMP-38). Não se deriva da ordem do CNPJ: a matriz pode ter outra ordem que não 0001 — transferência de sede sempre permitiu, e com o CNPJ alfanumérico a ordem nem é numérica. O identificador matriz/filial da Receita, quando a consulta ao CNPJ o traz, é comparado: divergência gera aviso na tela e não troca o cadastro. |
| 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 em Taxpayer (indIEDest = 1) — 422 ESTABLISHMENT_STATE_REGISTRATION_REQUIRED_UNPROCESSABLE; opcional em NonTaxpayer (indIEDest = 9, que pode ou não possuir IE); recusada em Exempt (indIEDest = 2, isento de inscrição) — 422 ESTABLISHMENT_STATE_REGISTRATION_NOT_ALLOWED_UNPROCESSABLE. 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 | gerado | RegistrationStatus = Null · Active · Suspended · Unfit · ClosedDown — os códigos 01 · 02 · 03 · 04 · 08 da Receita, o mesmo enum do SupplierEstablishment, persistido como texto. Situação do estabelecimento: uma filial pode estar baixada com a matriz ativa. Fonte: o mesmo gancho de provedor de consulta ao CNPJ que o Fornecedor usa — na v1, espelho dos dados abertos da Receita, com defasagem de semanas —, consultado na criação do estabelecimento e na varredura periódica em lote; ou a correção manual com comprovante (RN-EMP-36). Null = ainda não verificado. Bloqueia documento novo em Suspended, Unfit e ClosedDown. |
| registrationStatusDate | date? | gerado | A data da situação cadastral, como a Receita a informa ou como consta no comprovante. É ela que decide entre a consulta e a correção manual: vale a de data mais recente. |
| registrationSource · registrationCheckedAt | enum · instante? | gerado | Provider · Manual, persistido como texto, e o instante da última verificação. |
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. Filtra quem pede: falso, o estabelecimento não pode ser requestingEstablishmentId de documento novo. Não impede receber — isso é o allowsReceiving. Escritório administrativo que não compra é caso real. |
| allowsReceiving | bool | opc | Padrão true. Filtra quem recebe: falso, o estabelecimento não pode ser establishmentId de documento novo nem destinatário de ponto de entrega. Passar a falso com ponto de entrega ativo pendurado é recusado (RN-EMP-17). Não impede pedir — isso é o allowsPurchasing. Um centro de distribuição que só recebe tem allowsPurchasing = false e allowsReceiving = true, e os pontos de entrega dele funcionam. |
| isActive | bool | gerado | Só por /activate e /deactivate. Desativar não emite documento novo; documento em andamento conclui. Com isActive = false, o estabelecimento não é oferecido nem aceito em documento novo — criar ou submeter documento que o aponte é recusado —, e todo documento já submetido que o aponta segue até o fim: aprovação, pedido, recebimento, nota, devolução e estorno. A desativação não é recusada por haver documento em aberto. 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. |
| version | int | gerado | Token de concorrência. PUT, /activate, /deactivate e /registration-status exigem expectedVersion; divergente, 409 ESTABLISHMENT_CONCURRENT_CHANGE_CONFLICT. O PUT tem efeito fiscal em cascata sobre os pontos de entrega, e duas edições concorrentes não podem se sobrepor em silêncio. A cascata da empresa incrementa a versão de cada estabelecimento que desativa. |
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 que o filtro falhe fechado — não para fingir que a garantia continua estrutural.
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.
Resolução que não roda não libera. Sem contexto de tenant, ou com a leitura do escopo indisponível, a requisição falha com 500 — nunca segue com um conjunto mais amplo, nem com o de uma requisição anterior. Revogar vale na requisição seguinte (RN-EMP-22, RN-EMP-25).
O recorte é filtro de aplicação — e espelha as garantias da leitura de grupo da plataforma
O Gryd.IO isola tenant por filtro global de consulta (HasQueryFilter, em TenantQueryFilterExtensions), não por política de linha no banco. O recorte por empresa é, portanto, sempre filtro de aplicação — conferido no código em 10/09/2026, commit 585dbfc8.
A plataforma já tem um recorte de leitura com as garantias certas, a leitura de grupo do ADR 0006 (?scope=group), e o UserCompanyScope as espelha uma a uma. (1) Conjunto vazio é nada: lá, conjunto vazio "nunca é tudo" (TenantContextAccessor); aqui, ausência de linha e união vazia não alcançam empresa nenhuma. (2) Escrita nunca sob recorte amplo: lá, gravar com escopo de grupo ativo é recusado (TenantSaveChangesInterceptor); aqui, toda escrita resolve uma empresa (RN-EMP-26). (3) O cliente não envia a autoridade: lá, o cliente nunca manda id de tenant; aqui, companyId vindo do cliente só intersecta. (4) O que não foi declarado é recusado, não ignorado: lá, ?scope=group em rota não declarada é recusado; aqui, toda entidade com companyId ou establishmentId é registrada no filtro, e a entidade que não estiver quebra o build (RN-EMP-25).
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.
Empresa inativa não emite documento novo, e não some da leitura
Desativar não emite documento novo; documento em andamento conclui. Com isActive = false, a empresa não é oferecida nem aceita em documento novo — criar ou submeter documento em estabelecimento dela é recusado —, e todo documento já submetido nela segue até o fim: aprovação, pedido, recebimento, nota, devolução e estorno. A desativação não é recusada por haver documento em aberto. O lookup não oferece os estabelecimentos dela, e a criação ou a submissão contra um deles é recusada com 422 COMPANY_INACTIVE_UNPROCESSABLE.
A resolução de leitura do UserCompanyScope ignora isActive — quem alcançava a empresa continua lendo tudo o que ela emitiu, a auditoria inclusive. Incorporação e encerramento são rotina em grupo brasileiro, e o produto existe justamente para guardar essa história. Empresa inativa aparece marcada como inativa; ela não some.
Desativar empresa é um ato só, e ele cascateia
PATCH /companies/{id}/deactivate desativa a empresa e os estabelecimentos ativos dela na mesma transação, com um evento por estabelecimento, e a resposta lista o que caiu junto. Não existe o passo “primeiro zere os estabelecimentos”, nem “primeiro conclua os documentos”: documento em andamento em qualquer um deles conclui (RN-EMP-05). PATCH /activate não cascateia de volta: reativar a empresa reabre a empresa, e quais filiais voltam é decisão de quem reativa. Cascata assimétrica, a mesma das duas portas do plano de contas.
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: a linha de quem foi removido da organização no gryd já não dá nada, porque o token nunca mais traz aquele tenant — e a remoção a encerra, para que a readmissão não a reviva (RN-EMP-24). Não existe escalonamento de organização, papel ou permissão por este caminho, e por isso a ausência de chave estrangeira para o gryd não é uma fragilidade. O escalonamento entre empresas existe, e é o que as regras de concessão fecham (RN-EMP-19 a RN-EMP-24).
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.
Para acesso, ausência é nada
UserCompanyScope não tem nulo nem ausência que libere: sem linha vigente, a pessoa não alcança empresa nenhuma. Nulo com significado continua existindo onde descreve abrangência de cadastro, não acesso — CostCenter.companyId nulo é nó corporativo, e GLAccountCompany ausente é conta habilitada.
A diferença é a consequência do erro. Um cadastro abrangente demais aparece numa tela e se corrige; um acesso abrangente demais entrega o dado de outra empresa sem que ninguém veja. Por isso a regra do acesso é a fechada.
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 do GrydAuth embutido, lido em processo, na escrita, nunca na leitura: leitura com id órfão simplesmente não casa com token nenhum. |
| companyId | Guid | obrig | Obrigatória: não existe linha "todas as empresas". FK composta (tenantId, companyId) → companies(tenantId, id): empresa de outra organização é recusada pelo banco. |
| validFrom | instante (UTC) | gerado | O instante em que a linha é gravada, pelo servidor — no ato de suporte da plataforma, o fim da carência (RN-EMP-24). Concessão com início futuro não existe. |
| validTo | instante (UTC)? | opc | Fim da vigência, exclusivo: a linha vale em [validFrom, validTo). Informado na concessão para acesso temporário — posterior ao instante da concessão, senão 422, e nunca posterior ao da linha de quem concede quando esta tem fim (RN-EMP-42) — ou gravado pelo encerramento, com o instante do ato. Instante e não data, para que revogar valha na requisição seguinte e não à meia-noite. Linha encerrada não reabre: reconceder é linha nova. |
| origin | enum | gerado | Grant · CompanyCreation. Persistido como texto. CompanyCreation é a linha que POST /companies grava para quem criou a empresa (RN-EMP-20). |
| grantedByUserId | Guid | gerado | Quem concedeu, do token. Em CompanyCreation, quem criou a empresa. |
| onGrantorChange | enum? | gerado | Cascade · Restrict, persistido como texto. O que acontece com esta linha quando a vigência de quem a concedeu diminui (RN-EMP-42): Cascade quando a linha de quem concedeu, na mesma empresa, tinha validTo no instante da concessão — a linha é cortada junto —; Restrict quando não tinha — o ato exige decisão sobre ela. Gravado na concessão porque não se deriva depois: encerrar uma linha sobrescreve o validTo dela. Nulo nas linhas sem quem conceda dentro do produto: CompanyCreation e as do suporte da plataforma. Imutável; a linha reaberta por corte conserva o valor. |
| endedByUserId · endedAt | Guid? · instante? | gerado | Quem encerrou a linha por ato, e quando. No encerramento de sistema pela remoção da pessoa da organização, endedAt é preenchido e endedByUserId fica nulo (RN-EMP-24). Linha que vence pelo validTo informado na concessão não os preenche: nada aconteceu. |
A linha não se apaga nem se reescreve — só validTo, endedByUserId e endedAt são preenchidos, uma vez, no encerramento — e por isso ela é o próprio registro do ato. Unicidade por vigência sem sobreposição: para o mesmo (tenantId, userId, companyId) não existem duas linhas cujos intervalos [validFrom, validTo) se cruzem, a mesma mecânica do CostCenterAssignment (RN-EMP-22). É requisito, não recurso de banco: uma restrição de exclusão por faixa é exemplo de implementação; o equivalente na aplicação é serializar a escrita por (tenantId, userId) e conferir a sobreposição sob essa serialização. Sem a vigência na unicidade, reconceder a empresa A depois de uma linha encerrada exigiria apagar o histórico.
Como o conjunto é resolvido
Tabela de decisão. Cada linha é um caso de teste: o estado do tenant e as linhas do usuário determinam o conjunto, e o conjunto determina a resposta. GET /me/company-scope devolve o conjunto com a razão — reason, persistida como texto: Granted · NoGrant · Expired · Onboarding.
| Estado do tenant | Linhas do usuário | Conjunto resolvido | Resultado e erro |
|---|---|---|---|
| Sem empresa cadastrada (onboarding) | Qualquer | Vazio | Só o cadastro da primeira empresa, para quem tem create:companies e update:company-scopes. Rota de documento responde 404 ESTABLISHMENT_NOT_FOUND. reason = Onboarding |
| Uma empresa ou N empresas | Nenhuma linha | Vazio — o número de empresas não muda nada | Não opera documento nem dado de empresa. Listagem de agregado por empresa devolve página vazia (200); leitura por id responde 404 COMPANY_NOT_FOUND ou 404 ESTABLISHMENT_NOT_FOUND; escrita contra estabelecimento, 404 ESTABLISHMENT_NOT_FOUND. reason = NoGrant, e a interface diz que falta concessão. Cadastro do tenant, que não tem empresa — item, fornecedor, unidade —, segue só a permissão |
| Uma empresa ou N empresas | Só linhas encerradas ou vencidas | Vazio | Igual à linha anterior, com reason = Expired |
| Uma empresa ou N empresas | Linhas vigentes em A e B | {A, B} | Lê tudo de A e B, empresa inativa incluída; documento em andamento conclui em qualquer uma; documento novo só nas ativas — em inativa, 422 COMPANY_INACTIVE_UNPROCESSABLE (RN-EMP-05). Qualquer outra empresa responde como inexistente, 404. reason = Granted |
| Qualquer | Linhas de antes da remoção da pessoa da organização no GrydAuth | Não se aplica; vazio se readmitida | Fora da organização o token não traz o tenant, e a remoção encerra as linhas. A readmissão não as revive: linha com validFrom anterior ao início do vínculo vigente não é lida, reason = Expired até concessão nova. Não sustenta a invariante (RN-EMP-24) |
| Qualquer | A resolução não rodou ou falhou — sem contexto de tenant, leitura indisponível | Nenhum | A requisição falha com 500. Nunca segue com conjunto mais amplo nem com o de outra requisição (RN-EMP-25) |
| Qualquer | Execução fora de requisição (job, consumidor) | Não se aplica | Nenhum job executa comando em nome de usuário; o job lê pelo tenant do próprio payload e não usa escopo de pessoa |
Por que ausência de linha é nada, mesmo com uma empresa só. O padrão permissivo tinha um defeito que nenhuma tela compensa: negar tudo e liberar tudo viravam a mesma operação. Esvaziar o escopo de alguém — PUT com lista vazia, ou a última linha vencida — dava a essa pessoa todas as empresas do tenant. E "permissivo enquanto houver uma empresa" não resolve: a segunda empresa chega por um cadastro comum, e a regra muda de sentido para todo mundo no mesmo instante.
O custo do caminho fechado é operacional, e está pago em três lugares. Quem cria a empresa já a alcança (RN-EMP-20). O convite de usuário feito pelo Nexio pede as empresas antes de enviar. E a tela de criação de empresa lista quem alcançava todas as empresas até então — responsáveis de nó corporativo e aprovadores de reserva incluídos — para concessão explícita, pessoa por pessoa (RN-EMP-27). Nada é concedido sozinho.
Estabelecimento padrão — na preferência da plataforma
A pré-seleção do cabeçalho do documento é preferência de interface, e vive na UserPreference da plataforma — chave nexio.default-establishment-id, valor o Guid em texto. O Nexio não cria tabela nenhuma para isto: lê a linha da UserPreference em processo, pelo repositório do módulo (o GrydAuth está embutido no host), e a tela escreve por PUT /auth/me/preferences/{key}, rota do próprio host.
Preferência de tela é conceito da plataforma. "Preferências do usuário" é conceito da plataforma, e criar uma tabela homônima do lado do produto convidaria duas fontes para a mesma pergunta — por isso mesmo o lugar é a tabela da plataforma. Ela existe, conferida no código: UserPreference : TenantScopedEntity, par chave-valor único por (userId, tenantId, key), com controlador, rota em lote e as chaves conhecidas como constantes abertas — theme, language, sidebarCollapsed —, não enum. Acrescentar chave é a prática corrente da plataforma, não uma exceção.
O motivo que decidiu não foi conveniência, foi correção. localStorage é do navegador e não conhece tenant: quem opera em dois tenants — contador, BPO, o próprio time — teria um padrão só, apontando para um estabelecimento que no outro tenant responde 404. A UserPreference é escopada por (usuário, tenant), que é exatamente o grão do problema. E ela acompanha quem troca de máquina.
O que o Nexio paga por isso, e como. A UserPreference não tem esquema nem validação de domínio — o valor é texto livre — e não cascateia: desativar o estabelecimento, ou revogar o escopo da pessoa, não apaga a linha. Logo quem lê valida: se o Guid não aponta mais para um estabelecimento ativo que aquela pessoa alcança, a tela ignora a preferência e cai no comportamento sem padrão. O localStorage precisaria exatamente da mesma guarda, então não é custo novo — é o mesmo custo, num lugar que funciona.
A chave é do Nexio e está declarada aqui, com o prefixo do produto e em minúsculas, porque a plataforma normaliza a chave para lowercase na gravação — defaultEstablishmentId viraria defaultestablishmentid em silêncio. É a mesma disciplina do perfil de arquivo no GrydFiles: o produto declara o que usa do espaço da plataforma, em vez de usar e não contar. E a porta de saída continua aberta, com outro gatilho: se um dia a preferência precisar de esquema, integridade referencial ou consulta por valor, nasce UserDefaultEstablishment no Nexio — não porque a plataforma não tenha onde guardar, mas porque o dado terá deixado de ser preferência de tela.
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 no Centro de Custo. |
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 | obrigatória | Uma linha por empresa alcançada, com vigência. Ausência de linha = nenhuma empresa. |
GLAccountCompany · Budget | obrigatória | São, por definição, o recorte por empresa. |
CostAllocation — a fatia de rateio | do documento | Sem coluna de empresa: a fatia usa sempre a empresa do documento, e é contra ela que a invariante do centro de custo e a escolha do Budget são lidas. Não existe rateio entre empresas na v1. Ver Compra centralizada. |
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 guarda de move e version int + expectedVersion como token de concorrência de toda escrita — o mesmo token de Item, Fornecedor e Centro de Custo. 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. |
| version | int | gerado | Token de concorrência de toda escrita, como no Centro de Custo: expectedVersion no PUT, no PATCH /parent e na habilitação por empresa; divergência é 409 GL_ACCOUNT_CONCURRENT_CHANGE_CONFLICT. path fica como guarda de move. |
| 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. O ponto de aplicação é um só: a escolha da conta na fatia de rateio, nos três degraus da cascata, no lookup e de novo na submissão — sempre contra a empresa do documento. |
| 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. Resolução do código enviado ao ERP (RN-EMP-39): este campo, se preenchido; senão GLAccount.externalCode; sem nenhum dos dois, o movimento não sai e fica visível como aguardando código externo — nunca enviado sem código. |
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 — e vazio significa o usuário escolhe: a fatia aceita conta nula só em rascunho, e a submissão do documento a exige (422 ALLOCATION_GL_ACCOUNT_REQUIRED_UNPROCESSABLE, RN-CC-29 do Centro de Custo). 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.
E a cascata é filtrada pela empresa. Nos três degraus, conta com GLAccountCompany.isEnabled = false para aquela empresa não é sugerida; escolhida à mão, é recusada com 422 GL_ACCOUNT_NOT_ENABLED_FOR_COMPANY_UNPROCESSABLE — na fatia e outra vez na submissão, pela mesma razão de a conta obrigatória ser conferida duas vezes. O GET /gl-accounts/lookup exige companyId na query: lookup que não sabe a empresa devolve conta que o documento vai recusar, e devolver opção inválida é pior do que não devolver nada. A empresa lida é a do documento — a fatia não tem empresa própria; ver Compra centralizada.
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
Duas peças. NumberSequence é a configuração — uma só vigente por tipo de documento no tenant; NumberSequenceCounter é o contador de cada partição que o escopo cria — um no tenant, um por empresa ou um por estabelecimento. Trocar a forma de numerar é substituir a configuração, nunca editá-la (RN-EMP-28 a RN-EMP-32).
| Campo | Tipo | Regra | |
|---|---|---|---|
| documentType | enum | obrig | Requisition · QuotationRequest · PurchaseOrder · Receipt · BudgetChangeRequest. Cresce com a espinha transacional. O BudgetChangeRequest está na lista porque o Orçamento numera o pedido de verba por aqui, com escopo Company — sequência que uma spec usa e a outra não declara é numeração que ninguém consegue configurar na tela. Contract continua fora, e de propósito: nenhuma página o numera. O escopo de numeração da QuotationRequest é fixado pela spec da Cotação, junto com a consolidação multiempresa. |
| scope | enum | obrig | Tenant · Company · Establishment, persistido como texto. Padrão Company. Diz como os contadores se particionam. Imutável. |
| prefix | string(10) | obrig | Texto fixo no início do número — PC- —, de 1 a 10 caracteres. Imutável: prefixo novo é configuração nova, e não pode ser começo de prefixo anterior do mesmo documentType nem começar com um (RN-EMP-29). Obrigatório porque o prefixo vazio é começo de qualquer outro: a configuração sem prefixo nunca poderia trocar de prefixo. |
| padding | int | opc | Dígitos mínimos do contador, com zeros à esquerda, de 1 a 21 — senão 422 NUMBER_SEQUENCE_PADDING_UNPROCESSABLE. Padrão 6. O teto é o que cabe no número: varchar(60) menos prefixo 10, código do estabelecimento 20, período 7 e dois hífens (39). O único campo editável: vale do próximo número em diante, e não cria colisão porque o contador continua. |
| resetPolicy | enum | opc | Never · Yearly · Monthly, persistido como texto. Padrão Yearly, o hábito do mercado. Imutável. |
| timeZone | string(64) | obrig | Fuso IANA em que o período é calculado. Semente America/Sao_Paulo. Imutável. Sem ele o período sairia em UTC, e a virada do ano aconteceria às 21h de 31/12 em Brasília. |
| effectiveFrom | instante (UTC) | obrig | A partir de quando a configuração emite. Padrão: o instante da criação. Futuro só na substituição por período novo (RN-EMP-29); passado é 422. |
| retiredAt | instante (UTC)? | gerado | Gravado pela substituição com o effectiveFrom da sucessora. A configuração vigente num instante é a de effectiveFrom ≤ instante < retiredAt; o estado — agendada, vigente, aposentada — é derivado, não coluna. |
| replacesSequenceId | Guid? | cond | Obrigatório quando já existe configuração vigente ou agendada para o documentType: aponta para a mais recente delas. FK para a própria tabela. |
| version | int | gerado | Token de concorrência. A substituição manda o expectedVersion da substituída. |
| Campo | Tipo | Regra | |
|---|---|---|---|
| sequenceId | Guid | obrig | A configuração dona. Contador não passa de uma configuração para outra: a sucessora começa os seus. |
| companyId | Guid? | cond | Preenchido em Company e Establishment; nulo em Tenant. FK composta à empresa do tenant. |
| establishmentId | Guid? | cond | Preenchido só em Establishment. FK ao estabelecimento. Nenhuma das duas chaves usa valor sentinela. |
| partitionKey | varchar(40) | gerado | tenant, ou o companyId, ou o establishmentId, em texto — nunca nulo. Unicidade em (sequenceId, partitionKey): é ela que faz duas emissões simultâneas da mesma partição caírem no mesmo contador, sem depender de como o banco compara nulos. |
| currentPeriodKey | varchar(7) | gerado | O período do último número emitido pela partição: 2026 em Yearly, 2026-03 em Monthly, vazio em Never. Só avança (RN-EMP-31). |
| nextValue | bigint | gerado | O próximo valor do contador na partição. |
A emissão é uma operação atômica sobre o contador. Calcula o período pelo relógio do servidor no timeZone da configuração; se ele for maior que currentPeriodKey, grava o período e usa 1; senão, mantém o período guardado e usa nextValue; incrementa e devolve período e valor — tudo numa operação só, nunca leitura seguida de escrita na aplicação. Exemplo de implementação: um UPDATE … RETURNING com a condição dentro; equivalente: trava da linha do contador, leitura e escrita numa transação curta. A emissão roda em transação própria, confirmada antes da transação do documento: a trava do contador não fica presa enquanto o documento grava, e o número consumido continua consumido se o documento falhar — é a lacuna permitida (RN-EMP-32). O contador nasce na primeira emissão da partição, por inserção protegida pela unicidade; quem perde a corrida relê.
Como o número é composto
prefix + código do estabelecimento e hífen, só em Establishment + período e hífen, só com resetPolicy diferente de Never + contador com padding. PC-2026-000001 em Company ou Tenant anual; PC-SP01-2026-03-000001 em Establishment mensal; PC-000001 em Never. O número cabe em varchar(60) porque padding vai até 21. O código do estabelecimento está no número porque o número emitido é único por empresa, em qualquer escopo (RN-EMP-32): sem ele, PC-2026-000001 sairia igual em duas filiais da mesma empresa. Número emitido é imutável e nunca é renumerado.
Exemplo 1 · troca de escopo
Empresa ALFA, com os estabelecimentos SP01 e RJ01, e a empresa BETA. Configuração S1 de PurchaseOrder: Company, PC-, Yearly, America/Sao_Paulo. Em 15/11/2026 o último pedido da ALFA é PC-2026-000150. O grupo decide numerar todos os pedidos numa série só.
| Quando | Ato | Resultado |
|---|---|---|
| 15/11/2026 10:00 | S2: Tenant, PC-, Yearly, mesmo fuso, início imediato, substituindo S1 | Recusado — 422 NUMBER_SEQUENCE_REPLACEMENT_UNPROCESSABLE. Mesmo prefixo sem período novo: o contador único do tenant começaria em 1 e emitiria PC-2026-000001 de novo na ALFA |
| 15/11/2026 10:02 | S2: Tenant, prefixo PC-2026-, Never, início imediato, substituindo S1 | Recusado — 422 NUMBER_SEQUENCE_PREFIX_OVERLAP_UNPROCESSABLE. O prefixo é novo, mas começa com PC-, de S1: sem período no número, o contador emitiria PC-2026-000001, que S1 já deu à ALFA |
| 15/11/2026 10:05 | S2: Tenant, prefixo PG-, início imediato, substituindo S1 com o expectedVersion dela | Aceito. S1 recebe retiredAt = 15/11/2026 10:05. O próximo pedido da ALFA é PG-2026-000001; o seguinte, da BETA, PG-2026-000002 |
| 15/11/2026 10:05 — alternativa | S2: Tenant, PC-, Yearly, America/Sao_Paulo, effectiveFrom = 01/01/2027 00:00 em São Paulo, substituindo S1 | Aceito, agendado. S1 recebe retiredAt = aquele instante e continua emitindo até lá (PC-2026-000151…); a primeira emissão de 2027 já é de S2: PC-2027-000001, sem colisão — nenhuma configuração de PurchaseOrder com PC- e Yearly emitiu 2027 (RN-EMP-29) |
| 20/11/2026 | Nova substituição apontando para S1, com S2 agendada | 409 NUMBER_SEQUENCE_CONCURRENT_CHANGE_CONFLICT: a substituição aponta sempre para a configuração mais recente — S2 |
Exemplo 2 · virada de período no fuso
Configuração Company, PC-, Yearly, America/Sao_Paulo. Contador da ALFA com currentPeriodKey = 2026 e o último número PC-2026-000151.
| Relógio do servidor (UTC) | Em São Paulo | Período calculado | Número emitido |
|---|---|---|---|
| 01/01/2027 02:30:00 | 31/12/2026 23:30:00 | 2026 — igual ao guardado | PC-2026-000152. Calculado em UTC, o período já seria 2027 e o contador zeraria três horas antes |
| 01/01/2027 03:00:05 | 01/01/2027 00:00:05 | 2027 — maior que o guardado | PC-2027-000001. O período avançou: grava 2027 e zera |
| 01/01/2027 02:59:58, num servidor com o relógio atrasado, emitindo logo depois | 31/12/2026 23:59:58 | 2026 — menor que o guardado | PC-2027-000002. Não zera nem volta: usa o período guardado |
| 02/01/2027 12:00:00 | 02/01/2027 09:00:00 | 2027 | PC-2027-000003 para um pedido com data de 20/12/2026. A data do documento não escolhe o período; a emissão, sim |
O número pode ter buracos, e isso não é defeito. Transação abortada consome o número e não o devolve; colisão recusada também. 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. O número do Nexio não é fiscal.
Operação
Compra centralizada
Compra centralizada é rotina em grupo brasileiro, e chega em dois arranjos que o produto precisa distinguir. No primeiro a matriz compra e recebe — e aí a diferença entre suportar e não suportar são dois campos criados agora, contra uma mudança de chave em agregado já povoado, depois. No segundo a nota tem que cair na filial e quem opera é a central de compras — e isso não é dimensão do documento, é escopo de usuário. Os dois estão escritos abaixo, e nenhum dos dois pede campo além dos três da tabela.
| Campo do documento | O que responde | Regra |
|---|---|---|
establishmentId | Quem recebe fiscalmente | Obrigatório, com allowsReceiving. Define a empresa do documento, a numeração e a política de aprovação. Quem opera a compra é usuário, não campo: sai de UserCompanyScope, e o lookup devolve só o que aquele usuário pode usar. |
requestingEstablishmentId | Quem pediu | Obrigatório, nunca nulo: igual ao establishmentId quando quem pede é quem recebe — a interface preenche assim. Da mesma empresa do establishmentId, com allowsPurchasing. É o que faz o pedido aparecer para quem solicitou. |
companyId | Empresa do documento | gerado — derivada de establishmentId e congelada. Documento não muda de dono porque o cadastro mudou. |
Caso 1 · A matriz compra e recebe; a filial da mesma empresa pediu
establishmentId da matriz, requestingEstablishmentId da filial. A nota chega no CNPJ da matriz e o pedido aparece para quem solicitou. É o caso que os dois campos existem para registrar.
Matriz e filial são a mesma empresa, e por isso este arranjo não pede nada da fatia de rateio: a fatia usa a empresa do documento, e a invariante do centro de custo — linha da empresa A só aponta para nó corporativo ou nó de A — é lida contra ela. Quem escreve a mecânica do rateio é o Centro de Custo.
Caso 2 · A nota cai na filial; a central de compras opera
establishmentId da filial, requestingEstablishmentId igual a ele — quem pede e quem recebe são o mesmo estabelecimento. O comprador lotado na matriz age por UserCompanyScope: como tem escopo nas duas empresas, o GET /establishments/lookup já lhe devolve o estabelecimento da filial. Nenhum campo novo, e nenhum é necessário — centralizar compra é arranjo de trabalho, não dimensão do documento.
A política de aprovação segue quem recebe
Nos dois casos o fluxo é o da empresa do establishmentId do documento, pelo critério Empresa em lista — nunca o da empresa de quem operou a compra. É coerente com o resto: orçamento, conta contábil e alçada raciocinam em empresa, e a empresa que responde pelo gasto é a que recebe a nota.
Quem pede e quem recebe são da mesma empresa
requestingEstablishmentId de outra empresa é recusado com 422 REQUESTING_ESTABLISHMENT_COMPANY_MISMATCH_UNPROCESSABLE. Não existe rateio entre empresas na v1: a fatia usa sempre a empresa do documento, e a coluna de empresa na fatia não existe. Como demanda de várias empresas se consolida é decisão da spec da Cotação, que tem de respeitar a RN-EMP-26 — nenhum comando recebe conjunto de empresas — e a RN-EMP-34.
A transferência contábil fica fora
Cada documento é de uma empresa só, e o Nexio não cria acerto entre empresas. O que o grupo fizer por fora — nota de transferência, rateio intercompany, lançamento manual — é ERP e continua sendo ERP. Criar rateio entre empresas depois é coluna nova na fatia, preenchida a partir do documento.
Não existe buyerEstablishmentId. O documento tem três eixos e só três: quem recebe (establishmentId), quem pediu (requestingEstablishmentId) e a empresa derivada (companyId). Quem opera é usuário, resolvido por escopo — e por isso não aparece no esquema. Um quarto eixo pelo lado do comprador é o erro que esta seção existe para evitar: depois do agregado povoado, é mudança de chave.
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
Os campos de IBS e CBS entraram no leiaute do documento fiscal eletrônico, 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 o módulo de nota e o three-way match nascem tendo que saber lê-los. Ausência não é erro: durante 2026 as notas chegam com IBS/CBS incompletos ou ausentes e ainda assim autorizadas, porque o Ato Técnico Conjunto RFB/CGIBS nº 1/2026 adiou as validações, não a obrigação. A regra do produto é a que o Cadastro de Item já escrevia, e é ela que vale nas três páginas que tocam no assunto: os campos existem no leiaute, o preenchimento só é exigido a partir da data que a norma fixar, e o parser não trata ausência como erro.
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 | 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 — é a do Centro de Custo, só com o nome novo. Rename de coluna sem dado para migrar. O nó aponta para a empresa, não para o estabelecimento: apontar para o estabelecimento multiplicaria a árvore por CNPJ, que é o que o nó corporativo existe para evitar. |
| Fluxo de Aprovação | 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 Supplemental 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 | feito | Item.defaultGlAccountId e CategoryNode.defaultGlAccountId, ambos opcionais. Dão ao item o eixo contábil. Nenhuma mudança de dimensionalidade: item continua sem empresa. |
| Fornecedor | 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.Escopo por papel × empresa ficou fora, sem coluna reservada. Ele exigiria o Nexio consumir e cachear o catálogo role → permission do GrydAuth, porque o SessionDto entrega permissions já achatado no tenant. O caso que o justificaria — aprovar numa empresa e requisitar em outra — é resolvido por CostCenterAssignment, que é por nó, e o nó tem empresa, mais escopo na empresa do nó (RN-CC-30). Uma coluna reservada que o sistema recusa preencher não economiza migração nenhuma e contradiz "a tabela só estreita": se o escopo por papel vier, nasce como coluna nova, com a regra dela. |
| Unidade de Medida | nenhuma | Escopo global ou de tenant, como está. |
| Poda do webapp | 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. |
Implementado: a coluna “feito” da tabela acima registra que a spec do outro domínio foi revisada. No código, esses contextos (Approval, Budget, Catalog, Procurement, Reference, Sourcing) ainda são esqueletos; CostCenter e DeliveryLocation não existem. O que a Empresa entrega a eles são as portas publicadas da CO-19, em Nexio.Organization.Contracts (namespace Documents, mais INumberIssuer em Numbering), registradas nos quatro hosts por AddOrganization(): a regra mora aqui, uma vez, e o consumidor não a reescreve. As portas partem do estabelecimento e usam a mesma leitura filtrada pelo escopo da pessoa: estabelecimento inexistente, de outro tenant ou fora do conjunto responde igual, ESTABLISHMENT_NOT_FOUND; num fluxo sem escopo a porta falha fechada com CompanyScopeUnavailableException, e um fluxo tenant-wide (job) não alcança empresa nenhuma. Os resultados vêm em OrganizationOutcome (isSuccess, errorCode, message já na cultura da requisição, value). Nenhuma assinatura expõe tipo do Core, e nenhuma porta publica evento.
Ordem das recusas de IEstablishmentEligibility (a primeira que falha vence): no eixo de recebimento, COMPANY_INACTIVE → ESTABLISHMENT_INACTIVE → COMPANY_CLOSED (documento com data posterior ao closedOn; no próprio dia ainda vale) → ESTABLISHMENT_RECEIVING_NOT_ALLOWED → ESTABLISHMENT_REGISTRATION_STATUS; depois REQUESTING_ESTABLISHMENT_COMPANY_MISMATCH; no eixo de quem pede, ESTABLISHMENT_INACTIVE → ESTABLISHMENT_PURCHASING_NOT_ALLOWED → ESTABLISHMENT_REGISTRATION_STATUS (todos _UNPROCESSABLE). A empresa vem antes do estabelecimento de propósito: a cascata inativa as filiais, e quem vê empresa inativa recebe COMPANY_INACTIVE. A recusa por situação cadastral (Suspended, Unfit, ClosedDown; Null e Active não bloqueiam) é a mesma Specification que o lookup usa para ocultar.
Emissão e pool. O emissor usa conexão e transação próprias, confirmadas antes da transação do documento (a lacuna é permitida): cada requisição que emite ocupa duas conexões do pool. A unicidade (tenantId, documentType, companyId, number) é do índice do documento; DocumentNumberViolation traduz a violação desse índice para DOCUMENT_NUMBER_ALREADY_EXISTS, e o consumidor informa o nome do índice dele. Quem chama as portas ao criar ou submeter documento é a Requisição (RN-EMP-05, 12, 13, 33, 34, 36, 37 e 39 estão provadas até a porta); RN-EMP-06, 09, 10 e 11 são regras do Centro de Custo, da Requisição e do Fluxo de Aprovação, sem teste nomeado aqui.
| Porta | Método | Devolve | Recusas |
|---|---|---|---|
ICompanyResolver | CompanyOfAsync(establishmentId) | OrganizationOutcome<Guid>: a empresa derivada no servidor (RN-EMP-08). Empresa ou estabelecimento inativo ainda resolvem | ESTABLISHMENT_NOT_FOUND |
IEstablishmentEligibility | CheckAsync(establishmentId, requestingEstablishmentId, documentDate) | EligibleDocumentEstablishments (companyId, establishmentId, requestingEstablishmentId). requestingEstablishmentId nunca é nulo: quando quem pede é quem recebe, é igual a establishmentId | ESTABLISHMENT_NOT_FOUND e as da ordem abaixo |
IFiscalSnapshotProvider | SnapshotAsync(establishmentId) | FiscalSnapshot (taxRegime, icmsTaxpayerStatus, stateRegistration, suframaCode) lido na hora (RN-EMP-37). Congelar no documento é ato da Requisição | ESTABLISHMENT_NOT_FOUND |
IGLAccountEligibility | CheckAsync(glAccountId, companyId) e CheckAtSubmissionAsync(glAccountId?, companyId) | OrganizationOutcome (RN-EMP-12 e RN-EMP-13); conta nula na submissão é recusada, rascunho não chama | GL_ACCOUNT_NOT_FOUND, GL_ACCOUNT_POSTING_NOT_ALLOWED_UNPROCESSABLE, COMPANY_NOT_FOUND, GL_ACCOUNT_NOT_ENABLED_FOR_COMPANY_UNPROCESSABLE, ALLOCATION_GL_ACCOUNT_REQUIRED_UNPROCESSABLE |
IGLAccountExternalCodeResolver | ResolveAsync(glAccountId, companyId) | ExternalCodeResolutionDto (code, source, accountCode, companyCode), source ∈ Company, Account, Missing. Missing é sucesso com code nulo (“aguardando código externo”) (RN-EMP-39) | GL_ACCOUNT_NOT_FOUND, COMPANY_NOT_FOUND |
INumberIssuer | IssueAsync(documentType, establishmentId) | IssuedNumber (number, sequenceId, periodKey, value) (RN-EMP-30, 31, 32). Falha como NexioDomainException | ESTABLISHMENT_NOT_FOUND, NUMBER_SEQUENCE_NOT_CONFIGURED_UNPROCESSABLE |
Camada API
Endpoints
Prefixo api/v{version}/nexio. Todas exigem autenticação e declaram exatamente uma permissão; em POST /companies o caso de uso confere também update:company-scopes (RN-EMP-20). 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. Exige também update:company-scopes, conferida antes de gravar — sem ela, 403 COMPANY_CREATION_SCOPE_PERMISSION_FORBIDDEN (RN-EMP-20). O corpo traz a matriz inline — headquarters com o CNPJ completo e os campos do estabelecimento — e empresa e Establishment Headquarters nascem na mesma transação, junto com a linha de escopo de quem criou (origin = CompanyCreation, RN-EMP-20). Sem headquarters é 400. É também a tela do onboarding de um tenant novo | 400 · 403 · 409 · 422 |
| PUT /companies/{id} | update:companies | CompanyDetailDto | 400 · 404 · 409 |
| PATCH /companies/{id}/activate | update:companies | CompanyDetailDto | 404 · 409 |
| PATCH /companies/{id}/deactivate | update:companies | CompanyDetailDto. Cascateia: desativa os estabelecimentos ativos da empresa na mesma transação, um evento por estabelecimento, e devolve a lista do que caiu junto. Documento em andamento conclui (RN-EMP-05) | 404 · 409 |
| PATCH /companies/{id}/headquarters | update:companies | CompanyDetailDto. Corpo { establishmentId, expectedVersion }: troca a matriz num ato só (RN-EMP-38) | 404 · 409 · 422 |
| Rota | Permissão | Retorno | Erros |
|---|---|---|---|
| GET /companies/{id}/establishments | read:companies | array puro | 404 |
| GET /establishments/lookup | read:companies | agrupado por empresa. purpose obrigatório na query: Receiving · Requesting | 400 |
| GET /establishments/{id} | read:companies | EstablishmentDetailDto | 404 |
| POST /companies/{id}/establishments | create:companies | EstablishmentDetailDto. Nasce Branch; a situação cadastral é consultada na criação | 400 · 404 · 409 · 422 |
| PUT /establishments/{id} | update:companies | EstablishmentDetailDto. Alterar município, UF ou allowsReceiving revalida todos os pontos de entrega deste estabelecimento na mesma transação — a UF é recalculada só nos que herdam o endereço. O corpo aceita deliveryLocationInterstateNotes[], com deliveryLocationId e interstateNote, para os pontos que passam a ser interestaduais. A edição que deixaria algum ponto interestadual sem explicação, ou ponto ativo num estabelecimento que deixa de receber, é recusada com 422 ESTABLISHMENT_ADDRESS_CHANGE_BREAKS_DELIVERY_LOCATIONS_UNPROCESSABLE e a lista nomeada. Exige expectedVersion. Ver Local de Entrega | 400 · 404 · 409 · 422 |
| PATCH /establishments/{id}/activate | update:companies | EstablishmentDetailDto | 404 · 409 |
| PATCH /establishments/{id}/deactivate | update:companies | EstablishmentDetailDto | 404 · 409 |
| PATCH /establishments/{id}/registration-status | update:companies | EstablishmentDetailDto. Correção manual da situação cadastral: { status, statusDate, evidenceNote, expectedVersion } (RN-EMP-36) | 404 · 409 · 422 |
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 pelo propósito do campo — purpose=Receiving, para o establishmentId, filtra allowsReceiving; purpose=Requesting, para o requestingEstablishmentId, filtra allowsPurchasing —, com defaultEstablishmentId marcado. Sem purpose é 400 ESTABLISHMENT_LOOKUP_PURPOSE_REQUIRED: um lookup que escolhe o filtro sozinho é o defeito que esta regra fecha. 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 | companyId obrigatório na query. Devolve só as contas com allowsPosting, vigentes e habilitadas naquela empresa — linha ausente em GLAccountCompany ou isEnabled = true | 400 · 404 |
| 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 | read:number-sequences | array puro — vigentes, agendadas e aposentadas, com o estado derivado | 400 |
| POST /number-sequences | create:number-sequences | NumberSequenceDto. Cria a configuração. Havendo vigente ou agendada para o documentType, exige replacesSequenceId e expectedVersion e aposenta a substituída na mesma transação (RN-EMP-28, RN-EMP-29) | 400 · 409 · 422 |
| PUT /number-sequences/{id} | update:number-sequences | NumberSequenceDto. Só padding, com expectedVersion. Os demais campos são a identidade da configuração — mudar é substituir | 404 · 409 · 422 |
| Rota | Permissão | Retorno | Erros |
|---|---|---|---|
| GET /users/{userId}/company-scopes | read:company-scopes | UserCompanyScopeSetDto: scopeHash e as linhas, vigentes e encerradas, só das empresas que quem lê alcança | 400 · 404 |
| PUT /users/{userId}/company-scopes | update:company-scopes | UserCompanyScopeSetDto. Corpo { expectedScopeHash, companies: [{ companyId, validTo? }], dependentGrantDecisions?: [{ scopeId, decision }] }: substitui o conjunto vigente dentro do escopo de quem concede; decision = Keep · End · Trim, uma por linha Restrict que o ato alcança (RN-EMP-42) | 400 · 404 · 409 · 422 |
| GET /me/company-scope | autenticado | o conjunto resolvido do usuário atual, com reason | — |
PUT substitui o conjunto, não faz merge — e só dentro do escopo de quem concede. O servidor compara o corpo com as linhas vigentes daquela pessoa nas empresas que quem concede alcança: empresa que está no corpo e não tem linha vigente ganha linha nova; linha vigente cuja empresa saiu do corpo é encerrada no instante do ato; empresa que ficou com outro validTo tem a linha encerrada e reaberta no mesmo instante; o resto não é tocado. Linhas de empresas que quem concede não alcança não entram na comparação, não aparecem na resposta e não mudam (RN-EMP-19). Lista vazia encerra todo o acesso que quem concede alcança (RN-EMP-23). É a semântica da tela de Papéis do gryd — "salvar substitui" —, e é o que torna a tela uma lista de checkboxes honesta.
Cinco guardas antes de gravar. userId igual ao de quem concede é 403 USER_COMPANY_SCOPE_SELF_CHANGE_FORBIDDEN, com ou sem mudança no corpo (RN-EMP-41). expectedScopeHash tem de ser o hash das linhas vigentes que o GET devolveu a quem concede, conferido sob as travas da pessoa e de cada empresa tocada (RN-EMP-23) — senão 409 USER_COMPANY_SCOPE_CONCURRENT_CHANGE_CONFLICT, e nada é gravado. Linha aberta para outra pessoa não passa do validTo da linha de quem concede na mesma empresa — senão 422 USER_COMPANY_SCOPE_BEYOND_GRANTOR_UNPROCESSABLE (RN-EMP-42). Encerrar ou encurtar a linha de quem concedeu corta, na mesma transação e em cadeia, as linhas Cascade que ela concedeu, e exige dependentGrantDecisions cobrindo exatamente as Restrict que o novo fim alcança, cada linha uma vez — senão 409 USER_COMPANY_SCOPE_DEPENDENT_GRANTS_CONFLICT, com a lista (RN-EMP-42). E, sobre o resultado do ato inteiro, cortes incluídos: empresa — ativa ou inativa — que tinha linha vigente sem validTo de pessoa ativa com update:company-scopes continua tendo; nenhuma fica sem linha vigente de ninguém; e a que já não tinha não perde a última linha sem fim, de quem quer que seja que continue na organização — senão 409 USER_COMPANY_SCOPE_LAST_GRANT_CONFLICT, listando as empresas (RN-EMP-24).
Implementado: as 29 rotas desta seção existem, com a mesma permissão por action, e estão conferidas uma a uma, com corpo, retorno e erros, em Contrato para o frontend. O texto acima fica; o que o código acrescenta ou muda:
GET /companies,GET /gl-accountseGET /gl-accounts/{id}/childrenrespondemPagedResult<T>(items,totalCount,pageNumber,pageSize,totalPages,hasNext,hasPrevious,scope). Os lookups, a lista de estabelecimentos da empresa eGET /number-sequencessão array puro.includeDeleted=trueé400 LIST_INCLUDE_DELETED_UNSUPPORTED.GET /number-sequencesnunca responde 400.POST /number-sequencesresponde também404 NUMBER_SEQUENCE_NOT_FOUND(replacesSequenceIdinexistente).PUT /gl-accounts/{id}/companies/{companyId}responde também 409 por versão vencida, e a versão é a da conta.GET /users/{userId}/company-scopesnunca responde 404: pessoa sem linha recebe 200 com conjunto vazio.PATCH /companies/{id}/activateePUT /users/{userId}/company-scopesexigem só a permissão da própria rota: o diretório do GrydAuth embutido é lido em processo, eread:usersdeixou de ser pedida do chamador (DEC-CO-33-3).GET /establishments/lookupoculta também as situações cadastrais que bloqueiam documento novo.POST /companies/{companyId}/establishmentsnão levaexpectedVersion, mas sobe a versão da empresa: dois POST simultâneos podem dar409 COMPANY_CONCURRENT_CHANGE_CONFLICT, que se resolve repetindo.PUT /companies/{id}ignoracode,cnpjRootefunctionalCurrency.PUT /establishments/{id}aceitadeliveryLocationInterstateNotes[]e chama a cadeia de revalidação dos pontos de entrega na mesma transação, mas o registro da cadeia está vazio: a edição nunca é recusada por esse motivo até o contexto de pontos de entrega registrar o seu tratador.- Rotas que não existem:
GET /gl-accounts/{id}, ativar e desativar conta contábil, leitura das habilitações por empresa,GET /number-sequences/{id}. A emissão de número não é rota: é a portaINumberIssuer.
Contrato
Contrato de erros
O status HTTP é derivado do sufixo do código, nunca da mensagem: o mapeador do Core reconhece dez sufixos — _NOT_FOUND → 404 · _ALREADY_EXISTS, _ALREADY_ASSIGNED e _CONFLICT → 409 · _FORBIDDEN, _ACCESS_DENIED e _BLOCKED → 403 · _UNAUTHORIZED → 401 · _UNPROCESSABLE → 422 · _UNAVAILABLE → 503 · sem sufixo → 400. Código canônico exato vence o sufixo. Conferido no código da plataforma em 10/09/2026 (ErrorCodeSuffixes, commit e0eab4e5).
| 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. Vale também para empresa, no corpo do PUT de escopo, fora do escopo de quem concede |
| COMPANY_CREATION_SCOPE_PERMISSION_FORBIDDEN | 403 | POST /companies de quem tem create:companies mas não update:company-scopes (RN-EMP-20) |
| 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_CNPJ_ROOT_INVALID | 400 | Raiz que, normalizada, não tem 8 caracteres em 0–9A–Z |
| COMPANY_CONCURRENT_CHANGE_CONFLICT | 409 | expectedVersion divergente na empresa |
| COMPANY_CLOSED_UNPROCESSABLE | 422 | Documento novo com data posterior ao closedOn da empresa |
| COMPANY_INACTIVE_UNPROCESSABLE | 422 | Criação ou submissão de documento novo em estabelecimento de empresa inativa. Documento já submetido não recebe este código: conclui. Leitura nunca o devolve |
| ESTABLISHMENT_INACTIVE_UNPROCESSABLE | 422 | Criação ou submissão de documento novo em estabelecimento inativo de empresa ativa. Mesma regra: documento já submetido conclui |
| ESTABLISHMENT_RECEIVING_NOT_ALLOWED_UNPROCESSABLE | 422 | Estabelecimento com allowsReceiving = false como establishmentId de documento novo |
| ESTABLISHMENT_PURCHASING_NOT_ALLOWED_UNPROCESSABLE | 422 | Estabelecimento com allowsPurchasing = false como requestingEstablishmentId de documento novo |
| REQUESTING_ESTABLISHMENT_COMPANY_MISMATCH_UNPROCESSABLE | 422 | requestingEstablishmentId de outra empresa que não a do establishmentId. Sem rateio nem pedido entre empresas na v1 |
| COMPANY_FUNCTIONAL_CURRENCY_UNPROCESSABLE | 422 | functionalCurrency diferente de BRL na criação da empresa |
| 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_CNPJ_INVALID | 400 | CNPJ que, normalizado, não tem 14 caracteres no formato, ou com DV inválido — também no headquarters inline do POST /companies |
| ESTABLISHMENT_CONCURRENT_CHANGE_CONFLICT | 409 | expectedVersion divergente no estabelecimento |
| ESTABLISHMENT_LOOKUP_PURPOSE_REQUIRED | 400 | GET /establishments/lookup sem purpose |
| ESTABLISHMENT_ADDRESS_CHANGE_BREAKS_DELIVERY_LOCATIONS_UNPROCESSABLE | 422 | PUT /establishments/{id} que muda município, UF ou allowsReceiving e deixaria algum ponto de entrega pendurado no estabelecimento — com ou sem endereço próprio — interestadual sem explicação (sem nota em deliveryLocationInterstateNotes[] no mesmo PUT, fora de canteiro e sem recebedor distinto; ou canteiro sem centro de custo), ou ativo e apontando para estabelecimento que deixou de receber. A mensagem lista os pontos pelo código e pelo nome (RN-DL-19, RN-EMP-17) |
| ESTABLISHMENT_REGISTRATION_STATUS_UNPROCESSABLE | 422 | Documento novo com estabelecimento — em qualquer dos dois eixos — em situação Suspended, Unfit ou ClosedDown. A mensagem carrega a situação e a data dela |
| ESTABLISHMENT_HEADQUARTERS_REQUIRED_UNPROCESSABLE | 422 | Guarda do agregado: operação que deixaria a empresa sem matriz. A troca de matriz é só pelo PATCH /companies/{id}/headquarters |
| ESTABLISHMENT_LAST_ACTIVE_CONFLICT | 409 | Desativação do último estabelecimento ativo de uma empresa ativa, pela rota do estabelecimento. Não se aplica à cascata de /companies/{id}/deactivate, que desativa as duas coisas na mesma transação |
| ESTABLISHMENT_STATE_REGISTRATION_REQUIRED_UNPROCESSABLE | 422 | icmsTaxpayerStatus = Taxpayer sem inscrição estadual |
| ESTABLISHMENT_STATE_REGISTRATION_NOT_ALLOWED_UNPROCESSABLE | 422 | icmsTaxpayerStatus = Exempt com 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_NOT_ENABLED_FOR_COMPANY_UNPROCESSABLE | 422 | Fatia de rateio apontando para conta desabilitada em GLAccountCompany na empresa do documento. Verificado na fatia e na submissão; a mensagem carrega o código da conta e o da empresa |
| GL_ACCOUNT_TYPE_MISMATCH_UNPROCESSABLE | 422 | accountType divergente do pai |
| GL_ACCOUNT_CONCURRENT_CHANGE_CONFLICT | 409 | expectedVersion divergente — ou, num move, o path mudou desde a leitura |
| USER_COMPANY_SCOPE_NOT_FOUND | 404 | Linha inexistente, ou de um tenant que não é o do token |
| USER_COMPANY_SCOPE_DUPLICATE_CONFLICT | 409 | A mesma empresa duas vezes no corpo, ou vigência que se sobreporia a outra linha da mesma pessoa e empresa |
| USER_COMPANY_SCOPE_CONCURRENT_CHANGE_CONFLICT | 409 | expectedScopeHash diferente do hash das linhas vigentes que quem concede alcança. Nada é gravado; a tela recarrega e mostra o conjunto atual |
| USER_COMPANY_SCOPE_LAST_GRANT_CONFLICT | 409 | O ato deixaria uma empresa, ativa ou inativa, sem linha vigente sem validTo de pessoa ativa que tem update:company-scopes — no PUT de escopo, quando ela a tinha antes, inclusive pelo corte em cadeia; no PATCH /companies/{id}/activate, quando ela não a tem —, sem nenhuma linha vigente, de ninguém, ou, já sem a invariante, sem a última linha sem fim, de quem quer que seja que continue na organização. A mensagem lista as empresas. Conceda antes linha sem fim a outra pessoa com a permissão (RN-EMP-24) |
| USER_COMPANY_SCOPE_VALIDITY_UNPROCESSABLE | 422 | validTo que não é posterior ao instante da concessão |
| USER_COMPANY_SCOPE_BEYOND_GRANTOR_UNPROCESSABLE | 422 | Linha concedida sem validTo, ou com validTo posterior, na empresa em que a linha de quem concede tem fim (RN-EMP-42). A mensagem nomeia a empresa |
| USER_COMPANY_SCOPE_DEPENDENT_GRANTS_CONFLICT | 409 | O PUT encerra ou encurta linha de quem concedeu linhas Restrict que o novo fim alcança, em cadeia, e dependentGrantDecisions falta ou não cobre exatamente essas linhas. Nada é gravado; a resposta lista cada linha (pessoa, empresa, validTo) para a decisão Keep, End ou Trim; linha já alcançada pelo ato não entra de novo (RN-EMP-42) |
| USER_COMPANY_SCOPE_SELF_CHANGE_FORBIDDEN | 403 | PUT /users/{userId}/company-scopes com o userId de quem concede |
| COMPANY_SCOPE_SUPPORT_GRANT_BENEFICIARY_UNPROCESSABLE | 422 | Ato de suporte da plataforma: beneficiário sem identidade verificada, ou com o CPF de quem pede o ato havendo outra pessoa ativa com update:company-scopes — a conta é por CPF verificado, não por usuário. Conferido no pedido e de novo no fim da carência (RN-EMP-24). Regra da demanda de plataforma; esta página cita |
| USER_NOT_IN_TENANT_UNPROCESSABLE | 422 | O userId não tem vínculo ativo com esta organização no GrydAuth. Verificado só na escrita. O PUT de escopo com lista vazia é aceito sobre quem saiu da organização: é o ato que trata a remoção (RN-EMP-24) |
| NUMBER_SEQUENCE_REPLACEMENT_UNPROCESSABLE | 422 | Substituição sem prefixo novo nem período novo — inclusive período que não passa do maior já emitido por configuração com o mesmo prefixo e a mesma resetPolicy (RN-EMP-29); effectiveFrom no passado; ou effectiveFrom futuro que não cai no início de um período posterior, no fuso e com a resetPolicy da substituída |
| NUMBER_SEQUENCE_PREFIX_OVERLAP_UNPROCESSABLE | 422 | Prefixo diferente do de uma configuração já criada para o documentType que começa com ele ou é começo dele (RN-EMP-29). A mensagem nomeia a configuração anterior |
| NUMBER_SEQUENCE_PADDING_UNPROCESSABLE | 422 | padding fora de 1 a 21, na criação ou no PUT |
| NUMBER_SEQUENCE_TIME_ZONE_UNPROCESSABLE | 422 | timeZone que não é identificador IANA conhecido |
| NUMBER_SEQUENCE_CONCURRENT_CHANGE_CONFLICT | 409 | expectedVersion divergente, ou replacesSequenceId que não é a configuração mais recente do documentType |
| NUMBER_SEQUENCE_ALREADY_EXISTS | 409 | Já existe configuração vigente ou agendada para o documentType, e o corpo não traz replacesSequenceId |
| DOCUMENT_NUMBER_ALREADY_EXISTS | 409 | O número composto já foi emitido na empresa para o mesmo tipo de documento — a guarda final da RN-EMP-32. Nada é gravado, o número consumido não volta, e repetir o comando emite o próximo |
| COMPANY_EXTERNAL_CODE_ALREADY_EXISTS | 409 | Implementado. externalCode de empresa repetido no tenant, inclusive de linha apagada logicamente (DEC-CO-07-B) |
| ESTABLISHMENT_EXTERNAL_CODE_ALREADY_EXISTS | 409 | Implementado. externalCode de estabelecimento repetido (DEC-CO-07-B) |
| ALLOCATION_GL_ACCOUNT_REQUIRED_UNPROCESSABLE | 422 | Implementado. Fatia de rateio sem conta na submissão (RN-EMP-13); rascunho não chama. Sai só pela porta IGLAccountEligibility.CheckAtSubmissionAsync (DEC-CO-19-3) |
| GL_ACCOUNT_PARENT_CYCLE_UNPROCESSABLE | 422 | Implementado. Mover a conta para baixo de si mesma ou de um descendente (só ocorre no move; DEC-CO-08-1) |
| GL_ACCOUNT_SIBLING_NAME_ALREADY_EXISTS | 409 | Implementado. Nome já usado entre irmãos (comparação exata, depois de Trim), na criação, na revisão e no move (DEC-CO-08-1) |
| LIST_INCLUDE_DELETED_UNSUPPORTED | 400 | Implementado. includeDeleted=true em qualquer listagem: a exclusão lógica é global e não há como listar apagados |
| NUMBER_SEQUENCE_NOT_FOUND | 404 | Implementado. Configuração inexistente ou de outro tenant: replacesSequenceId do POST ou id do PUT (DEC-CO-09-1, DEC-CO-14-1) |
| NUMBER_SEQUENCE_NOT_CONFIGURED_UNPROCESSABLE | 422 | Implementado. Emissão de número sem configuração vigente para o tipo de documento. Sai só pela porta INumberIssuer (DEC-CO-09-1) |
Implementado: o catálogo OrganizationErrorCodes tem 62 códigos: os 49 da tabela original, 8 pedidos por itens, acrescentados acima e marcados, e os 5 da consulta de CNPJ, CEP e município (REGISTRY_CNPJ_INVALID, REGISTRY_ZIP_CODE_INVALID, REGISTRY_CNPJ_NOT_FOUND, REGISTRY_ZIP_CODE_NOT_FOUND, REGISTRY_LOOKUP_UNAVAILABLE). USER_DIRECTORY_UNAVAILABLE (503) existiu até a v1.20 e não existe mais: o diretório é lido em processo (DEC-EMB-6), e uma falha de leitura da identidade é erro de plataforma, 500. O status sai do sufixo (e o catálogo nunca digita o sufixo como texto, só a constante); a mensagem existe em en-US, pt-BR e es-ES e vem na cultura da requisição. Fora do catálogo ficam os erros da plataforma: VALIDATION_ERROR (400, com os erros por campo, inclusive errors.sortBy para ordenação fora do mapa), 401, 403 de permissão e tenant, e 500 de falha fechada. Em toda resposta de erro o ProblemDetails traz code e traceId.
Quem produz. Um código não tem produtor, de propósito: COMPANY_SCOPE_SUPPORT_GRANT_BENEFICIARY_UNPROCESSABLE (ato de suporte da plataforma, fora da entrega). Os códigos de elegibilidade de documento — COMPANY_CLOSED, COMPANY_INACTIVE, ESTABLISHMENT_RECEIVING_NOT_ALLOWED, ESTABLISHMENT_PURCHASING_NOT_ALLOWED, REQUESTING_ESTABLISHMENT_COMPANY_MISMATCH, ESTABLISHMENT_REGISTRATION_STATUS e GL_ACCOUNT_NOT_ENABLED_FOR_COMPANY (todos _UNPROCESSABLE) — e DOCUMENT_NUMBER_ALREADY_EXISTS são devolvidos pelas portas publicadas, não por rota HTTP; a rota de troca de matriz devolve ESTABLISHMENT_INACTIVE_UNPROCESSABLE por si. O código de pontos de entrega existe e é produzido pela cadeia, mas com o registro vazio nunca dispara.
Os erros que listam itens (USER_COMPANY_SCOPE_LAST_GRANT_CONFLICT, USER_COMPANY_SCOPE_DEPENDENT_GRANTS_CONFLICT, ESTABLISHMENT_ADDRESS_CHANGE_BREAKS_DELIVERY_LOCATIONS_UNPROCESSABLE) trazem a lista no texto da mensagem. O código por rota está em Contrato para o frontend.
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. Criar empresa exige também update:company-scopes (RN-EMP-20): o papel que cria empresa tem as duas |
| 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 |
| update:company-scopes | Definir quais empresas cada pessoa alcança — só entre as empresas que quem concede alcança | Administrativa e sensível: é o que separa as empresas do grupo umas das outras. Todo uso é ato de controle, com evento e registro (RN-EMP-21) |
| read:number-sequences, create:number-sequences e update:number-sequences | Criar e configurar numeração | Administrativa. Mudar a numeração com documento emitido é permitido por substituição — a anterior é aposentada, a nova exige prefixo ou período novo, e nada é renumerado |
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.
Implementado: são exatamente estas permissões (NexioPermissions = arquivo de importação docs/operations/nexio-permissions.json = o que as actions exigem). O Nexio não escreve as linhas de permissão: o GrydAuth embutido não tem registrador de catálogo do host (o catálogo dele é estático e a Permission é por tenant). O passo operacional é importar o arquivo em cada tenant pela rota do próprio host, POST /api/v1/permissions/import?mode=Catalog (ou /permissions/import/file), que exige create:permissions, depois de todo deploy que mude o catálogo.
- As rotas de estabelecimento usam as permissões de empresa (
read:companies,create:companies,update:companies); as de plano de contas, as três degl-accounts. - numeração tem uma permissão por verbo:
read:number-sequencesabre a leitura,create:number-sequencesoPOSTeupdate:number-sequencesoPUT. Nenhuma implica outra. GET /me/company-scopenão exige permissão — só autenticação.- Uma exigência fora do catálogo que o operador precisa conceder:
update:company-scopesno token de quem cria empresa, além decreate:companies(senão403 COMPANY_CREATION_SCOPE_PERMISSION_FORBIDDENe nada é gravado).read:usersnão é mais exigida de quem ativa empresa ou escreve escopo: o diretório é lido em processo. As permissões implícitas do GrydAuth (por exemploread:usersimplicandoread:user-email) não são expandidas pelo Nexio.
Conformidade
Estado da implementação
O backend de Empresa, Estabelecimento, Plano de contas, Numeração e Escopo por empresa está entregue e conferido contra o código; o que falta depende da plataforma, de outra especificação ou de um provedor externo, e está dito linha a linha abaixo. O contexto é o Nexio.Organization (src/contexts/Nexio.Organization.*; controllers em src/hosts/Nexio.Api/Controllers/Organization; persistência em src/shared/Nexio.Persistence), na branch de integração feature/organization-embedded-auth, sobre o Gryd.IO 10.0.0, com o GrydAuth embutido: o Nexio.Api é o emissor de identidade (emite e valida os próprios tokens, com banco próprio nexio-identity), e o Worker, o Portal e o Migrator leem a identidade em processo — o Nexio não consome a autenticação de outro serviço (DEC-EMB-0). A tabela regra → teste está em docs/definitions/development/company/conformidade.md: 901 métodos Rn_emp_*, os 846 da conferência de 4a5b0be mais os que a CO-19 (guardas), a CO-21 (envelope das listagens e outbox) e os lotes 8 e 9 (identidade embutida, CO-30 a CO-35) acrescentaram; a suíte da solução está verde na entrega do PR (o HEAD conferido e os totais ficam no corpo do PR); o contrato HTTP exato está em api-empresa.md, na mesma pasta. Não há tela: o backend sai antes do frontend, e o que a tela consome está em Contrato para o frontend. Os outros contextos do repositório (Approval, Budget, Catalog, Procurement, Reference e Sourcing) ainda são esqueletos: CostCenter, DeliveryLocation, Requisition e os demais documentos não existem.
| Item | No código | Situação | O que bloqueia |
|---|---|---|---|
| Autenticação e identidade (DEC-EMB-0) | O Nexio.Api é o emissor: AddGrydAuth e AddGrydAuthApiCookieSecurity, todas as rotas do GrydAuth.API no host (DEC-EMB-3), UseGrydAuth (CORS único em GrydAuth:Cors:AllowedOrigins, limite de taxa e sessão de tenant) e a guarda do X-Tenant-ID logo depois. Banco próprio nexio-identity (ConnectionStrings:DefaultPostgresConnection, DEC-EMB-2), migrado pelo Migrator junto dos outros três. Worker e Portal leem a identidade em processo (AddNexioIdentityReaders), sem validar token e sem JwtSettings. O modelo do NexioDbContext não ganha entidade do Auth, e a visibilidade de linhas sem tenant segue CurrentTenantOnly (nenhuma entidade do módulo é opcional por tenant; há teste no host composto) | Conforme | Operacional: o admin semeado do módulo (admin@local.com) existe em todo ambiente; trocar a senha ou desativá-lo e criar o administrador real é pré-requisito do corte em produção (runbook em docs/operations/backend.md). Chaves, segredos e SMTP por ambiente: docs/operations/configuracao-identidade.md |
| Entidades e migrations | Company, Establishment (a matriz é uma linha de establishments), GLAccount, GLAccountCompany, NumberSequence, NumberSequenceCounter, UserCompanyScope e a tabela de referência municipalities (5.571 linhas do IBGE, semeadas pela migration), mais as três tabelas do outbox (outbox, outbox_payload, outbox_delivery_state): 11 tabelas no esquema nexio, em três migrations — Organization_Lote1_ReferenciaMunicipios (23/10), Organization_Lote2_Agregados (24/10) e Organization_Lote6_Outbox (25/10). CostCenter e DeliveryLocation não existem (specs próprias). Nenhuma migration toca tabela da plataforma (RN-EMP-16, com teste de arquitetura) | Conforme | —. Operacional: o usuário do Migrator precisa poder criar a extensão btree_gist |
| Campos e derivados | cnpjRoot por CHECK (cnpj_root = left(cnpj, 8)) mais chave estrangeira composta (tenant_id, company_id, cnpj_root); headquartersId não nulo; establishmentCount; type e UF derivados (a UF sai sempre do código IBGE do município, nunca é digitada); version int como token de concorrência em empresa, estabelecimento, conta e configuração de numeração. code, cnpjRoot, cnpj e externalCode ficam reservados para sempre, mesmo com a linha apagada logicamente (D-12) | Conforme (o CHECK é acréscimo; a reserva perpétua é decisão do mantenedor) | — |
| Matriz única (RN-EMP-02) | Restrição de exclusão adiada (ex_establishments_single_headquarters, DEFERRABLE INITIALLY DEFERRED), no lugar do índice único filtrado: a troca de matriz atualiza duas linhas | Conforme — a spec chama o índice de exemplo de implementação (DEC-CO-07-2, ratificada em DEC-CO-07-C) | — |
| Permissões | Nove permissões em NexioPermissions = arquivo de importação nexio-permissions.json = o que as actions exigem (PermissionCatalogTests). O Nexio não escreve as linhas de permissão: o GrydAuth não tem registrador de catálogo do host (o catálogo dele é estático e a Permission é por tenant), então vale o arquivo de importação (DEC-CO-03-1, mantida pela DEC-EMB-9) | Conforme; passo operacional por tenant | Importar nexio-permissions.json em cada tenant pela rota do próprio host (POST /api/v1/permissions/import?mode=Catalog, que exige create:permissions), depois de todo deploy que mude o catálogo |
| Rotas | 32 rotas em sete controllers (api/v1/nexio/...), uma permissão por action; GET /me/company-scope não exige permissão. Tabela completa em Contrato para o frontend | Conforme à tabela de endpoints, com as divergências decididas listadas abaixo | — |
| Envelope das listagens | PagedResult<T> — items, totalCount, pageNumber, pageSize, totalPages, hasNext, hasPrevious, scope (sempre nulo) — desde a CO-21 e o Gryd.IO 10.0.0 (DEC-V10-A). sortBy fora do mapa responde 400 VALIDATION_ERROR com errors.sortBy; o desempate por Id é do próprio QuerySortMap. A resposta não ecoa sortBy nem sortDirection | Conforme (antes da CO-21 era { data, meta }; DEC-CO-13-A fechada) | — |
| Contrato de erros | 62 códigos em OrganizationErrorCodes (49 da spec, 8 pedidos por itens e 5 da consulta de cadastro; USER_DIRECTORY_UNAVAILABLE saiu, DEC-EMB-6), status pelo sufixo, mensagem em en-US, pt-BR e es-ES pela cultura da requisição. Os códigos de elegibilidade de documento, de numeração na emissão e ALLOCATION_GL_ACCOUNT_REQUIRED_UNPROCESSABLE só saem pelas portas publicadas, não por rota HTTP | Conforme; um código sem produtor, de propósito: COMPANY_SCOPE_SUPPORT_GRANT_BENEFICIARY_UNPROCESSABLE (ato de suporte da plataforma, fora da entrega) | — |
| Escopo por empresa — resolução | Tabela de decisão com as sete linhas, falha fechada (500, nunca um conjunto mais amplo), reason, filtro global por ICompanyScoped/IEstablishmentScoped com registro de entidades que quebra o build (teste de arquitetura), escrita que resolve exatamente uma empresa, “escopo completo” calculado. A leitura é feita a cada requisição pelas linhas de UserCompanyScope; o token não traz empresa | Conforme, inclusive a linha 5 da tabela de resolução (linha anterior ao início do vínculo vigente): o início do vínculo é a criação da linha UserTenant não apagada no GrydAuth embutido, lida em processo; readmitir cria linha nova, e renovar o período da mesma linha não move o início (DEC-CO-33-1). Sem vínculo, o início é desconhecido e só a linha 5 deixa de se aplicar | — |
| Escrita de escopo | PUT que substitui, cinco guardas na ordem, travas pg_advisory_xact_lock por pessoa e depois por empresa (ordem crescente), lock_timeout de 10 s, hash conferido sob as travas, cascata Cascade/Restrict em cadeia, ataque da RN-EMP-23 provado com duas conexões sobre o ato real. Não exige read:users: o diretório do GrydAuth é lido em processo, sem o bearer do chamador (DEC-CO-33-3) | Conforme | — |
| Invariante do administrador permanente (RN-EMP-24) | Uma só Specification (PermanentAdministratorSpecification), reaproveitada pela rota de escopo, pela ativação de empresa, pela rotina diária e pelo encerramento; teste de arquitetura impede reescrevê-la em outro lugar. Vale para empresa ativa e inativa | Conforme | — |
| Rotina diária e cadeia de avisos | Job por tenant organization.company-administrator-watch (06:00 UTC), cadeia de três degraus — quem tem linha vigente e update:company-scopes na empresa → administradores do tenant (membros elegíveis com o papel Admin do GrydAuth; GroupAdmin não conta) → canal de suporte, que só recebe o aviso —, lida em processo no GrydAuth embutido, com o tenant da rodada e sem credencial; OperationId determinístico por aviso, destaque “terminam em até N dias” (7 por padrão), lista das Restrict pendentes, modelos do GrydNotifications copiados por tenant em três culturas (NoticeTemplateCatalog.Version 2) | Conforme — cada degrau é provado contra o Postgres real sobre o Worker de produção, e a rotina não falha quando o degrau 1 está vazio e há administrador no degrau 2. Falha de leitura da identidade interrompe a rotina daquele tenant (nada é avisado naquele dia) e ela repete no dia seguinte (DEC-CO-34-3) | Operacional: SMTP configurado no Worker (sem GrydNotifications:Email:SmtpHost o envio cai no NoOpEmailSender, que descarta, e o aviso não chega a ninguém) e Platform:SupportChannel:Address preenchido (vazio, o degrau 3 falha alto). O módulo não tem a cultura do destinatário: Organization:Notices:Culture, padrão pt-BR |
| Remoção da organização (RN-EMP-24) | Três caminhos sobre regras comuns: (1) PUT /users/{userId}/company-scopes com lista vazia, ato de quem concede (cause = Act); (2) EndScopeOfRemovedUserCommand, ato de sistema acionado pela remoção do vínculo no GrydAuth embutido: o módulo publica UserRemovedFromTenantEvent antes do commit do Auth, o ouvinte só anota, o commit libera o sinal e um consumidor em segundo plano (só no Api; fila em memória, até três repetições) encerra o escopo no tenant do evento — exceção do Nexio nunca desfaz a remoção no Auth; (3) varredura organization.scope.removed-users-sweep, uma vez ao dia (03:30 UTC), ato de sistema, que é a rede para o sinal perdido (DEC-EMB-5). Os caminhos 2 e 3 deixam endedByUserId nulo e cause = UserRemovedFromTenant | Conforme — remover o usuário no GrydAuthDbContext real encerra as linhas, a linha concedida na janela e o administrador fantasma da readmissão são provados com o adaptador real, e rollback no Auth não libera nada (DEC-CO-35-1, 35-3). Limite assumido: a fila é de 256 sinais e a parada do host com sinais na fila os deixa para a varredura | —. O módulo não oferece gancho pós-commit; o DbTransactionInterceptor do Nexio faz essa ponte (pedido à plataforma) |
| Numeração | Configuração imutável, substituição por Specification (NumberSequenceReplacement), contador por partição criado na primeira emissão (UPDATE ... RETURNING mais INSERT ... ON CONFLICT DO NOTHING), emissão atômica em conexão e transação próprias, lacuna permitida, exemplos 1 e 2 da spec como teste. INumberIssuer está registrado nos quatro hosts, com o adaptador de IEstablishmentNumberingSource; DocumentNumberViolation traduz a violação do índice do documento para DOCUMENT_NUMBER_ALREADY_EXISTS | Conforme. Cada requisição que emite ocupa duas conexões do pool | —. O índice único (tenantId, documentType, companyId, number) é do documento, que ainda não existe (Requisição) |
| Plano de contas | Árvore por caminho materializado, tipo herdado do pai, allowsPosting só em folha, cascata assimétrica, move com guarda de path, habilitação por empresa, ordem do código externo (RN-EMP-39). A escolha de conta à mão (GLAccountChoice) é exposta como IGLAccountEligibility | Conforme à spec; três rotas que a tela vai querer não foram pedidas — ver Lacunas | — |
| Situação cadastral (RN-EMP-36) | Correção manual (PATCH /establishments/{id}/registration-status), consulta na criação, varredura organization.establishment-registration-sweep (04:30 UTC, do mais desatualizado ao teto da janela), vale a data de situação mais recente, e a Specification RegistrationAllowsDocuments, que o lookup e a elegibilidade compartilham | Parcial — sem provedor concreto de consulta ao CNPJ: UnavailableCnpjRegistryProvider devolve Null (não conclusivo), então a varredura só renova registration_checked_at e a situação só muda por correção manual (D-10) | Provedor concreto de consulta ao CNPJ (fora desta entrega) |
| Município | IMunicipalityCatalog sobre a tabela municipalities; município IBGE desconhecido é VALIDATION_ERROR no campo cityIbgeCode (mensagem em inglês); a UF é derivada | Conforme | — |
| Pontos de entrega (RN-EMP-17) | Porta IEstablishmentAddressChangeHandler com registro vazio, chamada na mesma transação do PUT /establishments/{id}; o corpo já aceita deliveryLocationInterstateNotes[] | Parcial — a porta não tem alvo: hoje a edição de município, UF ou allowsReceiving nunca é recusada por pontos de entrega, e ESTABLISHMENT_ADDRESS_CHANGE_BREAKS_DELIVERY_LOCATIONS_UNPROCESSABLE, embora produzido pela cadeia, nunca dispara com o registro vazio (D-08) | DeliveryLocation (spec própria) registrar o seu tratador |
| Eventos e outbox | Seis eventos de integração pela porta IIntegrationEventPublisher, adaptador TransactionalOutbox do Gryd.IO 10.0.0: o evento é enfileirado na mesma transação do fato (nexio.outbox) e a publicação é sempre antes do último SaveChanges do ato; rollback remove fato e evento. A guarda (tenant, aggregateId, sequence) vira 409 de alteração concorrente | Parcial — nenhum consumidor e nenhum worker: as linhas ficam Pending, nada é entregue. Pendência de comportamento: a documentação do Gryd.IO 10.0.0 não garante o que acontece com o backlog gravado antes de existir consumidor quando o worker for ligado; a pergunta ao time do Gryd.IO não tem resposta. Até lá, não ligar o worker sem consumidor e monitorar o crescimento de nexio.outbox (D-06) | Primeiro consumidor (entra em AddNexioOutboxDrain e o Worker ganha AddOutboxWorker) e a resposta do Gryd.IO sobre backlog, replay e descarte |
| Trilha de auditoria (RN-EMP-21) | A própria linha de UserCompanyScope (não se apaga nem se reescreve) os eventos granted/revoked e o registro no GrydAudit, todos na transação do ato. Nenhuma tabela de auditoria no Nexio (D-07) | Atendido — IAuditTrail.Stage, Gryd.IO 11.1.0; o registro fica legível depois da entrega pelo Worker | — |
| Portas para a espinha transacional (CO-19) | Em Nexio.Organization.Contracts: ICompanyResolver, IEstablishmentEligibility, IFiscalSnapshotProvider, IGLAccountEligibility (CheckAsync e CheckAtSubmissionAsync), IGLAccountExternalCodeResolver e INumberIssuer, registradas nos quatro hosts. Resultado em OrganizationOutcome; escopo de empresa aplicado (fora do conjunto responde ESTABLISHMENT_NOT_FOUND; sem escopo no fluxo, falha fechada com CompanyScopeUnavailableException). Nenhuma assinatura expõe tipo do Core (PublishedPortsTests) | Conforme — provadas até a porta, em unidade e contra o Postgres | Quem as chama: Requisição, Pedido, Recebimento e Nota (specs próprias). RN-EMP-06, 09, 10 e 11 seguem sem teste nomeado porque a regra é do Centro de Custo, da Requisição e do Fluxo de Aprovação |
| Escopo completo (RN-EMP-27) | FullScopeService.HasCompleteScopeAsync existe e é provado, mas não tem chamador: nenhuma rota lista as pessoas que alcançavam todas as empresas antes de uma criação | Parcial — cálculo pronto, sem superfície | Rota ou tela de criação de empresa que use o cálculo (a spec pede a lista na tela de criação) |
| Onboarding e migração | Tenant sem empresa registrada responde reason = Onboarding e conjunto vazio (uma empresa apagada logicamente não encerra o onboarding; DEC-CO-10-A); não há carga implícita de escopo | Conforme; a carga inicial de escopo é ato de suporte da plataforma, fora da API de negócio | — |
Situação: Conforme — o código faz o que a spec diz, com teste. Parcial — falta uma dependência fora do contexto (plataforma, provedor, outra spec); o que existe está provado. Divergente — o código faz diferente da letra da spec por decisão registrada; nenhuma linha acima está Divergente sem decisão, e as decisões estão na tabela seguinte.
| Onde | A spec dizia | O código faz | Decisão |
|---|---|---|---|
| RN-EMP-02 | índice único filtrado da matriz por empresa | restrição de exclusão adiada (DEFERRABLE INITIALLY DEFERRED) | a troca de matriz atualiza duas linhas; a spec chama o índice de “exemplo de implementação” (DEC-CO-07-2, DEC-CO-07-C) |
| RN-EMP-03 | chave estrangeira composta | a chave composta mais CHECK (cnpj_root = left(cnpj, 8)) | garante que a raiz derivada é a do próprio CNPJ (validação do lote 2) |
| RN-EMP-04 · cascata | reativar a empresa e as filiais | reativar a empresa não reativa filiais; empresa ativa pode ficar sem estabelecimento ativo até alguém reabrir uma | a spec: “quais filiais voltam é decisão de quem reativa” (DEC-CO-07-4, DEC-CO-07-C) |
| Cascata de desativação | “um evento por estabelecimento” (sem nome) | establishment.deactivated v1, um por estabelecimento que caiu, e também na desativação individual | decisão do mantenedor (DEC-CO-07-A) |
externalCode repetido | sem código | COMPANY_EXTERNAL_CODE_ALREADY_EXISTS e ESTABLISHMENT_EXTERNAL_CODE_ALREADY_EXISTS (409) | DEC-CO-07-B |
| Plano de contas | ciclo e nome repetido sem código | GL_ACCOUNT_PARENT_CYCLE_UNPROCESSABLE (422) e GL_ACCOUNT_SIBLING_NAME_ALREADY_EXISTS (409) | DEC-CO-08-1 |
| Numeração | sem código para “sem configuração vigente” | NUMBER_SEQUENCE_NOT_FOUND (404) e NUMBER_SEQUENCE_NOT_CONFIGURED_UNPROCESSABLE (422) | DEC-CO-09-1 |
POST /number-sequences | 400 · 409 · 422 | também 404 NUMBER_SEQUENCE_NOT_FOUND quando replacesSequenceId não existe | DEC-CO-14-1 |
GET /number-sequences | linha com 400 | nunca responde 400 (não há parâmetro) | DEC-CO-14-2 |
PUT /gl-accounts/{id}/companies/{companyId} | 404 · 422 | também 409 por versão vencida (a versão é a da conta) | DEC-CO-13-6 |
GET /users/{userId}/company-scopes | 400 · 404 | nunca 404: pessoa sem nenhuma linha devolve 200 com conjunto vazio | DEC-CO-15-7 |
GET /establishments/lookup | filtro por purpose | também oculta situação cadastral que bloqueia documento novo (Suspended, Unfit, ClosedDown) | decisão do mantenedor (DEC-CO-12-A) |
| Correção manual de situação cadastral | — | correção mais antiga que a gravada responde 200 sem mudança, sem evento; a nota do comprovante é exigida mas não tem coluna e não vai na carga do evento | DEC-CO-12-A (orquestrador); minimização de dados no outbox |
| Listagens | PagedResult<T> | PagedResult<T> | conforme desde a CO-21 (DEC-V10-A, Gryd.IO 10.0.0); DEC-CO-13-A fechada |
| RN-EMP-17 | cascata sobre pontos de entrega | porta IEstablishmentAddressChangeHandler com registro vazio | DeliveryLocation não existe (D-08) |
| RN-EMP-36 | provedor de consulta ao CNPJ | UnavailableCnpjRegistryProvider (devolve Null, não conclusivo) | D-10: provedor concreto fora desta entrega |
| RN-EMP-13 | código não listado na tabela de erros | ALLOCATION_GL_ACCOUNT_REQUIRED_UNPROCESSABLE (422) e IGLAccountEligibility.CheckAtSubmissionAsync | DEC-CO-19-3; catálogo de 57 códigos |
| RN-EMP-21 · evento de escopo | publica user-company-scope.revoked “depois do commit” | grava o evento no outbox na mesma transação do fato, antes do último SaveChanges; a entrega, quando houver worker, é que acontece depois do commit | D-06 (outbox transacional do Gryd.IO 10.0.0, CO-21) |
| Moeda | — | functionalCurrency só aceita BRL | RN-EMP-35 |
| RN-EMP-29 | — | a sucessora precisa começar depois do início da substituída; “início imediato” é effectiveFrom == now exato | consequência de retiredAt = effectiveFrom da sucessora (DEC-CO-09-4, DEC-CO-09-2) |
| Unicidade perpétua | — | code, cnpjRoot, cnpj e externalCode seguem únicos mesmo com a linha apagada logicamente | D-12 (mantenedor: “os quatro reservados”) |
| CNPJ de dígitos repetidos | — | CNPJ com os 14 caracteres iguais é recusado como inválido, mesmo que o dígito verificador bata | DEC-CO-05-4 (mantenedor) |
| Onboarding (RN-EMP-01) | — | qualquer empresa não apagada encerra o onboarding; empresa apagada logicamente não o encerra | DEC-CO-10-A (mantenedor) |
| Invariante do administrador | dois casos | só os dois casos da spec: a regra (iii) que a sessão acrescentara (encerrar a última linha sem fim) foi removida | DEC-CO-10-A (mantenedor) |
| Remoção da pessoa (guarda 5) | — | a guarda do administrador permanente não se aplica às linhas da pessoa removida; corte de linha de outro administrador ativo continua sob a guarda | DEC-CO-15-A (mantenedor) |
| Estabelecimento padrão | — | sem rota nova no Nexio para escrever a preferência nexio.default-establishment-id: a tela escreve na rota de preferências da plataforma | DEC-CO-12-A |
Riscos de produção que o frontend e a operação herdam. Os eventos são gravados e não entregues (o backlog de nexio.outbox cresce até haver consumidor). O admin semeado do GrydAuth (admin@local.com, senha de bootstrap pública no código do módulo) existe em todo ambiente: trocar a senha ou desativá-lo, criar o administrador real e conceder o AppId do Nexio é pré-requisito do corte em produção. O e-mail dos avisos só sai com SMTP configurado no Worker; sem ele, o envio cai no NoOpEmailSender e o aviso não chega a ninguém. O módulo não tem CPF verificado (HasVerifiedCpf segue nulo), nem cultura do destinatário. O cache de permissões do módulo atrasa a revogação, e, com mais de uma instância da API, ele precisa de Redis. Cada requisição que emite número usa duas conexões do pool. A lista completa, com a origem de cada risco, está em docs/operations/backend.md.
Integração
Contrato para o frontend
Esta seção é o contrato HTTP exato do que está implementado — conferido contra os sete controllers de src/hosts/Nexio.Api/Controllers/Organization, os contratos de Nexio.Organization.Contracts e os testes HTTP — e a orientação de tela que dele decorre. Onde ela difere do texto da spec acima, vale esta; as razões estão em Estado da implementação. A referência de desenvolvimento equivalente é docs/definitions/development/company/api-empresa.md.
Autenticação
O Nexio é o emissor de identidade. O Nexio.Api traz o GrydAuth embutido (DEC-EMB-0): o login, a renovação, o encerramento de sessão, o perfil e as preferências do usuário são rotas da própria API, e o token que ela emite é o que as 32 rotas de negócio validam. Não há mais um serviço de autoridade à parte. As rotas e os campos exatos das rotas de autenticação são os do GrydAuth 10.0.0 (docs/modules/auth do Gryd.IO); a tabela abaixo fixa o que muda para a tela.
| Tema | Contrato |
|---|---|
| URL base | A própria API, na mesma origem das rotas de negócio: autenticação em api/v1/auth/..., negócio em api/v1/nexio/.... Em desenvolvimento local, http://localhost:5000. Some a URL de identidade separada. |
| Aplicação | Issuer, Audience e AppId são novos e próprios do Nexio (JwtSettings), definidos pela operação em cada ambiente (em desenvolvimento: NexioDev, NexioDev.Clients e NEXIO_LOCAL). O login informa o AppId no cabeçalho X-App-Id (ou requestedAppId no corpo; o cabeçalho tem prioridade), e o usuário precisa estar autorizado para ele. |
| Rotas que a tela usa | POST /api/v1/auth/login (devolve o token do tenant quando o usuário tem um tenant só ou um padrão; com vários e nenhum padrão devolve um token global de 2 minutos e requiresTenantSelection); POST /auth/switch-tenant (escolha inicial e troca de tenant); POST /auth/refresh; POST /auth/logout (204, idempotente, encerra todas as sessões do usuário); GET /auth/me; GET|PUT|DELETE /auth/me/preferences/{key} (inclusive nexio.default-establishment-id); POST /auth/request-password-reset, /auth/reset-password, /auth/change-password, /auth/complete-first-login e GET /auth/password-policy; MFA em api/v1/mfa/.... Não há autocadastro: AllowUserRegistration é false. |
| Cookie de refresh | O refresh token não vem no corpo: vai no cookie __Secure-gryd.refresh (HttpOnly, Secure, SameSite=Strict, Path=/api/v1/auth), que o navegador só devolve às rotas de autenticação. A tela envia credentials: include (ou withCredentials) no login, no refresh e no logout. |
| CSRF | Cookie legível gryd.csrf; POST /auth/refresh exige o cabeçalho X-Gryd-Csrf com o mesmo valor (envio duplo). Sem o cookie de refresh, 401; cabeçalho ausente ou divergente, 403. A rotação é de uso único: o cookie é reescrito a cada refresh, e reusar o antigo, além da tolerância RefreshReuseGraceSeconds (30 s), é recusado. |
| Duração | Access token de 10 minutos (ExpirationMinutes, no máximo 15); refresh de 7 dias, ou 12 horas sem “manter conectado”. O token expirado responde 401 e a tela renova antes de refazer a chamada. |
| Token e claims | Mesmo formato de antes: Authorization: Bearer <access token>, claim tenant_id e as demais. O token continua sem empresa. X-Tenant-ID é aceito quando concorda com o claim; divergente, 403. |
| CORS | Uma só lista, GrydAuth:Cors:AllowedOrigins, para as rotas de autenticação e as de negócio, com credenciais. Origem fora da lista não recebe cabeçalho CORS. A lista é de cada ambiente: peça à operação a origem do frontend. |
| Tokens antigos | Tokens emitidos pela autoridade anterior (outro Issuer, Audience, AppId e chave) deixam de valer: 401, e a tela leva ao login. Data de corte: a definir pelo mantenedor. |
| Troca de senha pendente | Se o usuário precisa trocar a senha (primeiro acesso, ou troca exigida por um administrador: o login devolve MustChangePassword; a política de senha também liga a expiração, 90 dias com aviso aos 7, mas só o MustChangePassword gera a claim no módulo 10.0.0), o token emitido carrega a claim pwd_change=required e o módulo responde 403 PASSWORD_CHANGE_REQUIRED em toda rota que não esteja marcada para esse estado: as 29 rotas de negócio e quase todas as do módulo. Ficam livres POST /auth/complete-first-login, GET /auth/validate, GET /auth/session, GET /auth/me e as leituras de /auth/me/preferences, além das anônimas (refresh, logout, GET /auth/password-policy). Esse 403 não é falta de permissão: a tela de negócio o recebe, deve reconhecê-lo pelo code e levar à tela de troca de senha, e só depois repetir a chamada com o token novo, que não traz a claim. |
| Limite de taxa | O módulo limita as rotas sensíveis de autenticação (por IP e, em algumas, por conta) e responde 429 em application/problem+json com o cabeçalho Retry-After em segundos (60 quando o limitador não informa). A tela espera esse tempo antes de tentar de novo; não é erro de permissão nem de negócio. |
| Chaves públicas | GET /.well-known/jwks.json é rota pública, fora de api/v1, com as chaves que validam os tokens do host. A tela não precisa dela; quem valida o token fora da API (gateway, outro serviço) usa esta. |
| Demais rotas do módulo | O host serve todas as rotas do GrydAuth.API (DEC-EMB-3): usuários, papéis, permissões (inclusive a importação do catálogo), organizações (/tenants), segurança, favoritos de menu, diretório de usuários (GET /users/directory) e demais, cada uma sob as permissões do próprio GrydAuth. A federação não está configurada na v1: as rotas anônimas nunca respondem 5xx (start e exchange recusam; discover responde “sem federação”). |
Nota de migração para o frontend
O que o frontend troca: (1) a URL base de autenticação passa a ser a da API; (2) o login informa o novo AppId e a aplicação adota o novo Issuer e Audience quando valida o token do lado do cliente; (3) os cookies de refresh e de CSRF passam a ser da origem da API, e o credentials: include aponta para ela; (4) a origem do frontend entra em GrydAuth:Cors:AllowedOrigins; (5) todo usuário refaz o login no corte; (6) PATCH /companies/{id}/activate e PUT /users/{userId}/company-scopes não exigem mais read:users e o 503 USER_DIRECTORY_UNAVAILABLE não existe mais (catálogo de 57 códigos): a tela remove o tratamento especial dele, e uma falha de leitura da identidade é um 500 comum.
O que não muda: as 29 rotas de negócio e os caminhos api/v1/nexio/..., o envelope PagedResult<T>, o formato dos erros (ProblemDetails com code e traceId), as nove permissões de empresa e a regra de que o token não traz empresa. Datas de corte: a definir pelo mantenedor.
Convenções
| Tema | Contrato |
|---|---|
| Prefixo e versão | Toda rota é api/v1/nexio/... (versão 1). Os caminhos das tabelas abaixo omitem o prefixo. |
| Autenticação e tenant | Bearer emitido pelo próprio host (GrydAuth embutido, ver Autenticação), validado em cada chamada. O tenant vem do claim tenant_id; cabeçalho X-Tenant-ID ou query que nomeie outro tenant é recusado com 403, e o corpo nunca escolhe tenant. O token não traz empresa: a empresa em que se opera é sempre um dado que a tela lê (GET /me/company-scope) e envia como parâmetro de rota ou de corpo. Sem token, 401; sem a permissão da rota, 403 da plataforma. |
| Enums por nome | Enums do Nexio trafegam como texto em PascalCase ("icmsTaxpayerStatus": "Taxpayer"), na resposta e no corpo. Valores: TaxRegime (SimplesNacional, MEI, LucroPresumido, LucroReal, TaxExempt, NotApplicable; o último é recusado em empresa); RegistrationStatus (Null, Active, Suspended, Unfit, ClosedDown); IcmsTaxpayerStatus (Taxpayer, Exempt, NonTaxpayer); EstablishmentType (Headquarters, Branch); RegistrationSource (Provider, Manual); GLAccountType (Asset, Liability, Equity, Revenue, Expense); DocumentType (Requisition, QuotationRequest, PurchaseOrder, Receipt, BudgetChangeRequest); NumberSequenceScope (Tenant, Company, Establishment); ResetPolicy (Never, Yearly, Monthly); NumberSequenceState (Scheduled, Current, Retired); CompanyScopeOrigin (Grant, CompanyCreation); OnGrantorChange (Cascade, Restrict); DependentGrantDecision (Keep, End, Trim); CompanyScopeReason (Granted, NoGrant, Expired, Onboarding); EstablishmentPurpose (Receiving, Requesting). Valor fora do enum, ou corpo que não é JSON, é 400 da plataforma: só o status está provado por teste; a forma do corpo desse 400 não. |
| Datas e instantes | DateOnly em yyyy-MM-dd; instantes em ISO 8601 com offset (UTC por padrão). Em POST /number-sequences, effectiveFrom exige offset. |
| Concorrência otimista | Escrita em empresa, estabelecimento, conta contábil e configuração de numeração exige expectedVersion: o version que a tela leu. Ausente, 0 ou negativo → 400 por campo. Versão bem formada e vencida → 409 com o código *_CONCURRENT_CHANGE_CONFLICT da entidade (alguém gravou depois da leitura: recarregar e refazer). Atualização sem mudança real não grava e não sobe a versão. Cada ato devolve o objeto com a versão nova; a tela deve substituir o que tem. Na escrita de escopo o equivalente é o scopeHash; no move de conta, o expectedPath também. |
Erro (ProblemDetails) | Toda falha devolve ProblemDetails (RFC 7807) com code e traceId. O status vem do sufixo do código: _NOT_FOUND 404; _ALREADY_EXISTS e _CONFLICT 409; _FORBIDDEN 403; _UNPROCESSABLE 422; _UNAVAILABLE 503; sem sufixo 400. A mensagem vem na cultura da requisição (en-US, pt-BR, es-ES): a tela exibe o texto do servidor e decide o comportamento pelo code. Falha de forma do corpo é 400 VALIDATION_ERROR com os erros por campo (chave = nome do campo do corpo, como headquarters.cityIbgeCode). Não conte com dado estruturado nos erros de domínio: quando a mensagem lista itens (empresas sem administrador, linhas Restrict pendentes), a lista está no texto. |
| 404 em vez de 403 | Empresa ou estabelecimento fora do escopo da pessoa responde 404 (COMPANY_NOT_FOUND / ESTABLISHMENT_NOT_FOUND), nunca 403, igual ao inexistente. 403 é só permissão ausente, mais COMPANY_CREATION_SCOPE_PERMISSION_FORBIDDEN e USER_COMPANY_SCOPE_SELF_CHANGE_FORBIDDEN. O 403 do tenant trocado também é da plataforma. |
| Falha fechada | Se a resolução do escopo falha ou não há contexto de tenant, a requisição responde 500; nunca um conjunto mais amplo. A tela trata como erro genérico. |
Listagens, ordenação e lookups
| Parâmetro | Contrato |
|---|---|
| Envelope | PagedResult<T>: items, totalCount, pageNumber, pageSize, totalPages, hasNext, hasPrevious, scope. scope é sempre null. A resposta não ecoa sortBy nem sortDirection: a tela guarda o que enviou. |
page, pageSize | Página e tamanho (pageSize padrão 20, teto 100: acima do teto é reduzido a 100, não é erro). |
sortBy, sortDirection | sortBy fora do mapa da listagem → 400 VALIDATION_ERROR com errors.sortBy (texto na cultura da requisição). sortDirection aceita asc, desc e Ascending, Descending, sem distinguir caixa. Toda ordenação desempata por Id. |
search | Texto contido, sem distinção de caixa. Em empresas: código, razão social, nome fantasia e código externo; uma raiz (8 caracteres) ou um CNPJ inteiro (14), com ou sem máscara, casa pela raiz. Em contas: código e nome. |
| Filtros por rota | Empresas: isActive, taxRegime. Contas (e filhos): accountType, isActive, allowsPosting. |
includeDeleted | includeDeleted=true é 400 LIST_INCLUDE_DELETED_UNSUPPORTED (a exclusão lógica é global; não há como listar apagados). |
| Escopo | A listagem de empresas já vem recortada pelo escopo da pessoa; quem não tem linha vigente recebe 200 com items vazio. Empresas inativas aparecem, marcadas por isActive. |
| Listagem | Campos aceitos | Padrão |
|---|---|---|
GET /companies | code, legalName, tradeName, cnpjRoot, taxRegime, isActive, establishmentCount, createdAt | code |
GET /gl-accounts | code, name, path, depth, accountType, isActive, validFrom, validTo | path (ordem de árvore) |
GET /gl-accounts/{parentId}/children | os mesmos de /gl-accounts | code |
| Rota | Parâmetros | O que devolve e o que oculta |
|---|---|---|
GET /companies/lookup | search, isActive | Array puro, ordenado por nome fantasia (ou razão social) e código, sem corte e sem paginação. Recortado pelo escopo. |
GET /establishments/lookup | purpose obrigatório: Receiving (filtra allowsReceiving) ou Requesting (filtra allowsPurchasing) | Agrupado por empresa. Oferece só estabelecimento ativo, de empresa ativa, e cuja situação cadastral não bloqueia documento novo: Suspended, Unfit, ClosedDown ficam de fora; Null, Active entram. Inclui defaultEstablishmentId (abaixo). Sem purpose válido: 400 ESTABLISHMENT_LOOKUP_PURPOSE_REQUIRED. |
GET /gl-accounts/lookup | companyId obrigatório; search, onDate (padrão: hoje em America/Sao_Paulo) | Array puro em ordem de árvore. Só conta ativa, que aceita lançamento (allowsPosting), vigente em onDate e habilitada na empresa (ausência de linha = habilitada). Conta desabilitada em outra empresa continua sendo oferecida. 404 COMPANY_NOT_FOUND se a empresa está fora do escopo. |
Estabelecimento padrão do lookup. O defaultEstablishmentId vem da preferência nexio.default-establishment-id do usuário na plataforma (UserPreference, lida em processo no GrydAuth embutido, a cada chamada, para o usuário e o tenant da requisição). O valor é o Guid do estabelecimento em texto, e a chave é minúscula porque a plataforma normaliza a chave. Só vale se for um dos itens do próprio lookup: preferência que aponta para estabelecimento inativo, irregular ou fora do alcance é ignorada, e o campo volta nulo; se a leitura da preferência falha (banco de identidade fora), o lookup responde 500, e não mais sem padrão (DEC-CO-33-4). O Nexio não tem rota para escrever a preferência: a tela escreve na rota da plataforma (PUT /auth/me/preferences/nexio.default-establishment-id) e deve oferecer a escolha entre os itens do próprio lookup.
As 32 rotas
Todas exigem autenticação (401 sem token) e a permissão da coluna (403 sem ela); os erros de domínio são os da coluna final, e toda rota de escrita pode devolver também 400 VALIDATION_ERROR por campo. Os caminhos omitem o prefixo api/v1/nexio. Os campos em negrito são obrigatórios.
| Rota | Permissão | Corpo ou query | Retorno | Erros de domínio |
|---|---|---|---|---|
| GET /companies | read:companies | query: page, pageSize, sortBy, sortDirection, search, isActive, taxRegime | 200 PagedResult<CompanyDto> | 400 LIST_INCLUDE_DELETED_UNSUPPORTED, VALIDATION_ERROR |
| GET /companies/lookup | read:companies | query: search (≤ 100), isActive | 200 array puro de CompanyLookupDto, sem paginação e sem corte | 400 VALIDATION_ERROR |
| GET /companies/{id} | read:companies | — | 200 CompanyDetailDto | 404 COMPANY_NOT_FOUND (inclui fora do escopo) |
| POST /companies | create:companies e update:company-scopes | Empresa: obrigatórios code, cnpjRoot, legalName, taxRegime, headquarters; opcionais functionalCurrency (só BRL), tradeName, externalCode, legalNature, shareCapital, foundedOn, closedOn. Limites: code 1–20 (A–Z, 0–9, _, -), legalName ≤ 200, tradeName ≤ 160, externalCode ≤ 40, legalNature ≤ 4 caracteres; shareCapital ≥ 0 com até 2 casas. Estabelecimento (corpo da matriz e de POST /companies/{companyId}/establishments): obrigatórios code, cnpj, name, icmsTaxpayerStatus, zipCode, street, number, district, cityIbgeCode; opcionais externalCode, stateRegistration, municipalRegistration, suframaCode, primaryCnae, complement, countryCode e allowsPurchasing, allowsReceiving (padrão true). Formato: code 1–20 de A–Z, 0–9, _ ou - (gravado em maiúsculas); zipCode 8 dígitos sem máscara; cityIbgeCode 7 dígitos; countryCode 2 letras (padrão BR); name ≤ 160, externalCode ≤ 40, stateRegistration e municipalRegistration ≤ 20, suframaCode ≤ 9, primaryCnae ≤ 7, endereço ≤ 60 por campo. A inscrição estadual é obrigatória com icmsTaxpayerStatus = Taxpayer e proibida com Exempt. A UF não é enviada: sai do cityIbgeCode. | 201 CompanyDetailDto + Location → GET /companies/{id}. Grava empresa, matriz e a linha de escopo de quem criou (origin = CompanyCreation) numa transação | 400 COMPANY_CNPJ_ROOT_INVALID, ESTABLISHMENT_CNPJ_INVALID, VALIDATION_ERROR403 COMPANY_CREATION_SCOPE_PERMISSION_FORBIDDEN409 COMPANY_CODE_ALREADY_EXISTS, COMPANY_CNPJ_ROOT_ALREADY_EXISTS, COMPANY_EXTERNAL_CODE_ALREADY_EXISTS, ESTABLISHMENT_CNPJ_ALREADY_EXISTS422 COMPANY_FUNCTIONAL_CURRENCY_UNPROCESSABLE, ESTABLISHMENT_CNPJ_ROOT_MISMATCH_UNPROCESSABLE, ESTABLISHMENT_STATE_REGISTRATION_REQUIRED_UNPROCESSABLE, ESTABLISHMENT_STATE_REGISTRATION_NOT_ALLOWED_UNPROCESSABLE |
| PUT /companies/{id} | update:companies | expectedVersion (inteiro ≥ 1, o version lido); obrigatórios legalName, taxRegime (NotApplicable é recusado); opcionais tradeName, externalCode, legalNature, shareCapital, foundedOn, closedOn. Substitui o perfil inteiro: opcional omitido vai como nulo. code, cnpjRoot e functionalCurrency são ignorados (não recusados). Sem mudança real não grava e não sobe a versão. closedOn não mexe em isActive | 200 CompanyDetailDto | 400 VALIDATION_ERROR404 COMPANY_NOT_FOUND409 COMPANY_CONCURRENT_CHANGE_CONFLICT, COMPANY_EXTERNAL_CODE_ALREADY_EXISTS |
| PATCH /companies/{id}/activate | update:companies | expectedVersion (inteiro ≥ 1, o version lido). Reativa a empresa sem reativar filiais. Ativar o que já está ativo não faz nada | 200 CompanyDetailDto | 400 VALIDATION_ERROR404 COMPANY_NOT_FOUND409 COMPANY_CONCURRENT_CHANGE_CONFLICT, USER_COMPANY_SCOPE_LAST_GRANT_CONFLICT |
| PATCH /companies/{id}/deactivate | update:companies | expectedVersion (inteiro ≥ 1, o version lido). Cascateia: todo estabelecimento ativo cai junto | 200 CompanyDeactivationDto = CompanyDetailDto + deactivatedEstablishments[] | 400 VALIDATION_ERROR404 COMPANY_NOT_FOUND409 COMPANY_CONCURRENT_CHANGE_CONFLICT |
| PATCH /companies/{id}/headquarters | update:companies | expectedVersion (inteiro ≥ 1, o version lido); establishmentId (a nova matriz, da mesma empresa). Indicar a matriz atual devolve 200 sem mudança | 200 CompanyDetailDto | 400 VALIDATION_ERROR404 COMPANY_NOT_FOUND, ESTABLISHMENT_NOT_FOUND409 COMPANY_CONCURRENT_CHANGE_CONFLICT422 ESTABLISHMENT_INACTIVE_UNPROCESSABLE |
| Rota | Permissão | Corpo ou query | Retorno | Erros de domínio |
|---|---|---|---|---|
| GET /companies/{companyId}/establishments | read:companies | — | 200 array puro de EstablishmentDto, inativos incluídos, matriz primeiro | 404 COMPANY_NOT_FOUND |
| GET /establishments/lookup | read:companies | query: purpose obrigatório, Receiving ou Requesting (sem distinção de caixa) | 200 EstablishmentLookupDto, agrupado por empresa, com defaultEstablishmentId | 400 ESTABLISHMENT_LOOKUP_PURPOSE_REQUIRED (ausente, em branco ou desconhecido) |
| GET /establishments/{id} | read:companies | — | 200 EstablishmentDetailDto | 404 ESTABLISHMENT_NOT_FOUND |
| POST /companies/{companyId}/establishments | create:companies | Estabelecimento (corpo da matriz e de POST /companies/{companyId}/establishments): obrigatórios code, cnpj, name, icmsTaxpayerStatus, zipCode, street, number, district, cityIbgeCode; opcionais externalCode, stateRegistration, municipalRegistration, suframaCode, primaryCnae, complement, countryCode e allowsPurchasing, allowsReceiving (padrão true). Formato: code 1–20 de A–Z, 0–9, _ ou - (gravado em maiúsculas); zipCode 8 dígitos sem máscara; cityIbgeCode 7 dígitos; countryCode 2 letras (padrão BR); name ≤ 160, externalCode ≤ 40, stateRegistration e municipalRegistration ≤ 20, suframaCode ≤ 9, primaryCnae ≤ 7, endereço ≤ 60 por campo. A inscrição estadual é obrigatória com icmsTaxpayerStatus = Taxpayer e proibida com Exempt. A UF não é enviada: sai do cityIbgeCode. Sem expectedVersion. Nasce Branch. A situação cadastral é consultada na criação | 201 EstablishmentDetailDto + Location → GET /establishments/{id} | 400 ESTABLISHMENT_CNPJ_INVALID, VALIDATION_ERROR404 COMPANY_NOT_FOUND409 ESTABLISHMENT_CODE_ALREADY_EXISTS, ESTABLISHMENT_CNPJ_ALREADY_EXISTS, ESTABLISHMENT_EXTERNAL_CODE_ALREADY_EXISTS, COMPANY_CONCURRENT_CHANGE_CONFLICT422 ESTABLISHMENT_CNPJ_ROOT_MISMATCH_UNPROCESSABLE, ESTABLISHMENT_STATE_REGISTRATION_REQUIRED_UNPROCESSABLE, ESTABLISHMENT_STATE_REGISTRATION_NOT_ALLOWED_UNPROCESSABLEO COMPANY_CONCURRENT_CHANGE_CONFLICT aqui não é erro do cliente: o ato sobe a versão da empresa, e dois POST simultâneos disputam; repetir resolve |
| PUT /establishments/{id} | update:companies | expectedVersion (inteiro ≥ 1, o version lido); os campos do estabelecimento sem code (imutável; type e empresa também), mais cnpj (obrigatório e editável; a raiz continua a da empresa) e deliveryLocationInterstateNotes[] (opcional: deliveryLocationId + interstateNote, sem repetir ponto). Substitui o perfil inteiro. Mudar município, UF ou allowsReceiving revalida os pontos de entrega (registro hoje vazio) | 200 EstablishmentDetailDto | 400 VALIDATION_ERROR404 ESTABLISHMENT_NOT_FOUND409 ESTABLISHMENT_CONCURRENT_CHANGE_CONFLICT, ESTABLISHMENT_CNPJ_ALREADY_EXISTS, ESTABLISHMENT_EXTERNAL_CODE_ALREADY_EXISTS, COMPANY_CONCURRENT_CHANGE_CONFLICT422 ESTABLISHMENT_ADDRESS_CHANGE_BREAKS_DELIVERY_LOCATIONS_UNPROCESSABLE, ESTABLISHMENT_CNPJ_ROOT_MISMATCH_UNPROCESSABLE, ESTABLISHMENT_STATE_REGISTRATION_REQUIRED_UNPROCESSABLE, ESTABLISHMENT_STATE_REGISTRATION_NOT_ALLOWED_UNPROCESSABLE |
| PATCH /establishments/{id}/activate | update:companies | expectedVersion (inteiro ≥ 1, o version lido) | 200 EstablishmentDetailDto | 400 VALIDATION_ERROR404 ESTABLISHMENT_NOT_FOUND409 ESTABLISHMENT_CONCURRENT_CHANGE_CONFLICT, COMPANY_CONCURRENT_CHANGE_CONFLICT |
| PATCH /establishments/{id}/deactivate | update:companies | expectedVersion (inteiro ≥ 1, o version lido). Recusa desativar o último estabelecimento ativo de empresa ativa | 200 EstablishmentDetailDto | 400 VALIDATION_ERROR404 ESTABLISHMENT_NOT_FOUND409 ESTABLISHMENT_CONCURRENT_CHANGE_CONFLICT, COMPANY_CONCURRENT_CHANGE_CONFLICT, ESTABLISHMENT_LAST_ACTIVE_CONFLICT |
| PATCH /establishments/{id}/registration-status | update:companies | expectedVersion (inteiro ≥ 1, o version lido); status (Active, Suspended, Unfit, ClosedDown; Null é recusado); statusDate (não pode estar no futuro); evidenceNote (obrigatória). Vale a data mais recente: correção mais antiga que a gravada responde 200 sem mudança e sem evento | 200 EstablishmentDetailDto | 400 VALIDATION_ERROR404 ESTABLISHMENT_NOT_FOUND409 ESTABLISHMENT_CONCURRENT_CHANGE_CONFLICT |
| Rota | Permissão | Corpo ou query | Retorno | Erros de domínio |
|---|---|---|---|---|
| GET /gl-accounts | read:gl-accounts | query: page, pageSize, sortBy, sortDirection, search, accountType, isActive, allowsPosting. Lista o plano inteiro, todos os níveis | 200 PagedResult<GLAccountDto> | 400 VALIDATION_ERROR, LIST_INCLUDE_DELETED_UNSUPPORTED |
| GET /gl-accounts/lookup | read:gl-accounts | query: companyId obrigatório; search, onDate (padrão: hoje em America/Sao_Paulo) | 200 array puro de GLAccountLookupItemDto, em ordem de árvore | 400 VALIDATION_ERROR404 COMPANY_NOT_FOUND (400 sem companyId; 404 inclui fora do escopo) |
| GET /gl-accounts/{parentId}/children | read:gl-accounts | query como a listagem; só os filhos diretos | 200 PagedResult<GLAccountDto> | 400 VALIDATION_ERROR, LIST_INCLUDE_DELETED_UNSUPPORTED404 GL_ACCOUNT_NOT_FOUND |
| POST /gl-accounts | create:gl-accounts | obrigatórios code, name e, na raiz, accountType; opcionais parentId, allowsPosting, validFrom, validTo, externalCode. code ≤ 30, name ≤ 160, externalCode ≤ 40; validTo não anterior a validFrom. Tipo herdado do pai. code é único no tenant e reservado para sempre; name é único entre irmãos | 201 GLAccountDetailDto + Location → a listagem | 400 VALIDATION_ERROR404 GL_ACCOUNT_NOT_FOUND409 GL_ACCOUNT_CODE_ALREADY_EXISTS, GL_ACCOUNT_SIBLING_NAME_ALREADY_EXISTS, GL_ACCOUNT_CONCURRENT_CHANGE_CONFLICT422 GL_ACCOUNT_TYPE_MISMATCH_UNPROCESSABLE404 é o pai inexistente; todo filho criado versiona o pai |
| PUT /gl-accounts/{id} | update:gl-accounts | expectedVersion (inteiro ≥ 1, o version lido); name; opcionais allowsPosting, validFrom, validTo, externalCode. code, parentId, accountType não mudam. Pedir allowsPosting para nó com filho é recusado | 200 GLAccountDetailDto | 400 VALIDATION_ERROR404 GL_ACCOUNT_NOT_FOUND409 GL_ACCOUNT_CONCURRENT_CHANGE_CONFLICT, GL_ACCOUNT_SIBLING_NAME_ALREADY_EXISTS422 GL_ACCOUNT_POSTING_NOT_ALLOWED_UNPROCESSABLE |
| PATCH /gl-accounts/{id}/parent | update:gl-accounts | expectedVersion (inteiro ≥ 1, o version lido); parentId ou toRoot: true (nenhum, ou os dois, é 400); expectedPath (o path lido). Reescreve o caminho dos descendentes numa transação | 200 GLAccountDetailDto | 400 VALIDATION_ERROR404 GL_ACCOUNT_NOT_FOUND409 GL_ACCOUNT_CONCURRENT_CHANGE_CONFLICT, GL_ACCOUNT_SIBLING_NAME_ALREADY_EXISTS422 GL_ACCOUNT_PARENT_CYCLE_UNPROCESSABLE, GL_ACCOUNT_TYPE_MISMATCH_UNPROCESSABLE409 de concorrência vale para versão ou path mudado desde a leitura |
| PUT /gl-accounts/{id}/companies/{companyId} | update:gl-accounts | isEnabled; opcional externalCode (≤ 40); expectedVersion é a versão da conta. O código externo da empresa vence o da conta | 200 GLAccountCompanyDto (glAccountId, companyId, isEnabled, externalCode, accountVersion) | 400 VALIDATION_ERROR404 COMPANY_NOT_FOUND, GL_ACCOUNT_NOT_FOUND409 GL_ACCOUNT_CONCURRENT_CHANGE_CONFLICT |
| Rota | Permissão | Corpo ou query | Retorno | Erros de domínio |
|---|---|---|---|---|
| GET /number-sequences | read:number-sequences | — | 200 array puro de NumberSequenceDto | nunca responde 400 |
| POST /number-sequences | read:number-sequences | obrigatórios documentType, prefix (1 a 10 caracteres, sem espaço); opcionais scope (padrão Company), padding (padrão 6, de 1 a 21), resetPolicy (padrão Yearly), timeZone (IANA; padrão America/Sao_Paulo), effectiveFrom (com offset, Z ou ±hh[:mm]; sem offset é 400; padrão: agora), replacesSequenceId e expectedVersion (obrigatório com replacesSequenceId). Sem replacesSequenceId: abre a primeira do tipo. Com ele: substitui, e a substituída recebe retiredAt = effectiveFrom da sucessora | 201 NumberSequenceDto + Location → a listagem (não existe GET /number-sequences/{id}) | 400 VALIDATION_ERROR404 NUMBER_SEQUENCE_NOT_FOUND409 NUMBER_SEQUENCE_ALREADY_EXISTS, NUMBER_SEQUENCE_CONCURRENT_CHANGE_CONFLICT422 NUMBER_SEQUENCE_REPLACEMENT_UNPROCESSABLE, NUMBER_SEQUENCE_PREFIX_OVERLAP_UNPROCESSABLE, NUMBER_SEQUENCE_PADDING_UNPROCESSABLE, NUMBER_SEQUENCE_TIME_ZONE_UNPROCESSABLE404 é replacesSequenceId inexistente; 409 de concorrência vale para quem não é a mais recente, versão vencida e perda de corrida |
| PUT /number-sequences/{id} | update:number-sequences | padding e expectedVersion (inteiro ≥ 1, o version lido). Só o padding edita; o resto é identidade da configuração (mudar é substituir) | 200 NumberSequenceDto | 400 VALIDATION_ERROR404 NUMBER_SEQUENCE_NOT_FOUND409 NUMBER_SEQUENCE_CONCURRENT_CHANGE_CONFLICT422 NUMBER_SEQUENCE_PADDING_UNPROCESSABLE |
| Rota | Permissão | Corpo ou query | Retorno | Erros de domínio |
|---|---|---|---|---|
| GET /users/{userId}/company-scopes | read:company-scopes | — | 200 UserCompanyScopeSetDto. Só as linhas das empresas que quem lê alcança; vigentes primeiro, encerradas depois. Pessoa sem linha: 200 com conjunto vazio | nunca 404 |
| PUT /users/{userId}/company-scopes | update:company-scopes | expectedScopeHash (o scopeHash lido); companies: lista de { companyId, validTo? } (lista vazia é válida); opcional dependentGrantDecisions: lista de { scopeId, decision } com Keep, End, Trim. Substitui, não faz merge | 200 o UserCompanyScopeSetDto novo | 400 VALIDATION_ERROR403 USER_COMPANY_SCOPE_SELF_CHANGE_FORBIDDEN404 COMPANY_NOT_FOUND, USER_COMPANY_SCOPE_NOT_FOUND409 USER_COMPANY_SCOPE_CONCURRENT_CHANGE_CONFLICT, USER_COMPANY_SCOPE_DEPENDENT_GRANTS_CONFLICT, USER_COMPANY_SCOPE_LAST_GRANT_CONFLICT, USER_COMPANY_SCOPE_DUPLICATE_CONFLICT422 USER_COMPANY_SCOPE_BEYOND_GRANTOR_UNPROCESSABLE, USER_COMPANY_SCOPE_VALIDITY_UNPROCESSABLE, USER_NOT_IN_TENANT_UNPROCESSABLEGuardas nesta ordem: própria pessoa (403) → hash (409) → além da vigência de quem concede (422) → decisões das Restrict (409) → último administrador (409) |
| GET /me/company-scope | nenhuma (só autenticação) | — | 200 MyCompanyScopeDto = { reason, companies: [{ id, code, name, isActive }] } | sem escopo no fluxo, 500 (falha fechada) |
Formas das respostas
| Tipo | Campos |
|---|---|
CompanyDto | id, code, cnpjRoot, legalName, tradeName, taxRegime, isActive, closedOn, headquartersId, establishmentCount, version, createdAt |
CompanyLookupDto | id, code, name, cnpjRoot, isActive |
CompanyDetailDto | id, code, cnpjRoot, legalName, tradeName, externalCode, taxRegime, legalNature, shareCapital, functionalCurrency, foundedOn, closedOn, isActive, headquartersId, establishmentCount, version, createdAt, activeEstablishments[], inactiveEstablishments[], warnings[] |
CompanyEstablishmentDto (resumo dentro da empresa) | id, code, cnpj, name, type, isActive, allowsPurchasing, allowsReceiving, registrationStatus, version |
CompanyDeactivationDto | CompanyDetailDto + deactivatedEstablishments[] (lista de CompanyEstablishmentDto) |
EstablishmentDto (lista da empresa) | id, companyId, code, name, cnpj, type, cityIbgeCode, stateCode, isActive, allowsPurchasing, allowsReceiving, registrationStatus, version |
EstablishmentDetailDto | id, companyId, code, cnpj, cnpjRoot, type, name, externalCode, icmsTaxpayerStatus, stateRegistration, municipalRegistration, suframaCode, primaryCnae, registrationStatus, registrationStatusDate, registrationSource, registrationCheckedAt, zipCode, street, number, complement, district, cityIbgeCode, stateCode, countryCode, allowsPurchasing, allowsReceiving, isActive, version |
EstablishmentLookupDto | { defaultEstablishmentId, companies: [{ companyId, code, name, establishments: [{ id, code, name, type, stateCode, registrationStatus }] }] } |
GLAccountDto (e GLAccountDetailDto, que acrescenta createdAt, updatedAt) | id, code, name, parentId, path, depth, accountType, allowsPosting, validFrom, validTo, isActive, externalCode, version, hasChildren |
GLAccountLookupItemDto | id, code, name, accountType, path |
NumberSequenceDto | id, documentType, scope, prefix, padding, resetPolicy, timeZone, effectiveFrom, retiredAt, replacesSequenceId, version, state |
UserCompanyScopeSetDto | { userId, scopeHash, lines: [{ id, companyId, companyCode, companyName, validFrom, validTo, origin, grantedByUserId, onGrantorChange, endedByUserId, endedAt, isLive }] } |
Implementado: as tabelas de Endpoints e Contrato de erros continuam sendo a especificação; esta é a contraprova do código. Em caso de dúvida sobre um código de erro por rota, vale a coluna acima.
Preenchimento por consulta
O cadastro de empresa e de estabelecimento deixa de exigir que a pessoa digite CEP, endereço e código IBGE: a tela consulta o backend, que consulta provedores públicos (BrasilAPI; ViaCEP como reserva do CEP). A tela nunca fala com provedor nenhum e nada do que a consulta devolve é obrigatório: é sugestão para preencher o formulário, que continua válido digitado à mão. As três rotas usam read:companies, não gravam nada e não dependem do escopo por empresa.
| Rota | Permissão | Entrada | Retorno | Erros |
|---|---|---|---|---|
| GET /registry/cnpj/{cnpj} | read:companies | cnpj: 14 caracteres (dígitos e letras) sem máscara | 200 CompanyRegistryDto | 400 REGISTRY_CNPJ_INVALID · 404 REGISTRY_CNPJ_NOT_FOUND · 503 REGISTRY_LOOKUP_UNAVAILABLE |
| GET /registry/zip-codes/{zipCode} | read:companies | zipCode: 8 dígitos, sem hífen | 200 ZipCodeAddressDto | 400 REGISTRY_ZIP_CODE_INVALID · 404 REGISTRY_ZIP_CODE_NOT_FOUND · 503 REGISTRY_LOOKUP_UNAVAILABLE |
| GET /registry/municipalities | read:companies | query: search (2 a 100 caracteres), stateCode (opcional, UF), limit (1 a 50, padrão 20) | 200 array puro de { ibgeCode, name, stateCode }, sem paginação | 400 VALIDATION_ERROR (errors.search, errors.stateCode, errors.limit) |
| Tipo | Campos |
|---|---|
CompanyRegistryDto | cnpj, cnpjRoot, isHeadquarters, legalName, tradeName, primaryCnae (7 dígitos), legalNature, foundedOn, registrationStatus (Active, Suspended, Unfit, ClosedDown; null quando o provedor não diz), registrationStatusDate, address (zipCode, street, number, withoutNumber, complement, district, cityName, cityIbgeCode, stateCode, countryCode), source, checkedAt. Todo campo, exceto cnpj, cnpjRoot, source e checkedAt, pode ser null |
ZipCodeAddressDto | zipCode, street, district (null em CEP geral), cityName, cityIbgeCode, stateCode, countryCode, source |
O que a tela faz
- CNPJ. Ao sair do campo com um CNPJ completo, chama
GET /registry/cnpj/{cnpj}(sem máscara: tire os pontos, a barra e o hífen antes). Preenche razão social, nome fantasia, CNAE principal, natureza jurídica, data de início, endereço e a situação cadastral. Nunca sobrescreve em silêncio um campo que a pessoa já digitou: pergunta antes. Em empresa,isHeadquarters: falseé um aviso (o CNPJ é de filial), não um bloqueio. - Número.
withoutNumber: truemarca "sem número";numbervem"SN"(ounullquando o provedor deixou em branco). A tela mostra a caixa "sem número" marcada e envia o número que a pessoa confirmar. - CEP. Ao completar os 8 dígitos (sem hífen na chamada; a máscara é só visual), chama
GET /registry/zip-codes/{zipCode}e preenche rua, bairro, município e UF.streetedistrictnull(CEP geral) deixam esses dois campos para a pessoa. - Município. O campo de município é um autocomplete sobre
GET /registry/municipalities?search=&stateCode=&limit=, sem acento e sem diferença de caixa; comece a buscar a partir de 2 caracteres e use debounce. Escolher uma linha grava oibgeCodeemcityIbgeCode; a UF nunca é digitada, vem dostateCodeda resposta (D-09). - 404 (
REGISTRY_CNPJ_NOT_FOUND,REGISTRY_ZIP_CODE_NOT_FOUND): mensagem curta no campo ("não encontrado"); a pessoa segue digitando. - 503 (
REGISTRY_LOOKUP_UNAVAILABLE): nenhuma base respondeu. A tela não bloqueia o cadastro: avisa que a consulta está fora do ar e deixa o formulário à mão. Não repita a chamada em laço. limitque não é inteiro (abc) é recusado pelo binder, antes do validador: 400 da plataforma semcodee com a chaveerrors.Limit. Trate qualquer 400 desta rota como erro de campo.- 400 (
REGISTRY_CNPJ_INVALID,REGISTRY_ZIP_CODE_INVALID): a tela deveria ter validado antes (14 caracteres com dígitos verificadores; 8 dígitos); trate como erro de campo. source(brasilapi,viacep) echeckedAtdizem de onde veio e de quando é o dado; a resposta pode vir de um cache do servidor (CNPJ por até 1 dia, CEP por até 7 dias), echeckedAté o instante em que o provedor respondeu, não o da leitura.- O
registrationStatusdesta consulta é informativo: o campo que vale para o bloqueio de documento continua sendo o do estabelecimento (RN-EMP-36), atualizado pela varredura diária — que só lê este registro quando o servidor ligaRegistry:Cnpj:FeedRegistrationStatus.
Fluxos de tela e o que a UI precisa tratar
Onboarding — tenant sem empresa
- A tela decide o modo chamando
GET /me/company-scope(sem permissão):reasonOnboardingecompanies: []significam que o tenant ainda não registrou empresa. É o único estado em que o cadastro da primeira empresa é a única coisa a mostrar, e só a quem temcreate:companies,update:company-scopes; os demais veem “aguardando o cadastro da primeira empresa”. NoGrant(há empresas, mas a pessoa não tem linha) eExpired(só linhas encerradas ou vencidas) também devolvemcompanies: []: a tela diz que falta concessão, não que não há empresa.Grantedtraz as empresas alcançadas, inativas incluídas (isActive).- Cadastro do tenant que não tem empresa (item, fornecedor, unidade) segue só a permissão, sem depender do escopo.
Criação de empresa — matriz inline e escopo do criador
- Um só
POST /companies: o formulário tem dois blocos (empresa e matriz) e vai num corpo só, com o CNPJ de 14 da matriz emheadquarters.cnpje a raiz de 8 emcnpjRoot. Empresa, matriz e a linha de escopo de quem criou nascem na mesma transação. - Só quem criou alcança a empresa nova (
origin = CompanyCreation, semvalidTo); ninguém mais a vê até receber escopo. Depois do201, a tela deve oferecer a concessão da empresa a outras pessoas (PUT /users/{userId}/company-scopes). - Sem
update:company-scopesno token:403 COMPANY_CREATION_SCOPE_PERMISSION_FORBIDDENe nada é gravado. A tela esconde ou desabilita a ação sem as duas permissões; o 403 é a guarda, não o aviso. - Erros que o formulário mapeia a campo:
COMPANY_CNPJ_ROOT_INVALIDeESTABLISHMENT_CNPJ_INVALID(400),ESTABLISHMENT_CNPJ_ROOT_MISMATCH_UNPROCESSABLE(a raiz do CNPJ da matriz não é a da empresa), as inscrições estaduais (obrigatória comTaxpayer, proibida comExempt) e as duplicidades 409 decode,cnpjRoot,externalCodee CNPJ. Valores de linha apagada logicamente também colidem (reservados para sempre). Município IBGE desconhecido volta comoVALIDATION_ERRORemheadquarters.cityIbgeCode, com mensagem em inglês.
Desativação em cascata e reativação
PATCH /companies/{id}/deactivatedesativa também todos os estabelecimentos ativos. Antes de confirmar, a tela mostra o que vai cair (activeEstablishments[]do detalhe); depois, mostra a lista que a resposta traz emdeactivatedEstablishments[]. Cada estabelecimento que caiu tem a versão incrementada: recarregar a lista.- Reativar a empresa (
PATCH .../activate) não reativa filiais. A empresa pode ficar ativa sem estabelecimento ativo até alguém reativar um (PATCH /establishments/{id}/activate): a tela oferece a lista dos inativos (inactiveEstablishments[]) para escolher quais voltam. - Reativar exige só
update:companies.409 USER_COMPANY_SCOPE_LAST_GRANT_CONFLICT(a mensagem nomeia a empresa) significa que a empresa ficaria sem administrador permanente: levar a pessoa à tela de escopo. - Desativar um estabelecimento que é o último ativo de empresa ativa é
409 ESTABLISHMENT_LAST_ACTIVE_CONFLICT; a saída é desativar a empresa. Empresa inativa não emite documento novo, mas continua legível.
Troca de matriz
PATCH /companies/{id}/headquarterscom{ establishmentId, expectedVersion }: um ato só; a matriz antiga viraBranch. A tela oferece só estabelecimentos ativos da empresa (inativo é422 ESTABLISHMENT_INACTIVE_UNPROCESSABLE; de outra empresa ou fora do escopo é404 ESTABLISHMENT_NOT_FOUND).- Sobem a versão da empresa e das duas filiais: recarregar o detalhe inteiro. Indicar a matriz atual devolve 200 sem mudança.
Escopo por empresa
- Leitura:
GET /users/{userId}/company-scopesdevolve oscopeHashe as linhas — vigentes (isLive = true) primeiro, encerradas depois. Só aparecem as empresas que quem lê alcança; as demais linhas da pessoa existem e a tela não as vê. - Escrita: o
PUTsubstitui o conjunto vigente da pessoa dentro das empresas que quem concede alcança. A tela monta a lista do estado desejado (todas as empresas que a pessoa deve ter, cada uma comvalidToopcional) e devolve oscopeHashlido. Empresa que sai da lista é encerrada; empresa nova ganha linha; empresa com outrovalidTotem a linha encerrada e reaberta. Lista vazia encerra todo o acesso da pessoa nas empresas de quem concede. Empresas que quem concede não alcança ficam intocadas e invisíveis. 409 USER_COMPANY_SCOPE_CONCURRENT_CHANGE_CONFLICT: o hash mudou (ou as travas estouraram em 10 s). Nada foi gravado: a tela recarrega o conjunto e refaz.409 USER_COMPANY_SCOPE_DEPENDENT_GRANTS_CONFLICT: o ato encerra ou encurta uma linha de quem concedeu linhasRestricta outras pessoas. A tela precisa coletar, para cada linhaRestrictalcançada, uma decisão —Keep(mantém),End(encerra) ouTrim(corta para o novo fim) — e reenviar emdependentGrantDecisions, com oscopeIdde cada linha. A lista de decisões tem de cobrir exatamente as alcançadas. As linhasCascadesão cortadas em cadeia sem pergunta. Atenção: a mensagem lista pessoa, código da empresa evalidToe não traz oscopeId(ver Lacunas).403 USER_COMPANY_SCOPE_SELF_CHANGE_FORBIDDEN: ninguém altera as próprias linhas (a tela esconde a edição quandouserIdé o do usuário logado).422 USER_COMPANY_SCOPE_BEYOND_GRANTOR_UNPROCESSABLE: a linha passaria da vigência de quem concede (a mensagem nomeia a empresa).409 USER_COMPANY_SCOPE_LAST_GRANT_CONFLICT: a empresa ficaria sem administrador permanente (a mensagem lista as empresas).422 USER_NOT_IN_TENANT_UNPROCESSABLE: empresa no corpo para quem saiu da organização — para essa pessoa só a lista vazia é aceita.validToé um instante (ISO 8601 com offset), exclusivo, e precisa ser posterior ao momento do ato (senão422 USER_COMPANY_SCOPE_VALIDITY_UNPROCESSABLE). Revogar vale na requisição seguinte.
Avisos do detalhe da empresa — warnings[]
CompanyDetailDto.warningsé uma lista de textos de código. Hoje existe um:company-closed-but-active—closedOnpreenchido e a empresa ainda ativa. É aviso, não erro: a tela mostra a faixa “empresa encerrada em ..., ainda ativa” e oferece a desativação.closedOnnunca desativa sozinho.- Depois da data de encerramento, documento novo é recusado pelas portas (
COMPANY_CLOSED_UNPROCESSABLE); no próprio dia ainda vale.
Situação cadastral irregular
- O estabelecimento com situação
Suspended,Unfit,ClosedDownsome dolookup(quem escolhe estabelecimento para documento não o vê);Null,Activeaparecem. Na tela de cadastro ele continua listado, comregistrationStatus, e a correção manual éPATCH .../registration-status(status, data, nota do comprovante). - Sem provedor de consulta ao CNPJ, a situação só muda por correção manual:
Nullsignifica “não verificado”, não “regular”.registrationSourcediz se veio doProviderou foiManual. A nota do comprovante não é gravada em coluna e não volta em nenhuma resposta.
Numeração — configurações agendadas, vigentes e aposentadas
GET /number-sequencesdevolve tudo, ordenado por tipo e início, comstatederivado na hora:Scheduled(agendada:effectiveFromno futuro),Current(vigente) eRetired(aposentada: temretiredAt). A tela agrupa pordocumentTypee destaca a vigente.- Mudar a numeração é substituir:
POSTcomreplacesSequenceIdeexpectedVersionda configuração mais recente do tipo. A sucessora precisa de prefixo novo ou período novo (422 NUMBER_SEQUENCE_REPLACEMENT_UNPROCESSABLE), e começar depois do início da substituída. Só opaddingse edita no lugar. 409 NUMBER_SEQUENCE_ALREADY_EXISTS: já há vigente ou agendada e o corpo não trouxereplacesSequenceId. Nenhum número emitido é exibido por estas rotas: a emissão é porta interna (INumberIssuer).
Campos derivados e somente leitura
| Entidade | Campos que a tela mostra e nunca envia como valor editável |
|---|---|
| Empresa | id, headquartersId, establishmentCount, isActive, version, createdAt — isActive muda pelas rotas activate/deactivate; headquartersId pela troca de matriz. code, cnpjRoot, functionalCurrency só existem na criação (o PUT os ignora). |
| Estabelecimento | id, companyId, type, cnpjRoot, stateCode, isActive, registrationStatus, registrationStatusDate, registrationSource, registrationCheckedAt, version — a UF vem do cityIbgeCode; a situação cadastral só muda pela rota própria. code é imutável depois de criado. |
| Conta contábil | id, path, depth, hasChildren, accountType, isActive, version, createdAt, updatedAt — accountType é herdado do pai e imutável; path e depth são do servidor; o path lido volta no expectedPath do move. |
| Numeração | id, state, retiredAt, replacesSequenceId, version — state é calculado na resposta. |
| Escopo | scopeHash, id, validFrom, origin, grantedByUserId, onGrantorChange, endedByUserId, endedAt, isLive — validFrom é o instante do ato, nunca enviado. |
| Ação da tela | Permissões necessárias |
|---|---|
| Ver empresas e estabelecimentos (listas, detalhe, lookups, seletor) | read:companies. O seletor de empresa em si lê GET /me/company-scope, que não exige permissão. |
| Criar empresa | create:companies e update:company-scopes (esta no token de quem cria). |
| Criar estabelecimento | create:companies. |
| Editar empresa ou estabelecimento, trocar matriz, desativar, corrigir situação cadastral | update:companies. |
| Reativar empresa | update:companies. |
| Reativar estabelecimento | update:companies. |
| Ver plano de contas e lookup de contas | read:gl-accounts. |
| Criar conta | create:gl-accounts. |
| Editar conta, mover, habilitar por empresa | update:gl-accounts. |
| Ver numeração | read:number-sequences. |
| Criar e substituir numeração | create:number-sequences e update:number-sequences. |
| Ver o escopo de uma pessoa | read:company-scopes. |
| Definir o escopo de uma pessoa | update:company-scopes. |
| Escolher o estabelecimento padrão | Rota de preferências do GrydAuth, no próprio host (PUT /auth/me/preferences/{key}); o Nexio não tem rota de negócio para isso. |
O recorte por empresa não é permissão: uma pessoa com read:companies e nenhuma linha de escopo vê listas vazias e 404 por id. A tela deve tratar “tem a permissão, mas não alcança nenhuma empresa” como o estado “falta concessão”, não como erro.
Lacunas
| Falta | Efeito na tela e o que fazer |
|---|---|
Não há GET /gl-accounts/{id} | A tela de edição de conta parte do item da listagem ou do retorno do POST/PUT/PATCH; hasChildren, path e version vêm da listagem. |
| Não há ativar/desativar conta contábil | isActive aparece em GLAccountDto e é filtro de listagem, mas nenhuma rota o muda (as portas de status existem no agregado). Encerrar uma conta hoje só por validTo no PUT. |
| Não há leitura das habilitações por empresa | PUT /gl-accounts/{id}/companies/{companyId} grava e devolve a linha, mas nenhuma rota lista em quais empresas a conta está habilitada. A tela de “habilitar por empresa” não consegue mostrar o estado atual além do que acabou de gravar (ausência de linha = habilitada). |
Não há GET /number-sequences/{id} | O Location do POST aponta para a listagem. A tela relê a lista. |
| Não há rota para listar o que uma pessoa concedeu | Para decidir as linhas Restrict (Keep/End/Trim), a tela precisa do scopeId de cada uma, e o 409 USER_COMPANY_SCOPE_DEPENDENT_GRANTS_CONFLICT só lista pessoa, empresa e validTo em texto. O caminho é ler GET /users/{userId}/company-scopes dos beneficiários (as linhas trazem grantedByUserId, onGrantorChange e id) antes de montar o formulário de decisão. |
| Não há rota que liste quem alcançava todas as empresas | A spec pede, na tela de criação de empresa, a lista de quem tinha escopo completo para concessão explícita (RN-EMP-27). O cálculo existe em processo (FullScopeService) e não tem rota nem chamador. |
| Não há rota para escrever a preferência de estabelecimento padrão | A tela escreve na rota de preferências da plataforma, com a chave nexio.default-establishment-id. |
| Eventos não são entregues | Nenhum outro contexto reage ainda a desativação, troca de matriz, situação cadastral ou revogação de escopo. A tela não deve depender de efeito derivado desses eventos. |
| Pontos de entrega ainda não existem | Mudar município, UF ou allowsReceiving nunca é recusado por pontos de entrega; o campo deliveryLocationInterstateNotes[] do PUT é aceito e hoje não tem efeito. |
| Situação cadastral não é consultada de verdade | Sem provedor, registrationStatus nasce Null e só a correção manual a muda. |
| Conta inativa ou fora da vigência escolhida à mão | Não há código de erro no catálogo (DEC-CO-13-3); só o lookup a oculta. A tela deve usar sempre o lookup para escolher conta. |
Eventos de integração e jobs
| Evento | Quando | Carga (campos) |
|---|---|---|
establishment.deactivated | Um por estabelecimento desativado: na desativação individual e na cascata da empresa. Stream = a empresa. | tenantId, companyId, establishmentId, byCompanyCascade, occurredAt |
company.headquarters-changed | Troca efetiva de matriz (PATCH .../headquarters); indicar a matriz atual não publica. | tenantId, companyId, previousHeadquartersId, headquartersId, occurredAt |
establishment.registration-status-changed | Só quando a situação cadastral muda, por correção manual ou pela varredura. | tenantId, companyId, establishmentId, previousStatus, previousStatusDate, status, statusDate, source, occurredAt. A nota do comprovante não vai na carga. |
number-sequence.replaced | Substituição de configuração (publicada pelo ato de substituição, não pela rota). Stream = a configuração sucessora. | replacedSequenceId, sequenceId, documentType, effectiveFrom |
user-company-scope.granted | Por linha aberta, no PUT de escopo e no POST /companies. Stream = a linha. | scopeId, userId, companyId, validFrom, validTo, origin, grantedByUserId |
user-company-scope.revoked | Por linha encerrada: no PUT, no evento de remoção e na varredura de remoção. Stream = a linha. | scopeId, userId, companyId, validFrom, endedAt, origin, grantedByUserId, endedByUserId, cause, causingScopeId, removedAt. cause ∈ Act, GrantorChange, UserRemovedFromTenant; removedAt nulo = desconhecido. |
Todos são gravados em nexio.outbox na mesma transação do fato, antes do último SaveChanges. Nada é entregue: não há consumidor nem AddOutboxWorker. O kind é o nome literal e a versão fica em coluna própria; stream = (tenant, aggregateId), sequência = próxima posição do stream.
| Job | Padrão | O que faz |
|---|---|---|
organization.tenant-roster | 7 * * * * | Seleciona os tenants que têm empresa e grava, por tenant, uma entrada recorrente de cada job abaixo ({jobId}:{tenant}). Tenant novo é vigiado em até uma hora. |
organization.company-administrator-watch | 0 6 * * * | Confere a invariante do administrador permanente em toda empresa, ativa ou inativa, e avisa pela cadeia de três degraus (linha e permissão → administradores do tenant → canal de suporte); repete a cada dia enquanto a invariante não se sustenta. Não grava no banco do Nexio. |
organization.scope.removed-users-sweep | 30 3 * * * | Encerra, como ato de sistema, as linhas alcançadas pela remoção da pessoa da organização (caminho 3 da RN-EMP-24); é a rede sob o evento de remoção em processo, uma vez ao dia. |
organization.establishment-registration-sweep | 30 4 * * * | Consulta o provedor de CNPJ por estabelecimento ativo de empresa ativa, do mais desatualizado ao teto da janela (Organization:RegistrySweep:CeilingPerWindow, padrão 500). |
Invariantes
Regras de negócio
Tenant sem empresa ativa está em onboarding
Estado explícito, não seed: a plataforma não conhece Company e não chama o Nexio ao criar tenant. Nesse estado nenhum documento é emitido e a única tela disponível é o cadastro da primeira empresa, com o CNPJ pedido ali — mesmo precedente da união vazia de escopos: o usuário não opera, e a interface diz por quê
Toda empresa tem exatamente uma matriz
No máximo uma: unicidade de (companyId) entre os estabelecimentos com type = Headquarters — índice único filtrado é exemplo de implementação. Pelo menos uma: POST /companies exige a matriz inline e grava as duas na mesma transação; estabelecimento criado depois nasce Branch; o PUT ignora type; a única troca é o PATCH /companies/{id}/headquarters (RN-EMP-38). ESTABLISHMENT_HEADQUARTERS_REQUIRED_UNPROCESSABLE guarda o agregado
Todo estabelecimento carrega a raiz da sua empresa
Validação no agregado, 422 ESTABLISHMENT_CNPJ_ROOT_MISMATCH_UNPROCESSABLE. No esquema, a garantia é chave estrangeira composta, não check entre tabelas: o estabelecimento guarda cnpjRoot derivado do cnpj e referencia (tenantId, companyId, cnpjRoot) em companies(tenantId, id, cnpjRoot), com unicidade desse trio do lado da empresa
Empresa ativa não fica sem estabelecimento ativo
Na criação, pela matriz inline; depois, verificação na desativação do último estabelecimento ativo, 409. A desativação da empresa não passa por essa guarda: ela cascateia, e empresa inativa sem estabelecimento ativo é o estado correto
Desativar não emite documento novo; documento em andamento conclui
A mesma regra para empresa, estabelecimento e ponto de entrega. Criar ou submeter documento novo — submeter rascunho inclui — em estabelecimento inativo ou de empresa inativa é recusado com 422 ESTABLISHMENT_INACTIVE_UNPROCESSABLE ou 422 COMPANY_INACTIVE_UNPROCESSABLE. Todo documento já submetido segue até o fim: aprovação, pedido, recebimento, nota, devolução e estorno. A desativação nunca é recusada por haver documento em aberto. A resolução do UserCompanyScope não lê isActive: nenhuma leitura de histórico depende do estado
A fatia de rateio usa a empresa do documento
A fatia não tem coluna de empresa. A invariante do centro de custo e a resolução do Budget leem a empresa do documento. Não existe rateio entre empresas na v1
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 nem do token
Derivado no servidor do establishmentId, depois de validado contra o conjunto resolvido do usuário — fora dele, 404. O token não traz empresa, e o cliente nunca a informa como autoridade: companyId em query de listagem só intersecta com o conjunto
Documento aponta para estabelecimento e congela a empresa
Regra da linha do documento. Alteração de cadastro não reescreve documento emitido
A política de aprovação segue o estabelecimento do documento
Critério Empresa em lista do Fluxo de Aprovação, avaliado sobre o companyId do documento. Quem opera a compra é usuário; não existe eixo de política pelo lado do comprador
Linha da empresa A só usa centro de custo corporativo ou de A
Especificado no Centro de Custo, lido contra a empresa do documento e o companyId efetivo do nó
Conta sem allowsPosting não recebe rateio
Validação na fatia, 422
Fatia sem conta não submete
Validação na submissão do documento, 422 ALLOCATION_GL_ACCOUNT_REQUIRED_UNPROCESSABLE; nulo só em rascunho (RN-CC-29)
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 de quem saiu da organização é encerrada (RN-EMP-24)
Ausência de linha = nenhuma empresa
Resolvedor. Falha fechada, qualquer que seja o número de empresas do tenant; não existe concessão "todas as empresas". Ver a tabela de resolução em Escopo e segurança
Nenhuma tabela do gryd é alterada
Fronteira de camada. A plataforma não conhece Company, e não deve conhecer
Mudar município, UF ou allowsReceiving do estabelecimento revalida todos os pontos de entrega dele
PUT /establishments/{id} que altera cityIbgeCode, UF ou allowsReceiving revalida, na mesma transação, todos os pontos de entrega do estabelecimento, os de endereço próprio incluídos. Ponto sem endereço próprio copia o stateCode daqui, e só nele a UF é recalculada; isInterstate é recalculado em todos. A nota de cada ponto que passa a ser interestadual vem no mesmo corpo, em deliveryLocationInterstateNotes[] (deliveryLocationId + interstateNote); nota para ponto que não passa a interestadual é ignorada. A edição é recusada com 422 ESTABLISHMENT_ADDRESS_CHANGE_BREAKS_DELIVERY_LOCATIONS_UNPROCESSABLE, listando os pontos, quando algum ponto fica interestadual sem explicação — sem nota, fora de canteiro e sem recebedor distinto, ou canteiro sem centro de custo — ou quando allowsReceiving passa a falso com ponto ativo pendurado, que ficaria apontando para estabelecimento que deixou de receber: desative os pontos antes, o que nunca é recusado por documento (RN-DL-15). Passando, cada ponto afetado sobe a version e emite um evento. Escrita no Local de Entrega (RN-DL-19), com o mesmo gatilho e o mesmo código, aplicada aqui
CNPJ é varchar nos dois lados do produto, normalizado antes de validar
Esquema: cnpjRoot varchar(8) e cnpj varchar(14), iguais aos do Fornecedor. Entrada: trim, sem máscara, letras em maiúsculas. Validação: 12 caracteres em 0–9A–Z e 2 dígitos verificadores, DV com ASCII − 48. Formato ou DV inválido é 400 ESTABLISHMENT_CNPJ_INVALID; raiz inválida, 400 COMPANY_CNPJ_ROOT_INVALID
Concessão só dentro do escopo de quem concede
PUT /users/{userId}/company-scopes só abre ou encerra linha de empresa que quem concede alcança no momento do ato. Empresa fora desse conjunto, no corpo, responde 404 COMPANY_NOT_FOUND e nada é gravado. Linhas da pessoa em empresas que quem concede não alcança não são lidas, não aparecem no GET e não mudam. Consequência: um administrador de uma empresa não entrega outra. E ninguém mexe nas próprias linhas (RN-EMP-41).
Empresa recém-criada é concedida só a quem a criou
POST /companies exige, além de create:companies, update:company-scopes no token de quem cria, conferida antes de gravar — sem ela, 403 COMPANY_CREATION_SCOPE_PERMISSION_FORBIDDEN e nada é gravado. Com ela, grava na mesma transação uma linha de escopo para quem criou, com origin = CompanyCreation e sem validTo: a empresa nasce cumprindo a RN-EMP-24. Ninguém mais recebe a empresa — nem quem alcançava todas as outras. O evento é o mesmo de qualquer concessão (RN-EMP-21).
Mudança de escopo é ato de controle
Toda linha aberta publica user-company-scope.granted e toda linha encerrada por ato ou pela remoção da pessoa da organização publica user-company-scope.revoked, depois do commit, com pessoa, empresa, vigência, origin e autor. O evento notifica, mede e integra; o efeito — a linha — já está gravado. Vencer pelo validTo informado na concessão não publica nada. Registro da trilha: o requisito é registro isolado por tenant, gravado na transação do ato; desde o Gryd.IO 11.1.0 ele é preparado por IAuditTrail.Stage junto com o evento. A própria linha, que não se apaga nem se reescreve, também guarda autor e instantes.
Vigência sem sobreposição, e revogação na requisição seguinte
A linha vale em [validFrom, validTo), em instantes UTC. Para a mesma pessoa e empresa não há duas linhas com intervalos que se cruzem — 409 USER_COMPANY_SCOPE_DUPLICATE_CONFLICT. Encerrar grava validTo com o instante do ato, e a resolução seguinte já não a lê. Linha encerrada não reabre; reconceder é linha nova. validTo informado precisa ser posterior ao instante da concessão — 422 USER_COMPANY_SCOPE_VALIDITY_UNPROCESSABLE.
PUT com lista vazia remove o acesso, e escrita concorrente não se mistura
Lista vazia encerra todas as linhas vigentes da pessoa nas empresas que quem concede alcança — com escopo em todas, a pessoa fica sem empresa nenhuma. A escrita é serializada em duas camadas, adquiridas sempre nesta ordem: primeiro por (tenantId, userId) de cada pessoa cuja linha o ato grava, em ordem crescente de userId; depois por (tenantId, companyId) de cada empresa que o ato toca, em ordem crescente de companyId. A ordem é total nas duas camadas, e é por isso que o ato que grava linha de várias pessoas de uma vez — a carga da migração — não trava contra outro igual: duas cargas com as mesmas pessoas em ordens diferentes ficariam uma esperando a outra para sempre. No PUT, as empresas tocadas são as do corpo e as das linhas vigentes da pessoa que quem concede alcança; os cortes e as decisões da RN-EMP-42 ficam na empresa da linha de origem e por isso já estão cobertos. Só depois de adquirir todas as travas, e sob elas, o ato lê a linha de quem concede, as linhas dependentes em cadeia e as linhas que sustentam a invariante da RN-EMP-24, e confere expectedScopeHash; divergente, 409 USER_COMPANY_SCOPE_CONCURRENT_CHANGE_CONFLICT e nada é gravado. A trava da empresa é tomada, na mesma ordem, por todo ato que grava linha ou confere a invariante nela: PATCH /companies/{id}/activate, o encerramento por remoção da organização e a gravação do ato de suporte no fim da carência (RN-EMP-24), e a carga da migração. É requisito, não recurso de banco: trava de linha numa tabela de controle por empresa é exemplo de implementação; o equivalente é qualquer serialização com as mesmas chaves e a mesma ordem de aquisição. Caso de ataque. Com a trava só da pessoa, U1 e U2 têm as duas únicas linhas sem fim na empresa X; dois PUT simultâneos, um sobre U1 e outro sobre U2, encerram uma linha cada, cada um enxerga a do outro ainda vigente, os dois passam na guarda, e X fica sem administrador. Ou Z encurta a linha de B, travando só B, enquanto B concede a D até depois do novo fim, travando só D: a cascata de Z não enxerga D. Com a trava de X, o segundo ato espera o primeiro e lê o resultado dele. E, sem a trava da pessoa, "remover tudo" concorrente com "acrescentar B" terminava com a pessoa em B.
Toda empresa tem um administrador permanente — e a recuperação é do suporte
Invariante: toda empresa, ativa ou inativa, tem ao menos uma linha vigente sem validTo de pessoa ativa que tem update:company-scopes no GrydAuth. Ativa é quem tem vínculo ativo com a organização e não está bloqueada: linha de pessoa bloqueada ou removida não sustenta a invariante. Linha temporária nunca conta, por mais longa que seja: sem isso, A (sem fim) concede a C até 30/09, C encerra a linha de A, e em 30/09 a empresa fica sem ninguém. A invariante é conferida sob as travas da RN-EMP-23 — a de cada empresa tocada incluída —, com a permissão e o vínculo lidos no GrydAuth no instante do ato, em dois atos: no PUT /users/{userId}/company-scopes, recusado quando a empresa — ativa ou inativa — a cumpria antes e deixa de cumprir depois — encerrar, ou pôr validTo em, a última linha sem fim de quem tem a permissão, diretamente ou pelo corte em cadeia da RN-EMP-42 —; e no PATCH /companies/{id}/activate, recusado quando a empresa a reativar não a cumpre — só linhas temporárias, ou nenhuma linha sem fim de quem tem a permissão. Em qualquer caso, o ato que deixaria empresa, ativa ou inativa, sem nenhuma linha vigente, de ninguém, também é recusado. Todos respondem 409 USER_COMPANY_SCOPE_LAST_GRANT_CONFLICT, listando as empresas: conceda antes linha sem fim a outra pessoa com a permissão. A empresa inativa entra na guarda do PUT porque, fora dela, quem tem update:companies e só linha temporária desativaria a empresa, encerraria a última linha sem fim e deixaria a reativação em 409 para sempre — quem sobra não concede linha sem fim (RN-EMP-42). A guarda não segura o GrydAuth: a pessoa da linha sem fim pode sair da organização ou perder a permissão depois. Uma rotina diária — job com o tenant no payload, que não age em nome de ninguém — confere a invariante em toda empresa, ativa ou inativa, e, quando ela não se sustenta, avisa quem tem linha vigente e update:company-scopes na empresa, com destaque quando todas essas linhas terminam em até 7 dias; sem destinatário com linha e permissão, o aviso desce uma cadeia declarada, e só se cai num degrau quando o anterior não tem ninguém ativo: (1) quem tem linha vigente e update:company-scopes na empresa; (2) os administradores do tenant no GrydAuth — os membros elegíveis (vínculo ativo e dentro do período, conta ativa) que têm o papel Admin; o papel GroupAdmin não conta; (3) o canal de suporte da plataforma, que só recebe o aviso — com as empresas nomeadas e a lista das Restrict pendentes — e não abre o ato de suporte: o ato continua sendo pedido por um administrador do tenant, com confirmação no e-mail já cadastrado dele (adiante). O último degrau não depende de ninguém do tenant, e é por isso que a cadeia nunca termina sem destinatário — mas avisar não é conceder. Risco aceito, declarado aqui: tenant sem administrador ativo no GrydAuth (ninguém elegível com o papel Admin) não tem caminho pelo produto. A empresa segue sem administrador e as Restrict pendentes continuam órfãs até que o cliente devolva o papel Admin a alguém no GrydAuth, e nenhuma regra desta página compensa isso: abrir a porta pelo suporte recriaria a engenharia social que a exigência de quem pede fecha — recuperar superadministrador no Google Workspace e no Microsoft 365 exige outro superadministrador, e o suporte não concede escopo. A mitigação é o que se exige: o aviso é obrigatório, a lista das Restrict pendentes vai em todos os degraus e a rotina diária o repete enquanto a invariante não se sustentar, de modo que o estado fica visível em vez de silencioso. Quem é o administrador do tenant é leitura em processo do GrydAuth embutido, e o endereço do canal de suporte é configuração (Platform:SupportChannel); esta página declara a ordem e o que vai em cada degrau. Enquanto a invariante não se sustenta, o PUT que não a piora continua aceito, com uma exceção: encerrar ou pôr validTo na última linha sem fim da empresa, de quem quer que seja que continue na organização — inclusive a de quem perdeu a permissão ou está bloqueado —, é recusado com o mesmo 409, porque é por essa linha que a invariante volta. Tirar o acesso dessa pessoa não espera por isso: basta retirar os papéis dela, ou bloqueá-la, no GrydAuth, já que escopo sem permissão não dá acesso a nada. A invariante volta por dentro do produto só se o GrydAuth devolver a permissão, ou desbloquear, quem tem linha sem fim: quem só tem linha temporária não concede linha sem fim (RN-EMP-42). Fora disso, só o suporte da plataforma concede, fora da API de negócio, como ato de controle, com o mesmo evento e registro, e nunca para o próprio operador. O ato de suporte só é pedido por um administrador do tenant (papel Admin no GrydAuth), com confirmação no e-mail já cadastrado dele; todos que têm update:company-scopes no tenant recebem aviso; e a concessão só vale depois de uma carência de 24 h sem contestação. O beneficiário tem update:company-scopes, tem identidade verificada e, havendo outra pessoa ativa na organização com essa permissão, não pode ser quem pede; só sem ninguém mais quem pede é o beneficiário. A conta é por pessoa, não por usuário: "outra pessoa ativa com update:company-scopes" conta por CPF verificado — dois usuários do mesmo CPF são uma pessoa só e não fazem número —, e o CPF do beneficiário tem de ser diferente do CPF de quem pede. Beneficiário sem identidade verificada, ou com o CPF de quem pede, é recusado com 422 COMPANY_SCOPE_SUPPORT_GRANT_BENEFICIARY_UNPROCESSABLE, no pedido e de novo no fim da carência. Sem isso, quem pede cadastrava um segundo usuário com o próprio CPF, ele fazia número na conta e era o beneficiário do próprio ato de suporte. Nessa conta, quem teve update:company-scopes retirada nos 30 dias anteriores ao pedido conta como se ainda a tivesse, e recebe o aviso do pedido: sem isso, quem pede retiraria a permissão dos outros e passaria a ser o único. Retirada é a perda da permissão efetiva, por qualquer via — atribuição direta removida da pessoa, papel que a carrega retirado da pessoa, permissão retirada do papel, papel excluído: o que conta é a pessoa ter deixado de resolver update:company-scopes no GrydAuth, não o caminho pelo qual deixou. Sem essa amplitude, bastaria a quem pede tirar a permissão do papel, ou excluir o papel, para ninguém aparecer como retirado nos 30 dias e ele ficar sozinho na conta. Bloquear ou remover os outros da organização não entra nessa conta — risco aceito; o aviso e a carência continuam valendo. A linha nasce sem validTo e é gravada no fim da carência — o validFrom é esse instante, não o do pedido —, com as condições conferidas de novo nesse instante: se deixaram de valer, nada é gravado. Essas condições são regra da demanda de plataforma do Gryd.IO (ato de suporte "conceder escopo em empresa sem administrador"), e esta página só as cita. Não existe autoconcessão no produto.
Remoção da organização encerra; readmissão não revive. Quando a pessoa sai da organização no GrydAuth, as linhas alcançadas pela remoção são encerradas pelo primeiro de três caminhos. Alcançada se decide pelo vínculo da pessoa no instante em que o caminho roda, não pelo instante da remoção. Pessoa sem vínculo ativo com a organização: toda linha vigente dela é alcançada, qualquer que seja o validFrom. Pessoa com vínculo ativo, porque foi readmitida: alcançada é a linha vigente cujo validFrom é anterior ao início do vínculo vigente. Linha concedida depois da readmissão nunca é alcançada; linha anterior à remoção continua alcançada mesmo que a pessoa já tenha voltado. O instante da remoção informado pela plataforma fica no registro do encerramento e não recorta mais o alcance: linha aberta depois dele, na janela em que o Nexio ainda não sabia da saída, é alcançada como qualquer outra. Os três caminhos: (1) quem tem update:company-scopes trata a remoção pelo PUT /users/{userId}/company-scopes com lista vazia — o único PUT aceito sobre quem não está na organização; com empresa no corpo é 422 USER_NOT_IN_TENANT_UNPROCESSABLE — e decide no próprio ato as dependentes Restrict (RN-EMP-42); (2) o consumo do evento de remoção publicado pela plataforma; (3) a rotina diária, que procura linha alcançada em toda pessoa com linha vigente — a pessoa sem vínculo ativo e a pessoa com vínculo ativo que começou depois do validFrom da linha —, e não só quem está sem vínculo ativo. Em (2) e (3) o encerramento é de sistema: endedAt com o instante, endedByUserId nulo, user-company-scope.revoked com cause = UserRemovedFromTenant; as dependentes Cascade caem em cadeia e as Restrict ficam, com aviso a quem tem linha vigente e update:company-scopes na empresa listando-as para decisão. Sem destinatário com linha e permissão — o caso em que quem sai era o único administrador da empresa —, o aviso desce a mesma cadeia declarada do aviso da invariante — administradores do tenant e canal de suporte da plataforma —, e a lista das Restrict pendentes vai em todos os degraus, o último incluído — sem ele, quando quem saiu era também o administrador do tenant, ninguém ficaria sabendo que elas existem. O canal de suporte não decide nenhuma delas nem abre o ato de suporte: enquanto não houver administrador do tenant ativo para pedi-lo, as Restrict seguem pendentes e a empresa, sem administrador — é o risco aceito da cadeia, escrito na invariante. Enquanto houver Restrict pendente de decisão, a lista delas é repetida no aviso da rotina diária e no aviso do pedido do ato de suporte daquela empresa. Nos três caminhos o ato roda sob as travas da RN-EMP-23 e fora da guarda desta regra — linha de quem saiu não sustenta nada —; a empresa que ficar sem administrador entra no aviso da rotina e no ato de suporte. Readmitir a pessoa não revive escopo: a resolução não lê linha com validFrom anterior ao início do vínculo vigente da pessoa com a organização — a linha vale como encerrada até que um dos três caminhos a encerre —, e voltar a operar exige concessão nova. E o PUT não a revive: todo PUT /users/{userId}/company-scopes sobre pessoa readmitida encerra primeiro, no mesmo ato e como ato de sistema — endedAt com o instante, endedByUserId nulo, cause = UserRemovedFromTenant, Cascade em cadeia, Restrict mantidas com aviso —, as linhas alcançadas que ainda estiverem abertas, e só então compara o corpo com as linhas vigentes. Sem essa ordem, a empresa no corpo casaria com a linha antiga, que seria mantida, com o grantedByUserId e o validFrom de antes, em vez de concedida de novo por quem está gravando. Caso de ataque. Sem isso, a linha de quem saiu ficava vigente e inerte, e a readmissão meses depois, por engano ou de propósito, devolvia a empresa sem que ninguém concedesse nada. E sem o recorte por validFrom: a pessoa sai, é readmitida no mesmo dia e recebe linha nova, sem fim, na empresa X; o evento de remoção, atrasado, chega depois e encerra a linha nova — X fica sem administrador —, enquanto a rotina, que procurava só quem está sem vínculo ativo, nunca chega à linha antiga. E sem o corte pelo vínculo: a pessoa é removida no GrydAuth às 10h, o evento chega ao Nexio às 14h e, às 11h, quem ainda lia o vínculo antigo lhe concede a empresa Y — linha com validFrom posterior ao instante da remoção. Com o corte por esse instante, essa linha não era alcançada por caminho nenhum: a rotina não a achava, o PUT da readmissão não a encerrava, e a resolução a ignorava para sempre, por ser anterior ao início de qualquer vínculo futuro. Readmitida a pessoa, a linha — vigente, sem validTo, de pessoa de novo ativa com a permissão — passava a sustentar a invariante desta regra sem dar acesso a nada: um administrador fantasma. E o PUT normal nunca mais concedia Y àquela pessoa, porque a empresa no corpo casava com a linha morta e ela era mantida em vez de concedida de novo. Sem vínculo ativo, toda linha vigente é alcançada, e essa é encerrada com as demais.
A resolução falha fechada, e o filtro não esquece entidade
Sem contexto de tenant ou com a leitura do escopo indisponível, a requisição falha com 500; não existe caminho que siga com conjunto mais amplo, com o conjunto de outra requisição ou sem filtro. Toda entidade com companyId ou establishmentId é registrada no filtro de escopo, e um teste de arquitetura quebra o build quando uma entidade nova com uma dessas colunas não está registrada. Critério de aceite: requisição sem linha vigente devolve lista vazia em toda rota de listagem de agregado por empresa, e 404 em toda leitura por id desses agregados.
Escrita resolve exatamente uma empresa
Todo comando que grava dado por empresa resolve uma empresa — a do establishmentId validado, ou a do registro já existente — e confere que ela está no conjunto; nenhum comando recebe conjunto de empresas nem "todas". Leitura pode abranger várias empresas do conjunto; escrita, nunca. É o espelho do que a plataforma faz ao recusar escrita sob leitura de grupo.
"Escopo completo" é linha vigente em todas as empresas ativas, no momento da verificação
Onde outra regra exige que a pessoa alcance todas as empresas — responsável de nó corporativo no Centro de Custo, aprovador de reserva e alvo de política no Fluxo de Aprovação —, a condição é ter linha vigente em cada empresa ativa do tenant no instante em que a regra é verificada. Não existe marca "irrestrito". Criar empresa tira essa condição de todos, menos de quem criou; por isso a tela de criação lista quem a tinha, com os vínculos de nó corporativo e as reservas afetados, para concessão explícita. Na execução, a defesa em profundidade do Fluxo continua valendo para quem ficou sem a empresa do documento.
Uma configuração de numeração vigente por tipo de documento
Para cada (tenant, documentType) as vigências [effectiveFrom, retiredAt) das configurações não se sobrepõem — requisito de exclusão de faixas, a mesma mecânica do CostCenterAssignment. Criar configuração havendo vigente ou agendada, sem replacesSequenceId, é 409 NUMBER_SEQUENCE_ALREADY_EXISTS. A emissão usa a configuração vigente no instante da emissão.
Troca de numeração é substituição, com prefixo novo ou período novo
scope, prefix, resetPolicy e timeZone não se editam: mudar qualquer um é criar a sucessora com replacesSequenceId e o expectedVersion da mais recente, e a substituída recebe retiredAt = effectiveFrom da sucessora na mesma transação. A sucessora é aceita só com prefixo novo — nunca usado por configuração daquele documentType no tenant — ou período novo: mesma resetPolicy (diferente de Never) e mesmo timeZone da substituída, e effectiveFrom exatamente no início de um período posterior ao corrente, nesse fuso, e posterior ao maior período já emitido por qualquer configuração daquele documentType com o mesmo prefixo e a mesma resetPolicy — o maior currentPeriodKey dos contadores de todas elas, não só os da substituída. Sem isso, a troca de fuso recompõe número: PC- em UTC+14 emite PC-2027-000001 quando São Paulo ainda está em 2026; a troca para PD- em UTC−11 é prefixo novo; a volta a PC- em UTC−11, no início de 2027 nesse fuso, seria período novo contra PD- e emitiria PC-2027-000001 de novo, recusado pela unicidade da RN-EMP-32 com 409 a cada emissão, em série. Configuração com a mesma resetPolicy e prefixo diferente, ou com o mesmo prefixo e outra resetPolicy, compõe número de outro formato e não entra na conta. Fora disso, 422 NUMBER_SEQUENCE_REPLACEMENT_UNPROCESSABLE; versão ou alvo divergente, 409 NUMBER_SEQUENCE_CONCURRENT_CHANGE_CONFLICT. Nos dois caminhos, o prefixo da sucessora, quando difere do de uma configuração já criada para o documentType no tenant, não pode começar com ele nem ser começo dele — senão 422 NUMBER_SEQUENCE_PREFIX_OVERLAP_UNPROCESSABLE, nomeando a configuração anterior, na criação da configuração e não na emissão. É o que impede recompor número já emitido: S1 com PC- em Establishment emite PC-SP01-2026-000001, e S2 com PC-SP01- em Company emitiria o mesmo número. Dois prefixos em que nenhum é começo do outro nunca produzem o mesmo número. A regra é a mesma em qualquer troca, sem simular formato de número. Publica number-sequence.replaced depois do commit.
O escopo particiona contadores, e o contador nasce na primeira emissão
Tenant tem um contador; Company, um por empresa; Establishment, um por estabelecimento. O contador é criado na primeira emissão da partição, sob a unicidade (sequenceId, partitionKey), e nunca é compartilhado entre configurações.
O período só avança, pelo relógio do servidor no fuso da configuração
O período de cada emissão é calculado pelo relógio do servidor no timeZone da configuração — nunca em UTC, nunca pela data do documento. Maior que o guardado: grava e zera. Igual ou menor — relógio atrasado de outro servidor —: usa o guardado e incrementa. Na mesma operação atômica, sempre. Documento retroativo recebe o período da emissão.
Número emitido é único por empresa
Unicidade de (tenantId, documentType, companyId, number) no documento, em qualquer escopo — é o que garante a composição com o código do estabelecimento e o que pega colisão residual depois de uma troca. Colisão recusa a emissão com 409 DOCUMENT_NUMBER_ALREADY_EXISTS, sem gravar; o número consumido não volta e repetir emite o próximo. Lacunas são permitidas: o número não é fiscal, e a emissão confirma em transação própria antes do documento, então número consumido por documento que falhou não é reaproveitado.
Cada eixo do documento tem o seu filtro
establishmentId exige estabelecimento ativo, de empresa ativa, com allowsReceiving — senão 422 ESTABLISHMENT_INACTIVE_UNPROCESSABLE, 422 COMPANY_INACTIVE_UNPROCESSABLE ou 422 ESTABLISHMENT_RECEIVING_NOT_ALLOWED_UNPROCESSABLE. requestingEstablishmentId exige estabelecimento ativo com allowsPurchasing — senão 422 ESTABLISHMENT_PURCHASING_NOT_ALLOWED_UNPROCESSABLE. Verificado na criação e na submissão do documento novo; documento já submetido não é reavaliado (RN-EMP-05). O lookup aplica o mesmo filtro pelo purpose.
Quem pede é obrigatório e da mesma empresa de quem recebe
requestingEstablishmentId nunca é nulo: quando quem pede é quem recebe, é igual ao establishmentId. Estabelecimento de outra empresa é recusado com 422 REQUESTING_ESTABLISHMENT_COMPANY_MISMATCH_UNPROCESSABLE. Não existe rateio nem pedido entre empresas na v1; como demanda de várias empresas se consolida é decisão da spec da Cotação, dentro da RN-EMP-26 e desta regra.
Moeda funcional é BRL na v1
Company.functionalCurrency nasce BRL e não muda: outro valor na criação é 422 COMPANY_FUNCTIONAL_CURRENCY_UNPROCESSABLE, e o PUT ignora o campo. Documentos, preços, limites e cotações continuam multimoeda, com conversão para a moeda do limite na submissão.
Estabelecimento em situação irregular não emite documento novo
Criar ou submeter documento novo com estabelecimento — como establishmentId ou requestingEstablishmentId — em Suspended, Unfit ou ClosedDown é 422 ESTABLISHMENT_REGISTRATION_STATUS_UNPROCESSABLE. Null não bloqueia: é o estado de estabelecimento recém-cadastrado que o espelho da Receita ainda não traz, e o risco aqui é fiscal, não de fraude — a empresa não ganha nada mentindo sobre o próprio CNPJ. A situação vem do provedor de consulta ao CNPJ ou de PATCH /establishments/{id}/registration-status, que exige data da situação e nota do comprovante; entre os dois vale a data de situação mais recente, e a consulta seguinte com data posterior substitui a correção manual. Toda mudança publica establishment.registration-status-changed. Documento já submetido não é reavaliado (RN-EMP-05).
O documento congela os dados fiscais de quem recebe
Na submissão, o documento congela taxRegime da empresa e icmsTaxpayerStatus, stateRegistration e suframaCode do estabelecimento. Mudança de cadastro vale para documento novo e nunca reescreve documento submetido — o Simples é opção anual, e a nota de um pedido de dezembro não muda de tratamento porque o regime mudou em janeiro.
Trocar a matriz é um ato só
PATCH /companies/{id}/headquarters com { establishmentId, expectedVersion }: na mesma transação, a matriz atual vira Branch, o estabelecimento indicado vira Headquarters e headquartersId é regravado — a empresa nunca passa por "sem matriz" nem por "duas matrizes". Estabelecimento de outra empresa ou fora do escopo é 404 ESTABLISHMENT_NOT_FOUND; inativo, 422 ESTABLISHMENT_INACTIVE_UNPROCESSABLE; versão divergente, 409 COMPANY_CONCURRENT_CHANGE_CONFLICT; indicar a matriz atual devolve 200 sem mudança. Publica company.headquarters-changed. Documento emitido não muda: ele aponta para estabelecimento, não para "a matriz".
O código enviado ao ERP tem ordem de resolução
Para a conta de uma fatia, o código externo é GLAccountCompany.externalCode da empresa do documento; ausente a linha ou o código, GLAccount.externalCode. Sem nenhum dos dois, o movimento não sai para o ERP e fica visível como aguardando código externo, com a conta e a empresa nomeadas — nunca descartado e nunca enviado sem código. Preencher o código libera o envio.
Escrita em empresa e estabelecimento exige versão
Company e Establishment têm version int. PUT, /activate, /deactivate, /headquarters e /registration-status exigem expectedVersion — divergente, 409 COMPANY_CONCURRENT_CHANGE_CONFLICT ou 409 ESTABLISHMENT_CONCURRENT_CHANGE_CONFLICT, sem gravar nada. É o mesmo token do plano de contas, do Centro de Custo, do Item e do Fornecedor.
Ninguém mexe nas próprias linhas de escopo
Quem concede não abre, não encerra e não altera a vigência das próprias linhas de UserCompanyScope — nem para ampliar validTo, nem para repetir o que já tem. PUT /users/{userId}/company-scopes com o userId de quem concede é recusado com 403 USER_COMPANY_SCOPE_SELF_CHANGE_FORBIDDEN, com ou sem mudança no corpo, antes de qualquer outra guarda. As únicas linhas que nascem para a própria pessoa são as de sistema: a de quem cria a empresa (RN-EMP-20) e a do suporte da plataforma (RN-EMP-24). Sem esta regra, um administrador com acesso temporário a uma empresa estenderia o próprio prazo sem ninguém ver.
A concessão não passa da vigência de quem concede
"Só se concede o que está no próprio escopo" vale também no tempo. Na empresa em que a linha vigente de quem concede tem validTo, toda linha que o PUT /users/{userId}/company-scopes abre ou reabre para outra pessoa precisa de validTo menor ou igual ao dela; sem validTo, ou com validTo posterior, é 422 USER_COMPANY_SCOPE_BEYOND_GRANTOR_UNPROCESSABLE, nomeando a empresa, e nada é gravado. Quem concede com linha sem fim concede qualquer vigência. A comparação usa a linha de quem concede no instante do ato, e a regra continua valendo depois dele pelo onGrantorChange, gravado na concessão: Cascade quando a linha de quem concede tem validTo, Restrict quando não tem. (1) Cascade: sempre que o fim da vigência de quem concedeu, naquela empresa, diminui — validTo encurtado ou linha encerrada —, a linha dependente cujo validTo passa do novo fim é cortada no mesmo ato e na mesma transação: encerrada, se o novo fim é o instante do ato; encerrada e reaberta com o novo fim, se ele é futuro, conservando grantedByUserId e onGrantorChange. O corte segue em cadeia pelas linhas que ela concedeu, alcança inclusive a linha de quem faz o ato, e cada linha cortada publica user-company-scope.revoked com cause = GrantorChange e a linha de origem. (2) Restrict: o PUT que encerra ou encurta a linha de quem concedeu exige, em dependentGrantDecisions, uma decisão — Keep, End ou Trim — para cada linha Restrict que o novo fim alcança, também em cadeia (as dependentes de uma linha encerrada por End, cortada por Trim ou cortada por Cascade entram na conta). Trim corta a linha até o novo fim de quem concedeu: encerrada e reaberta com esse fim, conservando grantedByUserId e onGrantorChange; quando o novo fim é o instante do ato, vale como End. Cada linha entra uma vez: linha que o ato já alcançou — a encerrada ou encurtada pelo corpo, uma dependente já listada, decidida ou cortada — não volta à lista nem é cortada de novo, e a cadeia para nela; é o que fecha o ciclo em que A concede a B e B reabre a linha de A. Faltando decisão, ou sobrando, 409 USER_COMPANY_SCOPE_DEPENDENT_GRANTS_CONFLICT com a lista, e nada é gravado. A própria linha de quem faz o ato só admite Keep; End ou Trim nela é 403 USER_COMPANY_SCOPE_SELF_CHANGE_FORBIDDEN (RN-EMP-41). Aumentar a vigência, ou encerrar e reabrir sem encurtar, não corta nada. A guarda da RN-EMP-24 é conferida sobre o resultado do ato inteiro, cortes incluídos. Exemplo. A tem linha sem fim na empresa X e concede a B até 30/09 (Restrict: a linha de A não tem fim). B concede a C até 20/09 (Cascade: a de B tem). Em 10/09, Z encurta B para 15/09: C é cortada para 15/09 no mesmo ato, sem pergunta — sem o corte, C, ainda com linha até 20/09, devolveria a B o acesso até 20/09. Em 12/09, Z encerra a linha de A: o PUT sem decisão volta 409 USER_COMPANY_SCOPE_DEPENDENT_GRANTS_CONFLICT listando B; com End, B é encerrada e C cai junto, por Cascade; com Keep, B e C seguem até 15/09. Se, em vez de encerrar, Z encurtasse A para 14/09, a lista seria a mesma, e Trim levaria B a 14/09 e C, por Cascade, também. Ciclo: A concede a B, e B reabre a linha de A, que passa a ter B como quem concedeu; Z encerra A e decide End em B — a linha de A, dependente de B, já foi alcançada pelo ato e não volta à lista. Sem esta regra, um acesso temporário viraria permanente por interposta pessoa, e revogar alguém não revogaria o que ele espalhou.
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 · Onboarding no lugar do seed
Não se semeia Company a partir do tenant: a plataforma não conhece Company, nada no Gryd.IO chama o Nexio ao criar um tenant, e TenantSettings não existe nela. O tenant novo nasce sem empresa e isso é um estado explícito de onboarding — não emite documento, e a única tela disponível é o cadastro da primeira empresa, que pede o CNPJ ali e cria Company e Establishment Headquarters numa chamada só (POST /companies com a matriz inline). Não há tenant em produção: para os que existirem antes deste release, o mesmo onboarding serve, sem migrar dado.
2 · O CNPJ solto do tenant sai no mesmo release
O que o Nexio tinha de CNPJ único por tenant — do lado do Nexio, nunca da plataforma — sai junto com a entrada de Company. 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 "ninguém alcança empresa nenhuma". ApprovalPolicy também não ganha coluna: o critério resolve. Nenhuma coluna existente precisa de backfill — a renomeada continua anulável, porque nulo é nó corporativo, e a tabela nova nasce vazia —, e não há janela.
4 · Tenant existente recebe escopo explícito, nunca implícito
Não há cliente em produção. Para tenant que já exista antes deste release, a migração não concede nada sozinha: a carga é um ato do suporte da plataforma, fora da API de negócio — por isso fora da RN-EMP-19 e da RN-EMP-41, que regem o PUT do tenant —, e toma do ato de suporte da RN-EMP-24 o pedido de um administrador do tenant, o aviso e a carência, e nada além deles: a lista pessoa × empresa só é pedida por um administrador do tenant no GrydAuth, com confirmação no e-mail já cadastrado dele; todos que têm update:company-scopes no tenant recebem aviso com a lista; e ela só é gravada no fim da carência de 24 h sem contestação, com as condições conferidas de novo nesse instante. Quem pede só entra na lista se ninguém mais ativo na organização tiver update:company-scopes, contando quem a teve retirada nos 30 dias anteriores ao pedido (RN-EMP-24); havendo, ele recebe as próprias linhas pela tela, de quem recebeu linha na carga. Identidade verificada só onde há risco de empresa órfã: a carga não exige CPF verificado de cada pessoa da lista — exige identidade verificada de quem recebe linha em empresa que, sem essa linha, ficaria sem nenhuma linha sem fim de quem tem update:company-scopes. Para as demais pessoas da lista valem o pedido de quem pede e a reconferência do fim da carência, e nada mais. Pessoa nessa condição sem identidade verificada é recusada com 422 COMPANY_SCOPE_SUPPORT_GRANT_BENEFICIARY_UNPROCESSABLE, nomeando a pessoa e a empresa, no pedido e de novo no fim da carência. A exigência acompanha o risco que a guarda do beneficiário protege: remetida às condições inteiras do ato de suporte, ela alcançaria cada pessoa da carga e nenhum tenant migraria antes de a plataforma entregar a identidade verificada. A carga grava uma linha por par, com origin = Grant, grantedByUserId = o operador de suporte, sem validTo e sem onGrantorChange, publicando user-company-scope.granted para cada linha — é ato de controle como qualquer concessão (RN-EMP-21). O operador não recebe linha: a carga recusa lista que o inclua. E recusa a lista inteira se ela deixar empresa, ativa ou inativa, sem linha sem fim de quem tem update:company-scopes (RN-EMP-24). Quem cadastrou a empresa no onboarding já tem a própria linha (RN-EMP-20); quem não está na lista não opera até ser concedido pela tela. Com uma empresa só, o seletor não aparece e a numeração continua única.
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 como site — o site não tem identificação fiscal própria. O que existe é a Legal Reporting Unit, subdivisão da entidade legal que carrega registros fiscais; usá-la por filial resolve o CNPJ e cobra o preço de modelar cada filial na estrutura legal | 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 |
| Rateio, pedido e transferência entre empresas | Fora da v1. Quem pede e quem recebe são da mesma empresa (RN-EMP-34). Como demanda de várias empresas se consolida é decisão da spec da Cotação, que tem de respeitar a RN-EMP-26 e a RN-EMP-34. Acerto entre empresas é 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 |
Registro
Histórico de revisões
| Versão | Data | O que mudou |
|---|---|---|
| v1.23 | 30/10/2026 | Permissões padronizadas em cinco verbos (decisão do Roger, 30/10/2026): read, lookup, create, update e delete. De/para: manage:company-scopes → update:company-scopes; numeração, que só tinha manage:number-sequences (inclusive a leitura), passa a read:number-sequences (GET), create:number-sequences (POST) e update:number-sequences (PUT). Os seletores (GET /companies/lookup, GET /establishments/lookup, GET /gl-accounts/lookup) passam a exigir lookup:companies e lookup:gl-accounts; read e lookup não se implicam. Nenhuma regra de negócio muda. |
| v1.22 | 26/10/2026 | Consulta de CNPJ, CEP e município para o cadastro de empresa e estabelecimento (pedido do mantenedor, contrato confirmado em 26/10). Nenhuma regra (RN-EMP-01 a RN-EMP-42) muda. Contrato HTTP: três rotas de leitura sob api/v1/nexio/registry — GET /registry/cnpj/{cnpj}, GET /registry/zip-codes/{zipCode} e GET /registry/municipalities —, todas com read:companies, sem recorte por empresa e sem gravar nada; catálogo de erros de 57 para 62 códigos (REGISTRY_CNPJ_INVALID, REGISTRY_ZIP_CODE_INVALID, REGISTRY_CNPJ_NOT_FOUND, REGISTRY_ZIP_CODE_NOT_FOUND, REGISTRY_LOOKUP_UNAVAILABLE). O backend consulta a BrasilAPI (CNPJ e CEP) e o ViaCEP (reserva do CEP); a origem é configuração (Registry:*). RN-EMP-36 / D-10: o provedor concreto de CNPJ passa a existir, mas a varredura diária só o usa com Registry:Cnpj:FeedRegistrationStatus = true (padrão false: a API pública tem limite de requisições). Contrato para o frontend: nova seção Preenchimento por consulta. |
| v1.21 | 25/10/2026 | GrydAuth embutido (DEC-EMB-0, decisão do mantenedor de 25/10): na primeira versão o Nexio usa o Gryd.IO e o GrydAuth acoplados e não consome a autenticação de outro serviço. Regra alterada — RN-EMP-24: a cadeia de aviso passa de quatro para três degraus (quem tem linha e update:company-scopes na empresa → administradores do tenant → canal de suporte); sai o titular da organização, que o GrydAuth não tem (DEC-EMB-4), e o ato de suporte passa a ser pedido por um administrador do tenant (DEC-CO-36-1, a confirmar). As demais regras (RN-EMP-01 a RN-EMP-42) não mudam. Estado da implementação atualizado: autenticação embutida (Api emissor; Worker, Portal e Migrator leem em processo; banco nexio-identity); diretório, preferências e início do vínculo reais, de modo que a linha 5 da resolução passa a valer e o administrador fantasma da readmissão é provado contra o banco de identidade real; evento de remoção real (o ouvinte anota, o commit do Auth libera, e a varredura das 03:30 UTC, uma vez ao dia, é só a rede); cadeia de três degraus provada; somem os riscos de plataforma de início do vínculo, transporte do evento, administrador e titular, token de serviço por tenant, read:users e indisponibilidade do diretório. Contrato HTTP: PATCH /companies/{id}/activate e PUT /users/{userId}/company-scopes deixam de exigir read:users; USER_DIRECTORY_UNAVAILABLE (503) deixa de existir (catálogo de 57 códigos) e falha de leitura da identidade é 500. Contrato para o frontend: nova subseção Autenticação, com a nota de migração. Conferido em feature/organization-embedded-auth, sobre o Gryd.IO 10.0.0. |
| v1.20 | 25/10/2026 | Implementação do backend entregue; contratos conferidos. Nenhuma regra (RN-EMP-01 a RN-EMP-42) nem decisão foi alterada: o texto da página fica, e o que o código acrescenta ou muda aparece em blocos Implementado:. Novas seções: Estado da implementação (código × spec, item a item, e as divergências da letra decididas pelo mantenedor) e Contrato para o frontend (convenções, listagens, as 29 rotas com corpo, retorno e erros, fluxos de tela, permissões que exibem ou ocultam, lacunas, eventos e jobs). Reconciliadas: Relação com os demais domínios (portas publicadas da CO-19), Endpoints, Contrato de erros (58 códigos; nove acrescentados) e Permissões. Conferido na branch feature/organization-company, HEAD 3872c20, sobre o Gryd.IO 10.0.0: listagens em PagedResult<T>, eventos enfileirados no outbox na mesma transação (worker desligado até haver consumidor). |
| v1.19 | 20/09/2026 | Recuo da validação de 18/09 (achado 12). Sai desta página “a compra consolidada emite um pedido por empresa”, que colidia com a RN-EMP-26: como demanda de várias empresas se consolida, e o escopo de numeração da QuotationRequest, são decisões da spec da Cotação, que tem de respeitar a RN-EMP-26 e a RN-EMP-34. Nenhuma regra muda. |
| v1.18 | 13/09/2026 | Correção da validação R7 — EV-35 (decisão do Roger, 12/09). O degrau 4 da cadeia de aviso só recebe o aviso: o canal de suporte da plataforma não abre mais o ato de suporte, que continua sendo pedido só pelo titular da organização no GrydAuth, com confirmação no e-mail dele (RN-EMP-24). Tenant sem titular ativo não tem caminho pelo produto, e isso fica escrito como risco aceito — a empresa segue sem administrador e as Restrict pendentes seguem órfãs até o cliente reativar um titular —, com a mitigação exigida: aviso obrigatório, lista das Restrict em todos os degraus e repetição pela rotina diária. Sem degrau novo e sem regra compensatória |
| v1.17 | 12/09/2026 | Correção da validação R6. Alcance da remoção: "linha alcançada" passa a se decidir pelo vínculo — sem vínculo ativo, toda linha vigente é alcançada; com vínculo, a de validFrom anterior ao início dele —, e não mais pelo instante da remoção, que fechava a janela em que a linha concedida entre a saída no GrydAuth e a notícia dela virava administrador fantasma. Fallback do aviso: cadeia declarada em quatro degraus — quem tem update:company-scopes na empresa, administrador do tenant, titular da organização, canal de suporte da plataforma —, com a lista das Restrict pendentes em todos. Migração §4 (EV-34): identidade verificada exigida só de quem recebe escopo em empresa que ficaria sem administrador sem ele; sai a remissão às "condições inteiras" do ato de suporte |
| v1.16 | 11/09/2026 | Correção da validação R5. Remoção × readmissão: a remoção encerra só a linha alcançada — validFrom anterior ao instante da remoção, ou ao início do vínculo vigente —, a rotina diária procura linha alcançada em vez de pessoa sem vínculo, e o PUT sobre pessoa readmitida encerra as alcançadas como ato de sistema antes de comparar o corpo (RN-EMP-24). Aviso das Restrict mantidas: sem destinatário com linha e permissão, vai ao administrador do tenant, e a lista pendente é repetida no aviso da rotina e no do ato de suporte. Retirada da permissão é a perda da permissão efetiva por qualquer via — atribuição, papel retirado da pessoa, permissão retirada do papel, papel excluído. Ato de suporte (EV-26): beneficiário com identidade verificada e CPF diferente do titular; "outra pessoa com update:company-scopes" conta por CPF; 422 COMPANY_SCOPE_SUPPORT_GRANT_BENEFICIARY_UNPROCESSABLE. Travas: ordem total também entre pessoas, por userId crescente (RN-EMP-23). |
| v1.15 | 11/09/2026 | Correção da validação R4. Concorrência: a escrita de escopo é serializada também por (tenantId, companyId) de cada empresa tocada, em ordem de id, e a linha de quem concede, as dependentes e a invariante são lidas sob as travas (RN-EMP-23). Invariante: conta só pessoa ativa — vínculo ativo, não bloqueada — e vale para empresa ativa ou inativa, também na rotina diária (RN-EMP-24). Remoção da organização encerra as linhas — PUT com lista vazia sobre quem saiu, evento da plataforma ou rotina, cause = UserRemovedFromTenant, Restrict mantidas com aviso — e readmissão não revive escopo. Ato de suporte: retirada de update:company-scopes nos 30 dias anteriores ao pedido conta como se a pessoa ainda a tivesse; bloqueio e remoção não contam (risco aceito). Cascata: decisão Trim ao lado de Keep e End, e cada linha entra uma vez na lista (ciclo A ↔ B) (RN-EMP-42). Resíduos de data e de mudança saíram do corpo. |
| v1.14 | 11/09/2026 | Correção da validação R3. A guarda do administrador permanente vale também para empresa inativa, e sem a invariante a última linha sem fim, de quem quer que seja, não se encerra (RN-EMP-24). Cascata de escopo: campo onGrantorChange (Cascade · Restrict); linha concedida por quem tinha linha temporária é cortada junto, em cadeia; concedida por quem tinha linha sem fim exige decisão no PUT (dependentGrantDecisions), senão 409 USER_COMPANY_SCOPE_DEPENDENT_GRANTS_CONFLICT (RN-EMP-42). Ato de suporte: beneficiário com a permissão, nunca o titular se houver outra pessoa com ela, linha gravada no fim da carência (RN-EMP-24, validFrom); carga da migração segue as mesmas condições. Numeração: período novo conferido contra toda configuração com o mesmo prefixo e a mesma resetPolicy (RN-EMP-29); padding de 1 a 21, 422 NUMBER_SEQUENCE_PADDING_UNPROCESSABLE. Datas saíram do corpo. |
| v1.13 | 11/09/2026 | Correção da validação R2. Invariante do administrador permanente: empresa ativa tem sempre linha vigente sem validTo de quem tem update:company-scopes, conferida no PUT de escopo e no /activate; linha temporária não conta; aviso diário sem destinatário vai ao administrador do tenant (RN-EMP-24). Concessão não passa da vigência de quem concede (RN-EMP-42, USER_COMPANY_SCOPE_BEYOND_GRANTOR_UNPROCESSABLE). Prefixo de numeração passa a obrigatório e não pode sobrepor prefixo anterior (NUMBER_SEQUENCE_PREFIX_OVERLAP_UNPROCESSABLE, RN-EMP-29). Carga de escopo na migração é ato do suporte da plataforma, sem linha para o operador. Ato de suporte citado com quem pede, confirmação, aviso e carência. POST /companies exige também update:company-scopes (COMPANY_CREATION_SCOPE_PERMISSION_FORBIDDEN). allowsReceiving com o mesmo gatilho e o mesmo texto de erro do Local de Entrega |
| v1.12 | 11/09/2026 | Correção pós-validação. Ninguém mexe nas próprias linhas de escopo (RN-EMP-41, USER_COMPANY_SCOPE_SELF_CHANGE_FORBIDDEN). Guarda da última linha vale também para validTo programado, com aviso diário de empresa que ficará sem escopo em 7 dias. Emissão do número em transação própria, antes do documento. Códigos para recusas que não tinham: COMPANY_CLOSED_UNPROCESSABLE, ESTABLISHMENT_LOOKUP_PURPOSE_REQUIRED e o código do Local de Entrega na edição do estabelecimento, com deliveryLocationInterstateNotes[] no corpo. Tabela de resolução alinhada à desativação. Migração de tenant existente passa a gravar escopo explícito por lista aprovada; saiu o resto "todos anuláveis" e a nota de que até a v1.3 a seção mandava semear Company a partir de TenantSettings.cnpj |
| v1.11 | 10/09/2026 | Validação de 10/09. Regras numeradas RN-EMP-01 a RN-EMP-40. Escopo por empresa fecha por padrão: ausência de linha = nenhuma empresa, sem concessão "todas"; UserCompanyScope.companyId obrigatória, vigência em instantes, sai isActive e sai roleId, entram origin e autoria; concessão só dentro do escopo de quem concede, empresa nova só para quem a criou, guarda da última linha e recuperação pelo suporte da plataforma; tabela de decisão da resolução; reason em inglês; falha fechada e teste de arquitetura; sai a menção a RLS. Numeração: NumberSequence vira configuração com fuso e substituição por prefixo ou período novo; nasce NumberSequenceCounter; período só avança; número único por empresa, com o código do estabelecimento no escopo Establishment; sai a sentinela. Desativação: não emite documento novo e documento em andamento conclui, igual ao ponto de entrega. Eixos do documento: allowsReceiving filtra quem recebe e allowsPurchasing quem pede; requestingEstablishmentId obrigatório e da mesma empresa. Sem rateio entre empresas na v1: sai a empresa da fatia. Moeda funcional BRL na v1. Situação cadastral do estabelecimento com fonte e bloqueio; IE opcional em NonTaxpayer; documento congela os dados fiscais; troca de matriz atômica; CNPJ normalizado e chave composta no lugar do check entre tabelas; código externo da conta com ordem de resolução; token de concorrência em empresa e estabelecimento; dez sufixos de erro |
| v1.9 – v1.10 | 09/09/2026 | Desativar empresa cascateia para os estabelecimentos; closedOn deixa de mexer em isActive; empresa inativa restringe escrita e não leitura; GLAccountCompany.isEnabled ganha ponto de aplicação e o lookup de conta exige empresa; NumberSequence ganha POST, período guardado e período no número; edição de endereço do estabelecimento revalida pontos de entrega |
| até v1.8 | até 08/09/2026 | Estabelecimento padrão passa para a preferência da plataforma; regra de IBS e CBS alinhada ao Cadastro de Item (ausência não é erro); calendário do SLA sai da lista de parâmetros com entidade |
| v1.4 | — | Matriz inline no POST /companies, headquartersId não anulável e onboarding explícito; token de concorrência no plano de contas |
| v1.0 | 01/09/2026 | Primeira versão: empresa dentro do tenant, os dois modos de tenancy, escopo por empresa do Nexio, plano de contas por empresa, numeração e compra centralizada |