Customers
ProduçãoCadastro de pessoas físicas brasileiras com CPF validado e isolado por organização
Você guarda o cadastro dos seus clientes pessoa física com CPF conferido, endereço, renda e conta bancária no formato que a operação de crédito brasileira usa — sem escrever a tabela, a validação e o isolamento por empresa de novo em cada produto.
- Fintechs de crédito que precisam do cadastro do tomador antes de simular, decidir e contratar
- Plataformas B2B que operam várias empresas clientes na mesma instância e não podem misturar cadastro
- Times que integram vários building blocks e querem uma única identidade de pessoa entre eles
- Tabela de pessoas reescrita dentro de cada serviço, com a validação de CPF copiada e colada
- Camada caseira de "de qual empresa é este cliente" espalhada por consultas SQL
- Um CRM — não tem funil, oportunidade, campanha nem histórico de interação comercial
- Um cadastro de pessoa jurídica — o CNPJ está no enum de tipo, mas não é aceito hoje (ver §15)
- Um serviço de KYC ou consulta a bureau — não checa CPF na Receita, não consulta score, não faz prova de vida
- Um cadastro positivo ou base de enriquecimento de dados
6 endpoints em 2 recursos.
Resumo executivo
O Customers guarda quem é o seu cliente pessoa física. Nome, CPF, contato, data de nascimento, nome da mãe, estado civil, vínculo de trabalho, renda mensal, endereço completo e conta bancária — os campos que uma operação de crédito brasileira pede no primeiro formulário e usa em todas as etapas seguintes.
Na prática ele resolve o momento em que a sua plataforma passa de um serviço para cinco. Sem um cadastro central, o motor de decisão tem uma cópia da pessoa, a esteira de contrato tem outra, o faturamento tem uma terceira, e o dia em que o cliente troca de telefone você descobre que existem três telefones diferentes e nenhum deles é o certo. Aqui a pessoa tem um identificador só, e os outros building blocks apontam para ele.
Está em produção, publicado em staging e em produção como serviço próprio (customers.bb.stg.catalisa.app), com cobertura de testes unitários e de integração. É um building block pequeno de propósito: cinco rotas, uma tabela, nenhuma integração externa. O que ele entrega não é funcionalidade, é o cadastro certo no lugar certo — leia a §15 antes de assumir que ele faz KYC, porque ele não faz.
| Atributo | Valor |
|---|---|
| Identificador | customers |
| Categoria | Dados |
| Escopo | Tenant (exige organizationId no token em todas as 5 rotas) |
| Porta (standalone) | 3003 |
| Path alias | @customers |
| Prefixo HTTP | /customers |
| Schema PostgreSQL | customers |
| Status | Produção |
| Depende de | PostgreSQL, Redis (publicação de eventos), IAM |
| Permissões | CUSTOMERS_PEOPLE_CREATE, CUSTOMERS_PEOPLE_READ, CUSTOMERS_PEOPLE_UPDATE, CUSTOMERS_PEOPLE_DELETE |
O problema
negócioO cenário. Uma fintech de crédito começa com um produto e um banco de dados. O tomador é cadastrado na tela de originação e a vida segue. Dois anos depois existem uma esteira de proposta, um motor de decisão, um contrato eletrônico, uma régua de cobrança e um faturamento — e cada um deles guarda a própria versão da mesma pessoa.
O que trava hoje.
- A pessoa existe cinco vezes. Cada serviço tem a própria tabela de cliente, com o próprio nome de coluna e a própria regra de obrigatoriedade. Responder "qual é o telefone atual do cliente" vira uma pergunta com cinco respostas.
- A validação de CPF é copiada e colada. O algoritmo do dígito verificador está em três repositórios, em versões que divergiram. Um deles aceita
111.111.111-11, e ninguém sabe qual até um cadastro fantasma aparecer na carteira. - O isolamento entre empresas clientes é disciplina, não garantia. "Todo mundo lembra de filtrar por
organization_id" funciona até o dia em que alguém não lembra. Em cadastro com CPF, esse esquecimento é incidente de dado pessoal, não bug de listagem. - Apagar cliente apaga histórico. Um
DELETEfísico resolve o pedido do cliente e destrói a rastreabilidade da operação de crédito que ele contratou — que precisa sobreviver por anos. - O formulário brasileiro não cabe em modelo genérico. Nome da mãe, faixa de renda, tipo de vínculo empregatício, agência com dígito e tipo de conta não são campos de um cadastro internacional. Ou você modela isso, ou empurra tudo para um
metadataque ninguém consegue consultar.
O custo de não resolver. O custo visível é o retrabalho: cada produto novo começa reescrevendo o cadastro, a validação e o isolamento. O custo invisível é maior. Dado pessoal duplicado em cinco lugares é dado pessoal que você não consegue corrigir, exportar nem eliminar de forma completa quando o titular pedir — e a LGPD dá ao titular exatamente esses direitos (Lei 13.709/2018, art. 18). Uma base fragmentada transforma um pedido de dez minutos em um projeto.
Proposta de valor
negócio| Antes | Depois |
|---|---|
| Cinco tabelas de pessoa, cinco verdades | Um personId, e os outros building blocks apontam para ele |
| Validação de CPF copiada em cada serviço | Dígito verificador conferido em um lugar, na criação |
| Filtro por organização é disciplina de equipe | requireOrganization recusa a requisição antes da regra de negócio |
DELETE apaga a linha e o histórico junto | Exclusão lógica: sai das consultas, permanece para auditoria |
Campo brasileiro vira metadata sem consulta | UF, CEP, banco, agência, tipo de conta e faixa de renda são colunas |
O CPF é conferido, não só armazenado. A criação sanitiza a máscara, valida os dois dígitos verificadores e recusa sequências repetidas. Cadastro com CPF impossível não entra.
O tenant vem do token. Nenhuma rota lê organizationId do corpo ou da query. Ele sai do JWT assinado pelo IAM, e o requireOrganization devolve 403 quando o token não o carrega.
Atenção. O isolamento vale para leitura e escrita dentro da sua organização, mas a unicidade do CPF é global na plataforma: duas organizações não cadastram a mesma pessoa. É a limitação mais importante do BB e está detalhada na §15.
O modelo de dados fala português. Estado civil, tipo de vínculo, faixa de renda, UF e tipo de conta bancária são enumerações fechadas. Isso é o que permite filtrar, agregar e mandar para o motor de decisão sem tratamento no meio.
Cada mudança vira evento. Criar, atualizar e excluir publicam customers.person.created, .updated e .deleted no fluxo de eventos da plataforma. Quem quiser reagir — webhook, auditoria, sincronização — se inscreve, e o Customers não precisa saber que existe.
Casos de uso reais
negócioCaso 1 — Uma financeira para de ter três telefones para o mesmo cliente Cenário ilustrativo
Financeira de crédito pessoal com esteira própria de originação, motor de decisão e régua de cobrança, cada um construído por um time diferente ao longo de três anos.
O cliente atualizava o telefone na esteira, mas a cobrança continuava discando o número antigo, porque a cobrança tinha o próprio cadastro alimentado por uma carga noturna que quebrava em silêncio desde março. Ninguém sabia dizer qual das três bases era a boa.
As três aplicações passam a chamar GET /customers/api/v1/people/:personId em vez de manter cópia. A atualização é um PATCH num lugar só, e o evento customers.person.updated avisa quem precisa invalidar cache. O personId vira a chave estrangeira que a proposta, o contrato e a cobrança carregam.
flowchart LR EST["Esteira de originação"] --> P["Customers<br/>um personId, um telefone"] COB["Régua de cobrança"] --> P DEC["Motor de decisão"] --> P P -->|"PATCH em um lugar só"| EV["customers.person.updated"] EV --> INV["quem tem cache invalida"] INV -.->|"a carga noturna deixa de existir"| COB
Uma pergunta, uma resposta. E a carga noturna deixa de existir — junto com a categoria inteira de incidente que ela produzia.
Caso 2 — Um correspondente não vê o cliente do correspondente vizinho Cenário ilustrativo
Plataforma de crédito consignado que atende 40 correspondentes bancários na mesma instância. Cada correspondente é uma Organization no IAM.
Na versão anterior, a separação entre correspondentes era uma cláusula WHERE que cada desenvolvedor precisava lembrar de escrever. Um endpoint de relatório publicado às pressas esqueceu o filtro, e a lista de clientes de um correspondente apareceu para outro por dois dias.
flowchart LR
REQ["Requisição com Bearer JWT"] --> AUTH["authMiddleware<br/>verifica a assinatura HS256"]
AUTH --> PERM["requirePermission CUSTOMERS_PEOPLE_*"]
PERM --> ORG{"o token carrega organizationId?"}
ORG -->|"não"| F403["403 — a requisição para aqui,<br/>antes da regra de negócio"]
ORG -->|"sim"| SVC["PersonService"]
SVC --> REPO["PersonRepository<br/>organizationId na cláusula where"]
REPO --> Q1["findById"]
REPO --> Q2["findMany"]
Q1 --> N404["pessoa de outro correspondente = 404"]
Q2 --> N404Todas as cinco rotas exigem requireOrganization e recebem o organizationId do token assinado. O repositório monta a cláusula de organização em findById, findMany e na checagem de existência — não há caminho de leitura em que o filtro seja opcional. Um token sem organização recebe 403 antes de tocar o serviço.
O isolamento deixa de depender de quem escreveu a consulta. Adicionar um correspondente é criar uma organização no IAM, não provisionar um ambiente.
Caso 3 — O cadastro alimenta a decisão de crédito sem transformação no meio Cenário ilustrativo
Operação de crédito pessoal que usa o Decision Platform para aprovar ou recusar proposta com base em renda, vínculo e idade.
Os dados chegavam ao motor de decisão como texto livre. "CLT", "clt", "Carteira assinada" e "Empregado" eram a mesma coisa para o analista e quatro coisas diferentes para a regra. A taxa de recusa por dado inconsistente competia com a taxa de recusa por risco.
| Entrada da política | Antes, texto livre | Depois, tipo fechado |
|---|---|---|
| Vínculo | CLT, clt, Carteira assinada, Empregado — quatro strings para a mesma coisa | employmentType, enumeração de nove valores |
| Renda | valor com símbolo de moeda e separador variável | monthlyIncome decimal de duas casas, mais incomeRange em seis faixas |
| Idade | data em formato de digitação livre | birthDate em ISO 8601 |
employmentType é uma enumeração fechada com nove valores, incomeRange com seis faixas, monthlyIncome é decimal com duas casas e birthDate é data. O motor de decisão consome o retorno de GET /people/:personId direto, sem camada de normalização.
A regra de decisão passa a errar só onde deve errar: no risco. E mudar a política vira mudar a regra, não mudar o parser.
Caso 4 — O direito de eliminação da LGPD vira um processo com um dono Referência de mercado
O art. 18 da Lei 13.709/2018 garante ao titular, mediante requisição e sem custo, a confirmação da existência de tratamento (I), o acesso aos dados (II), a correção de dados incompletos, inexatos ou desatualizados (III), a portabilidade (V) e a eliminação dos dados pessoais tratados com o consentimento do titular, exceto nas hipóteses previstas no art. 16 (VI).
Quando o mesmo dado pessoal vive em cinco serviços, atender ao pedido exige encontrar as cinco cópias, e a prova de que todas foram tratadas não existe. Há ainda o § 6º do art. 18, que obriga informar de imediato os agentes com quem houve uso compartilhado para que repitam o procedimento — impossível de cumprir sem saber quem tem cópia. E o art. 16, I autoriza a conservação para cumprimento de obrigação legal ou regulatória, o que significa que "apagar tudo" nem sempre é a resposta certa. A decisão precisa ser tomada com o dado em um lugar só.
flowchart LR
T["Titular pede eliminação<br/>art. 18, VI"] --> D{"há obrigação legal ou regulatória<br/>de conservação? art. 16, I"}
D -->|"sim"| L["DELETE /people/:personId — exclusão lógica<br/>sai das consultas, permanece para rastreabilidade"]
D -->|"não"| E["Eliminação definitiva<br/>processo com dono, hoje sem endpoint — §15"]
L --> C["§ 6º do art. 18<br/>comunicar quem recebeu o dado em uso compartilhado"]
E --> C
C --> W["consumidores de customers.person.*<br/>hoje sem propagação automática — §15"]O dado pessoal do tomador mora em uma tabela, com um identificador. DELETE /people/:personId faz exclusão lógica: o registro some das consultas e permanece para a rastreabilidade que a operação de crédito exige. A eliminação definitiva é uma decisão consciente, executada por processo — e hoje não automatizada, o que está registrado na §15.
O pedido do titular deixa de ser uma caça a cópias e passa a ser uma decisão sobre um registro. Sem promessa de conformidade automática: o Customers dá o lugar único e o registro do que mudou; a política de retenção continua sendo sua.
Mercado e diferenciais
negócioPanorama
Panorama. Não existe uma categoria de mercado chamada "cadastro de pessoa física para fintech brasileira", e é por isso que quase todo mundo escreve o seu. Mas o conceito é padrão consolidado: os provedores de core banking e de BaaS tratam o cadastro de pessoa como recurso de primeira classe, separado da conta. A Pismo tem um domínio Customers próprio, com tipos pessoa e empresa e busca por documento. A Celcoin separa a proposta de cadastro de pessoa física da abertura de conta. A Zoop expõe busca de pessoa por CPF.
| Quem | O que trata como cadastro de pessoa |
|---|---|
| Pismo | Domínio Customers próprio, com tipos pessoa e empresa e busca por documento |
| Celcoin | Proposta de cadastro de pessoa física separada da abertura de conta |
| Zoop | Busca de pessoa por CPF |
E há um vocabulário oficial: a API Dados Cadastrais do Open Finance Brasil padroniza identificação de pessoa natural com cpfNumber, nome civil, nome social, data de nascimento, estado civil, nacionalidade, endereços, telefones e filiação (especificação OAS 3.0 no repositório oficial). O conjunto de campos do Customers é próximo desse vocabulário, e isso não é coincidência: é o mesmo domínio.
As três alternativas reais
As alternativas reais, então, são três, e nenhuma é concorrente direto.
flowchart TD N["Preciso de um cadastro de pessoa física<br/>compartilhado por vários serviços"] --> A1["1. Construir do zero"] N --> A2["2. Usar o cadastro do core banking ou do BaaS"] N --> A3["3. Usar o CRM que já está contratado"] A1 --> C1["envelhece em cada serviço novo"] A2 --> C2["vem embutido: você contrata o core, não o cadastro"] A3 --> C3["cobra por vendedor e não trata CPF como chave transacional"]
A primeira: construir do zero. A primeira é construir do zero: uma tabela, um validador de CPF copiado da internet e a promessa de sempre filtrar por tenant. É a opção mais escolhida e a que envelhece pior, porque cada serviço novo repete o exercício.
A segunda: o cadastro do core banking ou do BaaS. A segunda é usar o cadastro do core banking ou do BaaS. Ele é maduro e aderente ao regulatório — mas vem embutido. Você não contrata o cadastro, contrata o core, e o ciclo de evolução do seu modelo de dados passa a ser o do fornecedor. Pior: usar o cadastro do seu provedor bancário como cadastro geral da sua plataforma amarra o seu dado de cliente ao fornecedor que você talvez queira trocar.
A terceira: o CRM que já está contratado. A terceira é usar um CRM, tipicamente Salesforce ou HubSpot, porque já está contratado. É a comparação que mais aparece em reunião e a menos adequada: CRM foi desenhado para gerir relacionamento comercial, cobra por vendedor e não trata CPF como chave transacional. Cadastro de tomador de crédito não é oportunidade de venda.
Comparativo
| Critério | Catalisa Customers | Construir do zero | Cadastro de core banking / BaaS | Salesforce Sales Cloud |
|---|---|---|---|---|
| Base de cobrança | Precificação em definição | Engenharia própria | Contrato corporativo | Por usuário/mês |
| CPF validado no cadastro | Sim, dígito verificador | Você implementa | Sim | Não nativo |
| Campos brasileiros de crédito | Colunas tipadas | Você modela | Sim | Campos customizados |
| Isolamento multi-tenant | Claim assinado, obrigatório | Sua disciplina | Por instância ou por marketplace | Por org, com licenciamento |
| Adoção sem trocar o core | Sim | Sim | Não | Sim |
| Cadastro independente do provedor bancário | Sim | Sim | Não | Sim |
| Evento de mudança para o resto da stack | Sim, nativo | Você implementa | Depende do fornecedor | Via integração |
| Consulta a bureau, score, KYC | Não (ver §15) | Você contrata | Frequentemente sim | Não |
| Pessoa jurídica (CNPJ) | Não hoje (ver §15) | Você implementa | Sim | Sim |
Comparativo montado em 2026-08-16 a partir do código deste building block e da documentação pública de Pismo, Celcoin e Zoop. Nenhum dos três publica preço em página aberta para este recorte, e este documento não estima nenhum. Não conseguimos abrir documentação de produto suficiente de Dock, Matera e Sinqia — a última foi absorvida pela Evertec e o site redireciona —, então elas não entram no comparativo.
Nossos diferenciais
- O isolamento não é opcional em nenhum ponto do caminho. As cinco rotas exigem
requireOrganization, e as consultas do repositório recebem oorganizationIdcomo parâmetro obrigatório. Isso é difícil de copiar não pela ideia, mas pela consistência: é o mesmo middleware nos 32 building blocks, então auditar o isolamento da plataforma inteira é auditar um arquivo. - O modelo é brasileiro por dentro, não por tradução. Faixa de renda, tipo de vínculo, UF, CEP, código de banco e tipo de conta são enumerações e colunas. Um cadastro internacional adaptado guarda isso como texto, e texto não filtra nem alimenta motor de decisão sem uma camada de limpeza que ninguém quer manter.
- A pessoa é a mesma em todo o catálogo. O
personIdé o que o Decision Platform, o Pricing Engine, o Billing e o E-Signature carregam. Um cadastro isolado resolve o cadastro; este resolve a identidade compartilhada entre os blocos.
Quando escolher o concorrente
Quatro situações em que a resposta honesta é "não use o Customers".
| Se o seu problema é | Por que o concorrente ganha |
|---|---|
| KYC de verdade | Se você precisa de KYC de verdade — checagem do CPF na Receita, consulta a bureau, prova de vida, listas restritivas —, o Customers não faz nada disso e não vai fazer sozinho; contrate um provedor especializado e guarde aqui o resultado. |
| Cadastro de pessoa jurídica | Se você precisa de cadastro de pessoa jurídica, hoje o BB não aceita CNPJ (§15) e um core banking ou um cadastro próprio resolvem melhor. |
| Gestão comercial | Se o seu problema é gestão comercial — funil, tarefas, histórico de contato, automação de marketing —, o problema é de CRM e o Salesforce ou o HubSpot ganham disparado. |
| Operação inteira dentro de um core banking | E se a sua operação já roda inteira dentro de um core banking, com originação e ledger no mesmo fornecedor, extrair só o cadastro para fora cria uma sincronização que você não tinha. |
O Customers ganha quando o problema é um cadastro de pessoa física, compartilhado por vários serviços seus, com separação rígida entre empresas clientes.
Modelo de cobrança e ROI
negócioPrecificação em definição
Precificação em definição. O Customers não tem preço fechado. Ele é um bloco de fundação: quase nunca é contratado sozinho, e sim junto com os blocos que consomem o cadastro — Decision Platform, Pricing Engine, Billing, E-Signature. Não há valor a divulgar, e este documento não estima nenhum.
O que dispara custo
O que dispara custo.
| Driver | Por quê |
|---|---|
| Pessoas cadastradas | É o volume de dado armazenado e o que o cliente entende como unidade |
| Chamadas de API | Leitura de cadastro é a operação mais frequente numa esteira de originação |
Comparação de custo
Comparação de custo. Não há comparação de preço a fazer, por dois motivos honestos.
- Primeiro, a alternativa mais comum é construir do zero, cujo custo é de engenharia e não de licença.
- Segundo, o cadastro de core banking e de BaaS não é vendido separado do produto bancário — Pismo, Celcoin e Zoop não publicam preço para esse recorte, e comparar o preço de um cadastro com o de uma plataforma bancária inteira produziria um número sem significado.
ROI — a conta de guardanapo
ROI. A conta de guardanapo tem duas linhas.
| Linha | O que ela mede | Tem número defensável? |
|---|---|---|
| O que não se escreve | Semanas de engenharia por serviço que precisaria do próprio cadastro | Sim — de uma a duas semanas por serviço |
| O incidente que não acontece | Vazamento de cadastro entre empresas clientes, com dever de comunicação à ANPD e ao titular | Não — e por isso não colocamos número |
A primeira é o que não se escreve. Um cadastro de pessoa física com validação de CPF, enumerações brasileiras, exclusão lógica, paginação, filtros e isolamento por tenant é uma ou duas semanas de trabalho de um desenvolvedor experiente — por serviço que precisar dele. Com cinco serviços, isso são de cinco a dez semanas, mais a manutenção de cinco cópias que divergem.
A segunda é o incidente que não acontece. Vazamento de cadastro entre empresas clientes é incidente de dado pessoal com dever de comunicação à ANPD e ao titular (Lei 13.709/2018, art. 48). Não colocamos número nessa linha porque não existe número defensável — mas ela é a razão pela qual o isolamento é middleware obrigatório e não convenção de equipe.
Arquitetura
As camadas e o caminho da requisição
flowchart TD
CL["Cliente HTTP<br/>Bearer JWT emitido pelo IAM"] --> APP
APP["Hono app · basePath /customers · applyCommonMiddleware<br/>bodyLimit 1MB · CORS · security headers · rate limit global"]
APP --> RT["/api/v1/people → peopleRouter, 5 rotas"]
APP --> HL["/health → status estático do serviço"]
RT --> M1["authMiddleware"]
M1 --> M2["requirePermission CUSTOMERS_PEOPLE_*"]
M2 --> M3["requireOrganization<br/>403 sem organizationId"]
M3 --> M4["Zod parse do corpo"]
M4 --> SVC["services/PersonService<br/>valida CPF: sanitiza máscara e confere o dígito verificador<br/>checa unicidade do CPF<br/>grava → publica evento → devolve"]
SVC --> REPO["repositories/PersonRepository<br/>Prisma → PostgreSQL · schema customers<br/>organizationId em toda leitura<br/>deletedAt = exclusão lógica"]
SVC --> EVP["EventPublisher<br/>Redis Stream iam-events"]
REPO --> PG[("PostgreSQL<br/>customers.people")]
EVP --> EVT["customers.person.created<br/>customers.person.updated<br/>customers.person.deleted"]
EVT --> CONS["Consumidores do stream<br/>audit-trail · webhooks-engine"]O caminho de uma criação, ponta a ponta
O diagrama acima mostra as camadas paradas. Abaixo, as mesmas camadas em movimento — uma criação de pessoa, da autenticação no IAM até o consumo do evento por quem escuta o fluxo.
sequenceDiagram participant C as Cliente participant I as IAM participant CU as Customers participant PG as PostgreSQL participant RS as Redis stream iam-events participant CO as audit-trail e webhooks-engine C->>I: POST /iam/api/v1/users/login I-->>C: JWT assinado com organizationId e permissões C->>CU: POST /customers/api/v1/people com Bearer CU->>CU: authMiddleware verifica a assinatura HS256 CU->>CU: requirePermission CUSTOMERS_PEOPLE_CREATE CU->>CU: requireOrganization, 403 se faltar organizationId CU->>CU: Zod valida o corpo aninhado em data.attributes CU->>CU: sanitizeCpf e isValidCpf, 400 VALIDATION se reprovar CU->>PG: taxIdExists, o CPF já está cadastrado? PG-->>CU: não existe CU->>PG: INSERT em customers.people PG-->>CU: pessoa gravada, com id CU->>RS: XADD customers.person.created RS-->>CU: evento publicado CU-->>C: 201 com data.attributes RS->>CO: entrega do evento aos consumidores CO->>CO: linha do tempo da auditoria e webhook externo
Atenção. A publicação do evento está na mesma cadeia da escrita. Se o XADD falhar, a chamada devolve 500 INTERNAL — e não existe cadastro gravado sem evento publicado.
Decisões não óbvias
Decisões não óbvias. Cinco escolhas deste building block que não são as óbvias, cada uma com o trade-off que ela cobra.
| Decisão | O que se ganha | O que se paga |
|---|---|---|
| Tabela larga em vez de grafo de tabelas | Leitura sem JOIN e escrita atômica | Um endereço e uma conta bancária, sem lista nem histórico |
| CPF gravado sem máscara | Busca determinística por filter[taxId] | A formatação passa a ser da interface |
PATCH restrito a name e active | Integridade do cadastro que já originou crédito | Endereço e renda não mudam por API — §15 |
| Evento na mesma cadeia da escrita | Nenhum cadastro sem evento | Redis vira dependência de escrita |
| Exclusão sempre lógica | Rastreabilidade preservada | O CPF da pessoa excluída continua ocupado — §15 |
A pessoa é uma tabela larga, não um grafo de tabelas
A pessoa é uma tabela larga, não um grafo de tabelas. Endereço, telefone e conta bancária são colunas de people, não tabelas relacionadas. O trade-off é explícito: a pessoa tem um endereço e uma conta bancária, não uma lista. Ganha-se leitura sem JOIN e escrita atômica; perde-se histórico de endereço. Para operação de crédito brasileira, o endereço atual é o que a esteira usa, e o histórico de endereço quem guarda é o Audit Trail — através do changes de cada evento.
O CPF é guardado sem máscara
O CPF é guardado sem máscara. sanitizeCpf remove pontos e traço antes de gravar. Isso torna a busca por filter[taxId] determinística: você pode consultar com ou sem máscara, porque o filtro sanitiza a entrada do mesmo jeito. A formatação é responsabilidade da interface.
O PATCH altera dois campos, e só
O PATCH altera dois campos, e só. updatePersonSchema aceita name e active. Não é limitação de implementação: alterar CPF, data de nascimento ou nome da mãe de um cadastro que já originou crédito é o tipo de mudança que precisa de processo, não de um PATCH. Se o seu fluxo exige alterar endereço ou renda por API, isso está na §15 como lacuna reconhecida.
A publicação de evento faz parte da transação lógica da escrita
A publicação de evento faz parte da transação lógica da escrita. createPerson, updatePerson e deletePerson encadeiam a publicação com andThen: se o Redis estiver fora, a chamada devolve 500 com INTERNAL. A escolha é deliberada — cadastro gravado sem evento produz um Audit Trail com buraco e um webhook que nunca dispara, e um erro visível é melhor que uma inconsistência silenciosa. O custo é que o Redis vira dependência de escrita, não só de leitura.
A exclusão é lógica em toda parte
A exclusão é lógica em toda parte. deletedAt preenchido, linha preservada. Todas as consultas filtram deletedAt: null. É o que a rastreabilidade de crédito e a auditoria exigem — e é também o motivo de o CPF de uma pessoa excluída continuar ocupando a restrição de unicidade (ver §15).
Monolito vs. standalone
Monolito vs. standalone. O app.ts é montado no monolito em src/app.ts e responde em http://localhost:3000/customers. O main.ts sobe o mesmo app com Bun.serve na porta 3003 quando DEPLOYMENT_MODE=standalone — que é o modo usado em staging e produção. Não há diferença de comportamento entre os modos: o BB não chama nenhum outro building block por HTTP. O que muda é que, em standalone, o applyCommonMiddleware é a única fonte de limite de corpo, CORS, cabeçalhos de segurança e limite de taxa.
| Modo | Entrada | Onde responde | Observação |
|---|---|---|---|
| Monolito | src/app.ts monta o app.ts do BB | http://localhost:3000/customers | Convive com os demais building blocks no mesmo processo |
| Standalone | main.ts com Bun.serve | Porta 3003 | Modo usado em staging e produção; applyCommonMiddleware é a única camada de proteção comum |
Conceitos e modelo de dados
Glossário
Glossário
| Termo | Significa |
|---|---|
| Person | Uma pessoa física cadastrada por uma organização. É a única entidade do BB. No HTTP o recurso se chama people; no Prisma, Person; na tabela, people. |
| taxId | O documento fiscal, sempre um par { type, value }. O tipo é BR_CPF ou BR_CNPJ; só BR_CPF funciona hoje (§15). O valor é gravado sem máscara. |
| Organização | O tenant. Vem do claim organizationId do JWT do IAM. Toda pessoa pertence a exatamente uma. |
| Exclusão lógica | deletedAt preenchido. O registro sai de todas as consultas e permanece na tabela. Não há endpoint que o traga de volta. |
Faixa de renda (incomeRange) | Enumeração fechada de faixas em reais. Convive com monthlyIncome, que é o valor exato — os dois campos são independentes e nenhum deriva do outro. |
Ativo (active) | Marcador de negócio, não de exclusão. Uma pessoa inativa continua visível e consultável; é o filtro filter[active] que a separa. |
Modelo de dados
Modelo de dados — schema customers no PostgreSQL. 1 modelo.
| Modelo Prisma | Tabela | Propósito | Campos-chave |
|---|---|---|---|
Person | customers.people | Cadastro de pessoa física | organizationId, name, taxIdType, taxIdValue (único global — ver §15), active, deletedAt |
Uma entidade, duas relações: a Organization que a possui, no IAM, e as BillingAccount que podem apontar para ela, no Billing.
erDiagram
Organization ||--o{ Person : possui
Person ||--o{ BillingAccount : "é cobrada em"
Person {
uuid id PK
uuid organizationId FK
string name
string taxIdType
string taxIdValue UK
boolean active
string email
string phoneCountryCode
string phoneAreaCode
string phoneNumber
datetime birthDate
string motherName
string maritalStatus
string employmentType
decimal monthlyIncome
string incomeRange
string addressStreet
string addressNumber
string addressComplement
string addressNeighborhood
string addressCity
string addressState
string addressZipCode
string addressCountry
string bankCode
string bankBranch
string bankAccount
string bankAccountType
datetime createdAt
datetime updatedAt
datetime deletedAt
}Leia o diagrama junto com a tabela abaixo: taxIdValue é UK porque a restrição de unicidade é da coluna e vale para a plataforma inteira, não para a organização; monthlyIncome é Decimal(15, 2) no Prisma, com quinze dígitos e duas casas decimais; e deletedAt preenchido é o que tira a linha de todas as consultas sem apagá-la.
Campos por grupo:
| Grupo | Colunas | Obrigatório |
|---|---|---|
| Identificação | name, taxIdType, taxIdValue, active | Sim |
| Contato | email, phoneCountryCode, phoneAreaCode, phoneNumber | Não |
| Pessoal | birthDate, motherName, maritalStatus, employmentType | Não |
| Financeiro | monthlyIncome (decimal 15,2), incomeRange | Não |
| Endereço | addressStreet, addressNumber, addressComplement, addressNeighborhood, addressCity, addressState, addressZipCode, addressCountry | Não |
| Conta bancária | bankCode, bankBranch, bankAccount, bankAccountType | Não |
| Controle | createdAt, updatedAt, deletedAt | Automático |
Índices: organizationId, taxIdValue, name, active, deletedAt.
Enumerações
Enumerações
| Enum | Valores |
|---|---|
TaxIdType | BR_CPF · BR_CNPJ (declarado, não aceito — §15) |
BrazilianState | As 27 unidades federativas: AC AL AP AM BA CE DF ES GO MA MT MS MG PA PB PR PE PI RJ RN RS RO RR SC SP SE TO |
BrazilianMaritalStatus | SINGLE · MARRIED · DIVORCED · WIDOWED · SEPARATED · CIVIL_UNION |
BrazilianEmploymentType | CLT · PUBLIC_SERVANT · SELF_EMPLOYED · BUSINESS_OWNER · RETIRED · PENSIONER · LIBERAL_PROFESSIONAL · UNEMPLOYED · STUDENT |
BrazilianIncomeRange | UP_TO_2K · FROM_2K_TO_5K · FROM_5K_TO_10K · FROM_10K_TO_20K · FROM_20K_TO_50K · ABOVE_50K |
BrazilianAccountType | CHECKING · SAVINGS · PAYMENT |
Os valores são em inglês e em caixa alta. Uma versão anterior desta documentação listava
SOLTEIRO,CASADO,CLT/PJ/AUTONOMOeATE_1_SALARIO— nenhum desses existe no código. Confira sempre contrasrc/customers/types/index.ts.
Ciclo de vida de uma pessoa
Ciclo de vida de uma pessoa
stateDiagram-v2 [*] --> ativa: POST /people ativa --> inativa: PATCH com active false inativa --> ativa: PATCH com active true ativa --> excluida: DELETE /people/:id inativa --> excluida: DELETE /people/:id excluida --> [*] state "ativa · active=true" as ativa state "inativa · active=false" as inativa state "excluída logicamente · deletedAt preenchido" as excluida
O estado excluída logicamente é terminal: o registro sai de TODAS as consultas, não há rota de restauração e o CPF continua ocupado (§15). Já ativa e inativa são um par reversível — active é marcador de negócio, e a pessoa inativa continua consultável.
Referência da API
Prefixo: /customers. Em monolito, a base é http://localhost:3000. Em staging, https://customers.bb.stg.catalisa.app.
Todas as 5 rotas exigem, sem exceção: authMiddleware (Bearer JWT do IAM), requirePermission(...) e o middleware local requireOrganization, que devolve 403 quando o token não carrega organizationId.
Pessoas — /customers/api/v1/people
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /customers/api/v1/people | Cria pessoa. Responde 201 | CUSTOMERS_PEOPLE_CREATE |
GET | /customers/api/v1/people | Lista pessoas, paginado e filtrável | CUSTOMERS_PEOPLE_READ |
GET | /customers/api/v1/people/:personId | Busca uma pessoa | CUSTOMERS_PEOPLE_READ |
PATCH | /customers/api/v1/people/:personId | Atualiza name e/ou active | CUSTOMERS_PEOPLE_UPDATE |
DELETE | /customers/api/v1/people/:personId | Exclusão lógica. Responde 204 sem corpo | CUSTOMERS_PEOPLE_DELETE |
Saúde do serviço
| Método | Rota | Descrição |
|---|---|---|
GET | /customers/health | Status estático do serviço. Pública, não contabilizada nas 5 rotas |
A sonda de saúde não consulta o banco: responde
{"status":"ok","service":"customers","version":...}enquanto o processo estiver de pé. Para orquestrador ela serve; para detectar Postgres indisponível, não.
POST /customers/api/v1/people
Atenção ao envelope. O corpo é aninhado em data.attributes — não é um objeto plano.
Request
{
"data": {
"type": "people",
"attributes": {
"name": "Maria Aparecida de Souza",
"taxId": { "type": "BR_CPF", "value": "529.982.247-25" },
"email": "maria@exemplo.com.br",
"phone": { "countryCode": "+55", "areaCode": "11", "number": "998877665" },
"birthDate": "1988-04-12T00:00:00.000Z",
"motherName": "Joana de Souza",
"maritalStatus": "MARRIED",
"employmentType": "CLT",
"monthlyIncome": { "amount": 7200.00, "currency": "BRL" },
"incomeRange": "FROM_5K_TO_10K",
"address": {
"street": "Rua das Acácias", "number": "1200", "complement": "Apto 71",
"neighborhood": "Vila Mariana", "city": "São Paulo",
"state": "SP", "zipCode": "04101-300", "country": "BR"
},
"bankAccount": {
"bankCode": "341", "branch": "0987",
"accountNumber": "123456-7", "accountType": "CHECKING"
},
"active": true
}
}
}{
"data": {
"type": "people",
"attributes": {
"name": "Maria Aparecida de Souza",
"taxId": { "type": "BR_CPF", "value": "529.982.247-25" },
"email": "maria@exemplo.com.br",
"phone": { "countryCode": "+55", "areaCode": "11", "number": "998877665" },
"birthDate": "1988-04-12T00:00:00.000Z",
"motherName": "Joana de Souza",
"maritalStatus": "MARRIED",
"employmentType": "CLT",
"monthlyIncome": { "amount": 7200.00, "currency": "BRL" },
"incomeRange": "FROM_5K_TO_10K",
"address": {
"street": "Rua das Acácias", "number": "1200", "complement": "Apto 71",
"neighborhood": "Vila Mariana", "city": "São Paulo",
"state": "SP", "zipCode": "04101-300", "country": "BR"
},
"bankAccount": {
"bankCode": "341", "branch": "0987",
"accountNumber": "123456-7", "accountType": "CHECKING"
},
"active": true
}
}
}| Campo | Tipo | Obrigatório | Regra |
|---|---|---|---|
name | string | Sim | 1 a 200 caracteres |
taxId.type | BR_CPF | BR_CNPJ | Sim | Só BR_CPF passa na validação (§15) |
taxId.value | string | Sim | Formato de CPF, com ou sem máscara. Dígito verificador conferido |
email | string | Não | Formato de e-mail |
phone.areaCode | string | Não | Exatamente 2 dígitos |
phone.number | string | Não | 8 ou 9 dígitos |
phone.countryCode | string | Não | Padrão +55 |
birthDate | string | Não | ISO 8601 com data e hora |
motherName | string | Não | Até 200 caracteres |
maritalStatus | enum | Não | Ver §8 |
employmentType | enum | Não | Ver §8 |
monthlyIncome.amount | number | Não | Positivo |
monthlyIncome.currency | string | Não | Padrão BRL |
incomeRange | enum | Não | Ver §8 |
address.state | enum | Não | UF de duas letras |
address.zipCode | string | Não | 00000-000 ou 00000000 |
address.country | string | Não | Padrão BR |
bankAccount.bankCode | string | Não | Exatamente 3 dígitos |
bankAccount.branch | string | Não | 4 dígitos, com dígito opcional |
bankAccount.accountNumber | string | Não | Até 12 dígitos mais dígito verificador |
bankAccount.accountType | CHECKING | SAVINGS | PAYMENT | Não | — |
active | boolean | Não | Padrão true |
Quando você informa
addressoubankAccount, os campos internos marcados como obrigatórios no schema Zod passam a valer —street,number,neighborhood,city,stateezipCodeno endereço; os quatro campos da conta bancária. É tudo ou nada por bloco.
Resposta 201
{
"data": {
"type": "people",
"id": "6a5c3f21-9f0e-4a1c-9b76-2c1f4b8d0e33",
"links": { "self": "/api/v1/people/6a5c3f21-9f0e-4a1c-9b76-2c1f4b8d0e33" },
"attributes": {
"name": "Maria Aparecida de Souza",
"taxId": { "type": "BR_CPF", "value": "52998224725" },
"email": "maria@exemplo.com.br",
"active": true,
"createdAt": "2026-08-16T13:22:41.001Z",
"updatedAt": "2026-08-16T13:22:41.001Z"
}
},
"links": { "self": "/api/v1/people/6a5c3f21-9f0e-4a1c-9b76-2c1f4b8d0e33" }
}{
"data": {
"type": "people",
"id": "6a5c3f21-9f0e-4a1c-9b76-2c1f4b8d0e33",
"links": { "self": "/api/v1/people/6a5c3f21-9f0e-4a1c-9b76-2c1f4b8d0e33" },
"attributes": {
"name": "Maria Aparecida de Souza",
"taxId": { "type": "BR_CPF", "value": "52998224725" },
"email": "maria@exemplo.com.br",
"active": true,
"createdAt": "2026-08-16T13:22:41.001Z",
"updatedAt": "2026-08-16T13:22:41.001Z"
}
},
"links": { "self": "/api/v1/people/6a5c3f21-9f0e-4a1c-9b76-2c1f4b8d0e33" }
}O taxId.value volta sem máscara, do jeito que foi gravado. O campo links.self é relativo e não inclui o prefixo /customers — some-o você mesmo ao montar URL absoluta.
Erros
| Status | Código | Quando |
|---|---|---|
400 | — | Corpo reprovado no Zod. details traz field, message e code por problema |
400 | VALIDATION | CPF com formato aceito mas dígito verificador inválido, ou sequência repetida |
401 | — | Token ausente, inválido ou expirado |
403 | — | Falta CUSTOMERS_PEOPLE_CREATE, ou o token não carrega organizationId |
409 | CONFLICT | CPF já cadastrado. A unicidade é global na plataforma, não por organização — ver §15 |
GET /customers/api/v1/people
Parâmetros de consulta
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
page[number] | inteiro | 1 | Página, mínimo 1 |
page[size] | inteiro | 20 | Itens por página, máximo 100 |
filter[active] | true | false | — | Qualquer valor diferente de true é tratado como false |
filter[name] | texto | — | Busca por conteúdo, sem diferenciar maiúsculas |
filter[taxId] | texto | — | Igualdade exata. A máscara é removida antes da comparação |
Ordenação fixa: createdAt decrescente. Não há parâmetro para alterá-la.
Resposta 200
{
"data": [ { "type": "people", "id": "...", "attributes": { "...": "..." } } ],
"meta": {
"totalItems": 137, "totalPages": 7, "currentPage": 1,
"itemsPerPage": 20, "hasNextPage": true, "hasPreviousPage": false
},
"links": {
"self": "/api/v1/people?page[number]=1&page[size]=20",
"first": "/api/v1/people?page[number]=1&page[size]=20",
"next": "/api/v1/people?page[number]=2&page[size]=20",
"last": "/api/v1/people?page[number]=7&page[size]=20"
}
}{
"data": [ { "type": "people", "id": "...", "attributes": { "...": "..." } } ],
"meta": {
"totalItems": 137, "totalPages": 7, "currentPage": 1,
"itemsPerPage": 20, "hasNextPage": true, "hasPreviousPage": false
},
"links": {
"self": "/api/v1/people?page[number]=1&page[size]=20",
"first": "/api/v1/people?page[number]=1&page[size]=20",
"next": "/api/v1/people?page[number]=2&page[size]=20",
"last": "/api/v1/people?page[number]=7&page[size]=20"
}
}PATCH /customers/api/v1/people/:personId
Aceita apenas name e active. Qualquer outro campo é ignorado em silêncio pelo schema.
Request — as duas formas funcionam:
{ "data": { "attributes": { "active": false } } }{ "data": { "attributes": { "active": false } } }{ "active": false }{ "active": false }| Campo | Tipo | Regra |
|---|---|---|
name | string | 1 a 200 caracteres |
active | boolean | — |
Responde 200 com a pessoa atualizada, ou 404 NOT_FOUND quando o personId não existe na sua organização — inclusive quando ele existe em outra. Um personId que não seja UUID válido devolve 400.
DELETE /customers/api/v1/people/:personId
Exclusão lógica. Responde 204 sem corpo. Chamar de novo no mesmo personId devolve 404, porque o registro já saiu das consultas.
Início rápido
Do zero ao primeiro cadastro consultável. Use as credenciais de AMBIENTES.md — nunca credencial de produção.
Os comandos abaixo não foram executados na redação deste documento. As respostas mostradas são as previstas pelo código.
1. Autenticar no IAM
TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@catalisa.app",
"password": "root123456",
"organizationId": "b0000000-0000-0000-0000-000000000001"
}' | jq -r .accessToken)
echo "${TOKEN:0:24}..."TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@catalisa.app",
"password": "root123456",
"organizationId": "b0000000-0000-0000-0000-000000000001"
}' | jq -r .accessToken)
echo "${TOKEN:0:24}..."Resposta esperada — o começo do JWT, prova de que a variável foi preenchida:
eyJhbGciOiJIUzI1NiIsInR5...eyJhbGciOiJIUzI1NiIsInR5...2. Confirmar que o token carrega a organização
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.organizationId'echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.organizationId'Resposta esperada:
"b0000000-0000-0000-0000-000000000001""b0000000-0000-0000-0000-000000000001"Sem esse claim, todas as rotas do Customers respondem 403.
3. Criar a primeira pessoa
API=https://customers.bb.stg.catalisa.app/customers/api/v1
PERSON=$(curl -s -X POST $API/people \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"data": {
"type": "people",
"attributes": {
"name": "Maria Aparecida de Souza",
"taxId": { "type": "BR_CPF", "value": "529.982.247-25" },
"email": "maria@exemplo.com.br",
"employmentType": "CLT",
"incomeRange": "FROM_5K_TO_10K"
}
}
}')
PERSON_ID=$(echo "$PERSON" | jq -r '.data.id')
echo "$PERSON" | jq '.data.attributes.taxId'API=https://customers.bb.stg.catalisa.app/customers/api/v1
PERSON=$(curl -s -X POST $API/people \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"data": {
"type": "people",
"attributes": {
"name": "Maria Aparecida de Souza",
"taxId": { "type": "BR_CPF", "value": "529.982.247-25" },
"email": "maria@exemplo.com.br",
"employmentType": "CLT",
"incomeRange": "FROM_5K_TO_10K"
}
}
}')
PERSON_ID=$(echo "$PERSON" | jq -r '.data.id')
echo "$PERSON" | jq '.data.attributes.taxId'Resposta esperada:
{ "type": "BR_CPF", "value": "52998224725" }{ "type": "BR_CPF", "value": "52998224725" }A máscara sumiu — é o comportamento esperado.
4. Ler de volta
curl -s "$API/people/$PERSON_ID" -H "Authorization: Bearer $TOKEN" \
| jq '.data.attributes | {name, taxId, employmentType}'curl -s "$API/people/$PERSON_ID" -H "Authorization: Bearer $TOKEN" \
| jq '.data.attributes | {name, taxId, employmentType}'Resposta esperada — o mesmo cadastro, agora vindo do banco:
{
"name": "Maria Aparecida de Souza",
"taxId": { "type": "BR_CPF", "value": "52998224725" },
"employmentType": "CLT"
}{
"name": "Maria Aparecida de Souza",
"taxId": { "type": "BR_CPF", "value": "52998224725" },
"employmentType": "CLT"
}5. Confirmar que o isolamento é real
curl -s -o /dev/null -w "%{http_code}\n" "$API/people" \
-H "Authorization: Bearer token-invalido"curl -s -o /dev/null -w "%{http_code}\n" "$API/people" \
-H "Authorization: Bearer token-invalido"Resposta esperada — só o código de status:
401401Retorna 401. Não há caminho em que ausência de token válido resulte em cadastro.
Receitas
Encontrar uma pessoa pelo CPF
Objetivo. Descobrir se um CPF já está cadastrado na sua organização antes de abrir uma proposta.
1. Consultar pelo filtro de CPF
curl -s -G "$API/people" \
--data-urlencode 'filter[taxId]=529.982.247-25' \
-H "Authorization: Bearer $TOKEN" | jq '.meta.totalItems, .data[0].id'curl -s -G "$API/people" \
--data-urlencode 'filter[taxId]=529.982.247-25' \
-H "Authorization: Bearer $TOKEN" | jq '.meta.totalItems, .data[0].id'Resposta esperada quando o CPF já existe na sua organização:
1
"6a5c3f21-9f0e-4a1c-9b76-2c1f4b8d0e33"1
"6a5c3f21-9f0e-4a1c-9b76-2c1f4b8d0e33"2. Interpretar o resultado vazio
0
null0
nulltotalItems igual a 0 significa apenas que a sua organização não tem esse CPF. Vá para a receita "Descobrir por que o cadastro voltou 409" antes de concluir que ele está livre.
Armadilhas.
- O filtro sanitiza a máscara, então
529.982.247-25e52998224725encontram o mesmo registro. Não normalize antes. - Colchetes precisam de escape na maioria dos shells. Use
-G --data-urlencode, como acima, em vez de montar a query na mão. totalItems: 0não garante que o CPF esteja livre para cadastro: ele pode estar em uso por outra organização. Nesse caso oPOSTdevolve409mesmo com a busca vazia — ver §15.
Cadastrar com endereço e conta bancária completos
Objetivo. Criar o cadastro pronto para crédito consignado, em que a conta de pagamento é obrigatória.
1. Enviar o cadastro completo
curl -s -X POST $API/people \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"data": { "type": "people", "attributes": {
"name": "João Carlos Pereira",
"taxId": { "type": "BR_CPF", "value": "11144477735" },
"birthDate": "1975-09-30T00:00:00.000Z",
"motherName": "Terezinha Pereira",
"maritalStatus": "MARRIED",
"employmentType": "RETIRED",
"monthlyIncome": { "amount": 3100.50, "currency": "BRL" },
"incomeRange": "FROM_2K_TO_5K",
"address": {
"street": "Avenida Central", "number": "45",
"neighborhood": "Centro", "city": "Belo Horizonte",
"state": "MG", "zipCode": "30110-000"
},
"bankAccount": {
"bankCode": "104", "branch": "1234",
"accountNumber": "98765-4", "accountType": "SAVINGS"
}
}}
}' | jq '.data.id'curl -s -X POST $API/people \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"data": { "type": "people", "attributes": {
"name": "João Carlos Pereira",
"taxId": { "type": "BR_CPF", "value": "11144477735" },
"birthDate": "1975-09-30T00:00:00.000Z",
"motherName": "Terezinha Pereira",
"maritalStatus": "MARRIED",
"employmentType": "RETIRED",
"monthlyIncome": { "amount": 3100.50, "currency": "BRL" },
"incomeRange": "FROM_2K_TO_5K",
"address": {
"street": "Avenida Central", "number": "45",
"neighborhood": "Centro", "city": "Belo Horizonte",
"state": "MG", "zipCode": "30110-000"
},
"bankAccount": {
"bankCode": "104", "branch": "1234",
"accountNumber": "98765-4", "accountType": "SAVINGS"
}
}}
}' | jq '.data.id'Resposta esperada — 201, e o identificador da pessoa criada:
"9d3f7c10-2b8a-4e55-8f31-77b0c4a9e102""9d3f7c10-2b8a-4e55-8f31-77b0c4a9e102"2. Conferir que o endereço e a conta foram gravados
curl -s "$API/people/9d3f7c10-2b8a-4e55-8f31-77b0c4a9e102" \
-H "Authorization: Bearer $TOKEN" | jq '.data.attributes | {address, bankAccount}'curl -s "$API/people/9d3f7c10-2b8a-4e55-8f31-77b0c4a9e102" \
-H "Authorization: Bearer $TOKEN" | jq '.data.attributes | {address, bankAccount}'Resposta esperada:
{
"address": {
"street": "Avenida Central", "number": "45", "neighborhood": "Centro",
"city": "Belo Horizonte", "state": "MG", "zipCode": "30110-000", "country": "BR"
},
"bankAccount": {
"bankCode": "104", "branch": "1234",
"accountNumber": "98765-4", "accountType": "SAVINGS"
}
}{
"address": {
"street": "Avenida Central", "number": "45", "neighborhood": "Centro",
"city": "Belo Horizonte", "state": "MG", "zipCode": "30110-000", "country": "BR"
},
"bankAccount": {
"bankCode": "104", "branch": "1234",
"accountNumber": "98765-4", "accountType": "SAVINGS"
}
}Armadilhas.
addressebankAccountsão blocos tudo-ou-nada. Mandar sócitydentro deaddressreprova no Zod com erro emdata.attributes.address.street.birthDateexige data e hora ISO 8601."1975-09-30"sozinho é reprovado.bankCodetem exatamente 3 dígitos —"104", não"0104"nem104como número.monthlyIncome.amountprecisa ser positivo. Zero é reprovado.
Inativar sem excluir
Objetivo. Tirar a pessoa dos fluxos ativos preservando consulta e histórico.
1. Marcar como inativa
curl -s -X PATCH "$API/people/$PERSON_ID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"active": false}' | jq '.data.attributes.active'curl -s -X PATCH "$API/people/$PERSON_ID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"active": false}' | jq '.data.attributes.active'Resposta esperada:
falsefalse2. Conferir que ela sumiu da lista de ativos
# Confere que ela sumiu da lista de ativos
curl -s -G "$API/people" --data-urlencode 'filter[active]=true' \
-H "Authorization: Bearer $TOKEN" | jq '.meta.totalItems'# Confere que ela sumiu da lista de ativos
curl -s -G "$API/people" --data-urlencode 'filter[active]=true' \
-H "Authorization: Bearer $TOKEN" | jq '.meta.totalItems'Resposta esperada — o total de ativos, um a menos que antes:
136136Armadilhas. active=false é diferente de exclusão: a pessoa continua aparecendo em GET /people sem filtro e em GET /people/:id. Se o objetivo é sumir de tudo, use DELETE — que é irreversível pela API.
Descobrir por que o cadastro voltou 409
Objetivo. Separar "CPF duplicado na minha base" de "CPF em uso por outra organização".
1. O CPF está na sua organização?
# 1. O CPF está na sua organização?
curl -s -G "$API/people" --data-urlencode "filter[taxId]=$CPF" \
-H "Authorization: Bearer $TOKEN" | jq '.meta.totalItems'# 1. O CPF está na sua organização?
curl -s -G "$API/people" --data-urlencode "filter[taxId]=$CPF" \
-H "Authorization: Bearer $TOKEN" | jq '.meta.totalItems'Resposta esperada quando ele não está na sua base:
002. Reenviar a criação e ler o corpo do erro
curl -s -X POST $API/people \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"data\":{\"type\":\"people\",\"attributes\":{\"name\":\"Teste\",\"taxId\":{\"type\":\"BR_CPF\",\"value\":\"$CPF\"}}}}" \
| jq '{error, message, details}'curl -s -X POST $API/people \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"data\":{\"type\":\"people\",\"attributes\":{\"name\":\"Teste\",\"taxId\":{\"type\":\"BR_CPF\",\"value\":\"$CPF\"}}}}" \
| jq '{error, message, details}'Resposta esperada — 409, com o corpo de erro padrão da plataforma:
{
"error": "CONFLICT",
"message": "CPF already exists",
"details": { "field": "taxId.value" }
}{
"error": "CONFLICT",
"message": "CPF already exists",
"details": { "field": "taxId.value" }
}flowchart TD
P["POST /people devolve 409 CONFLICT"] --> B{"filter[taxId] na sua organização<br/>devolve totalItems maior que zero?"}
B -->|"sim"| M["CPF duplicado na sua própria base<br/>reaproveite o cadastro que já existe"]
B -->|"não"| O["CPF em uso por outra organização,<br/>ou por uma pessoa excluída logicamente"]
O --> S["Sem endpoint para resolver — acione o suporte, §15"]Se o resultado for 0 e o POST continuar devolvendo 409 CONFLICT, o CPF está cadastrado em outra organização da plataforma. A restrição de unicidade de taxIdValue é global, e a exclusão lógica não a libera: um CPF de pessoa excluída continua bloqueando novo cadastro. Não há endpoint para resolver isso — é a limitação registrada na §15, e o caminho é acionar o suporte.
Reagir a mudanças de cadastro em outro serviço
Objetivo. Manter um cache ou um índice de busca sincronizado sem carga noturna.
O Customers publica três eventos no fluxo Redis iam-events:
| Evento | Quando | Carga principal |
|---|---|---|
customers.person.created | Após gravar | personId, name, cpf, email, phone |
customers.person.updated | Após atualizar | personId, changes com os campos alterados |
customers.person.deleted | Após exclusão lógica | personId |
Todos carregam metadata.organizationId e metadata.timestamp.
flowchart LR W["POST · PATCH · DELETE<br/>no Customers"] --> S["Redis stream iam-events"] S --> AT["audit-trail<br/>linha do tempo do cadastro"] S --> WH["webhooks-engine<br/>entrega para fora da plataforma"] S --> MEU["seu consumidor<br/>cache ou índice de busca"] MEU -.->|"preferível ao acesso direto ao stream"| WH
Armadilhas.
- O evento de criação carrega CPF, nome, e-mail e telefone em texto puro na carga. Quem consome trata dado pessoal — trate o consumidor com o mesmo cuidado que trataria o banco.
- Se o Redis estiver indisponível, a escrita falha com
500. Isso é proposital (§7): não existe cadastro gravado sem evento publicado. - Para receber os eventos fora da plataforma, use o Webhooks Engine em vez de ler o fluxo direto.
Integração com outros building blocks
| Building block | Como se relaciona | Obrigatório |
|---|---|---|
| IAM | Emite o token que carrega organizationId e as permissões CUSTOMERS_PEOPLE_*. Sem ele, nenhuma rota responde | Sim |
| Audit Trail | Consome os eventos customers.person.* e monta a linha do tempo de cada cadastro | Não |
| Webhooks Engine | Entrega os mesmos eventos para sistemas fora da plataforma | Não |
| Decision Platform | Usa renda, vínculo e data de nascimento do cadastro como entrada da política de crédito | Não |
| Pricing Engine | Precifica por faixa de risco a partir do perfil da pessoa | Não |
| Products | Define o produto de crédito que a pessoa vai contratar. Não há chave estrangeira entre os dois — o vínculo é feito pela sua aplicação | Não |
| Billing | BillingAccount tem relação com Person no schema Prisma: a conta de cobrança pode apontar para a pessoa cadastrada aqui | Não |
| File Storage | Guarda documento do cadastro (RG, comprovante de renda, selfie) referenciando o personId | Não |
| E-Signature | Usa nome, CPF e e-mail da pessoa como signatário do contrato | Não |
flowchart TD IAM["IAM<br/>organizationId + permissões"] -->|"Bearer JWT"| CU["CUSTOMERS<br/>quem compra"] CU -->|"personId · sua app liga os dois"| PR["Products<br/>o que vende"] CU -->|"personId"| FS["File Storage<br/>documentos"] PR --> DP["Decision Platform<br/>aprova/recusa"] CU -->|"eventos customers.person.*"| AT["Audit Trail<br/>linha do tempo"] CU -->|"eventos customers.person.*"| WH["Webhooks Engine<br/>entrega externa"] DP --> PE["Pricing Engine<br/>taxa por risco"] PE --> ES["E-Signature<br/>contrato"] ES --> BI["Billing<br/>cobrança"]
A pessoa é cadastrada UMA vez e atravessa a esteira inteira pelo mesmo id.
Este diagrama é o argumento comercial: o cadastro não é um fim, é a primeira peça de uma esteira que já está montada. Um cadastro isolado obrigaria você a construir cada seta.
Configuração e operação
Variáveis de ambiente
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
DATABASE_URL | PostgreSQL. O BB usa o schema customers | Sim | — |
REDIS_URL | Redis. Usado para publicar eventos e para o contador de limite de taxa | Sim | — |
JWT_SECRET | Segredo HS256 do IAM, mínimo 44 caracteres. O serviço não sobe sem ele | Sim | — |
PORT | Porta em modo standalone | Não | 3000 (a topologia usa 3003) |
DEPLOYMENT_MODE | monolith ou standalone | Não | monolith |
RATE_LIMIT_ENABLED | Liga o limite global de taxa | Não | true no código; os stacks de staging e produção definem false |
RATE_LIMIT_GLOBAL_MAX | Requisições por janela, por IP | Não | 10000 |
RATE_LIMIT_GLOBAL_WINDOW | Janela em milissegundos | Não | 60000 |
CORS_ORIGINS | Origens permitidas, separadas por vírgula. Vazio bloqueia cross-origin em produção | Não | vazio |
Não há nenhuma variável específica do Customers. Ele não tem chave de cifragem própria, não fala com provedor externo e não guarda segredo.
Dependências de infraestrutura
| Dependência | Para quê | Se cair |
|---|---|---|
| PostgreSQL | Schema customers, tabela people | Toda rota devolve 500 INTERNAL |
| Redis | Publicação de evento e limite de taxa | Escrita falha com 500; leitura continua funcionando |
| IAM | Emissão do token. A verificação da assinatura é local, sem rede | Não é possível obter token novo; tokens válidos continuam funcionando até expirar |
Limites
| Limite | Valor | Onde |
|---|---|---|
| Tamanho do corpo | 1 MB | applyCommonMiddleware |
| Itens por página | 100 | getPaginationParams |
| Comprimento do nome | 200 caracteres | schema Zod |
| Nome da mãe | 200 caracteres | schema Zod |
| Precisão da renda | 15 dígitos, 2 decimais | coluna Decimal(15,2) |
| Limite de taxa global | 10.000 req/min por IP, quando ligado | configuração |
Catálogo de erros
| Status | Código | Significa | O que fazer |
|---|---|---|---|
400 | — | Corpo reprovado no Zod | Leia details[]: cada item traz field, message e code |
400 | VALIDATION | CPF com dígito verificador inválido | Confira o CPF na origem; o BB não corrige |
400 | — | personId não é UUID | Confira o identificador |
401 | — | Token ausente, inválido ou expirado | Renove pelo refresh token do IAM |
403 | — | Falta a permissão CUSTOMERS_PEOPLE_* | Confira o papel e as permissões da organização no IAM |
403 | — | Token sem organizationId | Autentique informando a organização |
404 | NOT_FOUND | Pessoa inexistente, excluída, ou de outra organização | Os três casos são indistinguíveis — é proposital |
409 | CONFLICT | CPF já cadastrado, em qualquer organização | Ver a receita de diagnóstico na §11 |
429 | — | Limite de taxa por IP | Aplique recuo exponencial; o cabeçalho Retry-After traz o tempo |
500 | INTERNAL | Falha de banco, ou Redis indisponível na escrita | Verifique Postgres e Redis antes de investigar código |
Observabilidade
GET /customers/healthresponde{"status":"ok","service":"customers","version":"..."}. É sonda de processo, não de banco.- Todo erro passa pelo
errorHandlere sai no log comlogger.error({ err }, 'Request error'). - Cada evento publicado é contabilizado por
recordEventPublished, com o tipo do evento e o building block de origem — dá para monitorar volume de cadastro por essa métrica. - O
EventPublisherinjeta contexto de rastreamento (traceId,spanId) na metadata do evento, então a criação de uma pessoa e o registro dela no Audit Trail ficam ligados no mesmo trace.
Segurança e compliance
Isolamento entre organizações
Isolamento entre organizações. O organizationId é claim assinado do JWT e chega ao serviço pelo c.get('user'). As cinco rotas aplicam o middleware local requireOrganization, que devolve 403 quando o claim está ausente. As três leituras do repositório — findById, findMany e a busca por identificador — recebem organizationId como parâmetro obrigatório e o incluem na cláusula where. Nenhuma rota lê organizationId do corpo, da query ou de cabeçalho. Uma pessoa de outra organização é indistinguível de uma pessoa inexistente: as duas devolvem 404.
Autenticação e permissões
Autenticação e permissões. Bearer JWT emitido pelo IAM, assinatura HS256 verificada localmente. Cada rota exige uma permissão nominal: CUSTOMERS_PEOPLE_CREATE, _READ, _UPDATE ou _DELETE. A organização é o teto — um papel que conceda CUSTOMERS_PEOPLE_DELETE numa organização que não contratou o Customers não concede nada.
| Rota | Permissão exigida | Middleware de tenant |
|---|---|---|
POST /people | CUSTOMERS_PEOPLE_CREATE | requireOrganization |
GET /people | CUSTOMERS_PEOPLE_READ | requireOrganization |
GET /people/:personId | CUSTOMERS_PEOPLE_READ | requireOrganization |
PATCH /people/:personId | CUSTOMERS_PEOPLE_UPDATE | requireOrganization |
DELETE /people/:personId | CUSTOMERS_PEOPLE_DELETE | requireOrganization |
Que dado pessoal este BB trata
Que dado pessoal este BB trata. Todos os campos de Person são dados pessoais na definição do art. 5º, I da LGPD: nome, CPF, data de nascimento, nome da mãe, e-mail, telefone, endereço, renda, vínculo empregatício e conta bancária. Não há dado pessoal sensível na definição do art. 5º, II — o BB não guarda origem racial, convicção religiosa, opinião política, filiação sindical, dado de saúde, vida sexual, genético ou biométrico.
Criptografia de campo: não há
Criptografia de campo: não há. O CPF, o nome, a renda e a conta bancária são gravados em texto no PostgreSQL. A proteção é de camada — TLS no transporte, credencial de banco em SOPS, rede fechada — e não de campo. Se o seu enquadramento exige cifragem em repouso por coluna, isso não está implementado e está registrado na §15. Cifragem de disco depende da infraestrutura em que o Postgres roda e não é garantida por este building block.
Mascaramento: parcial e só no caminho do log
Mascaramento: parcial e só no caminho do log. O Person retornado pela API traz o CPF completo, sem máscara. Não há endpoint que devolva o CPF parcialmente ocultado. O mascaramento que existe é do Audit Trail, que substitui campos com nome parecido com password, secret, token e apiKey por [REDACTED] — a lista não inclui CPF, e o CPF publicado no evento customers.person.created chega ao log de auditoria em texto. Considere isso ao definir quem tem AUDIT_LOGS_READ.
Retenção e eliminação
Retenção e eliminação. Não há política de retenção automática: o registro fica enquanto a organização existir. DELETE faz exclusão lógica — deletedAt preenchido, linha preservada, registro fora de todas as consultas. Isso se apoia no art. 16, I da LGPD, que autoriza a conservação após o término do tratamento para cumprimento de obrigação legal ou regulatória; para instituição regulada pelo BACEN, os cinco anos de guarda do art. 23 da Resolução CMN 4.893/2021 são exatamente essa obrigação. Mas exclusão lógica não é eliminação: atender a um pedido do titular sob o art. 18, VI exige expurgo definitivo, que hoje é operação manual de banco, sem endpoint e sem registro próprio. E o § 6º do art. 18 exige comunicar de imediato os agentes com quem houve uso compartilhado — o que, aqui, significa notificar quem consome os eventos customers.person.*, e não há automação para isso. Ver §15.
| Direito do titular | Base legal | O que o Customers oferece hoje |
|---|---|---|
| Confirmação e acesso | art. 18, I e II | GET /people/:personId e GET /people com filter[taxId] |
| Correção | art. 18, III | Parcial — o PATCH só altera name e active |
| Portabilidade | art. 18, V | O retorno da API é JSON completo do cadastro |
| Eliminação | art. 18, VI | DELETE faz exclusão lógica; expurgo definitivo é manual — §15 |
| Conservação obrigatória | art. 16, I e art. 23 da Resolução CMN 4.893/2021 | A exclusão lógica preserva a linha para a guarda regulatória |
| Comunicar quem recebeu o dado | § 6º do art. 18 | Sem automação — os consumidores dos eventos precisam ser avisados por processo |
Superfície de escrita
Superfície de escrita. O PATCH altera apenas name e active. CPF, data de nascimento, nome da mãe, renda, endereço e conta bancária não podem ser alterados por API depois da criação. Do ponto de vista de integridade isso é uma proteção; do ponto de vista do direito de correção do titular (art. 18, III), é uma lacuna operacional — registrada na §15.
Enquadramento regulatório
Enquadramento regulatório. O Customers é um cadastro, não um sistema regulado. Ele não é meio de pagamento, não movimenta recurso e não está sujeito por si só a exigência do BACEN. Quando ele alimenta uma operação de crédito de instituição autorizada, as obrigações de registro e de guarda recaem sobre a instituição, e a peça da plataforma que atende a rastreabilidade é o Audit Trail — que precisa estar efetivamente ligado, e cujo estado atual está descrito no README dele. Não há certificação PCI-DSS, SOC 2 ou ISO 27001 para este building block, e nenhuma deve ser afirmada em proposta.
Limitações conhecidas
| Limitação | Impacto | Situação |
|---|---|---|
| CNPJ não é aceito | TaxIdType.BR_CNPJ existe no enum, e o mapa de eventos da plataforma já prevê customers.company. Mas o schema Zod só aceita formato de CPF e o serviço chama isValidCpf para qualquer tipo. Enviar BR_CNPJ resulta em 400. Não há cadastro de pessoa jurídica neste BB, apesar de as permissões CUSTOMERS_COMPANIES_* existirem no IAM | Especificado, não implementado |
| A unicidade do CPF é global, não por organização | taxIdValue é @unique na tabela e a checagem não filtra por organização. Duas empresas clientes da mesma plataforma não conseguem cadastrar a mesma pessoa: a segunda recebe 409. Em operação com correspondentes ou marketplace de crédito, isso é bloqueio funcional real | Conhecido — priorizar antes de vender para operação com base de clientes sobreposta |
| A exclusão lógica não libera o CPF | A restrição de unicidade é da coluna, então mesmo com deletedAt preenchido o CPF continua ocupado. Excluir e recadastrar a mesma pessoa devolve 409, sem caminho pela API | Conhecido |
O PATCH só altera name e active | Corrigir endereço, telefone, e-mail, renda ou conta bancária exige intervenção fora da API. Isso atrapalha o atendimento ao direito de correção do titular | Lacuna reconhecida |
| Sem expurgo definitivo para LGPD | DELETE é lógico. A eliminação sob o art. 18, VI é operação manual de banco, sem endpoint e sem trilha própria. Também não há propagação automática aos consumidores dos eventos, que o § 6º do art. 18 exige comunicar | Roadmap |
| Sem cifragem de campo | CPF, renda e conta bancária ficam em texto no Postgres. Outros building blocks da plataforma (Meta Account, BaaS, E-Signature) cifram credencial com AES-256-GCM; o Customers não cifra dado pessoal | Não implementado |
| Sem histórico de endereço ou de conta | O modelo guarda um endereço e uma conta bancária. Trocar sobrescreve. O que sobra é o changes do evento no Audit Trail — que só existe se o consumidor de auditoria estiver ligado | Por design, com efeito colateral |
| Sem validação de CEP, banco ou agência contra fonte externa | Os formatos são conferidos por expressão regular. bankCode: "999" passa mesmo não existindo | Por design — o BB não consulta serviço externo |
| Sem KYC, bureau ou lista restritiva | Não consulta Receita Federal, Serasa, Boa Vista, PEP ou sanções. O CPF é validado matematicamente, não confirmado como existente | Por design — contrate um provedor e guarde o resultado |
| Sem importação em massa | Cadastrar dez mil pessoas são dez mil chamadas POST, sujeitas ao limite de taxa | Roadmap |
filter[name] sem índice de busca textual | A busca usa contains sem diferenciar maiúsculas, o que em PostgreSQL não usa o índice de name. Em base grande, a listagem filtrada por nome degrada | Conhecido |
| A sonda de saúde não testa o banco | GET /health responde ok com o Postgres fora do ar. Orquestrador não detecta o problema por ela | Conhecido |
| O CPF viaja em texto no evento | customers.person.created publica cpf, name, email e phone na carga do evento, e o mascaramento do Audit Trail não cobre CPF | Conhecido |
Perguntas frequentes
O Customers faz KYC?
Não. Ele valida o CPF matematicamente — dígito verificador e sequência repetida — e nada além disso. Não consulta a Receita Federal, não checa bureau de crédito, não verifica lista de pessoa politicamente exposta e não faz prova de vida. Contrate um provedor de KYC para isso e guarde aqui o cadastro e o resultado. Vender KYC com base neste building block é venda errada e aparece na primeira due diligence.
Posso cadastrar empresa, com CNPJ?
Hoje não. O tipo BR_CNPJ existe na enumeração e as permissões CUSTOMERS_COMPANIES_* existem no IAM, mas não há rota, tabela nem validação de CNPJ. Qualquer tentativa devolve 400. Está na §15 como especificado e não implementado.
Duas empresas clientes minhas podem ter o mesmo cliente?
Não, e essa é a limitação mais importante deste building block. O CPF é único na plataforma inteira, não por organização. Se você opera correspondentes, marketplace de crédito ou qualquer modelo em que a mesma pessoa apareça em duas carteiras, o segundo cadastro recebe 409. Leia a §15 antes de fechar contrato nesse cenário.
Por que não consigo atualizar o endereço pela API?
Porque o PATCH aceita apenas name e active. A decisão original protege a integridade de um cadastro que já originou crédito — alterar CPF ou data de nascimento por API é o tipo de mudança que costuma ser um erro. O efeito colateral é que endereço e renda também ficaram de fora, o que é uma lacuna e está registrada na §15.
O CPF é criptografado no banco?
Não. Ele é gravado em texto, sem máscara, na coluna tax_id_value. A proteção é de camada: TLS no transporte, rede fechada, credencial de banco em SOPS e permissão por rota. Se o seu enquadramento exige cifragem por coluna, isso não existe hoje.
Como atendo a um pedido de eliminação de dados da LGPD?
DELETE /people/:personId faz exclusão lógica — o registro sai de todas as consultas e permanece na tabela. Para muitos casos isso é o correto, porque o art. 16 da LGPD admite a conservação para cumprimento de obrigação legal, e operação de crédito costuma se enquadrar. Para eliminação definitiva sob o art. 18, VI, hoje é operação manual de banco, sem endpoint. Trate como processo com dono, não como chamada de API.
O que acontece se o Redis cair?
A leitura continua funcionando. A escrita falha com 500, porque a publicação do evento faz parte da cadeia de criação, atualização e exclusão. É deliberado: um cadastro gravado sem evento produz um Audit Trail com buraco e um webhook que nunca dispara. Se a sua chamada de escrita voltou 500 sem erro de validação, cheque o Redis antes de procurar bug.
Qual a diferença entre active: false e DELETE?
active: false é estado de negócio: a pessoa continua consultável e continua aparecendo na listagem sem filtro. DELETE é exclusão lógica: some de tudo, não tem rota de volta, e o CPF continua ocupado. Use o primeiro para pausar relacionamento e o segundo para encerrar cadastro.
Como sei que outra empresa cliente não vê meus cadastros?
Porque não existe caminho no código em que a organização venha da requisição. Ela vem do claim assinado do token, o middleware requireOrganization recusa token sem ela, e as três consultas do repositório recebem organizationId como parâmetro obrigatório. O teste prático é o da §10: uma pessoa de outra organização devolve 404, igualzinho a uma pessoa inexistente.
Por que o cadastro só tem um endereço?
Porque a esteira de crédito usa o endereço atual, e modelar uma lista custaria uma tabela, um JOIN em toda leitura e uma regra de "qual é o principal". A troca de endereço fica registrada no changes do evento customers.person.updated, que o Audit Trail persiste — desde que o consumidor de auditoria esteja ligado. Confira isso no README dele antes de contar com o histórico.
Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md