Ponto de partida
Dez decisões
Isto é proposta, não decisão fechada. As dez decisões abaixo saíram da conversa de 08/09/2026 e estão escritas para serem lidas contra o acervo, não para serem implementadas ainda. Três pontos continuam abertos e estão marcados no texto: o formato do questionário técnico da cotação, quem escolhe a alternativa vencedora quando o fornecedor manda três, e se o portal ganha idioma próprio antes de existir fornecedor estrangeiro de verdade.
O portal não cria usuário de tenant. A plataforma não tem identidade externa — ExternalIdentity é vínculo de usuário interno, e não existe UserType, IsExternal nem Guest. Podar permissões de um usuário de tenant para acomodar quem não é do tenant abre uma porta que ninguém fecha depois. A identidade do fornecedor é do Nexio, e o uploadedBy do arquivo continua sendo o serviço, como o Anexo já escreveu.
Ninguém se cadastra para cotar. O primeiro convite a um contato com papel comercial e e-mail válido cria o PortalUser em Invited. Vários por fornecedor é o normal, não a exceção: contato é por pessoa e pode estar amarrado a um estabelecimento, então filial e comprador diferentes já são contatos diferentes no cadastro que existe.
A v1 usa convite, token opaco e sessão curta, com limites de tentativa e revogação. submissionConfirmationRequired permite confirmação adicional no envio. Código pelo mesmo canal não é segundo fator independente; identidade convidada não prova quem controlava o canal.
A conta diz quem é; ela não concede nada. Todo alcance vem do link de acesso, que é por convite. A conta só agrega os convites vivos daquele fornecedor. Sem esta regra, dataAccessScope vira uma segunda fonte de verdade de autorização — exatamente o defeito que o ADR 0007 do Gryd.IO eliminou quando apagou allowCrossTenant.
Um link ativo por (PortalUser × cotação × rodada), válido até o envio ou o encerramento da rodada — o que vier primeiro. Reemitir invalida o anterior. Rodada nova é link novo. "Uso único" seria pior: impediria o fornecedor de revisar antes do prazo, que é o comportamento normal de quem cota.
O token é trocado por sessão no primeiro acesso e some da URL. Token em caminho de URL vaza em log de proxy, em histórico e no Referer. O primeiro GET resolve o convite, abre sessão curta e redireciona para um endereço sem segredo nenhum. O fornecedor volta quantas vezes quiser dentro da validade.
Tela linha a linha é a fonte da verdade; planilha é importador para a mesma tela; PDF é anexo, nunca dado. Aceitar preço em PDF acaba com o comparativo e transforma o módulo de cotação em gaveta de arquivo. A planilha obedece à convenção de importação — tudo ou nada, com dryRun obrigatório —, e sai gerada por cotação, com item, quantidade e unidade pedida travados.
Falta de cadastro do lado de cá não recusa envio de lá. Se o fornecedor cota numa unidade sem fator de conversão, a resposta entra como veio e a linha nasce marcada PendingConversion; quem resolve é o comprador, cadastrando o fator — e a linha recalcula. Recusar a submissão trancaria o fornecedor fora do prazo por um problema que ele não pode resolver.
O registro congela a taxa da submissão de cada um; o comparativo converte tudo na data de encerramento da rodada. Cinco propostas em dólar submetidas em cinco dias diferentes, comparadas com cinco taxas, comparam câmbio e não preço. A regra da Moeda continua valendo para o registro; o que esta página acrescenta é a data única da comparação.
enteredBy = Portal | Buyer no cabeçalho da resposta. Fornecedor pequeno responde por e-mail, e recusar isso trava a cotação. Mas comprador digitando proposta de fornecedor é o vetor clássico de fraude em compras: exige permissão própria, aparece no comparativo e entra na trilha. Marca, não trava — a mesma postura das três marcas do motor de aprovação.
A decisão central
Como o fornecedor entra
Havia três saídas, e duas já tinham sido recusadas por escrito antes desta página existir. A terceira é a que está proposta aqui, e ela não é meio-termo: é identidade nomeada sem segredo guardado.
| Saída | O que dá | O que custa | Estado |
|---|---|---|---|
| Usuário do tenant com permissões podadas | Autenticação, papéis e trilha prontos, sem construir nada | Cria usuário de tenant para quem não é do tenant. Um erro de papel, uma consulta sem filtro ou uma permissão nova semeada por migration e o fornecedor enxerga o comprador | Recusada pelo Anexo, nominalmente |
| Portal com conta e senha | Identidade forte, sessão longa, MFA | Senha, reset, bloqueio por tentativa, política de expiração e cofre — nada disso existe na plataforma e tudo nasceria dentro do Nexio. Vira módulo, não tela | Adiada · gancho declarado |
| Conta sem credencial + link por convite | Autor nomeado em todo acesso, anexo e envio; revogação por pessoa e por convite; zero atrito de adoção | O e-mail (ou o número) do contato passa a ser o perímetro. É um perímetro conhecido — é o mesmo do "esqueci minha senha" de qualquer produto —, mas precisa estar escrito como decisão | Proposta |
O acesso, ponta a ponta, em cinco passos
1. O comprador convida um contato do fornecedor na cotação. Se aquele contato ainda não tem PortalUser, ele é criado em Invited — sem formulário, sem espera, sem confirmação do outro lado.
2. O Nexio gera token aleatório, guarda seu hash no link e material de envio criptografado no outbox com TTL. Link e intenção de entrega são atômicos; retry não gera outro convite silenciosamente.
3. O fornecedor abre o link. O GET resolve o convite, marca a conta como Active, abre sessão curta e redireciona para a cotação num endereço sem token. A partir daí o segredo não circula mais.
4. Ele preenche, salva rascunho, sobe anexo, importa planilha e volta quantas vezes quiser — sempre pelo mesmo link, até o prazo.
5. Ele envia. O link morre no envio. Se precisar rever o que mandou, o comprador reemite — e a reemissão é um ato registrado, não um efeito colateral.
O que o link não protege, dito na cara
Quem lê a caixa de entrada do contato responde a cotação no lugar dele. Isso é verdade e é aceito: é o mesmo perímetro de qualquer fluxo de recuperação de senha do mercado, e trocar por senha só mudaria o perímetro de lugar — passaria a ser o mesmo e-mail, no dia em que ele esquecesse a senha.
O que se faz é encolher a janela e o alcance: o link abre uma cotação, de um fornecedor, numa rodada, até uma data. Ele não abre o cadastro, não abre outra cotação, não abre a rodada seguinte e não sobrevive ao envio. Encaminhar o link a um colega é possível, e nesse caso a trilha registra o PortalUser convidado — o fornecedor está agindo dentro da própria casa. Quando isso incomodar, a saída é convidar o colega, que cria outro PortalUser do mesmo fornecedor.
Confirmação adicional opcional no envio
Ligada, ela não vira autenticação de sessão: vira degrau no envio. Um código de seis dígitos enviado ao mesmo canal do convite, exigido apenas no ato que congela preço. É o ponto caro do fluxo e o único que justifica atrito. Fica declarada como configuração por tenant, com padrão false, para que ligá-la depois não mexa em modelo nem em rota.
Por que a conta existe, se o link já autoriza. Porque autor não é o mesmo que autorização. O Anexo exige supplierId obrigatório e portalUserId opcional — desenhado quando a hipótese era link anônimo. Com a conta nascendo do convite, o campo opcional passa a estar preenchido no caminho normal, e o Nexio ganha de graça o que o link sozinho não dá: quem exatamente abriu, quem exatamente enviou, e como cortar o acesso de uma pessoa sem cancelar a cotação inteira. O campo continua anulável no modelo — mudar isso obrigaria a reclassificar anexo já gravado, e não há motivo.
Modelo
Mapa de entidades
SupplierContact e carrega supplierId congelado. Estado de conta, idioma e último acesso. Nenhum campo de credencial na v1.
targetType: resolve o fornecedor, a empresa, o prazo da rodada e o que a sessão pode ler e escrever ali. Mesmo padrão do registro de donos do Anexo — tipo sem descritor derruba a inicialização.
roles[], isPortalAdmin, supplierEstablishmentId?, e-mail e celular. É daqui que sai o convidado, e é aqui que mora o consentimento de canal.
uploadedByKind = PortalUser, supplierId obrigatório, nasce Internal. O isolamento entre concorrentes já está resolvido lá e não é redecidido aqui.
Nem tudo o que tem nome é classe. PortalSession não é entidade: é um cookie assinado de vida curta, derivado do link, e a tentativa de guardá-lo em tabela só produziria uma segunda cópia do alcance para sair de sincronia com a primeira. QuotationRequestInvitation também não nasce aqui — a cotação precisa da lista de convidados de qualquer jeito, e o PortalAccessLink aponta para ela.
Por que o link é entidade e não três colunas no convite. A regra de contar consumidores diria "um consumidor, vira campo" — e ela foi aplicada, com o resultado invertido pelo segundo critério, que é a consequência do erro. Errar aqui é concorrente lendo proposta alheia, a consequência mais cara do acervo inteiro. E um convite não segura o que um segredo precisa ter: emissão, expiração, revogação, reemissão com histórico e canal de entrega. Três colunas guardariam o estado atual e perderiam a pergunta que a auditoria faz — quantas vezes este link foi reemitido, e para onde foi cada um.
Domínio
Campos · PortalUser
Herda de TenantScopedAggregateRoot. Id, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy e o bloco de soft delete vêm do BaseEntity e não estão repetidos aqui.
Legenda: obrig sempre · cond conforme a operação · opc opcional · gerado calculado pelo sistema ou pelo banco.
| Campo | Tipo | Regra | |
|---|---|---|---|
| supplierContactId | Guid | obrig | → SupplierContact, um para um. Imutável: o contato mudou de empresa, a conta não vai junto — cria-se outra. É o que garante que o cadastro continue sendo o dono do dado da pessoa e a conta, só do acesso. |
| supplierId | Guid | gerado | Resolvido do contato no nascimento e congelado. É o que o token devolve, é o que o Anexo exige gravado no anexo, e é o que faz o isolamento entre concorrentes ser comparação de coluna. |
| login | string(320) | gerado | O e-mail do contato, normalizado. Não é credencial — é rótulo de identidade e endereço padrão de convite. Único por tenant entre contas não revogadas. Acompanha o e-mail do contato quando ele muda; se aquele contato tiver papel Finance, a mudança não é um PUT — passa pela proposta do Fornecedor, decidida no motor. |
| accountStatus | enum | obrig | Invited · Active · Suspended · Revoked. Nasce Invited; vira Active no primeiro acesso; Suspended e Revoked são atos do comprador, por rota própria, com motivo. Conta não ativa não abre link nenhum, mesmo válido. |
| submissionConfirmationRequired | bool | gerado | Derivado da configuração do tenant, não editável por conta na v1. Padrão false. Quando ligado, exige código no envio — nunca no acesso. |
| locale | string(5) | opc | Idioma do convite e da tela. Nulo cai no idioma do tenant, como a Notificação já resolve. É aqui que o gancho de idioma do fornecedor deixa de ser hipotético — ver § Canais. |
| lastAccessOn | datetimeoffset? | gerado | Último acesso bem-sucedido. Serve à tela do comprador ("convidado há seis dias, nunca abriu") e é o insumo do lembrete de prazo. |
| dataAccessScope | enum | gerado | Supplier · Establishment. Filtra, nunca concede — ver decisão 4. Derivado de supplierEstablishmentId do contato: contato amarrado a uma filial só vê os convites daquela filial. |
Não existem, e a ausência é a decisão: passwordHash, passwordChangedAt, failedAttempts, lockoutUntil, refreshToken. Nenhum segredo de longa duração é guardado do lado de fora.
Domínio
Campos · PortalAccessLink
| Campo | Tipo | Regra | |
|---|---|---|---|
| portalUserId | Guid | obrig | → PortalUser. Não existe link anônimo: o convite é sempre para uma pessoa, ainda que ela nunca tenha aberto nada antes. |
| targetType | enum | obrig | Enum fechado. Na v1 tem um valor: QuotationRequest. Valor sem descritor registrado derruba a inicialização. PurchaseOrder e SupplierRegistration estão previstos e não implementados. |
| targetId | Guid | obrig | A chave do alvo no mundo dele. Sem chave estrangeira — é outro bounded context, e o precedente é o dono polimórfico do Anexo. |
| round | int | obrig | A rodada que este link abre. Rodada nova é link novo, e o link da rodada anterior é revogado na virada — sem isso, a proposta da rodada 1 continuaria editável depois de o comparativo ter rodado. |
tokenHash | char(64) | gerado | SHA-256 do token. Segredo de envio criptografado no outbox com TTL, nunca em logs ou tela do comprador. |
| expiresAt | datetimeoffset | obrig | Encerramento da rodada, resolvido pelo descritor do alvo. Prorrogar a rodada empurra a data dos links vivos; não cria link novo. |
consumedAt | datetimeoffset? | gerado | Envio encerra escrita; consulta da própria resposta permanece até readExpiresAt, salvo revogação. |
| revokedAt · revokedBy · reasonCodeId | datetimeoffset? · Guid? · Guid? | cond | Revogação explícita. Motivo pelo catálogo do Motivo, em uso novo PortalLinkRevoke — texto livre aqui seria a sexta reincidência da dívida que aquela spec fechou. |
| supersededByLinkId | Guid? | gerado | Reemissão. O link antigo aponta para o novo e é revogado na mesma unidade de trabalho. É o histórico que três colunas no convite não teriam. |
| sentChannels | enum[] | obrig | Email · WhatsApp. Por onde o link saiu de fato — não o que foi pedido. Canal recusado por falta de consentimento não entra na lista, e a tela do comprador mostra a diferença. |
| accessCount · lastAccessOn | int · datetimeoffset? | gerado | Quantas vezes o token foi trocado por sessão. Não limita nada — serve à auditoria e à conversa difícil ("o link foi aberto de três lugares em dez minutos"). |
writeExpiresAt · readExpiresAt | datetimeoffset | obrigatório | Prazos separados de escrita e leitura. Encerrar rodada não impede consulta histórica ainda concedida. Revogação remove ambas. |
Uma concessão de escrita ativa por conta/alvo/rodada, com unicidade parcial no PostgreSQL escolhido. consumedAt fecha escrita; a concessão de leitura tem readExpiresAt independente. Reemissão revoga o vínculo anterior atomicamente, sem reabrir edição de resposta enviada por omissão.
Contrato vigente · revisão consolidada
Convites duráveis e confirmação
Convite, link e intenção de envio são gravados na mesma transação do Nexio. E-mail é o canal inicial; WhatsApp depende de adaptador, consentimento/justificativa aplicável, número verificado e template aceito pelo provedor. Não marcar canal como entregue antes da confirmação correspondente.
O segredo de acesso tem entropia mínima de 128 bits e hash para validação. Para permitir entrega durável, seu material de envio fica criptografado no outbox com chave protegida, TTL e acesso restrito; não aparece em logs, auditoria, tela do comprador ou payload diagnóstico. Após entrega/expiração, expurgar o segredo conforme política. Dizer que nunca é persistido impediria recuperar envio após queda.
Retry reutiliza identidade do convite e link ainda válido; reemissão explícita revoga o anterior e cria nova operação, atomicamente. Token expirado antes do envio não é entregue: a operação registra expiração e solicita reemissão autorizada. Slugs são os quatro nexio.portal.* do catálogo de Notificação, com destinatário e locale do fornecedor.
Código enviado no mesmo canal do link é confirmação adicional, não segundo fator independente. Só anunciar MFA após integrar e validar fator independente conforme NIST SP 800-63B. A confirmação opcional da v1 deve usar nome submissionConfirmationRequired.
O ato
A resposta · rascunho, planilha e envio
Três estados e uma fronteira. Rascunho é do fornecedor e ninguém do lado comprador o enxerga; envio é o ato que congela; e o que estiver em branco no envio deixa de ser omissão para virar resposta.
Salvo a cada alteração, invisível ao comprador, editável até o prazo. Campo vazio aqui significa "ainda não preenchi". Anexos já valem, e já nascem isolados.
Congela preço, taxa de câmbio da submissão e fatores conhecidos. Encerra a escrita e preserva consulta. Exige confirmação adicional quando submissionConfirmationRequired estiver ligado; mesmo canal não constitui MFA. A partir daqui o comprador pode consultar.
Imutável. Correção é rodada nova ou reabertura explícita pelo comprador, com motivo — nunca edição em cima, porque a proposta já entrou no comparativo de alguém.
Linha em branco é resposta, e o envio é que a torna explícita
No envio, toda linha sem preço vira NotQuoted — valor gravado, não nulo. A diferença importa: nulo obriga o comparativo a adivinhar se o fornecedor recusou o item ou se esqueceu dele, e "não cotado" lido como zero é o defeito mais caro que um comparativo pode ter. Com o valor explícito, a tela do comprador escreve a frase certa e o mapa de cotação sabe que aquele fornecedor não disputa aquela linha.
O fornecedor pode marcar NotQuoted de propósito na tela, antes do envio, e nesse caso o campo de preço fecha. É a mesma informação por dois caminhos, e o segundo é o que permite enviar uma proposta parcial sem parecer incompleta.
A planilha é a mesma tela, por outro caminho
Sai gerada por cotação, nunca um modelo genérico: uma linha por item da rodada, com item, descrição, quantidade e unidade pedida travadas. O fornecedor preenche preço unitário, unidade em que cotou, prazo, marca e observação. Se ele pudesse alterar quantidade, o comparativo deixaria de comparar.
Tudo ou nada, com dryRun obrigatório, como manda a convenção de importação — a exceção assíncrona e parcial é do catálogo e não vale aqui. O dry-run devolve o que entraria e o que falharia, linha a linha, antes de qualquer gravação. Falha de estrutura recusa o arquivo inteiro; linha em branco não é falha, é NotQuoted.
Importar não envia. O arquivo cai no rascunho, o fornecedor confere na tela e envia de lá. Nenhum ato que congela preço acontece por upload.
Quando o comprador digita
O fornecedor que responde por e-mail existe e vai continuar existindo. A resposta entra pela tela do comprador, na mesma entidade, com enteredBy = Buyer e enteredByUserId preenchidos. Três consequências que não são da Cotação e por isso estão aqui:
1. Não passa pelo caminho do portal, então a regra de anexo do portal não se aplica: não há token, não há supplierId vindo dele, e o anexo entra pela rota interna com a visibilidade do tipo. 2. Exige permissão própria — não é o mesmo ato que registrar uma cotação recebida. 3. Aparece no comparativo como origem, e entra na trilha com ação própria. Sem as três, o campo vira decoração.
Comparabilidade
Unidade, comparabilidade e alternativa
O fornecedor cota na unidade dele. Pedimos em UN e ele vende em CX12; pedimos em KG e ele fatura em SC50. Isso é o normal, não a exceção — e a Unidade de Medida já resolveu a conversão. O que faltava era decidir o que acontece quando o fator não existe.
| Valor | Quando | O que o comparativo faz | Quem resolve |
|---|---|---|---|
| Comparable | Há fator vigente na hierarquia ItemSupplierOffer → Item → global intraclasse | Entra na comparação, com o fator em snapshot na linha | Ninguém |
| PendingConversion | A unidade cotada não tem fator para a unidade pedida, e as duas são da mesma classe | Mostra a linha fora da comparação, com o preço na unidade do fornecedor à vista | O comprador, cadastrando o fator. A linha recalcula e tira o snapshot naquele momento |
| NotComparable | Classe Serviço, que por decisão não converte | Mostra lado a lado sem número derivado. É trabalho de leitura humana, e sempre foi | Ninguém — e é essa a diferença |
As duas marcas não podem cair na mesma fila. PendingConversion é pendência: existe uma ação que a resolve, ela tem dono e some quando é feita. NotComparable é estrutura: não há fator a cadastrar, porque serviço não converte. Juntar as duas numa "fila de pendências de cotação" cria uma lista que nunca zera — e lista que nunca zera é lista que ninguém abre.
Cadastrar o fator é curadoria de catálogo, e o mecanismo já existe. É o processo ITEM do motor de aprovação, com a política que o tenant já configurou para o catálogo. Nada novo: a cotação avisa, o catálogo decide, e a linha recalcula quando o fator passa a existir.
Não existe erro para unidade sem conversão — e a ausência é a decisão
Seria fácil devolver UOM_CONVERSION_NOT_CONFIGURED_UNPROCESSABLE no envio e mandar o fornecedor "cotar direito". O código existe no acervo e não é usado neste caminho: ele recusaria a submissão de quem não pode consertar o cadastro do comprador, na véspera do prazo, com a proposta pronta. O custo de aceitar é uma linha marcada num comparativo; o custo de recusar é uma cotação a menos.
Alternativa é linha extra na mesma resposta
Marca diferente, embalagem diferente, prazo diferente — tudo isso é outra linha apontando para a mesma linha da cotação, e não outra resposta. Resposta separada obrigaria o comprador a comparar dois documentos do mesmo fornecedor e a decidir qual deles "conta" no total.
Exatamente uma alternativa por linha da cotação é isPrimary, marcada pelo fornecedor. Sem isso o total da proposta é ambíguo — três marcas cotadas viram três somas possíveis. O comprador enxerga todas e pode escolher a que não é a principal; quando escolhe, a escolha carrega motivo, no mesmo uso de "quem não é o mais barato" que o Motivo já catalogou. Em aberto: se a escolha de alternativa vira uma decisão registrada na cotação ou só um campo do mapa comparativo.
Dinheiro
Câmbio e comparativo
A Moeda fixou que se converte uma vez, na submissão. Num documento interno isso é uma data só. Numa cotação são n submissões, uma por fornecedor, em dias diferentes — e comparar as n com as n taxas compara câmbio, não preço.
Duas conversões, com propósitos diferentes
No registro: cada QuotationResponse congela a taxa da própria submissão, na moeda funcional da empresa do documento, fatia a fatia, com o resíduo pela regra única do Centro de Custo. Isso não muda nada do que já está escrito, e é o valor que vira empenho se aquela proposta virar pedido.
Na comparação: o mapa comparativo converte todas as propostas na taxa da data de encerramento da rodada. Uma taxa, todos os concorrentes, a mesma régua. A tela mostra a data e a taxa usadas, porque comparativo cuja régua não está à vista é comparativo que ninguém defende numa auditoria.
Rodada nova converte na data de encerramento dela. Prorrogar o prazo muda a régua da comparação e não muda nenhum registro — o que é o comportamento certo, e precisa estar escrito para não parecer defeito.
Por que não a data de abertura. Seria estável desde o início e conhecida por todos — e premiaria quem enviou tarde numa moeda em queda, porque o preço dele foi formado com a informação de câmbio que os outros não tinham. A data de encerramento é a única em que todo mundo já jogou.
Segurança
Isolamento entre concorrentes
Numa cotação com cinco convidados, nenhum pode ver nada do outro. Isso já foi resolvido pelo Anexo e não é redecidido aqui: o portal devolve os anexos cujo supplierId é o do vínculo, mais os anexos Supplier da própria cotação, que são para todos por definição. É comparação de coluna, não regra especial no código de autorização.
| Quem | Enxerga | Mecanismo |
|---|---|---|
| Fornecedor A, no portal | A cotação, as linhas, os anexos Supplier do documento, a própria resposta e os próprios anexos | Sessão derivada do link → supplierId, filtro em toda consulta |
| Fornecedor A sobre o fornecedor B | Nada. Nem que B foi convidado, nem quantos foram | A lista de convidados nunca sai pelas rotas do portal |
| Fornecedor A sobre a própria proposta enviada | Só depois de reemissão do link, e em leitura | consumedAt preenchido fecha o link; a reemissão é ato do comprador |
| Comprador | Tudo que foi enviado. Rascunho de fornecedor, nunca | Estado da resposta, não permissão |
Anexos usam POST /portal/quotations/{id}/attachments com sessão e vínculo de resposta validados. supplierId e portalUserId vêm do servidor. O próprio fornecedor consulta seus anexos Internal; publicação comum nunca expõe proposta concorrente automaticamente.
Contrato
Autorização · a exceção declarada
A convenção do produto diz que um endpoint declara exatamente uma permissão, no formato verbo:recurso, e que nada é implicado por outra. As rotas /portal/* não declaram nenhuma, e isso precisa estar escrito aqui — senão a primeira pessoa a implementar cria respond:quotations como permissão de tenant, semeia por migration, e a saída recusada pelo Anexo volta pela janela.
| Superfície | Autoriza por | Falha responde |
|---|---|---|
/portal/* | Sessão derivada do link → portalUserId + supplierId + alvo + rodada. Nenhuma permissão do catálogo é consultada | 401 sempre que o vínculo não se sustenta; 403 quando o vínculo é válido e o fornecedor está bloqueado |
| Rotas do comprador | Permissão do catálogo, uma por rota, como todo o resto do produto | 403 · 404 pelo escopo de empresa |
Três permissões novas do lado comprador, todas em verbo:recurso, com o recurso no plural desde 09/09/2026:
| Permissão | Cobre |
|---|---|
| read:portal-users | Ver as contas do portal de um fornecedor, último acesso e estado |
| manage:portal-users | Suspender, reativar e revogar conta. Não cria — quem cria é o convite |
| manage:portal-accesses | Emitir, reemitir e revogar link. É a permissão que reabre uma proposta já enviada, e por isso não anda junto com a de convidar |
Uma quarta é exigida e não nasce aqui: enter-on-behalf:quotation-responses, do lado da Cotação, para o lançamento pelo comprador. Fica declarada em § O que exige das outras specs — permissão sem endpoint na própria página é como gancho órfão vira dívida. Cada permissão nova precisa de migration com backfill idempotente, porque a linha de permissão é por tenant e não há caminho de replay.
Fronteiras
O que exige das outras specs
Esta proposta não escreve a Cotação. Ela declara o que a Cotação precisa carregar para o portal existir, e o que muda nas páginas já publicadas — com a regra de que nada aqui é aplicado lá antes de esta proposta ser validada.
| Onde | O que muda | Por quê |
|---|---|---|
| Anexo | Rota do portal passa a ser por sessão; "link de uso único" vira "link por convite"; a assimetria de portalUserId ganha a nota de que o caminho normal preenche | Decisões 2 e 6 |
| Fornecedor | PortalUser sai de "existe no modelo" e vira entidade especificada; SupplierContact ganha whatsappConsent e whatsappConsentOn; o locale do fornecedor deixa de ser gancho | Decisões 2 e 3 · § Canais |
| Notificação | Quatro slugs novos com resolvedor de destinatário externo; canal WhatsApp declarado; cascata de idioma passando pelo PortalUser | § Canais |
| Motivo | Dois usos novos em appliesTo: PortalLinkRevoke e PortalUserSuspend | Motivo é dado, e ato unilateral do comprador carrega motivo |
| Modelo de Classes | Duas caixas novas na banda do fornecimento; PortalUser deixa de ser condicional; enums PortalAccountStatus, PortalTargetType, PortalChannel, ComparabilityStatus, QuoteEntrySource | Consequência das duas anteriores |
| Cotação · a especificar | Lista de convidados por (fornecedor × contato); round e closesAt na rodada; enteredBy + enteredByUserId e NotQuoted na resposta; quotedUomId, comparabilityStatus, isPrimary e answersRequestItemId na linha; taxa da submissão no cabeçalho e taxa da comparação no mapa | É o alvo único da v1 — sem esses campos o portal não tem onde escrever |
| Mapa de domínio e contra-análise | Gate 13 fecha; o M7 sai da lista de achados abertos; a fronteira "Portal do fornecedor — a decidir" vira "decidido, v1 restrita" | Era a última decisão do gate antes da Requisição |
Uma mudança do Fornecedor já entrou, e vale para cá. Desde 09/09/2026, o e-mail e o telefone do contato com papel Finance são campos protegidos: só mudam por proposta decidida no motor, com aviso no canal anterior. O caminho normal do portal não é afetado — a conta nasce de contato comercial e o login é rótulo, não credencial. O efeito colateral é o esperado e é bom: o mesmo PUT /suppliers/{id}/contacts que reescreve o conjunto de contatos deixa de poder trocar, em silêncio, o endereço para onde vai o aviso de troca de conta bancária.
A ordem importa e é contraintuitiva. A Requisição vem antes da Cotação no sequenciamento, e mesmo assim o portal precisava ser decidido agora: ele é a única decisão do gate que muda modelo já publicado. Decidi-lo depois da Requisição significaria voltar em anexo já gravado e em contato já cadastrado sem saber de quem eles são — que é exatamente o argumento com que o Anexo justificou ter modelado o caminho do portal antes de o portal existir.
Dependências
O que falta na plataforma
Esta proposta foi desenhada para não depender de nada que não exista — é por isso que não há credencial na v1. Ainda assim, três itens da pauta com o Gryd.IO passam por aqui, e um deles é novo. O levantamento completo é assunto de documento próprio, no molde do GrydFiles: o que precisaria existir na plataforma para o portal ser um módulo dela, e não um canto do Nexio.
| Item | Estado na plataforma | O que esta proposta faz |
|---|---|---|
| Identidade externa | Não existe. ExternalIdentity é vínculo de usuário interno; sem UserType, IsExternal ou Guest | Contorna. A identidade do portal é do Nexio e não toca o GrydAuth |
| Canal de mensageria | Não existe. O módulo entrega e-mail e push; não há WhatsApp nem SMS | Depende. O modelo já carrega canal e consentimento; a entrega espera a plataforma |
| Armazenamento de arquivo com antivírus | Zero no repositório — é o épico do GrydFiles | Depende. O anexo do portal é o caso que mais precisa dele: é o único em que alguém de fora escreve bytes na nossa infraestrutura |
| Coluna de ação na trilha | Treze colunas, nenhuma é ação; hoje vai em AdditionalData | Convive. As ações do portal seguem a convenção <entidade>.<verbo-no-passado> como o resto |
Interface
Telas
Não é tela de login. O link abre direto na cotação, com o nome do fornecedor e do contato à vista — é assim que ele confere que o link é dele. Link morto mostra uma página que diz o que aconteceu e oferece "pedir novo link", que abre um aviso ao comprador; nunca um formulário.
Cabeçalho com prazo em destaque, condições, anexos Supplier do comprador. Grade de linhas com quantidade e unidade pedida travadas, preço, unidade cotada, prazo, marca. Botão de não cotar por linha. Salvamento contínuo, com "salvo às 14:32" à vista — a ansiedade de perder o preenchimento é o que faz o fornecedor voltar para o e-mail.
Baixar modelo, subir, ver o resultado do dry-run em duas listas — o que entra e o que falha, com o motivo em cada linha. Confirmar joga no rascunho, nunca envia. Falha de estrutura mostra o arquivo inteiro recusado e explica o que a planilha tinha de errado.
Revisão em uma página com total, linhas não cotadas, alternativas e anexos. Confirmação adicional, quando configurada, ocorre no envio. Depois, comprovante com data, revisão e consulta da própria proposta até o prazo de leitura.
Quem foi convidado, por quais canais o link saiu de fato, quem abriu, quem não abriu, quem enviou. Reemitir e revogar por linha, com motivo. É a tela que responde "por que o fornecedor X não respondeu" antes de alguém ligar para ele.
Origem de cada proposta à vista — portal ou digitada, e por quem. Linhas PendingConversion destacadas com o atalho de cadastrar o fator; linhas NotComparable agrupadas à parte, sem promessa de número. A taxa e a data usadas na comparação aparecem no cabeçalho, não num rodapé.
API
Endpoints
Portal · sem permissão de catálogo
| Rota | Autoriza | Devolve | Erros |
|---|---|---|---|
| GET /portal/access/{token} | o próprio token | Troca o token por sessão curta, marca a conta Active, incrementa accessCount e redireciona para a cotação sem o token na URL | 401 · 403 |
| GET /portal/quotations/{id} | sessão | Cabeçalho, condições, linhas com quantidade e unidade pedida, anexos Supplier do documento e o rascunho do próprio fornecedor | 401 · 403 · 404 |
| GET /portal/quotations/{id}/template | sessão | A planilha modelo gerada para esta rodada, com item, quantidade e unidade travados | 401 · 404 · 409 |
| PUT /portal/quotations/{id}/response | sessão | Salva o rascunho e devolve o recurso. Invisível ao comprador. Recusado depois do envio e depois do encerramento | 400 · 401 · 409 · 422 |
| POST /portal/quotations/{id}/response:import | sessão | Importa a planilha para o rascunho. dryRun=true devolve o que entraria e o que falharia, sem gravar. Tudo ou nada | 400 · 401 · 409 · 422 |
| POST /portal/quotations/{id}/response:submit | sessão | Congela a proposta, resolve NotQuoted, congela taxa e fatores, encerra a escrita, preserva consulta e avisa o comprador. Exige código quando submissionConfirmationRequired está ligado | 401 · 409 · 422 |
| POST /portal/quotations/{id}/attachments | sessão | O caminho do fornecedor, com upload-intent e confirmação embutidos. supplierId vem da sessão; nasce Internal. Substitui a rota por token escrita no Anexo | 401 · 409 · 422 |
| GET /portal/quotations/{id}/attachments | sessão | Os anexos do próprio supplierId mais os Supplier do documento. Comparação de coluna | 401 · 404 |
Comprador · uma permissão por rota
| Rota | Permissão | Devolve | Erros |
|---|---|---|---|
| GET /portal-users | read:portal-users | As contas de um fornecedor, com estado, canais consentidos e último acesso | 400 · 404 |
| PATCH /portal-users/{id}/activate | manage:portal-users | Reativa conta suspensa. Devolve o recurso | 404 · 409 |
| PATCH /portal-users/{id}/deactivate | manage:portal-users | Suspende, com motivo. Links vivos param de abrir na hora, sem serem revogados um a um | 404 · 409 · 422 |
| PATCH /portal-users/{id}/revoke | manage:portal-users | Encerra a conta, com motivo. Terminal: reconvidar cria conta nova | 404 · 409 · 422 |
| POST /portal-access-links:issue | manage:portal-accesses | Emite ou reemite. Revoga o ativo, aponta supersededByLinkId e envia pelos canais pedidos, na mesma unidade de trabalho | 400 · 404 · 409 · 422 |
| POST /portal-access-links/{id}:revoke | manage:portal-accesses | Mata um link sem emitir outro, com motivo | 404 · 409 · 422 |
| GET /portal-access-links | manage:portal-accesses | O histórico de uma cotação: emissões, reemissões, canais, aberturas. Não é read: — quem vê o histórico de acesso é quem pode mexer nele | 400 · 404 |
O ato sobre a resposta usa a forma POST /<recurso>:<ato> da convenção, aplicada a um recurso singular em vez de a uma coleção — a resposta de um fornecedor a uma cotação é uma só. Convidar não está aqui: é ato da cotação, com a permissão dela, e o link nasce como efeito na mesma transação.
Contrato
Contrato de erros
O status HTTP é derivado do sufixo do código, nunca da mensagem: o mapeador do Core reconhece nove sufixos — _NOT_FOUND → 404 · _ALREADY_EXISTS, _ALREADY_ASSIGNED e _CONFLICT → 409 · _FORBIDDEN, _ACCESS_DENIED e _BLOCKED → 403 · _UNAUTHORIZED → 401 · _UNPROCESSABLE → 422 · sem sufixo → 400.
Quando é seguro dizer o motivo, e quando não é. Token que não casa nenhum hash responde PORTAL_TOKEN_UNAUTHORIZED e nada mais — distinguir "não existe" de "expirou" para quem está adivinhando entregaria a existência de convites. Mas quando o hash casa, quem está do outro lado teve o token verdadeiro na mão, e aí dizer "expirou" ou "foi substituído" não revela nada que ele já não soubesse — e é a diferença entre uma tela que resolve o problema e uma que manda ligar para o comprador.
| Código | HTTP | Quando |
|---|---|---|
| PORTAL_TOKEN_UNAUTHORIZED | 401 | O token não casa nenhum hash. Resposta única e genérica, em tempo constante |
PORTAL_LINK_EXPIRED_UNAUTHORIZED | 401 | Vínculo ultrapassou readExpiresAt ou não tem concessão para a operação. Encerrar escrita não impede leitura vigente. |
| PORTAL_LINK_REVOKED_UNAUTHORIZED | 401 | Casou, e foi revogado — inclusive por reemissão, que revoga o anterior |
| PORTAL_LINK_ALREADY_CONSUMED_UNAUTHORIZED | 401 | Casou, e a resposta já foi enviada. Rever o enviado exige reemissão |
| PORTAL_CONFIRMATION_CODE_INVALID_UNAUTHORIZED | 401 | Código adicional errado ou vencido no envio; confirmação no mesmo canal não é MFA. |
| PORTAL_USER_SUSPENDED_FORBIDDEN | 403 | Vínculo válido, conta suspensa ou revogada. A pessoa perdeu o acesso, o fornecedor não |
| PORTAL_SUPPLIER_BLOCKED_FORBIDDEN | 403 | Fornecedor bloqueado, ou em estado que não permite cotar. O eixo de bloqueio é do Fornecedor e vale aqui sem cópia de regra |
| PORTAL_USER_NOT_FOUND | 404 | Não existe neste tenant — ou o fornecedor está fora do escopo do usuário. Os dois respondem igual |
| PORTAL_LINK_NOT_FOUND | 404 | Idem, nas rotas do comprador |
| PORTAL_TARGET_TYPE_UNKNOWN | 400 | targetType sem descritor registrado. Em produção não chega: a inicialização já teria falhado |
| PORTAL_TARGET_NOT_ALLOWED_UNPROCESSABLE | 422 | Alvo fora do que a v1 aceita. Existe para o dia em que PurchaseOrder entrar pela metade |
| PORTAL_CONTACT_EMAIL_MISSING_UNPROCESSABLE | 422 | Convidar contato sem e-mail. Recusado antes de gerar token — token emitido e não entregue é o pior dos dois mundos |
| PORTAL_CHANNEL_NOT_CONSENTED_UNPROCESSABLE | 422 | WhatsApp pedido sem consentimento registrado, quando é o único canal pedido. Havendo e-mail junto, o canal é omitido e o convite sai |
| PORTAL_ACTIVE_LINK_CONFLICT | 409 | Já existe link ativo para (conta × alvo × rodada) e a emissão não pediu reemissão. Corrida de duas telas |
| PORTAL_ROUND_CLOSED_CONFLICT | 409 | Qualquer escrita depois do encerramento da rodada |
| PORTAL_RESPONSE_ALREADY_SUBMITTED_CONFLICT | 409 | Segundo envio. A proposta enviada é imutável; correção é rodada nova ou reabertura com motivo |
| PORTAL_IMPORT_STRUCTURE_UNPROCESSABLE | 422 | Planilha fora do modelo — coluna faltando, aba trocada, cabeçalho alterado. Recusa o arquivo inteiro |
| PORTAL_IMPORT_ITEM_UNKNOWN_UNPROCESSABLE | 422 | Linha que não corresponde a nenhum item da rodada. Recusa o arquivo inteiro, porque importação é tudo ou nada |
| PORTAL_IMPORT_QUANTITY_CHANGED_UNPROCESSABLE | 422 | Quantidade ou unidade pedida alteradas na planilha. São campos travados, e mexer neles é o que quebra o comparativo |
| PORTAL_PRIMARY_ALTERNATIVE_MISSING_UNPROCESSABLE | 422 | Mais de uma alternativa para a mesma linha e nenhuma marcada como principal. Sem ela o total da proposta é ambíguo |
Não há código para unidade sem fator de conversão, e a ausência é deliberada — ver § Unidade. Limite de tentativas por token e por origem é middleware, não erro de domínio: não vira código de negócio nem entra nesta tabela.
Invariantes
Regras de negócio
O portal não cria, não altera e não consome usuário de tenant. O uploadedBy do arquivo continua sendo o serviço que subiu, como o Anexo já escreveu; a autoria de negócio é do Nexio.
Não existe autocadastro na v1 e não existe tela de registro. O primeiro convite a um contato cria o PortalUser em Invited, e o primeiro acesso o promove a Active.
Nenhuma consulta do portal parte do PortalUser: parte do PortalAccessLink vivo. dataAccessScope filtra o que já foi concedido e não concede nada — é o que impede uma segunda fonte de verdade de autorização.
Emitir com um ativo é conflito; reemitir revoga o anterior, aponta supersededByLinkId e envia o novo na mesma unidade de trabalho. Dois links vivos para o mesmo convite tornam a revogação inútil.
Guardar hash no link e segredo criptografado no outbox de envio com TTL. Retentativa mantém identidade. Segredo não vai a logs, tela do comprador ou trilha; expurgar após sua janela de entrega.
consumedAt bloqueia alteração. Sessão ou nova troca do link pode permitir leitura da própria proposta até readExpiresAt, salvo revogação/suspensão. Nova rodada encerra escrita antiga e cria vínculo novo; histórico permanece consultável por concessão de leitura própria.
Trocar token por sessão curta, HttpOnly/Secure, protegida contra CSRF. Rotas seguintes não recebem token na URL. Cada chamada consulta estado/versão do vínculo, conta e fornecedor; revogação invalida a próxima operação, inclusive sessão aberta.
Token desconhecido tem uma resposta só. Expirado, revogado e consumido são distinguidos, porque quem chegou até ali teve o token verdadeiro.
Suspender a conta tira uma pessoa; bloquear o fornecedor tira a empresa, pelos eixos de estado do Fornecedor, e vale para todos os contatos dela sem que nenhum link precise ser revogado.
submissionConfirmationRequired pode exigir código adicional no envio. Se código e link chegam ao mesmo canal, registrar confirmação adicional, nunca segundo fator independente. MFA verdadeiro depende de mecanismo separado ainda não especificado.
Nenhuma rota do lado comprador devolve resposta não enviada, nem contagem, nem indício de que existe. O que separa os dois lados é o estado da resposta, não permissão.
NotQuoted explícitoValor gravado, não nulo. É o que impede o comparativo de confundir recusa com omissão, e "não cotado" com zero.
Sem fator de conversão, a linha nasce PendingConversion e o envio não é recusado. Falta de cadastro do lado comprador nunca tranca o fornecedor fora do prazo.
NotComparable não é pendênciaClasse Serviço não converte por decisão da Unidade de Medida. Nada há a cadastrar, e a linha não entra em fila nenhuma.
Todas apontam para a mesma linha da cotação. Sem isPrimary única, o total da proposta é ambíguo e o envio é recusado.
Convenção de importação do produto, sem a exceção assíncrona do catálogo. Quantidade e unidade pedida vêm travadas, e alterá-las recusa o arquivo. Importar carrega o rascunho e nunca envia.
Correção é rodada nova, ou reabertura explícita do comprador com motivo e reemissão de link. Editar em cima apagaria o que já entrou no comparativo de alguém.
enteredBy = Buyer com o usuário, permissão própria e ação de trilha. Não passa pelo caminho do portal, então a regra de anexo do portal não se aplica àquela resposta.
Taxa da data de encerramento da rodada, igual para todos, com data e taxa à vista na tela. O registro de cada resposta continua congelando a taxa da própria submissão.
Exceção declarada à convenção do produto. Nenhuma permissão de tenant é criada para o fornecedor, em nenhuma hipótese — é a forma de a saída recusada não voltar pela janela.
E-mail é o padrão e é obrigatório para convidar. WhatsApp exige aceite com data e origem no contato, e some do envio quando não há — sem erro, quando há e-mail junto.
Registrar PortalUser, fornecedor, vínculo, sessão, canal verificado, data e ação. Identidade do convidado não prova pessoa física autora. Comprador que lança em nome do fornecedor fica identificado como autor interno.
Referência
Referência de mercado
O desenho proposto não é invenção: é o caminho que os dois maiores do mundo abriram depois de descobrir que a conta obrigatória custava caro.
| Produto | Como o fornecedor entra | O que se aprende |
|---|---|---|
| SAP Ariba Network | Conta obrigatória na rede, com cobrança por volume transacionado em algumas faixas | O atrito é real e tem nome. A queixa recorrente do fornecedor pequeno não é a tela — é ter que existir na rede para vender |
| SAP · Supplier Actionable Notifications | E-mail com link que abre o documento e permite responder sem conta | É o precedente direto desta proposta, e existe justamente porque a conta obrigatória barrava a cauda longa de fornecedores |
| Coupa Supplier Portal | Portal gratuito com conta, mais o mesmo caminho de resposta por e-mail para quem não quer conta | Os dois convivem. A conta é conveniência para quem transaciona muito, não pedágio para quem transaciona uma vez |
| Mercado Eletrônico · Nimbi | Rede com cadastro do fornecedor como produto em si | Modelo de rede, não de portal do comprador. Muda o produto — e é a fronteira que esta proposta não atravessa |
A conclusão que vem de fora e vale aqui: quem começou por conta obrigatória construiu depois o caminho sem conta; ninguém fez o contrário. Começar pelo link e deixar a conta crescer por cima é a ordem que o mercado descobriu na marra.
Limites
Fora de escopo
- Rede de fornecedores entre tenants. Um cadastro que serve a vários compradores é outro produto, com decisão jurídica antes da de produto. A spec de Fornecedor já registra a pergunta.
- Leilão reverso e lance ao vivo. Tempo real, desempate e trava de lance são um módulo, não uma tela a mais na cotação.
- Punchout e cXML. Catálogo do fornecedor dentro da tela do comprador é integração, e a spec de Fornecedor já a declarou projeto próprio.
- Faturamento pelo portal. Nota entra pela integração fiscal. O portal não é canal de documento fiscal, e virar isso arrastaria o Nexio para dentro do financeiro que ele decidiu não fazer.
- Autocadastro público. O
targetTypejá prevêSupplierRegistration; falta decidir quem aprova a entrada de quem ninguém convidou, e isso é uma instância do motor, não uma tela. - Confirmação de pedido.
PurchaseOrdercomo alvo é o segundo consumidor natural do link, e chega com o módulo de pedido — não antes. - Upload de documento com validade. Certidão pelo portal é
SupplierDocument, que tem número, órgão e vencimento — não é anexo, pela regra de corte do Anexo. - Conta com senha e MFA de sessão. Declarada, com campo no modelo, e adiada até haver fornecedor que transacione o bastante para querer uma.
- Questionário técnico da cotação. Perguntas além de preço e prazo — prazo de garantia, certificação, condição de pagamento — são formulário configurável. Em aberto: se nasce na Cotação ou reaproveita o questionário de homologação.
- Idioma do portal.
localeestá no modelo; traduzir a tela só se paga quando houver fornecedor estrangeiro cotando de verdade. Em aberto.
O que esta proposta fecha, e o que ela deixa aberto de propósito. Fecha o item 13 do gate — a última decisão antes da Requisição — respondendo às três perguntas que o mapa de domínio deixou: o portal é próprio, o fornecedor entra por convite nomeado sem credencial, e a cotação é o único ato da v1. Deixa aberto, e marcado no texto, o formato do questionário técnico, quem registra a escolha de alternativa, e o idioma. Nenhum dos três muda o modelo — é o que os torna adiáveis sem virar dívida.
Contrato vigente · revisão consolidada
Consulta após envio e isolamento
GET da resposta e de seus anexos continua autorizado em modo leitura depois de consumedAt, dentro de readExpiresAt. Tentativa de escrita retorna conflito de resposta enviada. A tela mostra confirmação, versão submetida, data e opção de baixar a própria proposta; correção exige reabertura/rodada nova auditada.
Identificadores de proposta, linha ou anexo são validados contra tenant, supplierId, alvo e rodada da sessão. Enviar id de concorrente retorna ausência, sem revelar contagens ou existência. Internal protege concorrência, mas não esconde do autor seu próprio anexo. Nenhuma rota genérica de GrydFiles contorna esta verificação.